Compose 型別穩定性註解:@Stable & @Immutable

語言: CN / TW / HK

theme: devui-blue highlight: androidstudio


前言

@Stable@Immuable 是 Compose 特有的型別穩定性註解,可以幫助 Compose 提升重組效能。本文將針對 Compose 型別的穩定性以及相關注解的使用做一個介紹。

1. 重組與穩定型別

我們知道 Compose 的重組非常“智慧”,一個 Composable 函式在重組中被呼叫時,如果引數與上次呼叫時相比沒有發生變化,則函式的執行會跳過重組,提升重組效能。但其實有時候即使引數沒有發生變化重組也會進行,看下面的例子:

```kotlin class MutableString(var data: String)

@Composable fun StableTest() { val str = remember { MutableString("Hello") } var state by remember { mutableStateOf(false) } if (state) { str.data = "World" } // WrapperText 會隨 state 的變化而重組 Button(onClick = { state = true }) { WrapperText(str) } }

@Composable fun WrapperText(data: MutableString) { Text("${data.data}") } `` 我們點選 Button 後state改變造成StableTest重組,MutableString型別的str在重組前後指向同一例項,只是data值發生 "Hello" > "World" 的變化,如果在呼叫WrapperText` 時,對重組前後的引數進行比較將無法發現變化,但是實際執行會發現,重組並沒有被跳過,此時 WrapperText 依然參與重組,正確地更新了文字。

重組中 Composable 引數進行比較的前提是引數型別必須是“穩定”型別,如果 Composable 引數中有不穩定型別,則 Composable 無法跳過重組。所以看來 MutableString 並非穩定型別,那什麼樣的型別算是“穩定”的呢?Compose 中穩定型別需符合以下特徵:

  • 對於型別 T 的兩個例項 ab,如果 a.equals.(b) 的結果是長期不變的,那麼 T 是一個穩定型別。所以一個 Immutable 型別自然也是穩定型別
  • 如果型別 T 存在可變的 public 屬性,且所有 public 屬性的變化都能被感知並正確反映到 Compositioin,即屬性的型別是 MutableState 的,那麼 T 也是一個穩定型別。
  • 穩定型別的所有 public 的屬性也必須是穩定型別。因為有可能你對 equals 進行了重寫造成某個 public 屬性不參與比較,但屬性卻有可能在 Composition 中被引用,為了保證引用的正確性,則要求它也必須是穩定的。

一言以蔽之,穩定型別要麼不可變,要麼其變化可被追蹤。回看前面例子中的 MutableString,它的成員 data 不是 final 的且其變化無法被追蹤,所以它並不是一個穩定型別。

2. @Stable 與 @Immutable

Compose 編譯器在編譯期會識別 Composable 函式的引數是否是穩定型別,當識別為穩定型別時,意味著引數比較的結果是可信的,此時會插入相關 equals 程式碼,以便於跳過不必要的重組。 編譯器會將以下型別自動識別為穩定型別:

  • Kotlin 中的基本型別,Boolean, Int, Long, Float, Char 等等
  • String 型別
  • 各種函式型別、Lambda
  • 所有 public 屬性都是 final (val 宣告)的物件型別,且屬性型別是不可變型別或可觀察型別

不符合上述規範的型別是不穩定型別,但是我們可以通過手動新增 @Stable 或者 @Immutable 註解讓編譯器將其看待為穩定型別,@Immutable 代表型別完全不可變,@Stable 代表型別雖然可變但是變化可追蹤。

文章開頭的例子中,如果為 MutableString 新增 @Stable 或者 @Immutable 後,再次執行會發現結果中 "Hello" 不會變為 "World"。

kotlin //data 雖然是 var 但是由於新增 @Stable,被認為是穩定型別 @Stable class MutableString(var data: String)

MutableString 作為穩定型別被插入了 equals 邏輯,由於比較結果恆為 true 所以跳過重組。這造成了 str 的更新無法整成顯示,不符合預期,因此我們新增 @Stable 註解時一定要慎之又慎,避免出現不符合預期的錯誤。

還有一點需要特別注意,對於 interface 新增 @Stable 註解後,其派生類預設都會被當做穩定型別處理。比如下面的 UiState 介面的子類都是穩定型別

```kotlin @Stable interface UiState> { val value: T? val exception: Throwable?

val hasError: Boolean
    get() = exception != null

} ``` @Stable 與 @Immutable 在編譯器的處理上並沒什麼不同,都是在適當的程式碼位置插入引數比較程式碼,而且 @Stable 相對於 @Immutable 的使用場景更廣泛,除了修飾 Class,還可以修飾函式、屬性等等,因此大家可以優先使用 @Stable,@Immutable 或許會在未來被逐漸廢棄。

下面通過幾個例子,再體會一下編譯器對穩定型別的處理:

```kotlin //1. 不可變型別:String @Composable fun showString(string: String) { Text(text = "Hello ${string}") }

//2. 可變型別:有可變的屬性 class MutableString(var data: String) @Composable fun showMutableString(string: MutableString) { Text(text = "Hello ${string.data}") }

//3. 不可變型別:成員屬性全是 final class ImmutableString(val data: String) @Composable fun showImmutableString(string: ImmutableString) { Text(text = "Hello ${string.data}") }

//4. 可變型別加 @Stable 註解 @Stable class StableMutableString(var data: String) @Composable fun showStableMutableString(string: StableMutableString) { Text(text = "Hello ${string.data}") }

//5. 變化可被追蹤 class MutableString2( val data: MutableState = mutableStateOf(""), ) @Composable fun showMutableString2(string: MutableString2) { Text(text = "Hello ${string.data}") } ```

以上除了 2 以外,其他 1,3,4,5 都都會被編譯器作為穩定型別對待,位元組碼如下:

```kotlin // 1,3,4,5 public static final void showString(String string, Composer $composer, int $changed) { //... Composer $composer = $composer.startRestartGroup(601350781); int $dirty = $changed; if ((i & 14) == 0) { // Composer#changed 對引數進行比較 $dirty |= $composer.changed((Object) string) ? 4 : 2; } if ((($dirty & 11) ^ 2) != 0 || !$composer.getSkipping()) { // 引數輸入有變化則呼叫 Text Text($composer, string.getData(), /.../); } else { // 沒有變化則不呼叫 Text $composer.skipToGroupEnd(); } ScopeUpdateScope endRestartGroup = $composer.endRestartGroup(); //... }

// 2 public static final void showMutableString(MutableString string, Composer $composer, int $changed) { //... Composer $composer = $composer.startRestartGroup(1498293802); Text($composer, string.getData(), /.../); ScopeUpdateScope endRestartGroup = $composer2.endRestartGroup(); //... } `` 可以看到,當編譯器將引數型別識別為穩定型別時,會插入$composer.changed((Object) string)` 對引數與上次重組中的輸入進行比較,看一下 changed 的實現非常簡單:

kotlin override fun changed(value: Any?): Boolean { return if (nextSlot() != value) { updateValue(value) true } else { false } }

如上,nextSlot() 從 Composition 中讀取儲存的上一次的引數與 value 進行比較,對 Slot 的概念不清楚的,可以看我的這篇文章:

需要注意,穩定型別的所有 public 子屬性必須全部為穩定型別,對於上面例子中的 3 和 5,一旦有成員是非 fianl 或者非 MutableState 的,那麼就不會被視為穩定型別了。

3. 提升型別的穩定性

通過前面介紹,我們知道了 Compose 編譯器針對穩定型別的特殊處理,在日常開發中,我們可以留意那些可以被提升穩定性的型別,以提高重組效能。我們以官方 Sample 中的 JetSnack 原始碼為例,原始碼中有一個 · 資料類,定義如下:

```kotlin / Copyright 2022 Google LLC. SPDX-License-Identifier: Apache-2.0 /

data class Snack( val id: Long, val name: String, val imageUrl: String, val price: Long, val tagline: String = "", val tags: Set = emptySet() ) ``` Snack 所有成員均為 final,看起來是一個穩定型別,但是很遺憾它會被視為一個非穩定型別,因為 · 作為一個 interface 將被視為一個非穩定型別。因為編譯器不知道其實現類是否是 Mutable 的,比如下面這樣:

kotlin val set: Set<String> = mutableSetOf(“foo”) 實際上在 JetSnack 中,· 並不存在動態修改的場景,如果能讓其被識別為穩定型別則可以提升重組效能。一個好訊息是 Compose 編譯器 1.2 之後,可以將 Kotlin 的 Immutable 集合(org.jetbrains.kotlin.kotlinx.collections.immutable)識別為穩定型別,例如 ·,· 等,即使他們是 interface。因此我們可以通過修改 · 的宣告型別來提升其穩定性:

kotlin val tags: ImmutableSet<String> = persistentSetOf() 另一個提升穩定性的方法,就是通過新增本文介紹的 @Stable 註解,如下:

```kotlin / Copyright 2022 Google LLC. SPDX-License-Identifier: Apache-2.0 /

@Stable data class Snack( val id: Long, val name: String, val imageUrl: String, val price: Long, val tagline: String = "", val tags: Set = emptySet() ) ``` 看一下 JetSnack 中引用了 Snack 的型別的穩定性的變化

```kotlin data class OrderLine( val snack: Snack, val count: Int )

data class SnackCollection( val id: Long, val name: String, val snacks: List, val type: CollectionType = CollectionType.Normal )

@Composable private fun HighlightedSnacks( index: Int, snacks: List, onSnackClick: (Long) -> Unit, modifier: Modifier = Modifier ) { ... } `` -OrderLine由於 snack 屬性變為穩定型別,其自身會被自動推斷為穩定型別。 -SnackCollection的 snacks 的 List 中的泛型型別是 Snack ,但是 List 本身是不穩定的,所以想要將 SnackCollection 改為穩定型別,可以新增 @Stable 或者 @Immutable,亦或者將 List 改為 ImmutableList -HighlightedSnacks` 也是同樣,可以通過新增 @Stable 註解,或者將 List 改為 ImmutableList 提升穩定性。注意 @Immutable 不能修飾函式。

前面講過,我們對於 @Stable 和 @Immutable 註解的使用要慎之又慎,所以優先推薦使用不依靠註解提升穩定性的方法。

4. 跨 Modules 的型別引用

通常我們的專案中可能不止一個 Gradle Module 。很多專案會按照官方推薦的架構規範,將 UI 層、Data 層等分 Module 管理,Composable 定義在 UI 層,而資料類可能定義在 Data 層,被 UI 層引用。此時需要特別注意的是,Data 層的 Module 由於沒有啟動 Compose 編譯器外掛,對於非基本型的穩定性無法自動推斷。比如前面 Snack 如果定義在單獨的 Module 且沒有啟動編譯期外掛,那麼即使將 List 改為 ImmutableList,對於使用到他的 UI 層 Composable 來說仍然無法識別為穩定型別。此時有以下幾種方式解決:

  1. 新增 @Stable 或者 @Immutable 註解,強制設為穩定型別,這會導致增加對 compose-runtime 的依賴,注意沒必要依賴 compose-ui 的任何庫
  2. 為 Data 層的 Module 開啟 Compose 外掛
  3. 在 UI 層對 Data 層的型別進行封裝,並新增穩定性註解。

當然,同樣的問題也發生在對三方庫的依賴上,而且三方庫沒法修改原始碼,只能用上面第三種方式予以解決。

5. 總結

  1. Compose 會針對穩定型別進行編譯期優化,通過對輸入引數的比較跳過不必要的重組
  2. 穩定型別包括所有的基本型、String型別、函式型別,以及符合以下條件的非基本型別:
  3. 非 interface
  4. 所有 public 屬性均為 final
  5. 所有 public 屬性均為穩定型別或者 MutableState
  6. 通過新增 @Stable 或者 @Immutable 註解可以提升重組效能,註解的使用要慎重
  7. 跨 Module 引用資料型別時,需要通過輔助手段提升其穩定性

我正在參與掘金技術社群創作者簽約計劃招募活動,點選連結報名投稿