Jetpack Compose(4)——重組

SharpCJ發表於2024-04-03

目錄
  • 一、狀態變化
    • 1.1 狀態變化是什麼
    • 1.2 mutableStateListOf 和 mutableStateMapOf
  • 二、重組的特性
    • 2.1 Composable 重組是智慧的
    • 2.2 Composable 會以任意順序執行
    • 2.3 Composable 會併發執行
    • 2.4 Composable 會反覆執行
    • 2.5 Composable 的執行是“樂觀”的
  • 三、重組範圍
  • 四、引數型別的穩定性
    • 4.1 穩定和不穩定
    • 4.2 @Stable 和 @Immutable

上一篇文章講了 Compose 中狀態管理的基礎知識,本文講解 Compose 中狀重組的相關知識。

一、狀態變化

1.1 狀態變化是什麼

根據上篇文章的講解,在 Compose 我們使用 State 來宣告一個狀態,當狀態發生變化時,則會觸發重組。那麼狀態變化是指什麼呢?
下面我們來看一個例子:

@Composable
fun NumList() {
    val num by remember {
        mutableStateOf(mutableListOf(1, 2, 3))
    }
    Column {
        Button(onClick = {
            num += (num.last() + 1)
            Log.d("sharpcj", "num: $num")
        }) {
            Text(text = "click to add one")
        }
        num.forEach {
            Text(text = "item --> $it")
        }
    }
}

這段程式碼中,我們定義了一個 State ,其包裹的型別是 MutableList, 並且每次點選,我們就給該 mutableList 增加一個元素。執行一下:

我們點選了按鈕,介面並沒有發生變化,但是,從日誌看到,每次點選後,list 中的元素的確增加了一個。

2024-03-18 20:51:41.472 12574-12574 sharpcj                 com.sharpcj.hellocompose             D  num: [1, 2, 3, 4]
2024-03-18 20:51:42.411 12574-12574 sharpcj                 com.sharpcj.hellocompose             D  num: [1, 2, 3, 4, 5]
2024-03-18 20:51:43.347 12574-12574 sharpcj                 com.sharpcj.hellocompose             D  num: [1, 2, 3, 4, 5, 6]

原因是什麼呢?其實狀態發生變化,實際上指的是 State 包裹的物件,進行 equals 比較,如果不相等,則認為狀態變化,否則認為沒有發生變化。所以這裡就解釋得通了,我們雖然在點選按鈕後,給 mutableList 增加了元素,但是 mutableList 在進行前後比較時,比較的是其引用,物件的引用並沒有發生變化,所以沒有發生重組。【這裡結論並不準確,下面穩定型別詳細解釋說】
那為了讓其發生重組,我們稍作修改,每次點選按鈕時,建立一個新的 list,然後賦值,看看是不是我們所期待的結果。

@Composable
fun NumList() {
    var num by remember {
        mutableStateOf(mutableListOf(1, 2, 3))
    }
    Column {
        Button(onClick = {
            val num1 = num.toMutableList()
            num1 += (num1.last() + 1)
            num = num1
            Log.d("sharpcj", "num: $num")
        }) {
            Text(text = "click to add one")
        }
        num.forEach {
            Text(text = "item --> $it")
        }
    }
}

再次執行程式:

結果符合我們的預期。那對於 List 型別的資料物件,每次狀態發生變化,我們建立了一個新物件,這樣在進行 equals 比較時,必定不相等,則會觸發重組。

1.2 mutableStateListOf 和 mutableStateMapOf

上面的問題,我們雖然接解決了, 但是寫法不夠優雅,其實 Compose 給我們提供了一個函式 mutableStateListOf 來解決這類問題,我們看看這個函式怎麼用,改寫上面的例子

@Composable
fun NumList() {
    val num = remember {
        mutableStateListOf(1, 2, 3)
    }
    Column {
        Button(onClick = {
            num += (num.last() + 1)
            Log.d("sharpcj", "num: $num")
        }) {
            Text(text = "click to add one")
        }
        num.forEach {
            Text(text = "item --> $it")
        }
    }
}

這樣就可以滿足我們的需求。 mutableStateListOf 返回了一個可感知內部資料變化的 SnapshotStateList<T>, 它的內部的實現為了保證不變性,仍然是複製元素,只不過它用了更加高效的實現,比我們單純用toMutableList要高效得多。
由於 SnapshotStateList 繼承了 MutableList 介面,使得 MutableList 中定義的方法,依然可以使用。
同理,對於 Map 型別的物件, Compose 中提供了 mutableStateMapOf 方法,可以更優雅,更高效地進行處理。

思考如下問題:
假如我定義了一個型別:data class Hero(var name: String, var age: Int), 然後使用 mutableStateListOf 定義了狀態,其中的元素是自定義的型別 Hero, 當改變 Hero 的屬性時, 與該狀態相關的 Composable 是否會發生重組?

data class Hero(var name: String, var age: Int)

@Composable
fun HeroInfo() {
    val heroList = remember {
        mutableStateListOf(Hero(name = "安其拉", age = 18), Hero(name = "魯班", age = 19))
    }

    Column {
        Button(onClick = {
            heroList[0].name = "DaJi"
            heroList[0].age = 22
        }) {
            Text(text = "test click")
        }

        heroList.forEach {
            Text(text = "student, name: ${it.name}, age: ${it.age} ")
        }
    }
}

二、重組的特性

2.1 Composable 重組是智慧的

傳統 View 體系透過修改 View 的私有屬性來改變 UI, Compose 則透過重組重新整理 UI。 Compose 的重組非常“智慧”,當重組發生時,只有狀態發生更新的 Composable 才會參與重組,沒有變化的 Composable 會跳過本次重組。

@Composable
fun KingHonour() {
    Column {
        var name by remember {
            mutableStateOf("周瑜")
        }
        Button(onClick = {
            name = "小喬"
        }) {
            Text(text = "改名")
        }
        Text(text = "魯班")
        Text(text = name)

    }
}

該例子中,點選按鈕,改變了 name 的值,觸發重組,Button 和 Text(text = "魯班"),並不依賴該狀態,雖然在重組時被呼叫了,但是在執行時並不會真正的執行。因為其引數沒有變化,Compose 編譯器會在編譯器插入相關的比較程式碼。只有最後一個 Text 依賴該狀態,會參與真正的重組。

2.2 Composable 會以任意順序執行

@Composable
fun Navi() {
    Box {
        FirstScreen()
        SecondScreen()
        ThirdScreen()
    }
}

在程式碼中出現多個 Composable 函式時,它們並不一定按照在程式碼中出現的順序執行,比如在一個 Box 中,處於前景的 UI 具有較高優先順序。所以不要試圖透過外部變數與其它 Composable 產生關聯。

2.3 Composable 會併發執行

重組中的 Composable 並不一定執行在 UI 執行緒,它們可以在後臺執行緒中併發執行,這樣利於發揮多喝處理器的優勢。正因為此,也需要考慮執行緒安全問題。

2.4 Composable 會反覆執行

除了重組會造成 Composable 的再次執行外,在動畫等場景中每一幀的變化都可能引起 Composable 的執行。因此 Composable 可能在短時間內多次執行。

2.5 Composable 的執行是“樂觀”的

所謂“樂觀”是指 Composable 最終會依據最新的狀態正確地完成重組。在某些場景下,狀態可能會連續變化,可能會導致中間態的重組在執行時被打斷,新的重組會插進來,對於被打斷的重組,Compose 不會將執行一半的結果反應到檢視樹上,因為最後一次的狀態總歸是正確的。

三、重組範圍

原則:重組範圍最小化。
只有受到了 State 變化影響的程式碼塊,才會參與到重組,不依賴 State 變化的程式碼則不參與重組。
如何確定重組範圍呢?修改上面的例子:

@Composable
fun RecompositionTest() {
    Column {
        Box {
            Log.i("sharpcj", "RecompositionTest - 1")
            Column {
                Log.i("sharpcj", "RecompositionTest - 2")
                var name by remember {
                    mutableStateOf("周瑜")
                }
                Button(onClick = {
                    name = "小喬"
                }) {
                    Log.i("sharpcj", "RecompositionTest - 3")
                    Text(text = "改名")
                }
                Text(text = "魯班")
                Text(text = name)
            }
        }
        Box {
            Log.i("sharpcj", "RecompositionTest - 4")
        }
        Card {
            Log.i("sharpcj", "RecompositionTest - 5")
        }
    }
}

執行,第一次我們看到,列印瞭如下日誌:

2024-03-22 15:36:15.303 19870-19870 sharpcj                 com.sharpcj.hellocompose             I  RecompositionTest - 1
2024-03-22 15:36:15.305 19870-19870 sharpcj                 com.sharpcj.hellocompose             I  RecompositionTest - 2
2024-03-22 15:36:15.326 19870-19870 sharpcj                 com.sharpcj.hellocompose             I  RecompositionTest - 3
2024-03-22 15:36:15.337 19870-19870 sharpcj                 com.sharpcj.hellocompose             I  RecompositionTest - 4
2024-03-22 15:36:15.344 19870-19870 sharpcj                 com.sharpcj.hellocompose             I  RecompositionTest - 5

這是正常的,每個控制元件範圍內都執行了。我們點選,button, 改變了 name 狀態。列印如下日誌:

2024-03-22 15:37:48.480 19870-19870 sharpcj                 com.sharpcj.hellocompose             I  RecompositionTest - 1
2024-03-22 15:37:48.480 19870-19870 sharpcj                 com.sharpcj.hellocompose             I  RecompositionTest - 2
2024-03-22 15:37:48.491 19870-19870 sharpcj                 com.sharpcj.hellocompose             I  RecompositionTest - 4

首先我們 name 這個狀態影響的元件時 Text,它所在的作用域應該是 Column 內部。列印 RecompositionTest - 2 好理解,可為什麼連 Column 的上一級作用域 Box 也被呼叫了,並且連該 Box 的統計 Box 也被呼叫了,但是 Card 卻又沒有被呼叫。這個好像與上面說的原則相悖。其實不然,我們看看 ColumnBoxCard 原始碼就清楚了。

@Composable
inline fun Column(
    modifier: Modifier = Modifier,
    verticalArrangement: Arrangement.Vertical = Arrangement.Top,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    content: @Composable ColumnScope.() -> Unit
) {
    val measurePolicy = columnMeasurePolicy(verticalArrangement, horizontalAlignment)
    Layout(
        content = { ColumnScopeInstance.content() },
        measurePolicy = measurePolicy,
        modifier = modifier
    )
}
@Composable
inline fun Box(
    modifier: Modifier = Modifier,
    contentAlignment: Alignment = Alignment.TopStart,
    propagateMinConstraints: Boolean = false,
    content: @Composable BoxScope.() -> Unit
) {
    val measurePolicy = rememberBoxMeasurePolicy(contentAlignment, propagateMinConstraints)
    Layout(
        content = { BoxScopeInstance.content() },
        measurePolicy = measurePolicy,
        modifier = modifier
    )
}
@Composable
fun Card(
    modifier: Modifier = Modifier,
    shape: Shape = CardDefaults.shape,
    colors: CardColors = CardDefaults.cardColors(),
    elevation: CardElevation = CardDefaults.cardElevation(),
    border: BorderStroke? = null,
    content: @Composable ColumnScope.() -> Unit
) {
    Surface(
        modifier = modifier,
        shape = shape,
        color = colors.containerColor(enabled = true),
        contentColor = colors.contentColor(enabled = true),
        tonalElevation = elevation.tonalElevation(enabled = true),
        shadowElevation = elevation.shadowElevation(enabled = true, interactionSource = null).value,
        border = border,
    ) {
        Column(content = content)
    }
}

不難發現, Column 和 Box 都是使用 inline 修飾的。
最後簡單瞭解下 Compose 重組的底層原理。
經過 Compose 編譯器處理後的 Composable 程式碼在對 State 進行讀取時,能夠自動建立關聯,在執行過程中,當 State 變化時, Compose 會找到關聯的程式碼塊標記為 Invalid, 在下一渲染幀到來之前,Compose 觸發重組並執行 invalid 程式碼塊, invalid 程式碼塊即下一次重組的範圍。能夠被標記為 Invalid 的程式碼必須是非 inline 且無返回值的 Composable 函式或 lambda。

需要注意的是,重組的範圍,與只能跳過並不衝突,確定了重組範圍,會呼叫對應的元件程式碼,但是當引數沒有變化時,在執行時不會真正執行,會跳過本次重組。

四、引數型別的穩定性

4.1 穩定和不穩定

前面,Composable 狀態變化觸發重組,狀態變化基於 equals 比較結果,這是不準確的。準確地說:只有當比較的狀態物件,是穩定的,才能透過 equals 比較結果確定是否重組。什麼叫穩定的?還是看一個例子:

data class Hero(var name: String)

val shangDan = Hero("呂布")

@Composable
fun StableTest() {
    var greeting by remember {
        mutableStateOf("hello, 魯班")
    }

    Column {
        Log.i("sharpcj", "invoke --> 1")
        Text(text = greeting)
        Button(onClick = {
            greeting = "hello, 魯班大師"
        }) {
            Text(text = "搞錯了,是魯班大師")
        }
        ShangDan(shangDan)
    }
}

@Composable
fun ShangDan(hero: Hero) {
    Log.i("sharpcj", "invoke --> 2")
    Text(text = hero.name)
}

執行一下,列印

2024-03-22 17:07:50.248 26973-26973 sharpcj                 com.sharpcj.hellocompose             I  invoke --> 1
2024-03-22 17:07:50.272 26973-26973 sharpcj                 com.sharpcj.hellocompose             I  invoke --> 2

點選 Button,再次看到列印:

2024-03-22 17:07:53.182 26973-26973 sharpcj                 com.sharpcj.hellocompose             I  invoke --> 1
2024-03-22 17:07:53.191 26973-26973 sharpcj                 com.sharpcj.hellocompose             I  invoke --> 2

問題來了, Shangdan 這個元件依賴的只依賴一個引數,並且引數也沒有改變,為什麼確在重組過程中被呼叫了呢?
接下來,我們將 Hero 這個類做點改變,將其屬性宣告由 var 變成 val

data class Hero(val name: String)

再次執行,

2024-03-22 17:35:41.435 28561-28561 sharpcj                 com.sharpcj.hellocompose             I  invoke --> 1
2024-03-22 17:35:41.458 28561-28561 sharpcj                 com.sharpcj.hellocompose             I  invoke --> 2

點選button:

2024-03-22 17:35:47.790 28561-28561 sharpcj                 com.sharpcj.hellocompose             I  invoke --> 1

這次,Shangdan 這個 Composable 沒有參與重組了。為什麼會這樣呢?

其實是在因為此前,用 var 宣告 Hero 類的屬性時,Hero 類被 Compose 編譯器認為是不穩定型別。即有可能,我們傳入的引數引用沒有變化,但是屬性被修改過了,而 UI 又確實需要顯示修改後的最新值。而當用 val 宣告屬性了,Compose 編譯器認為該物件,只要物件引用不要變,那麼這個物件就不會發生變化,自然 UI 也就不會發生變化,所以就跳過了這次重組。
常用的基本資料型別以及函式型別(lambda)都可以稱得上是穩定型別,它們都不可變。反之,如果狀態是可變的,那麼比較 equals 結果將不再可信。在遇到不穩定型別時,Compose 的抉擇是寧願犧牲一些效能,也總好過顯示錯誤的 UI。

4.2 @Stable 和 @Immutable

上面講了穩定與不穩定的概念,然而實際開發中,我們經常會根據業務自定義 data class, 難道用了 Compose, 雖然 Kotlin 編碼規範,強調儘量使用 val, 但是還是要根據實際業務,使用 var 來定義可變屬性。對於這種型別,我們可以為其新增 @Stable 註解,讓編譯器將其視為穩定型別。從而發揮智慧重組的作用,提升重組的效能。

@Stable
data class Hero(var name: String)

這樣,Hero 即便使用 var 宣告屬性,它作為引數傳入 Composable 中,只要物件引用沒變,都不會觸發重組。所以具體什麼時候使用該註解,還需要根據需求靈活使用。

除了 @Stable,Compose 還提供了另一個類似的註解 @Immutable,與 @Stable 不同的是,@Immutable 用來修飾的型別應該是完全不可變的。而 @Stable 可以用在函式、屬性等更多場景。使用起來更加方便,由於功能疊加,未來 @Immutable 有可能會被移除,建議優先使用 @Stable

最後總結一下:本文接著上篇文章的狀態,講解了重組的一些特性,如何確定重組的範圍,以及重組的中的型別穩定性概念,以及如何提升非穩定型別在重組過程中的效能。
下一篇文章將會講解 Composable 的生命週期以及重組的副作用函式。

相關文章