2
1

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.

Side-effects in Composeを読む

Last updated at Posted at 2023-06-28

前言

※AndroidのdevelopersサイトからUI architecture読んで、気になる部分をメモする感じです。

今回はSide-effectsを拝見してます。
https://developer.android.com/jetpack/compose/side-effects

キーワード

  • side-effect:composable functionのスコープ外で発生する状態の変化

メモ

Side-effects in Compose

予測できないrecompositionsがあるため、基本はside-effectがない方が理想です。

State and effect use cases

アプリの状態を変更が必要の場合はEffect Apiを使用、そうすると予想できる副作用に収めます。
また、こちらのAPIを使う場合はその処理がUI関連、unidirectional data flowを崩さないことを意識すること。

LaunchedEffect

composableの中に安全的にsuspend functionsを呼び出す方法。
recompositionの場合はLaunchedEffectのキーの値が変わったら、前回のcoroutineはキャンセルされ、新しいcoroutineで実行されます。

LaunchedEffect(snackbarHostState) {
	// showSnackbarはsuspend funですので、直接使えません
	snackbarHostState.showSnackbar(
		message = "Error message",
		actionLabel = "Retry message"
	)
}

rememberCoroutineScope

LaunchedEffectはcomposableの中しか使えないため、代わりにrememberCoroutineScopeが使えます。

val scope = rememberCoroutineScope()

Button(
	onClick = {
		// ここのscopeはcomposableではないので、LaunchedEffectが使えない
		scope.launch {
			snackbarHostState.showSnackbar("Something happened!")
		}
	}
) { Text("Press me") }

rememberUpdatedState

LaunchedEffectはkeyが変更するとキャンセルされ、再度実行されます。
もし時間が掛かる処理の後に実行したいメソットがある場合は、途中でkeyが変わると重い処理が再度発生する。この場合はこちらのeffectが使える、recompositionが発生しても都度値を更新します。

val currentOnTimeout by rememberUpdatedState(onTimeout)

LaunchedEffect(true) {
		delay(SplashWaitTimeMillis)
		// delayの間ても常に最新の値が更新される
		currentOnTimeout()
}

ソースコードを見ると、意外と難しいことしてませんでした。

@Composable
fun <T> rememberUpdatedState(newValue: T): State<T> = remember {
    mutableStateOf(newValue)
}.apply { value = newValue }

DisposableEffect

スコープの中にOnDisposeが提供されてる。Compositionから退場する場合はこちらのメソットが呼ばれます。
クリーンアップが必要な処理には向いてます。また、onDisposeの中身が空の場合は見直すべきと思います。

onDispose {
	// ここでobserverを解除する
	lifecycleOwner.lifecycle.removeObserver(observer)
}

SideEffect

composeの状態をcomposeではないオブジェクトに共有するeffects。こちらはrecompositionする度に実行します。

SideEffect {
	analytics.setUserProperty("userType", user.userType)
}

実際SideEffectを使わなくても実現できるではないでしょうかと思っちゃいました。それについてはこちらの記事が参考できます。https://jetc.dev/slack/2021-09-04-why-need-sideffect.html

Compositionは協議である、協議が成功するする前に観測できるside effectが存在すべきではない。そこで、SideEffectが担保できます。Composistionが成功しないとSideEffectは実行しません。

個人的なイメージは、自分が作ったComposableと直接な関係がなくて、実装的に実行したい。この場合はSideEffectを使って、Composableに影響がないスコープで実行します。

produceState

こちらは名前通りStateを生産できます。中身はLaunchedEffectですので、coroutineスコープになります。そして最後に返却される値はStateに包まれます。
また、同期のソースコードも対応て、その場合はawaitDisposeでサブスクリプションを解除できます。

@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository
): State<Result<Image>> {
		return <Result<Image>>(initialValue = Result.Loading, url, imageRepository) {
        // suspend funが使えます
        val image = imageRepository.load(url)

        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}

derivedStateOf

こちらは状態が他の状態から計算して導いた時に使えます。recompositionが発生しても計算は行いません、指定した状態の変更のみ再計算されます。

@Composable
fun TodoList(highPriorityKeywords: List<String> = listOf("Review", "Unblock", "Compose")) {

    val todoTasks = remember { mutableStateListOf<String>() }

    // recompositionされる度ではなく、todoTasksやhighPriorityKeywordsが変更される時のみ
    val highPriorityTasks by remember(highPriorityKeywords) {
        derivedStateOf {
            todoTasks.filter { task ->
                highPriorityKeywords.any { keyword ->
                    task.contains(keyword)
                }
            }
        }
    }
}

snapshotFlow

State<T>をcold Flowに変換する。

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it == true }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

Restarting effects

LaunchedEffectproduceStateDisposableEffectは複数のキーを設定できます。キーが変化するとeffectが再度実行されます。
effectsのスコープ内で使われる値はキーとして渡すべき、そうでなければrememberUpdataedStateを使います。

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit // Send the 'stopped' analytics event
) {
    // These values never change in Composition
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            /* ... */
        }

        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

currentOnStart, currentOnStopは変更しないため、rememberUpdatedStateを使います。
lifecycleOwnerを渡さないとrecomposition後、間違ったlifecycleOwnerが使われるため、問題になります。

Constants as keys

定数をキーとして使うと、前回話したlifecycleに沿うことができます。
例えばLaunchedEffectとか、ですが使う前にもう本当に必要かどうかを考えましょう。

終わり

思ったより深くて、難しいでした。
もし理解が間違ってた部分がありましたら、ぜひ教えてください!
一緒に成長しましょう😌

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?