Compose 中的副作用

組件應完全避免副作用。 不過,如果有必要使用組件改變應用程式狀態,則應透過會考量組件生命週期的受監控環境呼叫這些組件。在這個網頁中,我們會說明 Jetpack Compose 提供的各種不同副作用 API。

狀態與作用用途

Compose 中的思維說明文件所述,組件應完全避免副作用。當您需要變更應用程式狀態時 (如「管理狀態說明文件」文件中所述),應使用 Effect API,以可預測的方式執行這些副作用

由於「作用」在 Compose 中開啟的各種不同可能性,因此很容易遭到過度使用。確保您在其中執行的作業與 UI 相關,而且不會損毀單向資料流 (請參閱管理狀態說明文件中的解說)。

LaunchedEffect:在組件的範圍中執行暫停函式

如要從組件內部安全地呼叫暫停函式,請使用 LaunchedEffect 組件。當 LaunchedEffect 進入「組成」中,會啟動協同程式並隨之傳遞程式碼區塊當做參數。如果 LaunchedEffect 離開組成,協同程式就會取消。如果使用不同的金鑰重組了 LaunchedEffect (請參閱下方章節的重新啟動「作用」),系統會取消現有的協同程式,並在新的協同程式中啟動新的暫停函式。

舉例來說,在 Scaffold 中顯示 Snackbar 要透過 SnackbarHostState.showSnackbar 函式執行,這是一個暫停函式。

@Composable
fun MyScreen(
    state: UiState<List<Movie>>,
    scaffoldState: ScaffoldState = rememberScaffoldState()
) {

    // If the UI state contains an error, show snackbar
    if (state.hasError) {

        // `LaunchedEffect` will cancel and re-launch if
        // `scaffoldState.snackbarHostState` changes
        LaunchedEffect(scaffoldState.snackbarHostState) {
            // Show snackbar using a coroutine, when the coroutine is cancelled the
            // snackbar will automatically dismiss. This coroutine will cancel whenever
            // `state.hasError` is false, and only start when `state.hasError` is true
            // (due to the above if-check), or if `scaffoldState.snackbarHostState` changes.
            scaffoldState.snackbarHostState.showSnackbar(
                message = "Error message",
                actionLabel = "Retry message"
            )
        }
    }

    Scaffold(scaffoldState = scaffoldState) {
        /* ... */
    }
}

在上述程式碼中,如果該狀態含有錯誤就會觸發協同程式,如果不含錯誤就會取消。由於 LaunchedEffect 呼叫站在 if 陳述式內部,當陳述式為「否」時,如果 LaunchedEffect 存在於「組成」中,會遭到移除,因此,協同程式也會取消。

rememberCoroutineScope:取得會考量組成的範圍,以便在組件外部啟動協同程式

LaunchedEffect 是可組合函式,因此只能在其他可組合函式內部使用。如要在組件外部啟動協同程式,同時設定範圍,讓協同程式在離開組成時會自動取消,請使用 rememberCoroutineScope。此外,當您需要手動控制一或多個協同程式的生命週期時 (例如在使用者事件發生時取消動畫),請使用 rememberCoroutineScope

rememberCoroutineScope 是可組合函式,會將綁定該組成中的呼叫點的 CoroutineScope 傳回。當呼叫離開組成時,系統就會取消此範圍。

沿續前述範例,您可以利用這組程式碼,在使用者輕觸 Button 時顯示 Snackbar

@Composable
fun MoviesScreen(scaffoldState: ScaffoldState = rememberScaffoldState()) {

    // Creates a CoroutineScope bound to the MoviesScreen's lifecycle
    val scope = rememberCoroutineScope()

    Scaffold(scaffoldState = scaffoldState) {
        Column {
            /* ... */
            Button(
                onClick = {
                    // Create a new coroutine in the event handler to show a snackbar
                    scope.launch {
                        scaffoldState.snackbarHostState.showSnackbar("Something happened!")
                    }
                }
            ) {
                Text("Press me")
            }
        }
    }
}

rememberUpdatedState:參照一個不會在值有所變更時重新啟動的「作用」中的值

主要參數之一有所變更時,LaunchedEffect 會重新啟動。但在某些情況下,您可能會想要從「作用」中擷取一個值,並設定讓這個值即使有所變更,「作用」也不會重新啟動。為了達成這個目標,您必須使用 rememberUpdatedState 建立這個值的參照資料,可供擷取及更新。這種做法對於含有永久作業 (重建及重新啟用的代價高昂或遭到禁止) 的「作用」而言非常實用。

舉例來說,假設您的應用程式含有會在一段時間後消失的 LandingScreen。即使 LandingScreen 已重組,「作用」等待了一段時間,並通知經過的時間不應重新啟動:

@Composable
fun LandingScreen(onTimeout: () -> Unit) {

    // This will always refer to the latest onTimeout function that
    // LandingScreen was recomposed with
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    // Create an effect that matches the lifecycle of LandingScreen.
    // If LandingScreen recomposes, the delay shouldn't start again.
    LaunchedEffect(true) {
        delay(SplashWaitTimeMillis)
        currentOnTimeout()
    }

    /* Landing screen content */
}

為建立與呼叫站生命週期相符的「作用」,系統會傳遞永不改變的常數 (如 Unittrue) 做為參數。上述程式碼中使用了 LaunchedEffect(true)。為確保 onTimeout lambda「一律」含有重組 LandingScreen 時採用的最新值,必須使用 rememberUpdatedState 函式包覆 onTimeout。傳回的 State、程式碼中的 currentOnTimeout,都應運用在「作用」中。

DisposableEffect:必須清除的作用

有些副作用在金鑰變更後或組件離開該「組成」後就必須「清除」,針對這類副作用,請使用 DisposableEffect。如果 DisposableEffect 金鑰有所變更,組件必須「處置」 (進行清除) 目前的「作用」,並再次呼叫「作用」進行重設。

舉例來說,您可能會想使用 LifecycleObserver 根據 Lifecycle 事件傳送數據分析事件。如要在 Compose 中監聽這些事件,請視需要使用 DisposableEffect 註冊和取消註冊觀察工具。

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit // Send the 'stopped' analytics event
) {
    // Safely update the current lambdas when a new one is provided
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    // If `lifecycleOwner` changes, dispose and reset the effect
    DisposableEffect(lifecycleOwner) {
        // Create an observer that triggers our remembered callbacks
        // for sending analytics events
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) {
                currentOnStart()
            } else if (event == Lifecycle.Event.ON_STOP) {
                currentOnStop()
            }
        }

        // Add the observer to the lifecycle
        lifecycleOwner.lifecycle.addObserver(observer)

        // When the effect leaves the Composition, remove the observer
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    /* Home screen content */
}

在上述程式碼中,「作用」會將 observer 新增至 lifecycleOwner。如果 lifecycleOwner 有所變更,系統就會棄置「作用」再以新的 lifecycleOwner 將其重新啟動。

DisposableEffect 必須納入 onDispose 子句做為其程式碼區塊中的最終陳述式。否則 IDE 會顯示建構時間錯誤。

SideEffect:將 Compose 狀態發布至非 Compose 程式碼

如要與不是由 Compose 管理的物件共用 Compose 狀態,請使用 SideEffect 組件,因為系統會在每次成功重組時叫用這個組件。

舉例來說,您的數據分析程式庫可能會允許您針對所有後續數據分析事件附加自訂中繼資料 (在此例中是「使用者屬性」),來區隔使用者人口。如要將目前使用者的使用者類型連接到數據分析程式庫,請使用 SideEffect 更新其值。

@Composable
fun rememberAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        /* ... */
    }

    // On every successful composition, update FirebaseAnalytics with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

produceState:將非 Compose 狀態轉換為 Compose 狀態

produceState 會啟動協同程式,範圍限定為可將值推送至傳回的 State 裡的「組成」。請用這個協同程式將非 Compose 狀態轉換為 Compose 狀態,例如將外部的以訂閱為準狀態 (像是 FlowLiveDataRxJava) 帶入到「組成」中。

produceState 進入「組成」中,制作工具就會啟動;而離開「組成」時,製作工具就會取消。傳回的 State 會混合起來,設定相同的值不會觸發重組。

雖然 produceState 會建立協同程式,但也可以用來觀察非暫停的資料來源。如要移除針對該來源的訂閱,請使用 awaitDispose 函式。

以下範例說明如何使用 produceState 從網路載入圖片。loadNetworkImage 可組合函式會傳回可用於其他組件的 State

@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository
): State<Result<Image>> {

    // Creates a State<T> with Result.Loading as initial value
    // If either `url` or `imageRepository` changes, the running producer
    // will cancel and will be re-launched with the new inputs.
    return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {

        // In a coroutine, can make suspend calls
        val image = imageRepository.load(url)

        // Update State with either an Error or Success result.
        // This will trigger a recomposition where this State is read
        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}

derivedStateOf:將一或多個狀態物件轉換成其他狀態

如果某個特定狀態是從其他狀態物件計算或衍生而來,請使用 derivedStateOf。使用這個函式可保證只有在計算中使用的其中一個狀態變更時,才會進行計算。

以下範例顯示基本的「待辦事項」清單,其中含有使用者定義高優先度關鍵字的工作顯示在前面:

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

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

    // Calculate high priority tasks only when the todoTasks or highPriorityKeywords
    // change, not on every recomposition
    val highPriorityTasks by remember(highPriorityKeywords) {
        derivedStateOf { todoTasks.filter { it.containsWord(highPriorityKeywords) } }
    }

    Box(Modifier.fillMaxSize()) {
        LazyColumn {
            items(highPriorityTasks) { /* ... */ }
            items(todoTasks) { /* ... */ }
        }
        /* Rest of the UI where users can add elements to the list */
    }
}

在上述程式碼中,derivedStateOf 可保證每當 todoTasks 有所變更,highPriorityTasks 計算就會發生,而 UI 也會隨之更新。如果 highPriorityKeywords 發生變更,系統會執行 remember 區塊,並建立一個新的衍生狀態物件,然後記憶在舊物件所在的位置。由於篩選計算 highPriorityTasks 的花費高昂,只有在任何清單發生變更時,才有必要執行,不需要每次重組都執行。

此外,更新為 derivedStateOf 產生的狀態不會導致系統宣告組件重組,Compose 只有在其傳回的狀態獲讀取時,才會將這些組件重組 (在範例中的 LazyColumn 裡面)。

程式碼也假設了 highPriorityKeywords 變更頻率遠低於 todoTasks。如果情況並非如此,程式碼可能使用了 remember(todoTasks, highPriorityKeywords) 而非 derivedStateOf

snapshotFlow:將 Compose 的狀態轉換為流程

使用 snapshotFlowState<T> 物件轉換至冷流程。snapshotFlow 會在收集後執行其區塊,並發出在其中讀取到的 State 物件結果。當 snapshotFlow 區塊內部讀取到的 State 物件之一發生變化,如果新的值不等同於先前發出的值,流程就會將新的值發送至其收集工具中 (這項行為類似於 Flow.distinctUntilChanged 的行為)。

以下範例顯示使用者捲動經過清單中第一個項目前往數據分析時會進行記錄的副作用:

val listState = rememberLazyListState()

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

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

在上述程式碼中,listState.firstVisibleItemIndex 會轉換成能從流程運算子的強大功能受益的流程。

重新啟動「作用」

Compose 中的部分「作用」像是 LaunchedEffectproduceStateDisposableEffect,會採取數量會變化的引數、金鑰,這些項目是用來取消運作中的「作用」,並採用新的金鑰啟動新的「作用」。

這些 API 的一般形式如下:

EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }

由於這項行為十分細微,如果用來重新啟動「作用」的參數不是正確的參數,就會發生問題:

  • 重新啟動的「作用」少於應有的數量,可能造成應用程式中發生錯誤。
  • 重新啟動的「作用」多於應有的數量,可能使效率低落。

原則上,在程式碼的「作用」區塊使用的可變動和不可變動變數,應該新增為「作用」組件的參數。除此之外,您可以新增更多參數,以強制重新啟動「作用」。如果變更變數不會導致「作用」重新啟動,變數應包含在 rememberUpdatedState 中。如果變數因為包覆在不含金鑰的 remember 中而從未變更,您就不需要將變數當做金鑰傳遞給「作用」。

在上方顯示的 DisposableEffect 程式碼中,「作用」被當成 lifecycleOwner 在其區塊中使用的參數,因為發生任何變更都會導致「作用」重新啟動。

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

DisposableEffect 金鑰不需要 currentOnStartcurrentOnStop,原因是使用了 rememberUpdatedState,其值在「組成」中永不變更。如果您並未將 lifecycleOwner 當做參數進行傳遞,而且發生了變更,則 HomeScreen 會重組,但 DisposableEffect 不會遭棄置並重新啟動。由於從此處開始,系統採用了錯誤的 lifecycleOwner,因此會造成問題。

以常值為金鑰

您可以使用 true 這類常值做為「作用」金鑰,使其遵循呼叫站的生命週期。有效的用途確實存在,例如前述的 LaunchedEffect 範例。不過,在實際執行之前,請多考慮一下,確認您真的需要這麼做。