4
2

More than 3 years have passed since last update.

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

Posted at

本記事は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を完了させた状態のコードです。

MainActivity.kt
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つの処理

  1. Flowから定期的に流れてきたLocationをTextViewに設定する
  2. 非同期でLatest locationを取得して、TextViewに設定する

は本質的にLocationをTextViewに設定するロジックなので、無駄をなくすためまとめます。

2.まとめて一本のflowにする

Location Flowの初期値にLatest locationを追加するだけで、全部一本のFlowにまとめられます。
FlowにはonStartの拡張関数があるので、初期値を与えるのに使えます。

MainActivity.kt
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)。

MainViewModel.kt
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側は単純にLiveDataobserveするだけでいいです。(ViewModelを使ってるので、画面回転によるflowがもう一回作り直される問題からも開放されました)

MainActivity.kt
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で設定します。

build.gradle
android {
    ...
    dataBinding {
        enabled = true
    }
}

Result<Location>型をそのままTextViewに表示できないので、このためStringを返すように少し変更します。

MainViewModel.kt
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から変数をバインドするようにします。

activity_main.xml
<layout>
    <data>
        <variable
            name="location"
            type="androidx.lifecycle.LiveData&lt;String>" />
    </data>
    <androidx.constraintlayout.widget.ConstraintLayout
        ...
    >
        <TextView
            android:text="@{location}"
            ...
        />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
MainActivity.kt
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にあげています。
(実際のコードと本記事では一部違っている箇所があります。)

変更前との差分はこちらです。

他に間違い報告、より良い修正方法があればぜひ教えて下さいー

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2