Blog

Jetpack ComposeとKotlin Coroutinesを連携させる

この記事は Android Advent Calendar 2021 の13日目の記事です。

Jetpack Composeは内部でもKotlin Coroutinesを多く使っており、非常に相性が良いです。

今回はJetpack ComposeとKotlin Coroutinesを組み合わせて使ういくつかの方法について紹介します。

collectAsState

Jetpack Composeでは、Stateの値を変化させることで画面更新をさせることができます。

ViewModel等でStateFlowを使っている場合、collectsState を使うことでStateに変換することができ、Composeに反映させることができるようになります。

@Composable
fun Sample(
    viewModel: SampleViewModel = viewModel()
) {
    val count by viewModel.count.collectAsState()
    Text(text = count.toString())
}

StateFlowではないFlowStateに直す際は、初期値が必要です。

@Composable
fun Sample(
    viewModel: SampleViewModel = viewModel()
) {
    val count by viewModel.count.collectAsState(initial = 0)
    Text(text = count.toString())
}

LaunchedEffect

Composable関数内でsuspend functionを呼び出すには、LaunchedEffect を使うことで実現できます。

LaunchedEffect は表示時にCoroutinesが起動され、非表示になったタイミングでキャンセルが行われます。

また、指定したkeyが変更になった際には一度キャンセルされ、新たにCoroutinesが起動されます。

例えば、snackbarを表示するSnackbarHostStateshowSnackbar はsuspend functionになっており、Coroutines内で呼び出す必要があります。

以下は、引数のerrorが変更されたタイミングでsnackbarを表示するサンプルです。

@Composable
fun LaunchedEffectSample(
    error: Throwable?
) {
    val snackbarHostState = remember { SnackbarHostState() }
    LaunchedEffect(error) {
        error?.let {
            snackbarHostState.showSnackbar(it.message.orEmpty())
        }
    }
    SnackbarHost(hostState = snackbarHostState)
}

表示時に一度のみ起動したい場合は、keyUnittrue等、変更されないものを指定することで実現できます。

@Composable
fun LaunchedEffectUnitSample() {
    val snackbarHostState = remember { SnackbarHostState() }
    LaunchedEffect(Unit) {
        snackbarHostState.showSnackbar("Welcome!")
    }
    SnackbarHost(hostState = snackbarHostState)
}

LaunchedEffect 内で参照されている変数は基本keyに指定しておくことで、古い値を参照し続けることを防ぐことができます。

一方で、 SnackbarHostState 等、絶対に変更されない値はkeyに指定しなくて問題ありません。

ViewModelからFlowChannelでイベントを送っている場合も、LaunchedEffectを使うことで受け取ることができます。

@Composable
fun LaunchedEffectFlowSample(
    viewModel: SampleViewModel = viewModel()
) {
    val snackbarHostState = remember { SnackbarHostState() }
    LaunchedEffect(viewModel.message) {
        viewModel.message.collect {
            snackbarHostState.showSnackbar(it)
        }
    }
    SnackbarHost(hostState = snackbarHostState)
}

rememberCoroutineScope

LaunchedEffect以外にCoroutinesScopeを取得する方法として、rememberCoroutineScope があります。

以下はクリック時にsnackbarを表示する例です。

@Composable
fun RememberCoroutineScopeSample() {
    val scope = rememberCoroutineScope()
    val snackbarHostState = remember { SnackbarHostState() }
    Button(
        onClick = {
            scope.launch {
                snackbarHostState.showSnackbar("Hello!")
            }
        }
    ) {
        Text(text = "Button")
    }
    SnackbarHost(hostState = snackbarHostState)
}

rememberCoroutineScope で取得した CoroutinesScope も非表示時にキャンセルされるため、安全に扱うことができます。

produceState

FlowからStateへの変換はcollectAsStateが利用できますが、suspend functionからStateへの変換はproduceState を使います。

LaunchedEffectと同様に表示時に起動され、keyをしている場合はkeyの変更に合わせて再起動されます。

@Composable
fun ProduceStateSample(
    api: Api
) {
    val state by produceState(initialValue = "loading") {
        value = try {
            api.call()
            "successed"
        } catch (e: Throwable) {
            "failed"
        }
    }
    Text(text = state)
}

collectAsStateの実装にもproduceStateが使われていました。

今回は1例として上げましたが、実際はComposable関数から直接APIを叩くことは少ないと思うので、あまり使うことが無いかもしれません。

snapshotFlow

反対に、StateからFlowに変換するにはsnapshotFlow を使います。

例えば、以下のようにスクロール位置をFlowで監視することができます。

@Composable
fun SnapshotFlowSample(
    viewModel: SampleViewModel
) {
    val listState = rememberLazyListState()

    LaunchedEffect(listState) {
        snapshotFlow { listState.firstVisibleItemIndex }
            .filter { it == 0 }
            .collect {
                viewModel.changeIsTop(it)
            }
    }

    LazyColumn(state = listState) {
        // ...
    }
}

Flowの作成やFlowのオペレータによる変換は、Composable関数内で呼び出すとRecompose毎に再生成されてしまうので、rememberLaunchedEffect内でのみ行ってください。

@Composable
fun SnapshotFlowSamples() {
    // Recompose毎に新規flowが作られてしまうため行わない
    val flow = snapshotFlow { /* ... */ }

    // これはOK
    val flow = remember { snapshotFlow { /* ... */ } }

    // これもRecompose毎にflowが再生成されてしまう
    val transformed = flow.map { it.toString() }

    // これはOK
    val transformed = remember(flow) {
        flow.map { it.toString() }
    }

    // これもOK
    LaunchedEffect(flow) {
        flow.map { it.toString() }
            .collect { /* ... */ } }
    }
}

snapshotFlow で監視できるのは State なので、Composable関数の引数をFlowに変換することはできません。

@Composable
fun SnapshotFlowSample(
    count: Int
) {
    LaunchedEffect(Unit) {
        snapshotFlow { count } // 動作しない!
            .collect {
                // ...
            }
    }
}

MutableStateFlowremember した後にSideEffect で更新することでFlow化することができます。

@Composable
fun MutableStateFlowSample(
    count: Int
) {
    val flow = remember { MutableStateFlow(count) }
    SideEffect {
        flow.value = count
    }
    LaunchedEffect(Unit) {
        flow.collect {
            // ...
        }
    }
}

あまり利用ケースは多くないかも知れませんが、以下のようなComposable関数を用意しても良いでしょう。

@Composable
fun <T> rememberFlow(value: T): StateFlow<T> {
    val flow = remember {
        MutableStateFlow(value)
    }
    SideEffect {
        flow.value = value
    }
    return flow
}

Lifecycleを考慮する

上記の方法では、いずれもLifecycleを考慮していないため、バックグラウンドにいるときも動作し続けます。

repeatOnLifecycleを使うことでLifecycleに従って動作させることが可能になります。

LifecycleOwnerLocalLifecycleOwner から取得することができます。

LaunchedEffectonStartからonStopまでを動作させたい場合は、以下のように記述することで実現が可能です。

@Composable
fun Sample(
    viewModel: SampleViewModel = viewModel()
) {
    val lifecycleOwner = LocalLifecycleOwner.current
    val snackbarHostState = remember { SnackbarHostState() }
    LaunchedEffect(viewModel.message) {
        lifecycleOwner.repeatOnLifecycle(state) {
            viewModel.message.collect {
                snackbarHostState.showSnackbar(it)
            }
        }
    }
    SnackbarHost(hostState = snackbarHostState)
}

以下のようなComposable関数を用意しておくと便利かもしれません。

@Composable
fun <T> CollectInLifecycle(
    flow: Flow<T>,
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    state: Lifecycle.State = Lifecycle.State.STARTED,
    f: suspend CoroutineScope.(T) -> Unit
) {
    LaunchedEffect(flow) {
        lifecycleOwner.repeatOnLifecycle(state) {
            flow.collect {
                f(it)
            }
        }
    }
}

collectAsStateを行うケースも、ほとんどの場合はonStartからonStopの間でのみ行えば良いでしょう。

こちらも、以下のようにLifecycle指定でcollectできるようにしても良さそうです。

@Composable
@Suppress("StateFlowValueCalledInComposition")
fun <T> StateFlow<T>.collectAsStateInLifecycle(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    state: Lifecycle.State = Lifecycle.State.STARTED,
    context: CoroutineContext = EmptyCoroutineContext
): State<T> {
    return produceState(value, this, lifecycleOwner, state, context) {
        lifecycleOwner.repeatOnLifecycle(state) {
            if (context == EmptyCoroutineContext) {
                collect { value = it }
            } else withContext(context) {
                collect { value = it }
            }
        }
    }
}

repeatOnLifecycle については以下の記事も参考にしてください。

まとめ

Jetpack ComposeとKotlin Coroutinesを組み合わせる方法について紹介をしてきました。

ActivityやFragment、Android Viewと比べて、格段にCoroutinesを呼び出しやすくなっていると思います。

ライブラリ内の処理や標準コンポーネントの実装を見ることで、より良い使い方を学ぶことができます。

一方で、関心の分離の観点から、UIを表現するComposable関数にドメインロジックを含ませないように心がけるべきでしょう。

また、Lifecycleをどれくらい考慮すべきかについても議論があると思います。

最後に collectAsStateInLifecycle を紹介しましたが、公式で提供されている collectAsState を利用したほうがわかりやすいケースも多いと思います。

状況に合わせて選択してもらえると幸いです。

参考文献

人気の記事

kotlin coroutinesのFlow, SharedFlow, StateFlowを整理する

Layout Composableを使って複雑なレイアウトを組む【Jetpack Compose】

テスト用Dispatcherの使い分け【Kotlin Coroutines】

Flow.combineの内部実装がすごい話

Jetpack ComposeのRippleエフェクトを深堀り、カスタマイズも

Kotlin Coroutinesで共有リソースを扱う