本記事はFused Location ProviderでKotlin FlowとLiveDataを再入門してみたの続きです。
このCodeLabのサンプルBuilding a Kotlin extensions libraryはおそらく初心者でも読めるようにするため、あえてViewModelとかを使っていませんが、そんなのこっちの知ったこっちゃない、本気を出していい感じに改造しました。ただの自己満足ですのであしからず。
何をやったの?
やってることはすごく簡単です。
- Locationの値は
awaitLastLocation()
とlocationFlow()
で別々だったが、まとめて一本のflowにした - Flowを構築するコードをUIから排除するため、ViewModelを導入した
- UI側にLiveDataを
observe
するコードがあったが、それを省略するためDataBindingを導入した
1.改造前のコード
(一部ACCESS_FINE_LOCATION
権限を取得するためのコードとログを省略しています)
以下Building a Kotlin extensions libraryを完了させた状態のコードです。
class MainActivity : AppCompatActivity() {
private lateinit var fusedLocationClient: FusedLocationProviderClient
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
startUpdatingLocation() // (1)Flowから定期的に流れてきたLocationをTextViewに設定する
}
override fun onStart() {
super.onStart()
lifecycleScope.launch {
getLastKnownLocation() // (2)非同期でLatest locationを取得して、TextViewに設定する
}
}
private suspend fun getLastKnownLocation() {
try {
val lastLocation = fusedLocationClient.awaitLastLocation()
showLocation(R.id.textView, lastLocation)
} catch (e: Exception) {
findAndSetText(R.id.textView, "Unable to get location.")
}
}
private fun startUpdatingLocation() {
fusedLocationClient.locationFlow()
.conflate()
.catch { e ->
findAndSetText(R.id.textView, "Unable to get location.")
}
.asLiveData()
.observe(this, Observer { location ->
showLocation(R.id.textView, location)
})
}
}
まずこの2つの処理
- Flowから定期的に流れてきたLocationをTextViewに設定する
- 非同期でLatest locationを取得して、TextViewに設定する
は本質的にLocationをTextViewに設定するロジックなので、無駄をなくすためまとめます。
2.まとめて一本のflowにする
Location Flowの初期値にLatest locationを追加するだけで、全部一本のFlowにまとめられます。
FlowにはonStartの拡張関数があるので、初期値を与えるのに使えます。
class MainActivity : AppCompatActivity() {
private lateinit var fusedLocationClient: FusedLocationProviderClient
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
startUpdatingLocation() // Flowから定期的に流れてきたLocationをTextViewに設定する
}
private fun startUpdatingLocation() {
fusedLocationClient.locationFlow()
.onStart { offer(fusedLocationClient.awaitLastLocation()) } // ★onStartで初期値を設定する
.conflate()
.catch { e ->
findAndSetText(R.id.textView, "Unable to get location.")
}
.asLiveData()
.observe(this, Observer { location ->
showLocation(R.id.textView, location)
})
}
}
ただ厳密に言うと、処理の中身は前から少し変わります。
変更前 | 変更後 | |
---|---|---|
awaitLastLocation | onStartで発動する | onCreateで発動する |
locationFlow | onCreateで発動する | awaitLastLocationが終わったあとに発動する |
前は別々で並列で動かしていたので、awaitLastLocation
より先にlocationFlow
の値が来ることは原理上可能でしたが、変更後ではawaitLastLocation
より先にlocationFlow
が動くことはないです。
3.ViewModelを導入する
このLiveData
を構築する処理はUIと関係がないので、Activity内で書くべきではありません。
fusedLocationClient.locationFlow()
.onStart { offer(fusedLocationClient.awaitLastLocation()) }
.conflate()
.catch { e -> // ★例外処理が問題
findAndSetText(R.id.textView, "Unable to get location.")
}
.asLiveData()
ただ、ここで少し問題があります。中には例外が発生した場合、エラーメッセージ直接Viewに表示する処理がありました。
このViewへの依存をなくすために、一番かんたんな手段としてはLiveData
の値を失敗を表せる型にすることです。ここではResult
を使います(別に他の失敗を表せる型でもOK)。
class MainViewModel(context: Application) : AndroidViewModel(context) {
// 本来はこのインスタンスをDIで生成したほうが良いが...
private val fusedLocationClient = ... // 省略
val locationLiveData: LiveData<Result<Location>> =
fusedLocationClient.locationFlow()
.onStart { emit(fusedLocationClient.awaitLastLocation()) }
.conflate()
.map { Result.success(it) } // 成功した場合はResult.successで包む
.catch { emit(Result.failure(it)) } // 失敗した場合は例外をResult.failureで包む
.asLiveData()
}
さらにResultで包む処理を隠すために、Flow<T>.wrapByResult(): Flow<Result<T>>
の拡張関数を別途定義しました。(ソースはこちら)
val locationLiveData: LiveData<Result<Location>> =
fusedLocationClient.locationFlow()
.onStart { emit(fusedLocationClient.awaitLastLocation()) }
.conflate()
.wrapByResult() // 処理結果をResultで包む
.asLiveData()
UI側は単純にLiveData
をobserve
するだけでいいです。(ViewModelを使ってるので、画面回転によるflowがもう一回作り直される問題からも開放されました)
class MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// LiveDataの購読
viewModel.locationLiveData.observe(this) { result ->
if (result.isSuccess) {
showLocation(R.id.textView, location) // 成功した場合はLocationをViewに上書きする
} else {
findAndSetText(R.id.textView, "Unable to get location.") // 失敗した場合はエラーメッセージを表示
}
}
}
}
4.DataBindingを導入する
Data Bindingを使った場合、Viewで利用したいLiveData
をバインドすれば、明示的にLiveData#observe
で購読する必要がなくなります。
まずData Bindingを使うことをbuild.gradle
で設定します。
android {
...
dataBinding {
enabled = true
}
}
Result<Location>
型をそのままTextViewに表示できないので、このためString
を返すように少し変更します。
class MainViewModel(context: Application) : AndroidViewModel(context) {
private val fusedLocationClient = ...
private val locationFlow
get(): Flow<Result<Location>> =
fusedLocationClient.locationFlow()
.onStart { emit(fusedLocationClient.awaitLastLocation()) }
.conflate()
.wrapByResult()
val locationLiveData: LiveData<String> = // ★String型だったら特に変換を噛まさなくてもTextViewにセットできる
locationFlow
.map { result ->
result.fold( // Result<Location> -> Stringを変換するための処理
onSuccess = { it.asString(Location.FORMAT_MINUTES) },
onFailure = { "Unable to get location. $it" }
)
}
.asLiveData()
}
そしてView側は、Layoutで変数を受け取れるようにして、Activityから変数をバインドするようにします。
<layout>
<data>
<variable
name="location"
type="androidx.lifecycle.LiveData<String>" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
...
>
<TextView
android:text="@{location}"
...
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
class MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater).apply {
lifecycleOwner = this@MainActivity // Activityのライフサイクルを渡す
location = viewModel.locationLiveData // LiveDataを変数locationにバインドする
}
setContentView(binding.root)
}
}
こんな感じにViewと関連のないロジックを全部Activityから追い出せました!
アプリの見た目的な変化はないので、修正後のサンプル画像はとくに上げません。
完成
実際完成したコードはGitHubにあげています。
(実際のコードと本記事では一部違っている箇所があります。)
変更前との差分はこちらです。
他に間違い報告、より良い修正方法があればぜひ教えて下さいー