Compose 中的副作用

連帶效果是指在可組合函式範圍外發生的應用程式狀態變更。由於可組合項的生命週期和屬性 (例如無法預測的重組)、以不同順序執行可組合項的重組,或是可捨棄的重組,可組合項理想情況下應無副作用

但有時還是需要副作用,例如為了觸發一次性事件,例如顯示 Snackbar,或根據特定狀態條件前往其他畫面。您應從瞭解可組合項生命週期的受控環境呼叫這些動作。在這個頁面中,您將瞭解 Jetpack Compose 提供的各種副作用 API。

狀態與效果的用途

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

在 Compose 中開啟的不同可能性影響,因此很容易遭到過度使用。請確認您在其中執行的工作與 UI 相關,且不會中斷單向資料流,詳情請參閱管理狀態說明文件

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

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

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

@Composable
fun MyScreen(
    state: UiState<List<Movie>>,
    snackbarHostState: SnackbarHostState
) {

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

        // `LaunchedEffect` will cancel and re-launch if
        // `scaffoldState.snackbarHostState` changes
        LaunchedEffect(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.
            snackbarHostState.showSnackbar(
                message = "Error message",
                actionLabel = "Retry message"
            )
        }
    }

    Scaffold(
        snackbarHost = {
            SnackbarHost(hostState = snackbarHostState)
        }
    ) { contentPadding ->
        // ...
    }
}

在上述程式碼中,如果該狀態含有錯誤就會觸發協同程式,如果不含錯誤就會取消。由於 LaunchedEffect 呼叫網站位於 if 陳述式內,當陳述式為 false 時,如果 LaunchedEffect 出現在組合中,系統會移除該陳述式,並取消協同程式。

rememberCoroutineScope:取得組合感知範圍,在可組合函式外啟動協同程式

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

rememberCoroutineScope 這個可組合函式會傳回 CoroutineScope,此範圍是繫結至呼叫該函式的組成點。當呼叫離開組成時,系統就會取消此範圍。

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

@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {

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

    Scaffold(
        snackbarHost = {
            SnackbarHost(hostState = snackbarHostState)
        }
    ) { contentPadding ->
        Column(Modifier.padding(contentPadding)) {
            Button(
                onClick = {
                    // Create a new coroutine in the event handler to show a snackbar
                    scope.launch {
                        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 可保證效果會在每次成功重組後執行。另一方面,在系統保證成功重組前執行效果並不容易,也就是在可組合項中直接寫入效果時的情況。

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

@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        FirebaseAnalytics()
    }

    // 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) 帶入 Composition (組合)。

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

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

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

@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: 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:將一或多個狀態物件轉換成其他狀態

在 Compose 中,每當觀察到的狀態物件或可組合函式輸入內容有所變更,就會發生「重組」。狀態物件或輸入內容的變更頻率可能高於 UI 實際需要更新的頻率,因而導致不必要的重組。

當可組合項的輸入內容的變更頻率超過需要重組時,建議您使用 derivedStateOf 函式。當某些內容經常變更 (例如捲動位置) 時,就會發生這種情況,但可組合項只有在超過特定門檻時才需要回應。derivedStateOf 會建立新的 Compose 狀態物件,您可以觀察到只會視需要更新。這樣一來,它的運作方式會與 Kotlin 資料流 distinctUntilChanged() 運算子類似。

正確使用方式

下列程式碼片段為 derivedStateOf 的適當用途:

@Composable
// When the messages parameter changes, the MessageList
// composable recomposes. derivedStateOf does not
// affect this recomposition.
fun MessageList(messages: List<Message>) {
    Box {
        val listState = rememberLazyListState()

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

        // Show the button if the first visible item is past
        // the first item. We use a remembered derived state to
        // minimize unnecessary compositions
        val showButton by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex > 0
            }
        }

        AnimatedVisibility(visible = showButton) {
            ScrollToTopButton()
        }
    }
}

在這段程式碼中,每當第一個可見項目變更時,firstVisibleItemIndex 都會變更。捲動時,該值會變為 012345 等。不過,只有在值大於 0 時,才需要進行重組。如果更新頻率不符,表示這是 derivedStateOf 的理想用途。

用法不正確

常見的錯誤是,在合併兩個 Compose 狀態物件時,建議您使用 derivedStateOf,因為這是「衍生狀態」。不過,這只是負擔,不一定要執行,如以下程式碼片段所示:

// DO NOT USE. Incorrect usage of derivedStateOf.
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }

val fullNameBad by remember { derivedStateOf { "$firstName $lastName" } } // This is bad!!!
val fullNameCorrect = "$firstName $lastName" // This is correct

在這個程式碼片段中,fullName 需要像 firstNamelastName 一樣更新。因此,不會發生額外的重組情形,而且不需要使用 derivedStateOf

snapshotFlow:將 Compose 的狀態轉換為資料流

使用 snapshotFlowState<T> 物件轉換至冷流程。snapshotFlow 會在收集後執行其區塊,並發出在其中讀取到的 State 物件結果。當 snapshotFlow 區塊中讀取的其中一個 State 物件有變時,如果新值「不等於」先前所發出的值,Flow 就會向收集器發出新值 (這個行為與 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 範例。不過,在實際執行之前,請多考慮一下,確認您真的需要這麼做。