30
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ZOZOAdvent Calendar 2022

Day 18

Jetpack ComposeのSide-Effectsまとめ

Last updated at Posted at 2022-12-18

本記事は ZOZO Advent Calendar 2022 カレンダー Vol.2 の 18 日目の記事です。
明日の記事は@sassywind「Slickにおいてcreated_at, updated_atを意識しないようにする」 です。

Jetpack Composeを用いて開発していく中で、かなりの頻度で使用するside-efffect APIですが、どれを使用するか迷うことはありませんか? 筆者は毎回迷うため、それらを明確にする為にも本記事ではユースケースや具体例を交えてメモがてら整理していこうと思います。

またこの記事で使用しているIDEとdependenciesのバージョンはそれぞれ下記になります。

---使用しているバージョン:---
Android Studio: "Android Studio Electric Eel | 2022.1.1 Canary 10"
Jetpack Compose: "1.2.0"
Kotlin: "1.7.10"

Codeサンプル

本記事で紹介しているside-effectsのサンプルは、下記repositoryからも確認できます。
是非コントリビュート等お願いします!

ComposeにおけるSide-effects(副作用)とは

Composeでのside-effectとは、Composeのスコープ外で発生するアプリの状態変化を指します。コンポーザブルは基本Idempotent(同じInputに対し、何度実行しても結果が同じ)であるべきとされており、基本的にはside-effectがないようにするのが理想とされています。

ですが、ケースによってはネットワークからAPIコールを行う、ローカルのDB更新、SnackBarの表示やアニメーションを行うなどの副作用が必要になってく場合があります。

Effect handlers

このセクションでは、side-effect(副作用)を実行するために公式で用意されている様々なside-efffect APIの使い道と具体例を交えて紹介していきます。

LauchedEffect

主なユースケース:
Composable関数内から,suspend関数を安全に呼び出す為に使用するようなside-effect

具体例:
LaunchedEffectは以下のようなコードで定義されています。LaunchedEffectImpl内でComposableのライフサイクルに合わせてCoroutineScopeを自動でキャンセルと起動するように実装されています。

@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    val applyContext = currentComposer.applyCoroutineContext
    remember(key1) { LaunchedEffectImpl(applyContext, block) }
}

LauchedEffectは一番基本となるside-effect Composableであり、LaunchedEffectは引数に識別するためのkeyを引数として渡す必要があります。LauchedEffectはComposition入場時にCoroutineScopeが起動され、引数で渡したblockのラムダが実行されます。

また新しいkeyが引数で渡され、再コンポーズが行われる度にLauchedEffectはComposition退場時して再入場します。また退場時には、既存のCoroutineがキャンセルされます。

LaunchedEffectSample.kt
@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    val applyContext = currentComposer.applyCoroutineContext
    remember(key1) { LaunchedEffectImpl(applyContext, block) }
}

rememberCoroutineScope

主なユースケース:
Composableスコープの外部でcompositionに対応したコルーチンを起動するために使用するようなside-effect

具体例:
例えばですが、下記のようにボタンをクリックした後に3秒間だけシークレットテキストが表示されるようなシナリオを考えてみましょう。

RememberCoroutineScopeSample.kt
@Composable
fun RememberCoroutineScopeSample() {
    val scope = rememberCoroutineScope()
    var secretTextVisibility by remember {
        mutableStateOf(false)
    }

    Column{
        ...        

        Button(onClick = {
            // ここはComposableの外部であるため、coroutineScopeを起動するにはrememberCoroutineScopeを使用す必要がある
            scope.launch {
                secretTextVisibility = true
                // 3秒間のみ表示するため、ここで遅延する
                delay(3000L)
                secretTextVisibility = false
            }
        }) {
            Text(text = "Click to show secret text")
        }
    }
}

rememberUpdatedState

主なユースケース:
Compositionの時に、副作用全体を再起動することなく部分的に処理を更新したい時に使用するようなside-effect

具体例:
LaunchedEffectは,いずれかのkeyが変化するたびに、再起動されるような性質がある副作用でした。

ただし、状況によっては、keyが変化してもside-effect全体を再起動することなく、特定の値のみ部分的に更新したい場合があります。このような場合の解決策として、rememberUpdatedState を使用します。このside-effectは、再作成または再起動が高コストである場合や、特定な値の更新が長期的なオペレーションを伴う場合に役立ちます。(スプラッシュスクリーン、アニメーションまたはコストがかかる計算などが長期的なオペレーションに該当すると思います)

例として、下記のようなジャンケンのゲームを実装したコードを見てみましょう。このコードでは5秒のタイマーが設置されており、5秒の間にユーザーは自由に出すジャンケンの手(選択したの結果をoptionが保持)を切り替えることができるとします。最終的に5秒後にユーザーが選択したoptionに応じて勝敗が判定する様なゲームを想定してみましょう。※パーを選んだ場合のみ勝利にするというルールにした場合

この場合、optionが変更される度にside-effectを再起動し続けることになり、結果としてユーザー選択制限時間が5秒より長くなってしまう事があります。
そのため、rememberUpdatedStateを使用することで、optionの更新以外のオペレーションの再起動を防ぐことができます。

RememberUpdatedStateSample.kt
@Composable
fun GameScreen(option: Option, onOptionSelectedListener: (Option) -> Unit) {
    val currentOption by rememberUpdatedState(newValue = option)

    var result by remember {
        mutableStateOf<Boolean?>(null)
    }

    var timer by remember {
        mutableStateOf<Int>(5)
    }

    LaunchedEffect(Unit) {
        // この部分はcurrentOptionが更新されても再起動されない
        repeat(5) {
            delay(1000L)
            timer--
        }
        // パーを選んだ場合のみ勝利にする
        result = (currentOption === Option.PAPER)
    }

    ...
}

DisposableEffect

主なユースケース:
ListenerObserverなどの、クリーンアップが必要な副作用を実行する時に使用するようなside-effect

具体例:
LauchedEffectはkeyの更新があった度に、既存のCoroutineがキャンセルされ, tryCatchでキャンセルされたタイミングを取得することができます。

DisposableEffectSample.kt
@Composable
fun DisposableEffectSample(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onLifecycleResume: () -> Unit,
    onLifecycleStop: () -> Unit,
) {

    DisposableEffect(lifecycleOwner) {
        val lifecycleObserver = LifecycleEventObserver { _, event ->
            when (event) {
                Lifecycle.Event.ON_RESUME -> {
                    onLifecycleResume()
                }

                Lifecycle.Event.ON_STOP -> {
                    onLifecycleStop()
                }

                else -> {}
            }
        }

        // ライフサイクルのObserverを登録する
        lifecycleOwner.lifecycle.addObserver(lifecycleObserver)

        onDispose {
            // メモリーリークなどを避けるため, Observerを削除する
            lifecycleOwner.lifecycle.removeObserver(lifecycleObserver)
        }
    }
}

SideEffect

主なユースケース:
SideEffectは初回CompositionまたはRecompositionが行われるたびに起動されるside-effectです

具体例:

SideEffectSample.kt
@Composable
fun SideEffectSample() {
    var compositionCount by remember { mutableStateOf(0) }

    SideEffect {
        Log.d("ComposeInfo","Get recomposed $compositionCount times")
    }

    Box {
        Column(modifier = Modifier.align(Alignment.Center),) {
            Text(text = "Get recomposed $compositionCount times")
            
            Button(onClick = { compositionCount++ }) {
                Text(text = "Click to recompose")
            }
        }
    }
}

上記コードを実行した後に、ボタンを7回押した場合にcompositionCountのStateがTextで使用されているため、7回Recompositionが行われます。そのため、SideEffect関数の副作用も7回分起動されます。

下記スクリーンショットからも分かるように、

スクリーンショット 2022-12-18 19.04.29.png

produceStateOf

主なユースケース:
Compose外の状態をComposeのStateに変換する時に使用するようなside-effect

具体例:
例えば、composable関数内からsuspend関数を介して、ネットワークのMediaを取得しStateとして利用したいケースを考えてみます。

そのような場合にproduceStateを用いて実装すると、そのまま取得したMediaの結果をStateとして使用できます。

ProduceStateSample.kt
@Composable
fun ProduceStateSample(
    viewModel: MainViewModel = hiltViewModel(),
) {
    val fetchImageState = fetchNetworkImage { url, type ->
        viewModel.fetchMedia(url, type)
    }
@SuppressLint("ProduceStateDoesNotAssignValue")
@Composable
fun fetchNetworkImage(
    fetchMedia: suspend (String?, String?) -> Media?,
): State<Result<Media>?> {
    return produceState(initialValue = null) {
        val image: Media? = fetchMedia("anyUrl", "anyType")

        image?.let {
            Result.success(image)
        } ?: Result.failure(Exception())
    }
}

derivedStateOf

主なユースケース:
Keyの更新が値の更新より頻繁な場合、または1つ以上の状態オブジェクトを別の状態に変換する場合使用する際に利用するside-effect

具体例:
 Jetpack Composeではrememberという関数があり、LaunchedEffect同様に引数にkeyを渡すことができ、そのkeyが更新されると同時にrememberしたStateも更新されます。ですが、それがパフォーマンスに影響を与えることはあるのでしょうか? 実はケースによってはパフォーマンスに悪影響を与える可能性もあるのです。

 一つの例として、「LazyListで一つ目のアイテムを超えた箇所から常にTOPに戻れるようなボタンを表示したい」というようなシナリオを考えてみましょう。下記のようにrememberとkeyのみで実装してしまうと、hasReachedListEndのStateはスクロール開始直後、一つ目のIndexを超えた後から、値はTrueに変更されます。しかしながら、その後スクロールを続けている間はhasReachedListEndの値はTrueのままであり値に変更がないにも関わらず、再度remember関数内で計算が行われ値が代入されます。

パフォーマンスが良くない例:

DerivedStateOfSample.kt
@Composable
fun DerivedStateOfSample(
    itemCount: Int = 50,
) {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        val scope = rememberCoroutineScope()
        val listState = rememberLazyListState()

        LazyColumn(state = listState) {
            items(itemCount) { index ->
                Text(text = "Item: $index")
            }
        }

        val hasReachedListEnd by remember(listState.firstVisibleItemIndex) {
            mutableStateOf(
                listState.firstVisibleItemIndex > 0
            )
        }

        Column {
            AnimatedVisibility(hasReachedListEnd) {
                Button(
                    modifier = Modifier
                        .wrapContentWidth()
                        .height(70.dp),
                    onClick = {
                        scope.launch {
                            listState.animateScrollToItem(0)
                        }
                    }) {
                    Text("Back to top")
                }
            }
        }
    }
}

Android Studio Electric Eeelの機能であるLayout inspectorを用いてComposeの状態を覗いてみると、hasReachedListEndのStateを使用しているAnimatedVisibility composableが複数回スキップされているのが確認できます。
スクリーンショット 2022-12-18 21.40.15.png

ですが、このパフォーマンス問題はderivedStateOfを使用することによって解決できます。以下のように実装を変えることによって、hasReachedListEndの値に変化があった場合にのみ計算が行われるようになります。

パフォーマンスが良い例:

DerivedStateOfSample.kt
@Composable
fun DerivedStateOfSample(
    itemCount: Int = 50,
) {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        val scope = rememberCoroutineScope()
        val listState = rememberLazyListState()

        LazyColumn(state = listState) {
            items(itemCount) { index ->
                Text(text = "Item: $index")
            }
        }

        val hasReachedListEnd by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex > 0
            }
        }

        Column {
            AnimatedVisibility(hasReachedListEnd) {
                Button(
                    modifier = Modifier
                        .wrapContentWidth()
                        .height(70.dp),
                    onClick = {
                        scope.launch {
                            listState.animateScrollToItem(0)
                        }
                    }) {
                    Text("Back to top")
                }
            }
        }
    }
}

下記からも確認できるように、リストの一番下部までスクロールしきった際のStateの再コンポーズの回数は1回となりました。

スクリーンショット 2022-12-18 21.41.46.png

snapshotFlow

主なユースケース:
ComposeのStateをFlowに変換したい場合に使用するようなside-effect

StateオブジェクトをCold Flowに変換したい場合にsnapshotFlowを使用します。
snapshotFlowブロックでStateを読み取るのですが、その読み取られたStateオブジェクトの値のいずれかが変化した際に、新しい値がcollectされます。

具体例:
例えば、50枚のCard ComposableをLazyListで生成したときに、35番目のCardが画面内に映り込んだ場合にToastを表示するようなシナリオを実装したい場合、snapshotFlowの出番と言えるでしょう!

snapshotFlowのラムダ内で画面に映り込んだかのBooleanを判定し、値に変更があった場合にのみcollectされます。

SnapshotFlowSample.kt
@Composable
fun SnapshotFlowSample(
    itemCount: Int = 50,
    targetIndex: Int = 35,
) {
    val context = LocalContext.current
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        val listState = rememberLazyListState()

        LaunchedEffect(Unit) {
            snapshotFlow {
                listState.currentVisibleAreaContainsItem(targetIndex = targetIndex)
            }.distinctUntilChanged()
                .collect { isTargetWithinTheScreen ->
                    if (isTargetWithinTheScreen) {
                        Toast.makeText(context, "$targetIndex 番目のカードが画面内に入りました", Toast.LENGTH_SHORT)
                            .show()
                    }
                }
        }

        LazyColumn(
            state = listState,
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            items(itemCount) { index ->
                Card(
                    modifier = Modifier
                        .padding(horizontal = 30.dp)
                        .fillMaxWidth()
                        .padding(vertical = 15.dp),
                    elevation = 10.dp,
                    shape = RoundedCornerShape(5.dp),
                    backgroundColor = Color.Gray,
                ) {
                    Text(
                        modifier = Modifier.padding(vertical = 15.dp),
                        text = "Item: $index",
                        textAlign = TextAlign.Center,
                    )
                }
            }
        }
    }
}

private fun LazyListState.currentVisibleAreaContainsItem(
    targetIndex: Int,
): Boolean {

    // ターゲットのカードが画面内に少しでも収まっているかを判定するロジック
    return layoutInfo.visibleItemsInfo.map {
        it.index
    }.contains(targetIndex)
}
}

最後に

開発していく中で適切なside-effect APIを選択することは容易ではないですが、時にはパフォーマンスや計算量などに大きな影響を与える可能性があるため、とても重要です。
そのため、筆者も今後Composeを実装の際は適切なside-effect APIを選択することを心掛けるようにと思います。
もし本記事の内容に誤認識や誤表記等ある場合、是非ご連絡いただけると嬉しいです!

30
17
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
30
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?