隠岐のフェリー乗換案内アプリを作ってみた 〜Webアプリ編〜
前書き
久々にブログタイトル通りのプログラマっぽい記事です(笑)。
リリースから3ヶ月ほど経ちましたが、島根県の離島、隠岐諸島地域のフェリー乗換案内アプリを作ってみました。本土⇔隠岐(島前/島後)をつなぐフェリーと、島前3島をつなぐ内航船が対象です。最初にブラウザで動作するWebアプリ(もどき)版を開発して、それをベースにハイブリッドアプリ化して iPhone/iPad 版、Android版を作りました。
隠岐航路案内
Webアプリ版 http://naturebot-lab.com/ferry_transit/
普段はiPhoneアプリ開発を中心にやっていて、Webアプリベース(と言っても、ほとんどフロントエンドで処理するので、Webアプリと呼べるか怪しいですが...)のものを作るのは初めてだったので色々と調べながら作りました。Android は昔少しさわったことがありますが、Google Play ストアでのリリースは初めてです。それぞれの開発の記録やハマったところなどを書き残しておきます。3本立ての予定で、今回は Webアプリ版です。
開発の経緯
島根県の隠岐諸島では、本土と隠岐をつなぐ大型フェリーと隠岐諸島の島前各島をつなぐ内航船が就航しており、それぞれ隠岐汽船と隠岐観光が運営しています。隠岐に関係する港は全部で6つだけですが、大型フェリーと内航船の乗り継ぎルートも含めるとわりと複雑です。
西ノ島町観光協会HPより引用
さらに時期によって船がドック入りしたり、運行ルートが変わったりするので、紙の時刻表で確認するのはちょっと骨が折れます。また、内航船は大手の乗換案内サービスでは検索対象外です。
隠岐汽船公式サイトには時刻表検索機能があり、内航船を含めた乗り換えルートも検索できるのですが、スマホに対応していなかったり、時刻表の一覧はPDFベースだったりとちょいちょい不便なので、スマホで簡単に見れる時刻表/乗換案内アプリがあると便利そう、ということでWebアプリの勉強がてら作ってみることにしました。
アプリの仕様
- いわゆる電車乗換案内アプリのフェリー版
- 指定した港から出る船の時刻表(主に島民向け)
- 指定した出発地〜目的地の乗換ルート検索(主に観光客向け)
- 指定された日時に応じた時刻を表示する
- 駅順が決まっている電車と違い、時期や時間によってルートが異なる
- オフライン動作に対応できるようにする
- 島内は電波が悪いところが多くしばしば圏外になるので、観光中などにネットにアクセスできないケースを想定
- Webアプリ版では対応せず、ハイブリッドアプリでのオフライン動作を想定して設計
- 外国人観光客向けの英語表記対応
- 表示する言語はブラウザの言語設定から自動判別
配布形態の検討
HTML5のオフライン動作向けの機能は各ブラウザに普及してきたので、Webアプリで完結することもできそうですが、とりあえずHTML5 + JavaScriptで機能を作ってWeb版としてリリースした後、iPhone/Androidのハイブリッドアプリを作る方針としました。Webアプリは一般的なユーザにはあまり浸透していない(と思っている)形態なので、ブックマークやトップ画面への追加などができないユーザも多そう、という意味でスマホアプリとしてパッケージングしたほうが良さそうと考えました。広告の表示回数をアクセス解析代わりに使っていますが、実際にスマホアプリ版、特にiPhone/iPad版のアクセス数がダントツに多いです。
使った言語とフレームワーク
言語
オフライン対応のハイブリッドアプリ前提で作るので、なるべくフロントエンドで色々と処理してやる必要があります。なので基本的にはブラウザが実行できるJavaScript一択となりますが、昨今はAltJS(JavaScriptにコンパイルして使う、JavaScript の代替となる言語の総称)が全盛ということで、その中でも定番になりつつあるTypeScriptを使ってみることにしました。
TypeScript は名前の通り JavaScript + 静的型付けが基本的なコンセプトですが、ECMAScript 6 に準拠した機能を取り入れており、クラスベースのオブジェクト指向的な書き方がしやすいのでObjective-C、Swift 使いとしては馴染みやすかったです。
エディタは atom を使っているので 、atom-typescriptプラグインをインストールしました。TypeScriptで書いたファイルを保存するだけで、文法チェックとJSへのコンパイルを自動でやってくれるので非常に便利です。
なお、TypeScript で JavaScript のライブラリを使う際は、型定義情報が必要になります。有名どころのライブラリに関しては型定義情報が提供されています。型定義情報は Typings という型定義管理ツールで取得することができます。以前はTSDというツールが使われていたようですが、現在は非推奨になったようです。
バックエンド側はほとんど処理がないので、PHPで適当に書きました(^^;)
フレームワーク
フロントエンドで時刻表データを整形するのにMVC(MVW?)フレームワークのAngularJSのフィルタ機能が便利そう、ということでAngularJSを使ってみることにしました(後継の Angular 2 も開発されていますがまだ色々変わりそうなので...)。AngularJSは色々モジュールを組み込んで拡張できるので、多言語対応も angular-translate でできそうです。
デザイン方面は疎いので、素人でもそれなりにそれっぽい画面になると噂のTwitter社製 Bootstrapを今更ながら使ってみようかな、と思っていましたが、AngularJS と組み合わせて使えるようにポーティングされたUI-BootstrapがAngularJS開発チームによって提供されていたので、それを使ってみることにしました。
環境構築
TypeScript + AngularJS + UI-Bootstrap な環境を作ります。 色々依存があってややこしいですが、Macで一から構築する場合は以下の手順です。
homebrew (Mac OSX のパッケージ管理ツール)をインストール
homebrew で node.js (ブラウザなしでJavaScriptを実行するためのツール)をインストール
node.js で動作する各種ツールを使うために node.js をインストールします。 atom エディタも JavaScript で書かれているので node.js が必要です。
npm (node.js のパッケージ管理ツール) で bower (フロントエンド向けパッケージ管理ツール) をインストール(2017/10/26 追記)久々にアプリをアップデートしたら bower がお亡くなり(deprecated)になっていたので、フロントエンド系パッケージ類もnpm管理に移行しました。
npm で TypeScript をインストール
bowernpm でフロントエンド向けのライブラリ(今回は AngularJS と UI-Bootstrap)をインストール$ npm install angular-ui-bootstrap
ちなみに UI-Bootstrap は Bootstrap のCSSだけ流用するので、Bootstrap もインストールが必要でした。npm では angular-ui-bootstrap パッケージだけ入れれば良さそうです。bowernpm で AngularJS のモジュールをインストール多言語対応用モジュール
$ npm install --save-dev angular-translate
便利フィルタ詰め合わせ
$ npm install angular-filter
開発
時刻表のデータベース化
時刻表は各社からPDFで公開されていますが、それぞれフォーマットが違うので扱いやすい形に統一する必要があります。 今回は Google ドキュメントでスプレッドシートに各社のフォーマットをそのまま入力できるシートを作って、Apps Script で統一したフォーマットに変換することにしました。出力されたものを csv としてダウンロードして、それを SQLite の DB に取り込んだものをサーバに配置しました。
統一フォーマットは以下のように、「ある港」から「次の港」へ向かう便の情報を最小単位として時刻表を分解したものです。
便ID | 接続ID | 開始日 | 終了日 | 便名 | 出発地 | 出発時刻 | 到着地 | 到着時刻 |
---|---|---|---|---|---|---|---|---|
1 | 2 | 2016/01/01 | 2016/02/29 | FERRY_OKI | HONDO_SHICHIRUI | 9:00 | SAIGO | 11:25 |
2 | 3 | 2016/01/01 | 2016/02/29 | FERRY_OKI | SAIGO | 12:00 | HISHIURA | 13:10 |
3 | 4 | 2016/01/01 | 2016/02/29 | FERRY_OKI | HISHIURA | 13:20 | BEPPU | 13:35 |
便IDは各便を一意に識別するための識別子、接続IDはその便が接続する別の便のIDです。時期によって便が変わるので有効期限(開始日、終了日)があります。
バックエンド
ハード面は安いプランのレンタルサーバです。隠岐諸島の人口(約2万人)と観光客(ツアー客を除く)のキャパシティを考慮しても、大量アクセスが見込まれるサービスではないので、数百アクセス/日を捌ければ良いという見込みです。
ソフト面では、オフライン対応のために前項の時刻表DBをまとめてクライアントに渡す処理が必要です。PHP で時刻表 DB の全内容を JSON 化してクライアントに送信する処理を書きました(数行だけですが)。 DBファイルを直接クライアントに投げちゃってもいいような気もしましたが、クライアント側で SQLite3 DB が扱いにくいようなので、そのまま処理できる JSON 形式にしています。
フロントエンド
クライアント側のプログラムは TypeScript も AngularJS + UI-Bootstrap も初めてなので探り探りの開発です。 将来的な Angular2 への移行やメンテナンス性を考えると、以下のような記事を参考にするのが良さそうです。
http://qiita.com/armorik83/items/5542daed0c408cb9f605qiita.com
が、UI-Bootstrap 公式のサンプルコードは書き方が古めかしいので、それを参考にしているうちにあまりモダンでないコードになってしまいました...。いずれリファクタリングしたい(^^;)
UI(ユーザインタフェース)
アプリの主な機能は時刻表と乗換案内なので、タブで切り替えるのが良さそうでした。 UI-Bootstrap には Tabs というディレクティブ(=独自タグor要素)が用意されていて、簡単にタブの UI を作成できます。便利。
時刻表
時刻表の処理としては、以下のような感じです。
- サーバの時刻表DBからJSON化したデータ(数百KB)を取得
- AngularJS のフィルタで指定日時と出発地/到着地に一致するデータを取捨選択
- ngRepeat ディレクティブで表を生成
フィルタは拡張モジュールも合わせると様々な種類が用意されており、条件に一致するデータの抽出や排除、プレフィックスやサフィックスの追加などが簡単にできます。多言語対応モジュールもフィルタとして実装されており、登録したワードをフィルタにかけると言語設定に対応した文字列に置き換えてくれます。
SQLite3 や JSON オブジェクトには日時を表す型が存在しないので、時刻は文字列(mm:ss)として格納されており、JSON の文字列から JavaScript の Date 型に変換して保持しておきます。この時の変換元となる文字列のフォーマットは少し曲者で、ブラウザによって許容する表現が異なります。サポートしていないフォーマットを指定した場合、null が返ってしまうので全てのブラウザがサポートしているフォーマットを選択する必要があります。
というわけで、一番汎用的な"YYYY/MM/DD HH:mm:ss"フォーマットに加工してから Date 型に変換しています。
乗換案内
乗換案内のアルゴリズムは、以下のような感じです。
- 指定日時に従って有効期間外の便をフィルタで排除
- 指定出発地かつ指定時刻に最も近い便を抽出して、各経路を探索 2-1. 指定到着地にたどり着く便を抽出して結果リストに保存 2-2. たどり着けない便から接続可能な次の便を探す 2-2-1. 見つかった便から、指定到着地にたどり着く便を抽出して結果リストに保存 2-2-2. たどり着けない便から接続可能な次の便を探す ...以上を再帰的に繰り返してすべての経路をリストアップ
- 経路リストを優先度順にソート
- ngRepeat ディレクティブで経路リストから表を生成
6つしか港が無い割に、かなり色々な経路をとることができるので、隣の島に行くのに数百種類の経路が出たりします(笑)。全部いっぺんに表示するのはアレなので、最初は上位5経路を表示するようにして「もっと見る」ボタンを押すと他の経路も見れるようにしました。 となると、どの経路を優先して表示するかが問題となり、ユーザに優先したい条件(到着時刻が早い順、移動時間が短い順、値段の安い順など)を選んで貰えば解決、なのですが、それらのインターフェースも増やすとごちゃごちゃして使いにくそうなので、そこはアプリ側で優先すべき経路を勝手に判断することにしました。
多言語対応
angular-translate モジュールを使って日/英に対応しました。 使い方は以下の記事がわかりやすかったです。
http://angularjsninja.com/blog/2013/09/05/angularjs-i18n/angularjsninja.com
言語設定の自動判別は自分でやってやる必要があります。 $translateProvider.determinePreferredLanguage() に言語コードを判別して返す関数を渡します。 JavaScriptでの判別コードは以下の記事を参考にしました。
JavaScript でブラウザの言語設定を取得 | EasyRamble
デプロイ
デプロイはgitHub経由でやってみたら楽で良かったです。 手順は以下の通り。
gitHub からサーバに通知して自動 pull みたいなこともできるらしいですが、ちょっと面倒なので今回はそこまでやりませんでした。 なお、bower でインストールしたパッケージ群はgitリポジトリに含めないので、予めFTP転送ソフトでサーバにアップしておきました。
ハマりどころ
クロスドメイン通信
各ブラウザでは、クロスサイトスクリプティング(XSS)防止のため、クロスドメイン通信がデフォルトで禁止されています。つまり、JavaScript の処理からのアクセス先(今回はPHPスクリプト)が同じサーバにあるWebアプリ形態の場合は問題ないですが、ハイブリッドアプリ化した場合、JavaScript は端末のアプリ側に置かれるので、サーバに対するアクセスはクロスドメイン通信となり失敗します。
これの解決方法は、JSONP(JSON with Padding) や CORS(Cross-Origin Resource Sharing)、XDM (Cross Document Messaging)などいくつか方法があるようです。
https://blogs.msdn.microsoft.com/tsmatsuz/2011/06/24/jsonp-cross-domain/blogs.msdn.microsoft.com
JSONP は裏ワザっぽいし、XDM は大仰なので、今回は王道っぽい CORS で対応することにしました。 実装は PHP で一行追加するだけでした。
時刻入力
スマートフォンからアクセスする場合、UI-Bootstrap の Timepicker はボタンが非常に押しづらくて使いにくいです。代替として HTML5 の input type="time" を使用すると OS ネイティブの入力インタフェースが出て入力しやすいのですが、IE11、 デスクトップ版の FireFox、Safari などでは未サポートでした。
Can I use... Support tables for HTML5, CSS3, etc
解決方法としては、AngularJS の ng-hide ディレクティブで、スマホ/タブレット向け(xs, sm)は input type="time"、それ以外(md, lg)はTimepickerを表示するように切り分けました。(が、さらに Android 4.3 以下の標準ブラウザは input type="time" をサポートしていないことが判明したので、Android版では Cordova + Crosswalk でレンダリングエンジンを変更することで回避しました。詳細はAndroid版のエントリで...)
文字化け
JavaScript のコードで日本語を使っていたところ、iOS の Safari で文字化けしました(charset="uft-8" を指定してもダメ)。条件文で日本語を使っていたりすると、プログラムが動作しなかったりします。 回避方法は以下の記事で紹介されているとおり、UTF-8(BOM付き)で JavaScript を保存するというものですが、atom エディタは BOM 付き保存はサポートしていないので、JavaScript から日本語を排除するようにしました。DBに格納された日本語をif文で使ったりしていたので、DB内に日本語で格納していた船名や港名も英字に置き換えが必要でした...。
また、上の問題とは別に iOS7 では英語設定でのローマ字表記に使うラテン文字がどうしても文字化けするのですが、こちらはアプリ使用上はほぼ問題にならないため、対応しないことにしました。
その他
Favicon と App Icon
Favicon はブラウザの URL の左側に表示されるミニアイコンで、App Icon はスマホでWebサイトをホーム画面に追加したときに表示されます。アプリっぽくするために、それっぽいアイコンをこさえて設定しておきました。
アイコンの作成には Affinity Designer というソフトを使っています。Adobe Illustrator の代替のベクターベースのグラフィックソフトとして人気が出ているようですが、廉価なのになかなか使い勝手が良くて重宝しています。
フェリーのアイコンは以下のサイトで配布されているものを使わせてもらいました。 icooon-mono.com
以下のサイトに画像を投げると Favicon と App Icon を自動生成してくれます。 www.favicon-generator.org