修正穩定性問題

如果遇到不穩定類別導致效能問題,請最佳化 Compose 比較或例項化該類別的方式。本文將概述幾種可用技巧。

啟用嚴格略過

自 Kotlin 2.0.20 起,系統預設會啟用嚴格略過模式。嚴格略過模式會使用例項相等性 (===) 比較不穩定參數,藉此略過含有不穩定參數的可組合項,而穩定參數則繼續使用結構相等性 (Object.equals()) 進行比較。

如果您使用的 Kotlin 版本低於 2.0.20,請先啟用強略過模式。如果已啟用強化略過模式,但可組合項仍不必要地重組,請檢查每次重組時,是否都會分配不穩定參數的新例項。

詳情請參閱「強略過」。

將類別設為不可變

您也可以將不穩定的類別設為完全不可變,讓編譯器自動推斷其穩定性。

  • 不可變動:表示在建構該類型的例項後,任何屬性的值都無法變更,且所有方法都具有參照透明性。
    • 確認類別的所有屬性都是 val 而非 var,且屬於不可變動類型。
    • String、Int 和 Float 等原始類型一律不可變。
    • 在以 Compose 編譯器建構的模組中,具有 val 原始或 String 屬性的平面 data class 類型,已推斷為穩定,並會自動與 .equals() 進行比較,因此您不需要使用 @Immutable 加上註解。
    • 如果無法維持不變性,則必須針對任何可變動屬性使用 Compose 狀態。
  • 穩定:表示可變動的類型。如果類型的任何公開屬性或方法行為會產生與先前叫用不同的結果,Compose 執行階段不會察覺。

收集和穩定性

Compose 認為類別不穩定的常見原因之一是集合。如「診斷穩定性問題」頁面所述,Compose 編譯器無法完全確定 List、Map 和 Set 等集合介面是否確實不可變,因此會將其標示為不穩定。

請參考「診斷穩定性問題」指南中的這個類別:

unstable class Snack {
  …
  unstable val tags: Set<String>
  …
}

啟用嚴格略過模式 (Kotlin 2.0.20 以上版本的預設模式) 後,只要傳遞相同的參照 ,不穩定集合和包含集合的類別就會使用例項相等性 (===) 略過 重組。

請謹慎考慮是否要將 kotlin.collections.List、Set 或 Map 替換為 Kotlinx 不可變集合 (ImmutableList、ImmutableSet、ImmutableMap),或將 @Immutable、@Stable 或 stability_config.conf 規則新增至保留集合的模型。在強略過模式下,標準 List 會使用參照相等性 (===) 略過,而 ImmutableList 和加上註解的集合模型則會在每次重組時,逐一執行元素 .equals() 比較。

如果是大型集合或延遲版面配置 (LazyColumn、LazyRow、LazyGrid),檢查每個項目的 .equals() 可能會比重組更耗時。如果將 ImmutableList 屬性從 data class 傳遞至子項可組合函式,這項成本會加倍:

data class SnackCategory(
    val name: String,
    val snacks: ImmutableList<Snack>
)

@Composable
fun CategorySection(category: SnackCategory) {
    Text(category.name)
    // Compose compares category.snacks twice: once in SnackCategory.equals()
    // for CategorySection, and again for HighlightedSnacks.
    HighlightedSnacks(snacks = category.snacks)
}

請勿強制集合使用結構相等性,而是在發出時保留集合的執行個體參照 (例如在 ViewModel 中快取轉換,或使用 remember),這樣 Compose 就能略過使用執行個體相等性 (===)。只有在無法保留集合的執行個體參照,且基準測試確認結構相等性檢查比重組更快時,才考慮在保留集合的模型上使用 ImmutableList 或穩定性註解。如果您使用的 Kotlin 版本低於 2.0.20,請先啟用強略過模式。

加上 Stable 或 Immutable 註解

如要解決小型類別的穩定性問題,並使用低成本的 .equals() 檢查,可行的做法是使用 @Stable 或 @Immutable 為不穩定類別加上註解。

為類別加上註解會覆寫編譯器對類別的推論。這與 Kotlin 中的 !! 運算子類似。使用這些註解時,請務必謹慎。覆寫編譯器行為可能會導致無法預料的錯誤,例如可組合項未在預期時重組。

啟用強略過模式後,請遵循下列規範,決定是否要新增 @Stable 或 @Immutable:

  • 請勿為已推論為穩定的類別加上註解:在以 Compose 編譯器建構的模組中,具有穩定類型 val 屬性的類別已推論為穩定。
  • 只有在重新建立例項時,才使用低成本的 .equals() 檢查為類別加上註解:如果編譯器無法推論穩定性 (例如非 Compose 模組的介面或類別),且資料來源會在每次發出時為未變更的項目分配新的物件例項,請新增 @Stable 或 @Immutable,前提是類別較小且具有低成本的 .equals() 檢查。
  • 謹慎處理包含集合的模型:避免將 @Stable 或 @Immutable 新增至包含大型集合 (List、Set、Map) 的資料模型,尤其是用於延遲版面配置 (LazyColumn、LazyRow、LazyGrid、Pager) 的模型。不為這些模型加上註解,Compose 就能使用執行個體相等性 (===) 比較模型,而不必對每個元素執行結構 .equals() 檢查。

以下程式碼片段提供一個基本範例,說明在編譯器無法推論穩定性的模組中,如何定義標註為不可變動的平面資料類別 (不含集合屬性):

@Immutable
data class SnackSummary(
    val id: Long,
    val name: String,
    val price: Long
)

無論您使用 @Immutable 還是 @Stable 註解,Compose 編譯器都會將 SnackSummary 類別標示為穩定,並使用 Object.equals() 進行比較。

集合中的註解類別

假設可組合函式包含 List<Snack> 類型的參數:

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun HighlightedSnacks(
  stable index: Int
  unstable snacks: List<Snack>
  stable onSnackClick: Function1<Long, Unit>
  stable modifier: Modifier? = @static Companion
)

即使 Snack 穩定或加上 @Immutable 註解,Compose 編譯器仍會將 HighlightedSnacks 中的 snacks 參數標示為 unstable,因為 List 是介面。

啟用嚴格略過模式後,HighlightedSnacks 仍會標示為 skippable,並在傳遞相同的 List 例項時,使用例項相等性 (===) 略過重組。您可以將 List<Snack> 包裝在 @Immutable 包裝函式類別中、將 List<Snack> 替換為 ImmutableList<Snack>,或將 kotlin.collections.* 新增至穩定性設定檔,將 snacks 參數設為 stable。

不過,這樣會導致 Compose 在每次重新組合時,逐一比較 .equals() 元素。只有在無法保留清單的執行個體參照,且基準測試顯示有益時,才應使用這些方法。

穩定性設定檔

從 Compose Compiler 1.5.5 開始,您可以在編譯時間提供視為穩定的類別設定檔。這樣一來,您無法控制的類別 (例如 LocalDateTime 等標準程式庫類別) 就能視為穩定。

設定檔是純文字檔案,每列一個類別。支援註解、單一和雙萬用字元。

設定範例:

// Consider LocalDateTime stable
java.time.LocalDateTime
// Consider my datalayer stable
com.datalayer.*
// Consider my datalayer and all submodules stable
com.datalayer.**
// Consider my generic type stable based off it's first type parameter only
com.example.GenericClass<*,_>

如要啟用這項功能,請將設定檔的路徑傳遞至 Compose 編譯器 Gradle 外掛程式設定的 composeCompiler 選項區塊。

composeCompiler {
  stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf")
}

Compose 編譯器會分別在專案中的每個模組上執行,因此您可以視需要為不同模組提供不同設定。或者,您也可以在專案的根層級設定一項設定,並將該路徑傳遞至每個模組。

多個模組

另一個常見問題與多模組架構有關。只有在類別參照的所有非原始類型都明確標示為穩定,或位於同樣使用 Compose 編譯器建構的模組中時,Compose 編譯器才能推論類別是否穩定。

如果資料層與 UI 層位於不同的模組 (建議做法),您可能會遇到這個問題。在強略過模式下,非 Compose 模組的類別會視為不穩定,且在傳遞相同執行個體時,仍會使用執行個體相等性 (===) 略過重組。不過,如果資料層經常分配小型平面模型的新例項,且每次發出時都具有相同的值,您可以選擇加入結構相等 (Object.equals())。

解決方案

如要解決這個問題,可以採取下列任一做法:

  1. 將小型、扁平的類別和低成本的 .equals() 檢查新增至編譯器設定檔。
  2. 在資料層模組上啟用 Compose 編譯器,或視情況使用 @Stable 或 @Immutable 標記類別。
    • 這包括在資料層中新增 Compose 依附元件。不過,這只是 Compose 執行階段的依附元件,而非 Compose-UI 的依附元件。
  3. 在 UI 模組中,將資料層類別包裝在 UI 專屬的包裝函式類別中。

如果外部程式庫未使用 Compose 編譯器,也會發生同樣的問題。

並非所有可組合項都應可略過

修正穩定性問題時,請勿嘗試讓每個可組合項都可略過,或強制每個參數都具有結構相等性。略過可組合項並非免費:Compose 執行階段會儲存可略過可組合項的每個參數,就像記住一樣,且 Compose 每次重新組成函式時,都必須比較每個參數與先前的值,判斷是否應略過。如果嘗試在所有地方強制略過,可能會導致過早最佳化,反而增加更多負擔。

在許多情況下,強制結構穩定性並不會帶來任何實際效益,反而可能降低效能或可維護性。例如:

  • 不常或完全不會重組的可組合項。
  • 可組合項本身只會呼叫可略過的可組合項。
  • 具有大量參數的可組合項,或具有昂貴 .equals() 實作的集合模型。在這種情況下,檢查是否有任何參數變更的成本,可能會高於低成本重組的成本。

請勿過度著重於重組次數。請著重於讓可組合項輕量化,以便執行、將快速變更的狀態讀取作業延遲至版面配置或繪圖階段,並使用基準測試驗證變更。如果可略過可組合項,則會增加少量負擔,可能不值得。如果您認為可重新啟動或可略過會造成更多負擔,甚至可以將可組合函式加上註解,設為不可重新啟動或不可略過。