ACCESS Advent Calendar 2020 5日目の記事です。
と言いつつ、内容は過去に自分の書いた記事の続編です。
昔流行ったズンドコキヨシを通じて Coroutines Flow の良さを考えていきます。
やりたいこと
前回、Coroutines Flow を使用してジェネリックなズンドコキヨシを実装しました。
検証はコンソール環境で行いましたが、せっかく Kotlin で実装したので Android でも使用してみたいと思います。
Android 向け Coroutines にはFlow
をLiveData
に変換するasLiveData
拡張関数が用意されています。
これを使用しつつ、その利点を追求していきます。
再掲 : Flow 版ジェネリック・ズンドコキヨシ
fun <T> randomFlow(source: List<T>) = flow<T> {
while (true) {
emit(source.shuffled().first())
}
}
fun <T> terminateIfSatisfiedFlow(
scanned: List<T>,
pattern: List<T>,
terminal: T
): Flow<T?> =
if (scanned == pattern) {
flowOf(scanned.last(), terminal, null)
} else {
flowOf(scanned.last())
}
@FlowPreview
@ExperimentalCoroutinesApi
fun <T> genericZDK(
source: List<T>,
pattern: List<T>,
terminal: T
): Flow<T> =
randomFlow(source)
.scan(emptyList<T>()) { list, e -> (list + e).takeLast(pattern.size) }
.dropWhile { it.isEmpty() }
.flatMapConcat { scanned -> terminateIfSatisfiedFlow(scanned, pattern, terminal) }
.takeWhile { it != null }
.filterNotNull()
Android でもズンドコしたい
バージョン情報
こちら
- Android Studio 4.2 Canary 15
- org.jetbrains.kotlin:kotlin-stdlib:1.4.10
- org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.0
- androidx.core:core-ktx:1.3.2
- androidx.fragment:fragment-ktx:1.2.5
- androidx.lifecycle:lifecycle-livedata-ktx:2.2.0
- androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0
asLiveData
について
Flow<T>
から収集(collect
)された値を保持するLiveData<T>
を作成します。
定義を確認しましょう。
fun <T> Flow<T>.asLiveData(
context: CoroutineContext = EmptyCoroutineContext,
timeoutInMs: Long = DEFAULT_TIMEOUT
): LiveData<T>
asLiveData
で生成されたLiveData
は Cold の性質を持ちます。
つまり、誰かがobserve
してLiveData
がアクティブな状態になった時に上流Flow
が起動します。
Flow
が完了する前にLiveData
が非アクティブになると、timeoutInMs
で指定した時間経過後にFlow
をキャンセルします(デフォルトは5秒)。
キャンセル後にLiveData
が再びアクティブになると、Flow
が再起動します。
なぜこんなことを説明しているのかというと、この性質がズンドコキヨシをAndroidで利用する上で重要だからです。
ズンドコキヨシはキ・ヨ・シ!
が出るまで無限ループでズン
ドコ
を放出し続けますが、キ・ヨ・シ!
が出る前にユーザが飽きてしまい、アプリを閉じたりスリープした場合にどうなるかを考える必要があります。
Flow.asLiveData
ならこのような問題にも容易に対処することができます。
ではまず、asLiveData
を使用してViewModel
を実装します。
ViewModel
genericZDK
でズンドコフローを取得したら、onEach
内でdelay
してテキストの更新間隔を調整します。
上流Flow
はメインスレッドで動作させたくないので、flowOn
でデフォルトディスパッチャを指定します。
ViewModel
でasLiveData
を使用する場合はviewModelScope.coroutineContext
を指定するのが良いでしょう。
間違ってもGlobalScope
を指定してはいけません。
ズンドコが止まらなくなる恐れがあります。
@ExperimentalCoroutinesApi
@FlowPreview
class ZDKViewModel : ViewModel() {
companion object {
private const val ZUN = "ズン"
private const val DOKO = "ドコ"
private const val KI_YO_SHI = "キ・ヨ・シ!"
private val SOURCE = listOf(ZUN, DOKO)
private val PATTERN = listOf(ZUN, ZUN, ZUN, ZUN, DOKO)
private const val INTERVAL_MS = 600L
}
val zdk = genericZDK(SOURCE, PATTERN, KI_YO_SHI)
.onEach { delay(INTERVAL_MS) }
.flowOn(Dispatchers.Default)
.asLiveData(viewModelScope.coroutineContext)
}
View (Fragment)
Data Binding を使っても良いですが、大した規模ではないので今回は採用しません。
やることは単純で、ViewModel
で定義したzdk
プロパティをTextView
にバインドするだけです。
@FlowPreview
@ExperimentalCoroutinesApi
class MainFragment : Fragment() {
private val viewModel by viewModels<ZDKViewModel>()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View {
return inflater.inflate(R.layout.main_fragment, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel.zdk.observe(viewLifecycleOwner) {
message.text = it
}
}
}
レイアウト(ほぼテンプレ通り)
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.main.MainFragment">
<TextView
android:id="@+id/message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="60dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="MainFragment" />
</androidx.constraintlayout.widget.ConstraintLayout>
問題点
見てわかる通り、このままではズン
(あるいはドコ
)が連続で流れてきた時にテキストが更新されたことを目視で確認できないため、テンポ感を掴みにくいです。
いくつかの解決策を模索していきましょう。
主題に沿うため、すべてFlow
のオペレータを使って解決します。
解決策1 : 点滅させる
flatMapConcat
を使ってテキストを点滅させてみましょう。
flatMapConcat
は流れてきた値を別のFlow
に変換し、各Flow
を順番通りに連結します(Rx のconcatMap
に相当します)。
val zdk = genericZDK(SOURCE, PATTERN, KI_YO_SHI)
.flatMapConcat {
flow {
emit(it)
delay(INTERVAL_MS * 2 / 3)
if (it != KI_YO_SHI) {
emit("")
delay(INTERVAL_MS / 3)
}
}
}
.flowOn(Dispatchers.Default)
.asLiveData(viewModelScope.coroutineContext)
まず流れてきた値をそのままemit
し、一定時間置いた後に空文字列をemit
することで点滅を表現します。
ただし最後のキ・ヨ・シ!
は消したくないのでその条件を付与します。
解決策2 : 左右に振る
いまいち躍動感に欠けるので別の方法を模索します。
テキストを左右に振ってみましょう。
といってもTextView
の座標をいじるのは面倒なので、前後にスペースを挿入することにより擬似的に揺れを表現します。
val zdk = genericZDK(SOURCE, PATTERN, KI_YO_SHI)
.withIndex()
.map {
when {
it.value == KI_YO_SHI -> it.value
it.index % 2 == 0 -> "${it.value} "
else -> " ${it.value}"
}
}
.onEach { delay(INTERVAL_MS) }
.flowOn(Dispatchers.Default)
.asLiveData(viewModelScope.coroutineContext)
withIndex
はFlow<T>
を、インデックス情報を付与したFlow<IndexedValue<T>>
に変換します。
map
を使用して、偶数回なら後ろにスペースを、奇数回なら前にスペースを挿入します。
ただし、最後のキ・ヨ・シ!
は常にセンターに表示するものとします。
解決策3 : ランダムに文字サイズ(あるいは文字色)を変える
ランダムに文字サイズや文字色を変えることが出来ればより華やかになるでしょう(?)。
これを実現するために、前回のズンドコキヨシで実装したrandomFlow
関数を再利用します。
以下は文字サイズを変更する例です。
// random text size factor (0.5f ~ 1.0f)
private val randomSizeFlow = randomFlow((50..100 step 10).toList())
.distinctUntilChanged()
.map { it / 100f }
private val zdk = genericZDK(SOURCE, PATTERN, KI_YO_SHI)
.zip(randomSizeFlow) { text, size ->
text to if (text == KI_YO_SHI) 1.1f else size
}
.onEach { delay(INTERVAL_MS) }
.flowOn(Dispatchers.Default)
.asLiveData(viewModelScope.coroutineContext) // LiveData<Pair<String, Float>>
val zdkText = zdk.map { it.first }
val zdkSizeFactor = zdk.map { it.second }
サイズ(の倍率)候補からランダムに1つを選び続けるFlow
をrandomFlow
で作成します。
ただし、前回とは必ず異なるサイズを選択したいので、distinctUntilChanged
をつけます。
ズンドコフローとサイズフローの2本をzip
関数で1本のFlow
にまとめます。川が合流するイメージです。
ここでテキストとサイズ倍率のPair
をつくります。
ただし、最後のキ・ヨ・シ!
は固定の特大サイズ(1.1倍)で表示するものとします。
最後にLiveData
のmap
でテキストとサイズ倍率に分解します(これは必須ではありません)。
こうすることでテキストと文字サイズの更新タイミングを揃えることができます。
Fragment
を以下のように書き換えたら完成です。
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel.zdkText.observe(viewLifecycleOwner) {
message.text = it
}
val textSize = message.textSize
viewModel.zdkSizeFactor.observe(viewLifecycleOwner) {
message.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize * it)
}
}
ズンドコキヨシはバッテリーに優しいか
ズンドコキヨシは無限ループを使用しています。
冒頭で述べたとおり、もしアプリ終了やスリープしても無限ループが止まらなかったら困りものです。
バッテリーに優しくないズンドコキヨシという烙印を押されてしまいます。
randomFlow
関数に以下のようにログを仕込み、この点について検証してみましょう。
fun <T> randomFlow(source: List<T>) = flow<T> {
try {
while (true) {
emit(source.shuffled().first().also { Log.i("ZUNDOKO", "emit $it") })
}
} catch (e: Throwable) {
Log.i("ZUNDOKO", "Thrown $e")
throw e
}
}
「解決策3」の実装で動作を確認します。
アプリ起動したら、キ・ヨ・シ!
が出現する前に端末をスリープ状態にします。
2020-11-21 22:34:29.657 22142-22176/com.example.zdk I/ZUNDOKO: emit ドコ
2020-11-21 22:34:29.659 22142-22176/com.example.zdk I/ZUNDOKO: emit 70
2020-11-21 22:34:30.301 22142-22176/com.example.zdk I/ZUNDOKO: emit ズン
2020-11-21 22:34:30.303 22142-22176/com.example.zdk I/ZUNDOKO: emit 70
2020-11-21 22:34:30.303 22142-22176/com.example.zdk I/ZUNDOKO: emit 90
2020-11-21 22:34:30.945 22142-22176/com.example.zdk I/ZUNDOKO: emit ズン
2020-11-21 22:34:30.947 22142-22502/com.example.zdk I/ZUNDOKO: emit 60
2020-11-21 22:34:31.561 22142-22176/com.example.zdk I/ZUNDOKO: emit ドコ
2020-11-21 22:34:31.565 22142-22502/com.example.zdk I/ZUNDOKO: emit 50
2020-11-21 22:34:32.171 22142-22502/com.example.zdk I/ZUNDOKO: emit ズン
2020-11-21 22:34:32.173 22142-22502/com.example.zdk I/ZUNDOKO: emit 80
2020-11-21 22:34:32.782 22142-22502/com.example.zdk I/ZUNDOKO: emit ズン
2020-11-21 22:34:32.785 22142-22176/com.example.zdk I/ZUNDOKO: emit 90
2020-11-21 22:34:33.411 22142-22502/com.example.zdk I/ZUNDOKO: emit ズン
2020-11-21 22:34:33.413 22142-22502/com.example.zdk I/ZUNDOKO: emit 60
2020-11-21 22:34:33.965 22142-22502/com.example.zdk I/ZUNDOKO: Thrown kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@53327ff
2020-11-21 22:34:33.965 22142-22176/com.example.zdk I/ZUNDOKO: Thrown kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@53327ff
「解決策3」では2本のランダムフローが動作していますが、そのどちらもキャンセルされていることがわかります。
これはLiveData
が非アクティブになることで未完了の上流Flow
がキャンセルされるためです。
なお、アプリ(Activity)を閉じた場合はviewModelScope
が閉じられることによりJobCancellationException
がスローされ、やはりキャンセルされます。
これにより、バッテリーに優しいズンドコキヨシであることが示されました。
Flow
とLiveData
の組み合わせでは、このようにライフサイクルのケアが手厚く行われる点が大きな利点といえます。
また、map
とdistinctUntilChanged
程度しかオペレータの無かったLiveData
と違い、Flow
には豊富なオペレータが用意されているので、両者を組み合わせることで実装の幅が大きく広がると思います。