この記事は Android Advent Calendar 2021 の13日目の記事です。
Jetpack Composeは内部でもKotlin Coroutinesを多く使っており、非常に相性が良いです。
今回はJetpack ComposeとKotlin Coroutinesを組み合わせて使ういくつかの方法について紹介します。
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
ではないFlowをState
に直す際は、初期値が必要です。
@Composable
fun Sample(
viewModel: SampleViewModel = viewModel()
) {
val count by viewModel.count.collectAsState(initial = 0)
Text(text = count.toString())
}
Composable関数内でsuspend functionを呼び出すには、LaunchedEffect を使うことで実現できます。
LaunchedEffect
は表示時にCoroutinesが起動され、非表示になったタイミングでキャンセルが行われます。
また、指定したkeyが変更になった際には一度キャンセルされ、新たにCoroutinesが起動されます。
例えば、snackbarを表示するSnackbarHostStateのshowSnackbar は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)
}
表示時に一度のみ起動したい場合は、key
にUnit
やtrue
等、変更されないものを指定することで実現できます。
@Composable
fun LaunchedEffectUnitSample() {
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
snackbarHostState.showSnackbar("Welcome!")
}
SnackbarHost(hostState = snackbarHostState)
}
LaunchedEffect
内で参照されている変数は基本keyに指定しておくことで、古い値を参照し続けることを防ぐことができます。
一方で、 SnackbarHostState
等、絶対に変更されない値はkeyに指定しなくて問題ありません。
ViewModelからFlow
やChannelでイベントを送っている場合も、LaunchedEffect
を使うことで受け取ることができます。
@Composable
fun LaunchedEffectFlowSample(
viewModel: SampleViewModel = viewModel()
) {
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(viewModel.message) {
viewModel.message.collect {
snackbarHostState.showSnackbar(it)
}
}
SnackbarHost(hostState = snackbarHostState)
}
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
も非表示時にキャンセルされるため、安全に扱うことができます。
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を叩くことは少ないと思うので、あまり使うことが無いかもしれません。
反対に、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毎に再生成されてしまうので、remember
やLaunchedEffect
内でのみ行ってください。
@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 {
// ...
}
}
}
MutableStateFlow
をremember した後に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を考慮していないため、バックグラウンドにいるときも動作し続けます。
repeatOnLifecycleを使うことでLifecycleに従って動作させることが可能になります。
LifecycleOwnerはLocalLifecycleOwner から取得することができます。
LaunchedEffect
でonStart
から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
を利用したほうがわかりやすいケースも多いと思います。
状況に合わせて選択してもらえると幸いです。