Fused Location ProviderでKotlin FlowとLiveDataを再入門してみた


前書き

CodeLabのBuilding a Kotlin extensions libraryをやってみました。

名前からしてライブラリの作り方のベストプラクティスじゃないかと思って、勉強しようと思ったら拡張関数の内容でした。
でも拡張関数よりも、中で使ってるFlowのほうがすごかったです。

勉強したいものと違いましたが、改めてFlowの凄さを再認識できたので記事にして記録を残そうと思いました。

このCodeLabの内容は?

このCodeLabはリアルタイムで地理情報を表示するアプリを作っています。

数秒ごとにLocationを更新しています。
やろうとしていることは結構かんたんですが、結構クセがあるAPIと、Androidのライフサイクルの特有の問題があるので、気をつけて書かないと結構かんたんに罠にハマります。(ライフサイクルの難しさはこの記事であまり解説しないつもり)

一応このCodeLabのタイトルは「Building a Kotlin extensions library」なので、拡張関数のすごさを説明するものでしたが、中で使ってるFlowがすごすぎて、拡張関数なんて目じゃないレベルでした。

1. Flowを使わないJavaのAPI

地理情報を表示するのにLocationServicesを使います。このAPIはJavaで書かれているので、Callbackを前提とした使い方になっています。
使い方として

  • リアルタイムのLocation情報を取得する1
  • 前回取得した最後のLocationを取得する2

があります。

「リアルタイムのLocation情報を取得する」でも十分ですが、最初の更新まで時間がかかるのと、回線不安定などでデータを上手く取得できない場合もあるので、だいたい「前回取得した最後のLocationを取得する」と組み合わせて使うイメージです。

前回取得した最後のLocationを取得する

val fusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(this) // Activity or Context

fusedLocationProviderClient.lastLocation.addOnSuccessListener {
       // 成功時の処理
       ...
   }.addOnFailureListener {
       // 失敗時の処理
       // 位置情報の権限がない場合はここに来る
       ...
   }

なぜかlastLocationの戻り値はTask<Location?>なので、結果をaddOnSuccessListeneraddOnFailureListenerで非同期で取得しなければいけない。

リアルタイムのLocation情報を取得する

val callback = object: LocationCallback() {
    override fun onLocationResult(result: LocationResult?) {
        // コールバックで画面等を更新する
    }
}

// Locationを取得する頻度を設定する
val locationRequest = LocationRequest().apply {
    interval = 3000
    fastestInterval = 2000
    priority = LocationRequest.PRIORITY_HIGH_ACCURACY
}

val fusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(this) // Activity or Context

// 上記の設定をセットアップして、更新を開始する
fusedLocationProviderClient.requestLocationUpdates(
    locationRequest,
    callback,
    Looper.getMainLooper()
).addOnFailureListener {
    // 更新が失敗したときにここに来る
}

// 使用完了後(例えばActivity#onDestroy, Activity#onStop)は不要な更新を止める
fusedLocationProviderClient.removeLocationUpdates(callback)

Callback内で更新したLocationを使うので、RxもしくはFlowなどのリアクティブプログラミングの力を借りなければ、setTextとかのコードをCallbackの中に書いてしまいそうです。そうなるとメンテナンスしづらいコードになります。

また、Activity#onDestroyもしくはActivity#onStopでCallbackを削除しなくてもコケはしないですが(一応Callback内でやってることによります)、常時Locationを使う必要がなければ無駄に電池を使うことになるので、止めてあげたほうがいいです。

2. Coroutine / FlowでJavaの非同期APIをラップする

前回取得した最後のLocationを取得する

lastLocation: Task<Location?>(※1)の非同期は一回限りの処理なので、Flowじゃなくて、Coroutineで対応します。

ここではFusedLocationProviderClientの拡張関数にして、fusedLocationProviderClient.lastLocationを呼ぶ代わりにfusedLocationProviderClient.awaitLastLocation()のsuspend functionで非同期の値を取得できます。

suspend fun FusedLocationProviderClient.awaitLastLocation(): Location? =
   suspendCancellableCoroutine<Location> { continuation ->
       lastLocation.addOnSuccessListener { location ->
           continuation.resume(location)
       }.addOnFailureListener { e ->
           continuation.resumeWithException(e)
       }
   }

使うときは例えばこんな感じで使います。

lifecycleScope.launch {
    try {
        val location = fusedLocationProviderClient.awaitLastLocation()
        ...
    } catch (e: Exception) {
        Log.d(TAG, "Unable to get location", e)
   }
}

※1: ここのCodeLabのコードが間違っているようです。CodeLabはNull非許容型で書いていましたが、Successの場合でもnullが帰ってくることがあるそうです2

リアルタイムのLocation情報を取得する

リアルタイムで定期的にデータが流れてくるので、ここはFlowの出番です。使用するBuilderはcallbackFlow<T>です。

fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
    val callback = object: LocationCallback() {
        override fun onLocationResult(result: LocationResult?) {
            result ?: return
            for (location in result.locations) {
                offer(location) // emit location into the Flow using ProducerScope.offer
            }
        }
    }

    requestLocationUpdates(
        createLocationRequest(),
        callback,
        Looper.getMainLooper()
    ).addOnFailureListener { e ->
        close(e) // in case of error, close the Flow
    }

    awaitClose {
        removeLocationUpdates(callback) // clean up when Flow collection ends
    }
}

ここでのポイントは、
1. Flowが閉じられたらawaitCloseデータの更新を止める
2. 例外が投げられたらFlowを止める
3. (そしてFlowの性質上、購読する人がいなくなれば自動的に閉じられるので、リソースリークにならない)

使う側は例えばActivity内で購読する場合はこうなります。

override fun onCreate(savedInstanceState: Bundle?) {
    // よくないコード。後述
    lifecycleScope.launch {
        fusedLocationClient.locationFlow()
            .catch { e ->
                // 例外処理
            }
            .collect { location ->
                // 成功時のUIの更新など
            }
    }
}

これでUIを更新する処理とLocationを非同期で取得する処理に分けられたので、スッキリ書けました。
ただし、上記のコードには実は一点問題が残っています。lifecycleScope.launchActivity#onDestroyが呼ばれるまで死なないので、Activityの画面が見えなくても(例えばバックグラウンドに移したなど)動き続けて、無駄にflowから値を取得し続けます。クラッシュはしないですが、無駄に電力を消耗しています

launchの代わりに[Lifecycle.State.STARTED]3の時しか動かないlaunchWhenStartedを使えばこの問題を回避できます4 間違い。確かにlaunchWhenStarted[Lifecycle.State.STARTED]時にしか発動しないですが、Activityが死ななかったらlifecycleScope自体が死ぬわけじゃないので、Flowはキャンセルされませんし、awaitClose呼ばれません

LiveDataを購読する

問題を回避するためにFlow<T>.asLiveData()を使ってFlowをLiveDataに変換して使えば、
Flowの生存期間・ライフサイクルの管理をLiveDataが自動でやってくれます。

  • [Lifecycle.State.STARTED]の状態じゃなくなればupstream flowを閉じて5くれる
  • [Lifecycle.State.STARTED]の状態に戻ればもう一度upstream flowを実行してくれる

このため、View側で使うFlowでしたら、全部Flow<T>.asLiveData()でLiveDataに変換してから使ったほうが良いでしょう。

override fun onCreate(savedInstanceState: Bundle?) {
    fusedLocationClient.locationFlow()
        .conflate() // 購読する側の処理が遅かったら値を破棄する。意味がわからなければ読み飛ばして大丈夫
        .catch { e ->
            // 例外処理
        }
        .asLiveData() // ★ Flow<T>をLiveData<T>に変換する
        .observe(this) { location ->
            // 成功時のUIの更新など
        }
}

完成したコード

CodeLabのサンプルなので、すでにGitHubにあります。
このCodeLabすごく良いので、ぜひやってみてください。

一応このサンプルではViewModelを使ってないし、awaitLastLocation()locationFlow()を別々で取得してくるので、シンプルに書けてない問題が残っています。 もう少し改造してきれいに書けるサンプルを別途上げる予定です。 続きの記事を書きました。よかったら見てください。