Jetpack Compose(5)——生命週期與副作用函式

SharpCJ發表於2024-04-04

目錄
  • 一、 Composable 的生命週期
  • 二、 Composable 的副作用
    • 2.1 SideEffect
    • 2.2 DisposableEffect
    • 2.3 LaunchedEffect
    • 2.4 rememberCoroutineScope
    • 2.5 rememberUpdatedState
    • 2.6 derivedStateOf
    • 2.7 snapshotFlow
    • 2.8 produceState
  • 三、總結
  • 寫在最後

一、 Composable 的生命週期

Composable 元件都是函式,Composable 函式執行會得到一棵檢視樹,每一個 Composable 元件對應檢視樹上的一個節點。Composable 的生命週期定義如下:

  • onActive(新增到檢視樹) Composable 首次被執行,即在檢視樹上建立對應的節點。
  • onUpdate(重組) Composable 跟隨重組不斷執行,更新檢視樹上對應的節點。
  • onDispose(從檢視樹移除) Composable 不再被執行,對應節點從檢視樹上移除。

對於 Compose 編寫 UI 來說,頁面的變化,是依靠狀態的變化,Composable 進行重組,渲染出不同的頁面。當頁面可見時,對應的節點被新增到檢視樹,當頁面不可見時,對應的節點從檢視樹移除。所以,雖然 Activity 有前後臺的概念,但是使用 Compose 編寫的頁面,對於 Composable 沒有前後臺切換的概念。當頁面切換為不可見時,對應的節點也被立即銷燬了,不會像 Activity 或者 Fragment 那樣在後臺儲存例項。

二、 Composable 的副作用

上一篇將重組的文章講到,Composable 重組過程中可能反覆執行,並且中間環節有可能被打斷,只保證最後一次執行的狀態時正確的。
試想一個問題,如果在 Composable 函式中彈一個 Toast ,當 Composable 發生重組時,這個 Toast 會彈多少次,是不是就無法控制了。再比如,在 Composable 函式中讀寫函式之外的變數,讀寫檔案,請求網路等等,這些操作是不是都無法得到保證了。類似這樣,在 Composable 執行過程中,凡是會影響外界的操作,都屬於副作用。在 Composable 重組過程中,這些副作用行為都難以得到保證,那怎麼辦?為了是副作用只發生在生命週期的特定階段, Compose 提供了一系列副作用函式,來確保行為的可預期性。下面,我們看看這些副作用函式的使用場景。

2.1 SideEffect

SideEffect 在每次成功重組的時候都會執行。
Composable 在重組過程中會反覆執行,但是重組不一定每次都會成功,有的可能會被中斷,中途失敗。 SideEffect 僅在重組成功的時候才會執行

特點:

  1. 重組成功才會執行。
  2. 有可能會執行多次。
    所以,SideEffect 函式不能用來執行耗時操作,或者只要求執行一次的操作。

典型使用場景,比如在主題中設定狀態列,導航欄顏色等。

SideEffect {
    val window = (view.context as Activity).window
    window.statusBarColor = colorScheme.primary.toArgb()
    WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
}

2.2 DisposableEffect

DisposableEffect 可以感知 Composable 的 onActiveonDispose, 允許使用該函式完成一些預處理和收尾工作。

典型的使用的場景,註冊與取消註冊:

DisposableEffect(vararg keys: Any?) {
    // register(callback)
    onDispose {
        // unregister(callback)
    }
}

這裡首先引數 keys 表示,當 keys 變化時, DisposableEffect 會重新執行,如果在整個生命週期內,只想執行一次,則可以傳入 Unit
onDispose 程式碼塊則會在 Composable 進入 onDispose 時執行。

2.3 LaunchedEffect

LaunchedEffect 用於在 Composable 中啟動協程,當 Composable 進入 onAtive 時,LaunchedEffect 會自動啟動協程,執行 block 中的程式碼。當 Composable 進入 onDispose 時,協程會自動取消。
使用方法:

LaunchedEffect(vararg keys: Any?) {
    // do Something async
}

同樣支援可觀察引數,當 key 變化時,當前協程自動結束,同時開啟新協程。

2.4 rememberCoroutineScope

LaunchedEffect 只能在 Composable 中呼叫,如果想在非 Composable 環境中使用協程,比如在 Button 的 OnClick 中開啟協程,並希望在 Composable 進入 onDispose 時自動取消,則可以使用 rememberCoroutineScope 。
具體用法如下:

@Composable
fun Test() {
    val scope = rememberCoroutineScope()
    Button(
        onClick = {
            scope.launch {
                // do something
            }
        }
    ) {
        Text("click me")
    }
}

DisposableEffect 配合 rememberCoroutineScope 可以實現 LaunchedEffect 同樣的效果,但是一般這樣做沒有什麼意義。

2.5 rememberUpdatedState

rememberUpdatedState 一般和 DisposableEffect 或者 LaunchedEffect 配套使用。當使用 DisposableEffect 或者 LaunchedEffect時,程式碼塊中用到某個值會在外部更新,如何獲取到最新的值呢?看一個例子,比如玩王者榮耀時,預選英雄,然後將英雄顯示出來,十秒倒數計時後,顯示最終選擇的英雄,倒數計時期間,可以改變選擇的英雄。

@Composable
fun ChooseHero() {
    var sheshou by remember {
        mutableStateOf("狄仁傑")
    }

    Column {
        Text(text = "預選英雄: $sheshou")
        Button(onClick = {
            sheshou = "馬可波羅"
        }) {
            Text(text = "改選:馬可波羅")
        }
        FinalChoose(sheshou)
    }
}

@Composable
fun FinalChoose(hero: String) {
    var tips by remember {
        mutableStateOf("遊戲倒數計時:10s")
    }
    LaunchedEffect(key1 = Unit) {
        delay(10000)
        tips = "最終選擇的英雄是:$hero"
    }
    Text(text = tips)
}

程式碼執行效果如下:

我們預選了狄仁傑,倒數計時期間,點選 button, 改選馬可波羅,最終選擇的英雄確顯示狄仁傑。
分析原因如下:在 FinalChoose 中引數 hero 來源於外部,它的值改變,會觸發重組,但是,由於 LaunchedEffect 函式,key 賦值 Unit, 重組過程中,協程程式碼塊並不會重新執行,感知不到外部的變化。要使能夠獲取到外部的最新值,一種方式是將 hero 作為 LaunchedEffect 的可觀察引數。修改程式碼如下:

@Composable
fun FinalChoose(hero: String) {
    var tips by remember {
        mutableStateOf("遊戲倒數計時:10s")
    }
    LaunchedEffect(key1 = hero) {
        delay(10000)
        tips = "最終選擇的英雄是:$hero"
    }
    Text(text = tips)
}

此時再次執行,在倒數計時期間,我們點選 button, 改變預選英雄,結果顯示正常了,最終選擇的即為馬可波羅。但是該方案並不符合我們的需求,前面講到, LaunchedEffect 的引數 key,發生變化時,協程會取消,並重新啟動新的協程,這意味著,當倒數計時過程中,我們改變了 key , 重新啟動的協程能夠獲取到改變後的值,但是倒數計時也重新開始了,這顯然不是我們所期望的結果。

rememberUpdatedState 就是用來解決這種場景的。在不中斷協程的情況下,始終能夠獲取到最新的值。看一下 rememberUpdatedState 如何使用。
我們把 LaunchedEffect 的引數 key 還原成 Unit。使用 rememberUpdatedState 定義 currentHero。

@Composable
fun FinalChoose(hero: String) {
    var tips by remember {
        mutableStateOf("遊戲倒數計時:10s")
    }

    val currentHero by rememberUpdatedState(newValue = hero)

    LaunchedEffect(key1 = Unit) {
        delay(10000)
        tips = "最終選擇的英雄是:$currentHero"
    }
    Text(text = tips)
}

這樣,執行結果就符合我們的預期了。

2.6 derivedStateOf

上面的例子中,有一點不完美的地方,遊戲倒數計時時間沒有更新。下面使用 derivedStateOf 來最佳化這個功能。

@Composable
fun FinalChoose(hero: String) {
    var time by remember {
        mutableIntStateOf(10)
    }

    val tips by remember {
        derivedStateOf {
            "遊戲倒數計時:${time}s"
        }
    }

    LaunchedEffect(key1 = Unit) {
        repeat(10) {
            delay(1000)
            time--
        }
    }
    Text(
        text = if (time == 0) {
            "最終選擇的英雄是:$hero"
        } else {
            tips
        }
    )
}

現在效果好多了。這裡我們不再需要 rememberUpdatedState 了。首先定義了時間,時一個 Int 型別的 State,然後藉助 derivedStateOf 定義 tip ,時一個 String 型別的 State。
derivedStateOf 的作用是從一個或者多個 State 派生出另一個 State。如果某個狀態是從其他狀態物件計算或派生得出的,則可以使用 derivedStateOf。使用此函式可確保僅當計算中使用的狀態之一發生變化時才會進行計算。
derivedStateOf 的使用不難,但是和 remember 的配合使用可以有很多玩法來適應不同的場景,主要的關注點還是在觸發重組的條件上,這個要綜合實際的場景和效能來覺得是用 key 來觸發重組還是改變引用的狀態來觸發重組。

2.7 snapshotFlow

前面使用 rememberUpdatedState 可以在 LaunchedEffect 中始終獲取到外部狀態的最新的值。但是無法感知到狀態的變化,也就是說外部狀態變化了,LaunchedEffect 中的程式碼無法第一時間被通知到。用 snapshotFlow 則可以解決這個場景。
snapshotFlow 用於將一個 State<T> 轉換成一個協程中的 Flow。 當 snpashotFlow 塊中讀取到的 State 物件之一發生變化時,如果新值與之前發出的值不相等,Flow 會向收集器發出最新的值(此行為類似於 Flow.distinctUntilChaned)。
看具體使用:

@Composable
fun FinalChoose(hero: String) {
    var time by remember {
        mutableIntStateOf(10)
    }

    var tips by remember {
        mutableStateOf("遊戲倒數計時:10s")
    }

    LaunchedEffect(key1 = Unit) {
        launch {
            repeat(10) {
                delay(1000)
                time--
            }
        }
        launch {
            snapshotFlow { time }.collect {
                    tips = "遊戲倒數計時:${it}s"
                }
        }
    }

    Text(
        text = if (time == 0) {
            "最終選擇的英雄是:$hero"
        } else {
            tips
        }
    )
}

執行結果和上一次一樣,這裡我們不再使用 derivedStateOf, 而是啟動了兩個協程,一個協程用於倒數計時技術,另一個協程則將 time 這個 State 轉換成 Flow, 然後進行收集,並更新 tips。

2.8 produceState

produceState 用於將任意外部資料來源轉換為 State。
比如上面的例子中,我們將倒數計時時間定義在 ViewModel 中,並且倒數計時的邏輯在 ViewModel 中實現,在 UI 中就可以藉助 produceState 來實現。

@Composable
fun FinalChoose(hero: String) {
    val time = viewModel.time

    val tips by produceState<String>(initialValue = "遊戲倒數計時:10s") {
        value = "遊戲倒數計時:${time}s"

        awaitDispose {
            // 做一些收尾的工作
        }
    }
    Text(
        text = if (time == 0) {
            "最終選擇的英雄是:$hero"
        } else {
            tips
        }
    )
}

我們看一下 produceState 的原始碼實現:

@Composable
fun <T> produceState(
    initialValue: T,
    vararg keys: Any?,
    producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
    val result = remember { mutableStateOf(initialValue) }
    @Suppress("CHANGING_ARGUMENTS_EXECUTION_ORDER_FOR_NAMED_VARARGS")
    LaunchedEffect(keys = keys) {
        ProduceStateScopeImpl(result, coroutineContext).producer()
    }
    return result
}

很好理解,就是定義了一個狀態 State, 然後啟動了一個協程,在協程中去更新 State 的值。引數 key 發生變化時,協程會取消,然後重新啟動,生成新的 State。
同時注意到,在 produceState 中可以使用 awaitDispose{ } 方法做一些收尾工作。這是不是很容易聯想到 callbackFlow 的使用場景。沒錯,基於回撥的介面實現,利用 callbackFlow 很容易轉換為協程的 Flow, 而 produceState 即可將其轉換為 Compose 中的 State。比如 BroadcastReceiver、ContentProvider、網路請求等等。

val currentPerson by produceState<Person?>(null, viewModel) {
    val disposable = viewModel.registerPersonObserver { person ->
        value = person
    }

    awaitDispose {
        disposable.dispose()
    }
}

再看一個網路請求的例子:

@Composable
fun GetApi(url: String, repository: Repository): Recomposer.State<Result<Data>> {
    return produceState(initialValue = Result.Loading, url, repository) {
        val data = repository.load(url)
        value = if (result == null) {
            Result.Error
        } else {
            Result.Success(data)
        }
    }
}

三、總結

本文主要介紹了 Composable 的宣告週期,以及常用的副作用函式。
在重組過程中,應該極力避免副作用的發生。根據場景,使用合適的副作用函式。

寫在最後

個人認為 Compose 中最重要的知識域有兩個——狀態和重組、Modifier 修飾符。經過前面這些文章的講解,狀態和重組基本上主要的知識點都講到了,知識有一定的前後連貫性。而 Modifier 修飾符龐大的類別體系中,將不再具有這樣的關聯,可以挨個獨立學習。接下來的文章,我將不依次介紹 Modifier 的類別。而是介紹 Android 開發中的應用領域在 Compose 中的處理方式,比如自定義 Layout, 動畫,觸控反饋等等,然後在這些知識點中,講解涉及到的 Modifier。歡迎大家繼續關注!

相關文章