位置情報を正確にトラッキングする技術 in Android — (第2回)走行データをGoogle Mapにマッピングする
この回では走行データをマップに表示する方法について説明したいと思います。
本ブログはJavaのコードで説明していますがこのたびKotlin版のコードを公開しました。こちらもご参考ください!
位置情報は使ってもマップは表示しないアプリもあると思いますが、デバッグやアルゴリズムのパフォーマンス測定時にマップに位置情報を表示させないと位置情報の精度や取得タイミングなどが直感的にわからないのでマップは開発の早い段階から必要になってきます。
この回でサンプルアプリにマップを搭載してみたいと思います。
Google Maps Android SDKの公式のドキュメントでは↓になります。
途中まではこちらに即した内容で説明をしていきます。
API Keyの取得
まず上の公式ページからキーを取得ボタンを押すと以下のような手順が出てくるのでそれに従いましょう。
ここで、Create a projectが選択された状態でContinueボタンをおします。
これで、My Projectという名前のプロジェクトが作られ下のような画面が表示されます。(このプロジェクト名は後で設定から変えられるのでわかりやすいようにアプリのプロジェクト名と同じにしておくことをお勧めします)
この画面で、API Keyの名前(ここではAndroidLocationStarterKitとしました)を入力し、Key restrictionのところでAndroid appを選択し、AndroidアプリからしかAPI Keyを使えないようにします(この設定は各自の事情で判断してください)。
次に、+Add package name and fingerprintというボタンを押して、package nameとSHA-1のfingerprintを入力する必要があり、これがやや面倒です。
package nameはアプリのパッケージ名です。忘れてしまった場合はプロジェクトのManifestファイルを開くと確認できます。
次にfingerprintですが、
keytool -list -v -keystore mystore.keystore
とターミナルで入力し、SHA-1のfingerprintを取得して、ここにペーストすると書いてありますが、
デバッグビルドでは~/.android/以下にdebug.keystoreというkeystoreがあり自動的にこのkeystoreが使われています。このkeystoreファイルを指定してfingerprintを取得し、このfingerprintを登録しておけばデバッグビルドでGoolge Mapを表示することができます。
まず、
$ keytool -list -v -keystore ~/.android/debug.keystore
とターミナルに打ち込むと
キーストアのパスワードを入力してください:
という文章が返ってくるのですが、パスワードは設定していないはずなのでEnterキーを押します。
すると、SHA-1フィンガプリトが表示されます。下のように2桁の16進数がコロンを挟んで20個並んだ文字列がfingerprintです。
5F:77:F6:40:9B:E2:3D:C4:F9:65:92:4D:55:F0:1C:05:CB:36:FC:FF
これを登録し、その後は指示通り進んでいくだけでAPI Keyの文字列が取得できます。これを後で使うので控えておいてください。
(なおアプリリリース時には、リリース用のkeystoreを作ると思いますが、このkeystoreを指定して得たSHA-1フィンガープリントを使って別のAPI Keyを取得し、そちらを使う必要があります。そうしないとリリースビルドでマップが表示されなくなるので気をつけてください。)
それでは、Android Studioに戻って作業を続けましょう。
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="YOUR_API_KEY"/>
のようなmeta-data要素をManifestに追加し、”YOUR_API_KEY”の部分を取得したAPI Keyに置き換えます。必ずこのタグを<application>タグの中に入れよるようにしましょう。下のような感じになります。
これでAPI Keyの取得と設定は終わりました。
MapViewを使う
Google Mapを表示させるにはMapViewというViewをレイアウトに埋め込みます。下のような感じになります。
この後の手順は、
- MapViewのインスタンスをonCreate内で取得する。
- MapViewのライフサイクルメソッドをActivtyクラスの各ライフサイクルメソッドから呼ぶ
- GoogleMapクラスのオブジェクトをMapViewから取得する。
となります。
MapViewのインスタンスをonCreate内で取得する
Activity内で下のようにMapViewクラスのメンバー変数を宣言しておき、
private MapView mapView;
その後、ActivityのOnCreate()内で下のようにMapViewのインスタンスを取得します。
mapView = (MapView) this.findViewById(R.id.map);
MapViewのライフサイクルメソッドをActivtyクラスの各ライフサイクルメソッドから呼ぶ
下の公式ドキュメントの「MapView」というセクションに「アクティビティ ライフサイクル メソッドを、MapView
クラスの対応するメソッドに転送する必要があります。」と書いてあります。
これはどういうことかというと、ActivityのonCreate()、onDestroy()、onStart()、onStop、onResume()、onPause()、onLowMemory()、onSaveInstanceState()の中で、MapViewのonCreate()、onDestroy()、onStart()、onStop、onResume()、onPause()、onLowMemory()、onSaveInstanceState()を呼んでくださいということです。
これは何とも面倒な仕様なのですが粘り強くコードを追加しましょう。
下のような感じでひたすらライフサイクルメソッドをオーバーライドしていきます。
GoogleMapクラスのオブジェクトをMapViewから取得する。
最後にMapViewからgetMapAsyncメソッドを使ってGoogleMapオブジェクトを取得します。このメソッドを呼ぶと非同期的にOnMapReadyCallbackのonMapReadyというメソッドが呼ばれます。この中でGoogleMapオブジェクトが取得できるので、これを後で使えるようにmapというメンバー変数に格納します。
次に6〜9行目の箇所で、
- ズームコントロールを非表示にする。
- 自分の位置を示すインディケーターを非表示にする。
- コンパスの機能をオンにする
- 現在地に飛ぶボタンを表示にする
という設定をしています。自分の位置を示すインディケーターをあえて非表示にしている理由はこの後、今回は自分の位置や位置情報の精度を示す円を自分でカスタムで描画するからです。デフォルトのアイコンではなく、自分独自のグラフィックで現在位置と位置情報の制度を表現する方法をこの後説明します。
11行目のsetOnCameraMoveStartedListenerで設定しているリスナーはは、マップの位置やズームレベルが変化した時に呼ばれるリスナーです。このリスナーの中のコードについては下の「自動ズーム」のセクションで詳しく説明します。
これでMapViewに触れるコードは全て書き終わりました。
この後、現在地の描画、移動経路の描画、現在の位置へマップを移動&ズームさせる処理は全てこのGoogleMapオブジェクトに対して行います。
現在地の描画
ここからは現在地にマーカーと精度を示す円を描画する方法を説明します。
位置情報の取得
MapViewに現在の位置情報を表示するために、前回作成したLocationServiceから位置情報を受け取る必要があります。
ArrayList<Location> locationList;
boolean isLogging;
まず上のように2つのメンバー変数をLocationServiceで宣言し、onCreateで初期化します。
@Override
public void onCreate() {
locationList = new ArrayList<>();
isLogging = false;
}
前回までのところでは、位置情報を取得する関数onLocationChangedの中で緯度経度をログに出力しているだけでしたがこのonLocationChangedを変更します。
上の5行目〜のところで、まずisLoggingフラグを確認し、trueならばlocationListに位置情報を格納します。
これは、位置情報を”ログ”している時だけlocationListに自分の移動経路を記録していくという処理になります。
次にIntentを作り、その中に取得した位置情報を格納して、LocalBroadcastManagerを使って位置情報をブロードキャストします。
これをActivity側で受け取ることで、位置情報の更新のたびにActivityが位置情報を取得することができます。
Activity側で位置情報を受け取る
MainActivityで、locationUpdateReceiverというBroadcastReceiverクラスのメンバーをonCreate内で下のように初期化します。onReceiveメソッドをオーバライドして、ここで先ほどのブロードキャストを受け取る処理を書きます。
locationUpdateReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
Location newLocation = intent.getParcelableExtra("location");
drawLocationAccuracyCircle(newLocation);
drawUserPositionMarker(newLocation);
if (locationService.isLogging) {
addPolyline();
}
zoomMapTo(newLocation);
}
};
これでLocationServiceからの情報をMainActivity側でリアルタイムに受信できるようになりました。
Mapへの情報の描画
このLocationオブジェクトを下の3つのメソッドに渡し、Mapへ情報を描画していきます。
- drawLocationAccuracyCircle(newLocation) :位置情報の制度を表す円をユーザーの周りに描画する
- drawUserPositionMarker(newLocation) :ユーザーの位置を描画する
- addPolyline() :移動経度を線として描画する
これら各メソッドによって下のようなものが最終的にマップに描画されるようになります。
最後にもう一つ、zoomMapTo()というメソッドにLocationオブジェクトを渡しますが、これは一番最後に説明します。
位置情報精度の円の描画 — drawLocationAccuracyCircle(newLocation)
先ほどと同じようなコードなのですが、今度はユーザーの現在地の周りに位置情報の精度を表す円を描画します。GoogleMapアプリで現在地の周りに半透明の青色の円が出ますがこれと同じものです。
今度はaddCircleメソッドを使います。このcenterに現在地を指定し、radius(半径)にLocationオブジェクトから取得できるaccuracyを設定します。このaccuracyという変数は精度の大きさをメートルで表示しているのでこのままCircleのraidusとして使えます。
例えば精度が100メートルであれば「このLocationオブジェクトの緯度経度の位置から半径100メートル以内のどこかにユーザーがいることは確か」という意味になります。
現在地の描画 — drawUserPositionMarker(newLocation)
このメソッドではユーザーの位置に赤い丸を表示しています。まず赤い丸のイメージからBitmapDescriptorクラスのオブジェクトを生成し(4行目)。これを使ってGoogleMapオブジェクトのaddMarkerメソッドを呼び出してマーカーを地図上に表示させます。2回目以降この関数が呼ばれた場合はマーカーの位置をsetPositionメソッドを使って更新します。
移動経路の描画 — addPolyline()
次に移動経路を描画する関数を作ります。
まずLocationServiceオブジェクトからlocationListを取り出します。ここにLocationServiceがログした位置情報が全て格納されているので、この中の最新の位置情報と一つ前の位置情報を取り出し、その2点の間に直線を書いていくことで移動経路を描画します。
4行目と18行目のところで、if/else ifと分岐させていますが、これはログした位置情報が2個だけの場合と2個より多い場合に分けています。2個だけ(if locaitonList.size() == 0)ということはまだ直線が一つも地図上に引かれていないということなので、新しくPolylineオブジェクトをマップ上に追加します。Polylineとは直線の集合という意味で、このオブジェクトをマップに追加するとマップ上に線が引けます。
14行目のaddPolylineで新規にPolylineオブジェクトをMapに追加し、精製されたPolylineオブジェクトをrunningPathPolylineという変数に格納しておきます。
ログした位置情報が2個より多い場合(18行目以下)は最初に描画した際に生成したPolylineオブジェクト(runningPathPolyline)に新たに現在の位置を追加するということをしています(26行目)
自動ズーム
zoomMapTo(newLocation)
マップ上へのカスタムドローイングはこれで全て終わりですが、もう一つだけ実装しておきたい機能があります。ユーザーがアプリを持って移動を続けるとユーザーの場所がマップから消えてしまいます。
ユーザーをマップ上で自動追跡する機能をつけて常にユーザーがマップの中心にいるようにしましょう。
下のような関数を追加し、位置情報が更新されるたびに呼ぶようにするとマップが最新の位置(ユーザーの現在地)にアニメーション付きで移動&ズームしてくれます。
しかしこのコードを実行してみるとUX上考慮しなければならないことが起きます。
- 自分でマップを触って位置やズームレベルを変えたい時があるのに位置情報が取得されるたびに自分の位置にズームしてしまう。
このUX上の改善を行うために以下の対策を入れます
- 最初だけユーザーのいる位置にズームする。
- マップが開いた時にいきなり現在地を表示していた方がかっこいいので上記の最初のズームはアニメーションさせない
- 初回以降で、ユーザーがマップに触った後はしばらくの間は自動的に現在地に移動しないようにする。(ユーザーがマップのある地点(ゴール地点など)を見ているのではないかという想定をする)
- 初回以降で、ユーザーがしばらくマップに触っていない時だけ自動的に現在地に移動する。この時ズームレベルは変えない。(ユーザーが最後にマップに触って設定したマップのズームレベルを尊重する)
これだけのことをzoomMapTo関数に盛り込むと以下のようになります。
まず4行目で、このアプリが起動後に一度でもユーザーの現在地にマップをズームさせたかのフラグ(didInitalZoom)をチェックします。まだ一度もチェックしていない場合はマップは世界全体を表示した状態になっているのでアニメーションなしで現在地に移動し、ズームレベル17.5でズームします。
初回以降は16行目以降の処理になります。16行目でzoomableというフラグを見ています。このフラグはユーザーがマップに触ってから10秒間はfalseになるようになっています。これがtrueの場合だけ自動ズームします。19行目で現在地にマップをアニメーション付きで移動させます。この時ズームレベルは変えません。最後にユーザーがマップを触った状態のズームレベルを尊重します。
アニメーション中はzoomableフラグをfalseにしています。
これでほぼ終わりですが、最後に「ユーザーがマップを触った後に10秒間zoomableフラグをfalseにする」という処理を付け加えなければなりません。先ほどActivityのOnCreateの中で、MapViewからgetMapAsyncメソッドを使ってGoogleMapオブジェクトを取得した箇所に戻ります。
ここでGoogleMapオブジェクトのsetOnCameraMoveStartedListenerを呼んでリスナーを登録します。するとマップのフォーカスが変わるたびにonCameraMoveStartedというメソッドが呼ばれるようになります。ユーザがマップに触ってフォーカスを変えた時も、プログラムからmoveCameraやanimateCamera関数を使ってマップを動かした場合もこのonCameraMoveStartedが呼ばれます。
この中で渡されるreasonというパラメータを見るとどうゆう理由でマップが動いたのかがわかります。
この値がGoogleMap.OnCameraMoveStartedListener.REASON_GESTUREの時はこのユーザーが触ったことによってマップが動いたということですので、この時だけ、タイマーを作成して10秒間だけzoomableフラグをfalseにします。
以上の実装によって例えばジョギンアプリを作った場合には、
”走っている自分の場所を常にマップの中心に表示させるようにマップを自動的に動かしてくれるが、ユーザーがもっとマップを拡大したり縮小したりして眺めたい時は、10秒間この自動ズームの機能が止まり、10秒後からはズームレベルはそのままに位置だけユーザを追跡する”
といったUXを実現できます。
このzoomMapTo関数をlocationUpdateReceiverのonReceiveの最後に追加し、これで今回のすべての実装が終わりました。
locationUpdateReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
Location newLocation = intent.getParcelableExtra("location");
drawLocationAccuracyCircle(newLocation);
drawUserPositionMarker(newLocation);
if (locationService.isLogging) {
addPolyline();
}
zoomMapTo(newLocation);
}
};
お疲れ様でした!
ここまでのサンプルは以下のGitHubレポジトリのコミット
38d83401805970ee2b631b5dac18c2823b98b03dで見ることができます。
https://github.com/mizutori/AndroidLocationStarterKit
次回は、高精度の位置情報を取得するフィルタの作り方について説明します。
このブログの内容に関する質問や位置情報トラッキング機能開発に関するご相談などはこちらにご連絡ください。
@mizutory
mizutori@goldrushcomputing.com