位置情報を正確にトラッキングする技術 in Kotlin— (第2回)走行データをGoogle Mapにマッピングする

この回では走行データをマップに表示する方法について説明したいと思います。

位置情報は使ってもマップは表示しないアプリもあると思いますが、デバッグやアルゴリズムのパフォーマンス測定時にマップに位置情報を表示させないと位置情報の精度や取得タイミングなどが直感的にわからないのでマップは開発の早い段階から必要になってきます。
この回でサンプルアプリにマップを搭載してみたいと思います。

Google Maps Android SDKの公式のドキュメントでは↓になります。

途中まではこちらに即した内容で説明をしていきます。

API Keyの取得

まず上の公式ページからGET STARTEDボタンを押すと以下のような画面が出てくるのでMapsをチェックし、CONTINUEを押します。

次に、どのプロジェクトでMapを使うかを聞かれます。ここで+Create a new projectを選択してください。

つぎにプロジェクト名を入力します。ここではMy Projectという名前にしてますが、アプリの名前にすることをおすすめします。

次に、支払いの設定を求められます。残念ながら最近Google Mapの使用は有料になってしまいましたので、ここで支払いの設定をしていない人は支払いの設定を行ってください。(従量課金ですので、テストアプリ程度では0円でつかえると思います)

すでに支払いの設定をしている人は下のようにその設定を選ぶかきかれるので、選んでSET ACCOUNTをクリックして次にすすみます。

下の画面でNEXTをクリックします。

下のように、API KEY が取得できました。

これで、My Projectという名前のプロジェクトが作られ、上のようにAPI Keyが取得できました。(プロジェクト名は後で設定から変えられるのでわかりやすいようにアプリのプロジェクト名と同じにしておくことをお勧めします)

このAPI Keyは下のGoogle Cloud Platform Consoleからいつでも参照できます。

このConsoleではこれまでの手順で有効にしたAPIのリストがみれます。(左のメニューからAPIsを選択してください)

上の画面で、Maps SDK for Androidを選択し、下のように出てきた画面のCredentialsタブを開くと、API Keyを確認することができます。

ここで作ったAPI Keyに警告のマーク(黄色い三角形のなかにビックリマークのあるアイコン)が出ていることがわかると思います。

ここにマウスオーバーしてみましょう。

This API Key is unrestricted.と書かれています。これはこのAPI Keyに使用制限がかかっていないため、だれでも使えてしまいますよという警告を言っています。

API Keyに制限をかけることで、他人に使われたり、Androidアプリ以外の目的で使われたりすることを防止することができます。

この警告文の下にあるEdit settingsをクリックして、制限の設定に進みます。

下の画面で制限の設定を行います。(この画面で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

これを登録します。

これで自分のマシーンでビルドしたアプリでしか動かないように制限をかけることができました。

なおアプリリリース時には、リリース用のkeystoreを作ると思いますが、このkeystoreを指定して得たSHA-1フィンガープリントを使って別のAPI Keyを取得し、そちらを使う必要があります。そうしないとリリースビルドでマップが表示されなくなるので気をつけてください。デバッグ用のAPI Keyとリリース用のAPI Keyを両方Android Studioプロジェクトのなかで設定する方法は後ほど説明します。

もう1つだけ同じ画面で必要な設定があります。API restrictionsのタブを選択し、API restrictionsのドロップダウンリストから、Maps SDK for Androidを選択します。

これでこのAPI KeyはAndroidアプリのみで使えるように制限をかけることができました。

それでは、Android Studioに戻って作業を続けましょう。

以前は下のようなmetaタグをAndroid Manifestに追加して、使う方法が主流でした。

しかし、最近は、

下のようにgoogle_maps_api.xmlというXMLを配置して設定するようになっています。

このXMLファイルはFinderで見ると下のような場所に配置します。

/app/src/debug/res/values/google_maps_api.xmlには、先程作ったdebug用のAPI Keyを、

/app/src/release/res/values/google_maps_api.xmlにはリリース用のkeystoreで作ったリリース用のAPI Keyを設定します。

これで、Android StudioでBuild Variantsdebug <-> releaseと切り替えるだけで、API Keyも自動的に切り替えられるようになります。

このXMLファイルにはしたのような構造を記述します。

<resources>    
<string name="google_maps_key" translatable="false" templateMergeStrategy="preserve">API Key</string>
</resources>

わからなくなったら

もしAPI Keyを配置する場所やその中身がわからなくなったら、いちどAndroid Studioの新しくプロジェクトをつくってみてください。新しくプロジェクトをつくるときに選択できる、下のGoogle Maps Activityを選択して、新しくアプリプロジェクトを作ると、API Keyを配置するgoogle_maps_api.xmlがdebug用、release用ともに自動的に生成されるので一度まっさらなMap表示のプロジェクトで構造を理解することができます。また、このアプリプロジェクトでgoogle_maps_api.xmlにAPI Keyを設定してMaps Activityを起動してみると、API Keyがただしければ地図が表示されるので、いちどこのアプリでAPI Keyがただしいかも確認してみるといいかもしれません。

コーディング

ようやく長い道のりを経てAPI Keyの設定が終わり実際にコーディングするところまでこれました。

レイアウト

まずはMainActivityのレイアウトactivity_main.xmlです。

下のようにAndroidに用意されているSupportMapFragmentを埋め込むことでマップを表示することができます。(従来はMapViewを埋め込んでマップを表示していましたが、SupportMapFragmentを使ったほうがMapのライフサイクル管理も行ってくれるため便利です)

つぎにMainActivityのコードを見ていきましょう。

@RuntimePermissions
class MainActivity : AppCompatActivity() {
private val TAG = "MainActivity"

private lateinit var map
: GoogleMap

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

val mapFragment = supportFragmentManager
.findFragmentById(R.id.map) as SupportMapFragment
mapFragment.getMapAsync { googleMap ->
setupGoogleMapWithPermissionCheck
(googleMap)

}

ここでおこなっていることは、

  1. GoogleMapオブジェクトを保持する変数mapを宣言する
  2. onCreateのなかで、supportFragmentManager.findFragmentById()を使ってactivity_main.xmlレイアウトに貼り付けたSupportMapFragmentをオブジェクトとして取り出します。
  3. SupportMapFragmentオブジェクトに対してgetMapAsyncを呼びます。この関数の中では、GoogleMapの描画の準備が終わると、ラムダ表現のなかで非動的的にGoogleMapオブジェクトを取得できます。(ラムダの中は非同期的によばれるため、このgetMapAsyncブロックの下に書いたコードよりも通常遅く実行されることに注意してください。
setupGoogleMapWithPermissionCheck(googleMap)

受け取ったgoogleMapオブジェクトはsetupGoogleMapWithPermissionという関数に渡しています。

この中身を見てみましょう。

まず取得したGoogleMapオブジェクトを後で使えるようにmapというメンバー変数に格納します。

次に4〜7行目の箇所で、

  • ズームコントロールを非表示にする。
  • 自分の位置を示すインディケーターを非表示にする。
  • コンパスの機能をオンにする
  • 現在地に飛ぶボタンを表示にする

という設定をしています。自分の位置を示すインディケーターをあえて非表示にしている理由はこの後、今回は自分の位置や位置情報の精度を示す円を自分でカスタムで描画するからです。デフォルトのアイコンではなく、自分独自のグラフィックで現在位置と位置情報の制度を表現する方法をこの後説明します。

9行目のsetOnCameraMoveStartedListenerで設定しているリスナーは、マップの位置やズームレベルが変化した時に呼ばれるリスナーです。このリスナーの中のコードについては下の「自動ズーム」のセクションで詳しく説明します。

これでMap表示に関するコードは全て書き終わりました。

この後、現在地の描画、移動経路の描画、現在の位置へマップを移動&ズームさせる処理は全てこのGoogleMapオブジェクトに対して行います。

ここでみたのはsetupGoogleMap関数ですが、呼び出すときは下のように”WithPermissionCheck”というPostfixを付けて呼び出しています。

setupGoogleMapWithPermissionCheck(googleMap)

これはPermissionDispatcherというライブラリ(下)をつかって、Android OS 6.0から必須となったランタイムパーミッションの取得をスマートに行うためにこのようなWithPermissionCheckというpostfixがついています。

またsetupGoogleMap関数の前に@NeedPermission(Manifest.permission.ACCESS_FINE_LOCATION)というアノテーションがついていると思いますが、これはsetupGoogleMapがACCESS_FINE_LOCATIONのパーミッションを必要とする処理を含んでいるためこの関数を呼び出したときにユーザーにパーミションの許可ダイアログを出すためのアノテーションです。

@NeedsPermission(Manifest.permission.ACCESS_FINE_LOCATION)
fun setupGoogleMap(googleMap: GoogleMap) {

もう1つRuntime PermissionをPermissionDispatcherを使って実現するために以下の関数をオーバーライドしています。

@SuppressLint("NeedOnRequestPermissionsResult")
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
// NOTE: delegate the permission handling to generated function
onRequestPermissionsResult
(requestCode, grantResults)
}

詳しくはPermissionDispatcherのページを覗いてみてください。

通常の実装方法よりもシンプルにRuntime Permissionの実装ができるのでおすすめです。

現在地の描画

ここからは現在地にマーカーと精度を示す円を描画する方法を説明します。

位置情報の取得 (LocationService.kt)

MapViewに現在の位置情報を表示するために、前回作成したLocationServiceから位置情報を受け取る必要があります。

var locationList: ArrayList<Location>
var isLogging: Boolean = false

まず上のように2つのメンバー変数をLocationServiceで宣言し、initで初期化します。

init {
locationList = ArrayList()
isLogging = false

前回までのブログの実装では、位置情報を取得する関数onLocationChangedの中で緯度経度をログに出力しているだけでしたがこのonLocationChangedを変更します。

上の7行目〜のところで、まずisLoggingフラグを確認し、trueならばlocationListに位置情報を格納します。
これは、位置情報を”ログ”している時だけlocationListに自分の移動経路を記録していくという処理になります。

次にIntentを作り、その中に取得した位置情報を格納して、LocalBroadcastManagerを使って位置情報をブロードキャストします。

これをActivity側で受け取ることで、位置情報の更新のたびにActivityが位置情報を取得することができます。

Activity側で位置情報を受け取る

MainActivityで、locationUpdateReceiverというBroadcastReceiverクラスのメンバーをonCreate内で下のように初期化します。onReceiveメソッドをオーバライドして、ここで先ほどのブロードキャストを受け取る処理を書きます。

locationUpdateReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val newLocation = intent.getParcelableExtra<Location>("location")

drawLocationAccuracyCircle(newLocation)
drawUserPositionMarker(newLocation)

this@MainActivity.locationService?.let{
if
(it.isLogging) {
addPolyline()
}
}
}
}

これで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クラスのオブジェクトを生成し(5行目)。これを使ってGoogleMapオブジェクトのaddMarkerメソッドを呼び出してマーカーを地図上に表示させます。2回目以降この関数が呼ばれた場合はマーカーの位置をsetPositionメソッドを使って更新します。

移動経路の描画 — addPolyline()

次に移動経路を描画する関数を作ります。
まずLocationServiceオブジェクトからlocationListを取り出します。ここにLocationServiceがログした位置情報が全て格納されているので、この中の最新の位置情報と一つ前の位置情報を取り出し、その2点の間に直線を書いていくことで移動経路を描画します。

4行目のところでrunningPathPolylineの有無を確認しています。runningPathPolylineがまだnullであればまだ直線が一つも地図上に引かれていないということなので、新しくPolylineオブジェクトをマップ上に追加します(14行目以降)。Polylineとは直線の集合という意味で、このオブジェクトをマップに追加するとマップ上に線が引けます。
28行目のaddPolylineで新規にPolylineオブジェクトをMapに追加し、精製されたPolylineオブジェクトをrunningPathPolylineという変数に格納しておきます。

runningPathPolylineがすでに存在する場合(5行目以降)は最初に描画した際に生成したPolylineオブジェクト(runningPathPolyline)に新たに現在の位置を追加するということをしています(11行目)

自動ズーム

zoomMapTo(newLocation)

マップ上へのカスタムドローイングはこれで全て終わりですが、もう一つだけ実装しておきたい機能があります。ユーザーがアプリを持って移動を続けるとユーザーの場所がマップから消えてしまいます。

ユーザーをマップ上で自動追跡する機能をつけて常にユーザーがマップの中心にいるようにしましょう。

下のような関数を追加し、位置情報が更新されるたびに呼ぶようにするとマップが最新の位置(ユーザーの現在地)にアニメーション付きで移動&ズームしてくれます。

しかしこのコードを実行してみるとUX上考慮しなければならないことが起きます。

  • 自分でマップを触って位置やズームレベルを変えたい時があるのに位置情報が取得されるたびに自分の位置にズームしてしまう。

このUX上の改善を行うために以下の対策を入れます

  • 最初だけユーザーのいる位置にズームする。
  • マップが開いた時にいきなり現在地を表示していた方がかっこいいので上記の最初のズームはアニメーションさせない
  • 初回以降で、ユーザーがマップに触った後はしばらくの間は自動的に現在地に移動しないようにする。(ユーザーがマップのある地点(ゴール地点など)を見ているのではないかという想定をする)
  • 初回以降で、ユーザーがしばらくマップに触っていない時だけ自動的に現在地に移動する。この時ズームレベルは変えない。(ユーザーが最後にマップに触って設定したマップのズームレベルを尊重する)

これだけのことをzoomMapTo関数に盛り込むと以下のようになります。

まず4行目で、このアプリが起動後に一度でもユーザーの現在地にマップをズームさせたかのフラグ(didInitalZoom)をチェックします。まだ一度もチェックしていない場合はマップは世界全体を表示した状態になっているのでアニメーションなしで現在地に移動し、ズームレベル17.5でズームします。
初回以降は16行目以降の処理になります。16行目でzoomableというフラグを見ています。このフラグはユーザーがマップに触ってから10秒間はfalseになるようになっています。これがtrueの場合だけ自動ズームします。19行目で現在地にマップをアニメーション付きで移動させます。この時ズームレベルは変えません。最後にユーザーがマップを触った状態のズームレベルを尊重します。

アニメーション中はzoomableフラグをfalseにしています。

これでほぼ終わりですが、最後に「ユーザーがマップを触った後に10秒間zoomableフラグをfalseにする」という処理を付け加えなければなりません。先ほどActivityのOnCreateの中でGoogleMapオブジェクト取得後に呼び出したsetupGoogleMap関数に戻ります。

ここでGoogleMapオブジェクトのsetOnCameraMoveStartedListenerを呼んでリスナーを登録します。するとマップのフォーカスが変わるたびにonCameraMoveStartedというメソッドが呼ばれるようになります。これをラムダ表現のなかで受け取ります。ユーザがマップに触ってフォーカスを変えた時も、プログラムからmoveCameraanimateCamera関数を使ってマップを動かした場合もこのonCameraMoveStartedが呼ばれます。
この中で渡されるreasonというパラメータを見るとどうゆう理由でマップが動いたのかがわかります。
この値がGoogleMap.OnCameraMoveStartedListener.REASON_GESTUREの時はこのユーザーが触ったことによってマップが動いたということですので、この時だけ、タイマーを作成して10秒間だけzoomableフラグをfalseにします。

以上の実装によって例えばジョギンアプリを作った場合には、

”走っている自分の場所を常にマップの中心に表示させるようにマップを自動的に動かしてくれるが、ユーザーがもっとマップを拡大したり縮小したりして眺めたい時は、10秒間この自動ズームの機能が止まり、10秒後からはズームレベルはそのままに位置だけユーザを追跡する”

といったUXを実現できます。

このzoomMapTo関数をlocationUpdateReceiveronReceiveの最後に追加し、これで今回のすべての実装が終わりました。

locationUpdateReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val newLocation = intent.getParcelableExtra<Location>("location")

drawLocationAccuracyCircle(newLocation)
drawUserPositionMarker(newLocation)

this@MainActivity.locationService?.let{
if
(it.isLogging) {
addPolyline()
}
}

zoomMapTo(newLocation)
}
}

お疲れ様でした!
このチュートリアルのコードはのRepositoryにあります。

masterブランチの最新のコードはこのチュートリアルのコードをすべて含んだ完成形になっています。

vol2_mapというブランチにこの記事までの実装のみを含んだコードが置かれているので、

git checkout vol2_map

で、vol2_mapブランチに移動し、ここまで自分で実装してみてなにか動かないことがあれば自分のコードとこちらのコードとを比較してみてください。

次回は、高精度の位置情報を取得するフィルタの作り方について説明します。

(第3回) — RunKeeperやNike+並みのパフォーマンスを実現する高精度位置情報フィルターの作り方

このブログの内容に関する質問や位置情報トラッキング機能開発に関するご相談などはこちらにご連絡ください。
@mizutory
mizutori@goldrushcomputing.com

--

--

Taka Mizutori (JP)
位置情報を正確にトラッキングする技術

Goldrush Computing株式会社代表。iPhone/Androidアプリのプログラマー。守備範囲はSwift, Kotlin, Python, Js, Java, Obj-C, C#, C, Assembly。前職はSonyEricsson。3児のパパ。