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 陳述式內部,當陳述式為「否」時,如果 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) 帶入「組成」中。

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 Flows 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.distinctUntilChanged 類似),Flow 就會向收集器發出新值。

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

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 範例。不過,在實際執行之前,請多考慮一下,確認您真的需要這麼做。