Compose 中的副作用

副作用是指應用程式狀態在可組合函式的範圍以外發生變化。由於可組合項的生命週期和屬性可能有例如預期之外的重新組成、以不同順序重新組成可組合項,或可捨棄的重新組成等等因素,因此可組合項最好應該沒有任何副作用

但有時候我們又必須用到副作用,例如想在特定狀態條件下觸發顯示 Snackbar 或導覽到其他螢幕等單次事件時就必須利用副作用。您應透過會考量可組合項生命週期的受監控環境呼叫這些操作。在這個頁面中,您將瞭解 Jetpack Compose 提供的各種副作用 API。

狀態與作用用途

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

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

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

如要在可組合函式的生命週期內執行工作,並能夠呼叫暫停函式,請使用 LaunchedEffect 可組合函式。當 LaunchedEffect 進入「組成」中,會啟動協同程式並隨之傳遞程式碼區塊當做參數。如果 LaunchedEffect 離開組成,協同程式就會取消。如果使用不同的金鑰重組了 LaunchedEffect (請參閱下方章節的重新啟動「作用」),系統會取消現有的協同程式,並在新的協同程式中啟動新的暫停函式。

舉例來說,以下是動畫,可透過可設定的延遲時間脈衝 Alpha 值:

// Allow the pulse rate to be configured, so it can be sped up if the user is running
// out of time
var pulseRateMs by remember { mutableStateOf(3000L) }
val alpha = remember { Animatable(1f) }
LaunchedEffect(pulseRateMs) { // Restart the effect when the pulse rate changes
    while (isActive) {
        delay(pulseRateMs) // Pulse the alpha every pulseRateMs to alert the user
        alpha.animateTo(0f)
        alpha.animateTo(1f)
    }
}

在上述程式碼中,動畫會使用暫停函式 delay 等待設定的時間長度。接著,它會使用 animateTo 依序將 alpha 動畫設為零,然後再將其設回原狀。這會在可組合函式的生命週期中重複執行。

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 的行為)。

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

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

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

以常值為金鑰

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