Jetpack Compose(4)——重組

上一篇文章講了 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 的生命週期以及重組的副作用函數。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章