Flow 版ズンドコキヨシを Android で使用する

[ACCESS Advent Calendar 2020](https://qiita.com/advent-calendar/2020/access) 5日目の記事です。
と言いつつ、内容は[過去に自分の書いた記事](https://qiita.com/aMasatoYui/items/60057f51d6e444231f96)の続編です。
昔流行った[ズンドコキヨシ](https://qiita.com/shunsugai@github/items/971a15461de29563bf90)を通じて Coroutines Flow の良さを考えていきます。

# やりたいこと

[前回](https://qiita.com/aMasatoYui/items/60057f51d6e444231f96)、Coroutines Flow を使用してジェネリックなズンドコキヨシを実装しました。
検証はコンソール環境で行いましたが、せっかく Kotlin で実装したので Android でも使用してみたいと思います。
Android 向け Coroutines には`Flow`を`LiveData`に変換する`asLiveData`拡張関数が用意されています。
これを使用しつつ、その利点を追求していきます。

## 再掲 : Flow 版ジェネリック・ズンドコキヨシ

```kotlin:GenericZDK.kt
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 でもズンドコしたい

## バージョン情報

<details><summary>こちら</summary><div>

- 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

</div></details>

## `asLiveData`について

`Flow<T>`から収集(`collect`)された値を保持する`LiveData<T>`を作成します。
定義を確認しましょう。

```kotlin
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`を指定してはいけません。
ズンドコが止まらなくなる恐れがあります。

```kotlin:ZDKViewModel.kt
@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`にバインドするだけです。

```kotlin:MainFragment.kt
@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
        }
    }
}
```

<details><summary>レイアウト(ほぼテンプレ通り)</summary><div>

```xml:main_fragment.xml
<?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>
```
</div></details>

![zdk0.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38581/6604d72e-a570-b18c-6cb9-f8f868a08291.gif)

# 問題点

見てわかる通り、このままでは`ズン`(あるいは`ドコ`)が連続で流れてきた時にテキストが更新されたことを目視で確認できないため、テンポ感を掴みにくいです。
いくつかの解決策を模索していきましょう。
主題に沿うため、すべて`Flow`のオペレータを使って解決します。

## 解決策1 : 点滅させる

`flatMapConcat`を使ってテキストを点滅させてみましょう。
`flatMapConcat`は流れてきた値を別の`Flow`に変換し、各`Flow`を順番通りに連結します(Rx の`concatMap`に相当します)。


```kotlin:ZDKViewModel.kt
    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`することで点滅を表現します。
ただし最後の`キ・ヨ・シ！`は消したくないのでその条件を付与します。

![zdk1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38581/7222e38b-3037-c73a-8b3a-660cf98b58d1.gif)

## 解決策2 : 左右に振る

いまいち躍動感に欠けるので別の方法を模索します。
テキストを左右に振ってみましょう。
といっても`TextView`の座標をいじるのは面倒なので、前後にスペースを挿入することにより擬似的に揺れを表現します。

```kotlin:ZDKViewModel.kt
    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`を使用して、偶数回なら後ろにスペースを、奇数回なら前にスペースを挿入します。
ただし、最後の`キ・ヨ・シ！`は常にセンターに表示するものとします。

![zdk2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38581/de23f7bc-98f6-4f08-caac-a7076596c678.gif)

## 解決策3 : ランダムに文字サイズ(あるいは文字色)を変える

ランダムに文字サイズや文字色を変えることが出来ればより華やかになるでしょう(?)。
これを実現するために、前回のズンドコキヨシで実装した`randomFlow`関数を再利用します。
以下は文字サイズを変更する例です。

```kotlin:ZDKViewModel.kt
    // 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`を以下のように書き換えたら完成です。

```kotlin:MainFragment.kt
    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)
        }
    }
```

![zdk3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38581/e6b6fd33-f86b-a900-0f92-e7b5c0937e3f.gif)

# ズンドコキヨシはバッテリーに優しいか

ズンドコキヨシは無限ループを使用しています。
冒頭で述べたとおり、もしアプリ終了やスリープしても無限ループが止まらなかったら困りものです。
バッテリーに優しくないズンドコキヨシという烙印を押されてしまいます。
`randomFlow`関数に以下のようにログを仕込み、この点について検証してみましょう。

```kotlin:GenericZDK.kt
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」の実装で動作を確認します。
アプリ起動したら、`キ・ヨ・シ！`が出現する前に端末をスリープ状態にします。

```:Logcat
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`には豊富なオペレータが用意されているので、両者を組み合わせることで実装の幅が大きく広がると思います。