LoginSignup
7
1

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-12-04

ACCESS Advent Calendar 2020 5日目の記事です。
と言いつつ、内容は過去に自分の書いた記事の続編です。
昔流行ったズンドコキヨシを通じて Coroutines Flow の良さを考えていきます。

やりたいこと

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

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

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 でもズンドコしたい

バージョン情報

こちら
  • 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でデフォルトディスパッチャを指定します。
ViewModelasLiveDataを使用する場合はviewModelScope.coroutineContextを指定するのが良いでしょう。
間違ってもGlobalScopeを指定してはいけません。
ズンドコが止まらなくなる恐れがあります。

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にバインドするだけです。

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
        }
    }
}

レイアウト(ほぼテンプレ通り)
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>

zdk0.gif

問題点

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

解決策1 : 点滅させる

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

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

解決策2 : 左右に振る

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

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)

withIndexFlow<T>を、インデックス情報を付与したFlow<IndexedValue<T>>に変換します。
mapを使用して、偶数回なら後ろにスペースを、奇数回なら前にスペースを挿入します。
ただし、最後のキ・ヨ・シ!は常にセンターに表示するものとします。

zdk2.gif

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

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

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つを選び続けるFlowrandomFlowで作成します。
ただし、前回とは必ず異なるサイズを選択したいので、distinctUntilChangedをつけます。

ズンドコフローとサイズフローの2本をzip関数で1本のFlowにまとめます。川が合流するイメージです。
ここでテキストとサイズ倍率のPairをつくります。
ただし、最後のキ・ヨ・シ!は固定の特大サイズ(1.1倍)で表示するものとします。
最後にLiveDatamapでテキストとサイズ倍率に分解します(これは必須ではありません)。
こうすることでテキストと文字サイズの更新タイミングを揃えることができます。

Fragmentを以下のように書き換えたら完成です。

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

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

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

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がスローされ、やはりキャンセルされます。
これにより、バッテリーに優しいズンドコキヨシであることが示されました。

FlowLiveDataの組み合わせでは、このようにライフサイクルのケアが手厚く行われる点が大きな利点といえます。
また、mapdistinctUntilChanged程度しかオペレータの無かったLiveDataと違い、Flowには豊富なオペレータが用意されているので、両者を組み合わせることで実装の幅が大きく広がると思います。

7
1
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
7
1