GoogleMapでmap-utilを使用したマーカーのInfoWindowをカスタムする方法

# はじめに
developerサイトの`Info Window`ガイドページによればGoogleMap上に設置したMarkerをタップした時に以下イメージのような表示ができるとあります。
![01_GoogleDevelopSample.png](https://qiita-image-store.s3.amazonaws.com/0/38780/fc840b64-3fdf-330f-da69-aa330f0feede.png)
引用元: Maps SDK for Android -> GUIDES -> Info Windowsより 
 (https://developers.google.com/maps/documentation/android-sdk/infowindows)

このInfoWindowのレイアウトをカスタマイズする方法が分からず色々調査しました。
Markerは設置数が多くなると地図表示が重くなったりメモリを食ってしまいます。それを回避するためmap-utilライブラリを使おうと思ったらさらに手間がかかったため今回調べたことをまとめて記事に残すことにしました。
本記事の目的はmap-utilライブラリを使用して上記画像イメージのようなInfoWindowを表示することです。
（なお、GoogleMapを使用するため`Google Maps Android API`が必要です。）

## 検証環境
環境やライブラリは以下の通りです。ライブラリはdatabindingやConstraintLayoutも使ってますがそういうのは除外して今回の主旨に関係するものを記載しています。

  - 環境
    - API Level: 19〜27
  - ライブラリ
    - play-services-maps: 16.0.0
    - android-maps-utils: 0.5（Markerを自動でまとめるClusterが使えるライブラリ）
    - Glide: 4.6.1 (Picassoでも同じことができると思います)
    - OkHttp: 3.11.0

## コードや参考サイトなど
GitHubにコードを載せておきますのでどうすればいいかだけ手っ取り早く知りたい方はこちらでコード読んでください。
https://github.com/hotdrop/mapmarkertest

また、[Maps SDK for AndroidのInfo Windowsガイド](https://developers.google.com/maps/documentation/android-sdk/infowindows)のCode samplesに挙げられている`ApiDemos repository`もコードの参考になると思います。
ここからは補足やハマった点などを記載します。

## 1. Markerをまとめるには
android-maps-utilsライブラリのClusterManagerを使用することで以下のように一定のズームアウトでMarkerを自動でまとめてくれます。
<img width="314" alt="02_map-utilSample.png" src="https://qiita-image-store.s3.amazonaws.com/0/38780/31ea97f3-9659-41b5-3caa-82dcb57458ec.png">

Maps SDK for Androidの[Google Maps Android Marker Clustering Utility](https://developers.google.com/maps/documentation/android-sdk/utility/marker-clustering)もご参照ください。
簡単にClusterManagerが何をやっているのか調べました。

### 1-1. 通常のMarkerとClusterManagerのClusterItemを使ったMarkerの扱いの違い
通常、地図上にMarkerを付ける場合は`com.google.android.gms.maps.model`のMarkerクラスを使用しGoogleMapオブジェクトに対して以下のようなコードを書くと思います。

```kotlin
googleMap.addMarker(
  MarkerOptions().position(LatLng(35.681167, 139.767052))
                 .title("東京駅"))
```

これに対し、ClusterManagerを使って地図上にMarkerを付ける場合は`com.google.maps.android.clustering`のClusterItemクラスを使用します。緯度経度情報やMarkerをタップした時のタイトルもClusterItemが持ちます。ClusterItemはインタフェースなので自身で実装を書きます。コード例です。

```kotlin
clusterManager.addItem(
  MyClusterItem(location = LatLng(35.681167, 139.767052), title = "東京駅")
)

data class MyClusterItem constructor(
        private val location: LatLng,
        private val title: String,
        private val snippet: String? = null
        // 他に地図タップ時にInfoWindow上に表示したい情報もModelクラスなどでここに持てる
): ClusterItem {
    override fun getPosition(): LatLng = location
    override fun getTitle(): String = title
    override fun getSnippet(): String? = snippet
}
```

MarkerはMarkerクラスを単位として扱います。例えば`setOnMarkerClickListener`で渡ってくる引数の型はMarkerクラスになるのでMarkerしか扱わない場合はとても簡単です。これがClusterManagerを使った場合にちょっと厄介な話になります。（何が厄介なのかは後述します。）
ClusterManagerが何やってるのかいまいち不明だったので簡単に中身を覗いてみました。詳しくコードを追っていないのでより詳しく知りたい方はぜひコードを読んでみてください。

1. Algorithmクラス  
  ClusterManagerクラスのaddItemメソッドやremoveItemメソッドなどを読むとAlgorithm型のオブジェクトにClusterItemの持ち方や処理を委ねていることがわかります。  
このAlgorithm型の実装クラスがズームインやズームアウトで地図のMarkerをどうまとめるのかなど具体的な処理を担当しているようです。  
  デフォルトで使用されているのは`NonHierarchicalDistanceBasedAlgorithm`というクラスでヘッダーコメントにMarkerをどうクラスタするのか簡単に書いてありました。どうもMarker間の距離計算をしてクラスタを生成したり分解しているようです。  
  さらにClusterManagerクラスではこのAlgorithmクラスを`PreCachingAlgorithmDecorator`クラスでラップしていました。こいつは単にズームレベルに応じてよしなにフェッチして必要に応じてキャッシュするもののようです。  
  ClusterManagerクラスには`setAlgorithm`メソッドがあるのでクラスタの計算を自作したい場合も対応可能です。  
2. ClusterRendererクラス  
  名前の通りGoogleMapオブジェクトとの中継をするクラスで、クラスタやMarkerをタップした時のListenerやカメラ操作、地図上へのMarker設置などViewとのやり取りをこのクラスが担当しているようです。中身はほとんど読んでいません。。
3. clusterメソッド  
  このメソッドを実行すると地図上にMarkerが配置されます。実行する際はAsyncTaskを継承したClusterTaskの中で`doInBackground`でAlgorithmクラスに自身が持つClusterItem群の処理を依頼し`onPostExecute`でClusterRendererクラスに描画依頼をしています。
4. 補足  
  通常のMarkerを使い慣れた方だとaddMarkerを実装するだけで地図上に表示されるので、ClusterManagerを使う場合はaddItemに加えclusterメソッドの呼び出しが必要な点に注意です。  
  あと、通常のMarkerはMapにセットすると同時にMarkerの参照を自分で保持してonDestoryなどで参照を明示的に破棄しないとリークの原因になっていたと思います。  
でもClusterManagerを使うのであれば、Markerに相当するClusterItemの参照はAlgorithmが持っているのでClusterManagerのclearメソッドを呼んであげれば良いことになると思います。

## 2. 地図上のMarkerをタップした時の表示ウィンドウをカスタマイズするには
AndroidおなじみのAdapterクラスを使用します。`GoogleMap.InfoWindowAdapter`を継承したAdapterクラスを作成することになりますが、セット方法は通常の場合とmap-utilを使った場合とで若干異なります。

 - 通常のMarkerを使用している場合: GoogleMapクラスの`setInfoWindowAdapter`で設定します。
 - ClusterManagerを使用している場合: ClusterManagerクラスの`markerCollection.setOnInfoWindowAdapter`で設定します。

### 2-1. InfoWindowAdapterについて
InfoWindowAdapterは`getInfoContents`メソッドと`getInfoWindow`メソッドをもつインタフェースクラスです。
レイアウトとなるXMLを用意し、用途に応じてどちらかのoverrideメソッドを実装します。

#### getInfoContentsメソッド
InfoWindowの中身をカスタマイズしたい場合はこっちを実装し、getInfoWindowはnullを返すようにします。
カスタマイズしたイメージです。
<img width="392" alt="03_getInfoContents.png" src="https://qiita-image-store.s3.amazonaws.com/0/38780/116663d7-2fda-6ab2-fdff-d53d9f7eebe0.png">

コーナーを丸くするようなレイアウトXMLを指定しても内側のレイアウトまでしか適用してくれません。
右は`android:background="@drawable/shape_border"`を指定したレイアウトリソースをgetInfoContentsで実装した場合のイメージです。
左は背景色を変えた場合です。このようにgetInfoContentsメソッドでは一番外側の枠には手を出せません。
<img width="298" alt="04_getInfoContents_more.png" src="https://qiita-image-store.s3.amazonaws.com/0/38780/ed4b35dd-dc77-b68c-4e70-a2f82de02141.png">

#### getInfoWindowメソッド
InfoWindow丸ごとカスタマイズしたい場合はこっちを実装し、getInfoContentsはnullを返します。
今時のようにコーナーを丸くしたい場合、getInfoWindowメソッドを使います。
getInfoContentsで説明したものと全く同じレイアウトXMLを指定した場合のイメージです。
<img width="252" alt="05_getInfoWindow_image.png" src="https://qiita-image-store.s3.amazonaws.com/0/38780/2d556dc5-6098-034d-a3c2-c6f16ede7309.png">

#### コーナーを丸くしてMarkerを指す矢印も欲しい
getInfoWindowメソッドではlayoutのXMLに完全準拠するのでMarkerを指す矢印の部分も欲しい場合、自分で頑張る必要があります。
この矢印をXMLだけで定義するのはかなり難しいと思います。
（ひょっとしたら`Material Theming`を使えばレイアウト定義のみで簡単にできるかもしれません。）
[Maps SDK for AndroidのInfo Windowsガイド](https://developers.google.com/maps/documentation/android-sdk/infowindows)の[ApiDemos repository](https://github.com/googlemaps/android-samples/tree/master/ApiDemos)にて、9-patch画像を使って実現していました。
以下、イメージです。
<img width="392" alt="06_getInfoWIndow_9patch.png" src="https://qiita-image-store.s3.amazonaws.com/0/38780/e87462e2-2b6a-e3c5-0dbf-c5ed00f3403c.png">

## 3. 詰まった点
ここまでの説明でClusterManagerとInfoWindowAdapterを組み合わせて実装しようとすると詰まります。
以下、実際に困った点です。

### 3-1. InfoWindowAdapterの中でタップしたMarkerのClusterItemをどうやって取得するか？
InfoWindowAdapterのoverrideメソッドは引数がMarker型のため地図をタップするとMarkerが渡ってきます。
しかし、ClusterManagerではClusterItemをメインで扱っているのでMarkerが渡ってきてもInfoWindowに表示したい情報が取得できません。
解消方法はいくつかあると思いますが、私は仕方ないのでMarkerタップ時にそのClusterItemをadapterに渡すようにしました。これがベストかは定かではありません。。

```kotlin
clusterManager.setOnClusterItemClickListener { myClusterItem ->
  // clusterManagerのListenerはClusterItem型（実際にはClusterItemを継承した自作ClusterItem型）が渡ってくるのでこれを保持
  adapter.selectedItem = myClusterItem
  false
}

// Adapterクラス
class CustomInfoWindowAdapter {
  //〜〜省略〜〜
  var selectedItem: MyClusterItem? = null

  override fun getInfoWindow(marker: Marker?): View? {
    // この中ではmarkerではなくselectedItemを使う。
  }
}
```

### 3-2. Glideで画像を非同期に取得しようとしたら出来なかった
InfoWindowに表示する画像は、大体がクラウドストレージ上にあってURLを参照すると思います。
この問題はアプリ内のresディレクトリから静的に参照する場合発生しませんがそんなサンプルで記事書いても意味無さすぎるのでOkHttpClientを使用してGlideを使用しFirebaseStorageに適当な画像を置いて表示しようとしました。
そしたら最初の1回目タップ時は必ずNoImageになり、2回目タップ時は画像が表示される現象になりました。
[Maps SDK for AndroidのInfo Windowsガイド](https://developers.google.com/maps/documentation/android-sdk/infowindows)の`Custom info windows`の章にあるNoteにこれに関する重要なことが書いてあります。
以下、最初の数行を引用します。

>The info window that is drawn is not a live view. The view is rendered as an image (using View.draw(Canvas)) at the time it is returned. This means that any subsequent changes to the view will not be reflected by the info window on the map. To update the info window later (for example, after an image has loaded), call showInfoWindow()

つまり、地図上に表示されたInfoWindowは1度レンダリング処理が走ってしまうと以降は更新されなくなります。Glideで非同期に取得した画像はViewに反映されないというわけです。2回目タップではGlideがキャッシュとして画像を持っているので表示されるのだと思います。
これを回避するには表示に必要な情報が全て取得出来た後にshowInfoWindow()を呼ぶ必要があるとのことです。
これは結構みんな困る話じゃないのかなと思って調べたら予想通り[Glideリポジトリのissue290](https://github.com/bumptech/glide/issues/290)で解決策が示されていました。
ちょっと強引ですが、InfoWindowAdapterのところで書いたViewのreturnにnullを指定するとレンダリングされないことを使ってGlide経由で画像が取得できるまでレンダリングしないようにします。
そしてGlideでリソースの取得イベントをハンドリングし、画像が取得できたらshowInfoWindow()を呼ぶようにします。
ちょっと嫌なのが、取得した画像をMapでメモリに保持しておくところで読み込む画像が多くなると危ない気がしました。Glideのキャッシュとかうまく使えないかな。。

## 4. 終わりに
InfoWindowに表示する画像の扱いがもうちょっとなんとかならないかなと思って調査したのですが断念しました。時間があれば継続調査したいところです。
また、検証していませんがPicassoだともうちょっとうまい方法があるっぽいことをStack overflowでチラ見しました。