Compose 效能

Jetpack Compose 旨在提供拆箱即用的效能。本頁面說明如何編寫及設定應用程式以獲得最佳效能,並指出一些應避免的模式。

閱讀本文前,建議您先熟悉在 Compose 的程式設計概念中提到的核心 Compose 概念。

正確設定應用程式

如果您的應用程式效能不佳,可能表示設定有問題。第一步是查看下列設定選項。

在版本模式中建構並使用 R8

如果您發現效能問題,請務必在版本模式中執行應用程式。偵錯模式有助於找出許多問題,但這需要投入大量效能,而且可能會難以找出其他可能影響效能的程式碼問題。另外,您還應該使用 R8 編譯器從應用程式中移除不必要的程式碼。根據預設,在版本模式中進行建構會自動使用 R8 編譯器。

使用基準設定檔

Compose 是以程式庫的形式發布,本身不屬於 Android 平台,這讓我們得以經常更新 Compose 並支援較早的 Android 版本。然而,將 Compose 發布為程式庫會產生費用。這是因為,Compose 不像 Android 平台程式碼那般,早已經過編譯並安裝在裝置上; 程式庫必須在應用程式啟動時載入,並在需要函式時及時解釋。如此一來,應用程式在啟動之際和在首次使用程式庫功能時,執行速度都可能變得較慢。

您可以定義基準設定檔來改善效能。這些設定檔會定義關鍵使用者旅程中所需的類別和方法,並與應用程式的 APK 一起發布。在安裝應用程式期間,ART 會預先編譯這些重要的程式碼,以便在應用程式啟動時即可開始使用。

定義良好的基準設定檔並不容易,因此我們在交貨時便已直接為 Compose 備妥這個設定檔,您可能直接就能享受預設設定檔的好處,不必特別進行什麼操作。不過,您當然也可以選擇定義自己的設定檔,只是產生的設定檔未必能實際改善您的應用程式效能。建議您測試自己定義的設定檔,驗證是否能發揮預期效用。有個理想的做法是撰寫應用程式的巨集基準測試,並在寫入及修改基準設定檔時查看測試結果。請參閱 Macrobenchmark Compose 範例,瞭解如何撰寫 Compose UI 的巨集基準測試。

如需版本模式、R8 和基準設定檔的效果詳細分析資料,請參閱「Why should you always test Compose performance in release?」(為什麼應該只在版本中測試 Compose 效能?) 這篇網誌文章。

三個 Compose 階段對效能的影響

Jetpack Compose 階段 中所述,當 Compose 更新頁框時,會經過三個階段:

  • 組成:Compose 可以決定顯示內容– 這個程式庫會執行可組合函式並建構 UI 樹狀結構。
  • 版面配置:Compose 會決定 UI 樹狀結構中每個元素的大小和位置
  • 繪圖:Compose 實際上會算繪個別 UI 元素。

Compose 可以視需求有意略過任何階段。舉例來說,假設單一圖形元素會在相同大小的兩個圖示之間進行切換。由於該元素不會改變大小,且不會新增或移除 UI 樹狀結構中的元素,因此 Compose 可以略過可組合項及版面配置階段,只重新繪製該單獨元素。

但是有些程式設計錯誤可能會讓 Compose 難以得知哪些階段可以安全略過。如有任何疑慮,Compose 會結束執行全部三個階段,這將使 UI 速度慢於預期速度。因此,許多效能最佳做法都涉及到協助 Compose 略過不需要的階段。

一般來說,改善效能時需遵守幾項通用原則。

首先,請盡可能將計算結果從可組合函式中移出。 只要有 UI 發生變更,就可能需要重新執行可組合函式;您放入可組合項中的任何程式碼都會重新執行,可能是針對動畫的每一個畫面重新執行。因此,您應該將可組合項的程式碼限制在建構 UI 所實際需要的範圍。

其次,盡可能延遲狀態讀取。 將狀態讀取移至子項可組合項或後續階段,即可最小化重新組合或完全略過組合階段。為此,您可以傳遞 lambda 函式(而不是頻繁變更狀態的狀態值),以及在傳遞頻繁變更的狀態時首選 lambda 輔助鍵。您可以前往「盡可能延遲讀取時間」一節查看這項技巧的範例。

下一節將說明可能導致這類問題的具體程式碼錯誤。希望本文列出的具體範例也能協助您找出程式碼中的其他類似錯誤。

運用工具找出問題

效能問題有時很難找出原因,也很難得知該從哪段程式碼開始最佳化。您可以先使用工具,藉此縮小問題範圍。

取得重組次數

您可以使用版面配置檢查器,檢查某個組件的重組或略過頻率。

版面配置檢查器中顯示的重組計數

詳情請見工具區段

遵循最佳做法

您可能會遇到一些 Compose 常見錯誤。這些錯誤可能會使程式碼看起來似乎執行成功,但卻會對 UI 效能造成負面影響。 本節列舉幾項可協助您避免出現這種錯誤的最佳做法。

使用注意事項將費用計算降到最低

可組合函式可能會頻繁執行,就像動畫每個畫格一樣。因此,您應盡可能避免在可組合項的主體中進行計算。

一個重要技巧就是,透過 remember 儲存計算結果。這樣一來,系統只會執行一次計算,並在需要時擷取結果。

例如,下列程式碼顯示經過排序的名稱清單,但這種簡單的排序成本非常高:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier) {
        // DON’T DO THIS
        items(contacts.sortedWith(comparator)) { contact ->
            // ...
        }
    }
}

問題是,每次重新撰寫 ContactsList 時,即使聯絡人清單尚未變更,也會再次被排序。使用者捲動清單時,每當出現新列時,系統就會重新編譯可組合項。

如要解決這個問題,請將 LazyColumn 以外的清單排序,並使用 remember 儲存排序後的清單:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    val sortedContacts = remember(contacts, sortComparator) {
        contacts.sortedWith(sortComparator)
    }

    LazyColumn(modifier) {
        items(sortedContacts) {
          // ...
        }
    }
}

現在,系統會在第一次撰寫 ContactList 時對清單排序一次。如果聯絡人或比較子發生變更,系統會重新產生經過排序的清單。否則,可組合項可以持續使用快取的排序清單。

使用延遲版面配置鍵

延遲版面配置會盡可能以最有效的方式重複使用項目,並且只在需要時重新產生或重新組合這些項目。不過,這有助於您做出最佳決策。

假設使用者作業造成項目在清單中移動。例如,假設您可以顯示附註清單,並依據修改時間排序,則最近修改的內容會顯示在最上方。

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes
        ) { note ->
            NoteRow(note)
        }
    }
}

不過,此程式碼會有問題。假設底部附註發生變化。這是目前最近期修改的附註,因此位於清單最頂端,而其他附註會依序向下移動。

此處的問題是,如果沒有您的幫助,Compose 不會發現在清單內,未變更的項目發生了移動。因此,Compose 認為較早的「項目 2」已被刪除,然後建立新的項目,項目 3、項目 4 等等其他項目以此類推。因此,Compose 會重新組合清單上的所有項目,即使只有一個項目發生了變更。

此解決方法是提供項目索引鍵為每個項目提供穩定的索引鍵,即可讓 Compose 避免不必要的重新組合。在本例中,Compose 可以看出現在位於位置 3 的項目與先前位於位置 2 的項目相同。由於該項目中沒有資料發生變更,因此 Compose 不需要重新組合。

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes,
             key = { note ->
                // Return a stable, unique key for the note
                note.id
            }
        ) { note ->
            NoteRow(note)
        }
    }
}

使用 derivedStateOf 限制重組

使用組合項中的狀態時,存在一種風險,即如果狀態快速變更,則 UI 可能會進行過多不必要的重組。舉例來說,假設您正在顯示可捲動清單 您可以檢查清單的狀態,看看哪個項目是清單中的第一個可見項目:

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

這樣做的問題是,如果使用者捲動清單,listState 會隨著使用者拖曳手指而不斷改變。這表示清單會持續重新組合。不過,您並不需要經常重新組合,在底部出現新的項目之前,您不需要重新組合。因此,這需要大量額外運算,這會造成您的使用者介面效能不佳。

解決方法是使用衍生狀態。衍生狀態可讓您告訴 Compose 哪些狀態變更實際上會觸發重組。在這種情況下,請指定您關心的是哪個項目首次可見時需要變更。當狀態值改變時,UI 就必須重新組合,但如果使用者沒有充分捲動,以至可以將新項目移到頂端,則不必重組。

val listState = rememberLazyListState()

LazyColumn(state = listState) {
  // ...
  }

val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

盡可能延遲讀取時間

建議您盡可能延遲讀取狀態變數。延遲狀態讀取作業可確保 Compose 在進行重組時,盡可能以最少程式碼重新執行。例如,如果您的 UI 包含可堆疊式樹狀結構中高度較高的狀態,且您讀取了子項可組合項的狀態,則您可以將狀態讀取納入 lambda 函式中。如此一來,只有在實際需要時,系統才會讀取資料。您可以查看如何將此方法套用至 Jetsnack 範例應用程式。Jetsnack 會在詳細資訊畫面上實作類似收合式工具列的效果。

為實現此效果,Title 可組合項需要知道捲動偏移,才能使用 Modifier 進行自身偏移。以下是經過最佳化的 Jetsnack 程式碼的簡化版本:

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack, scroll.value)
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scroll: Int) {
    // ...
    val offset = with(LocalDensity.current) { scroll.toDp() }

    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
        // ...
    }
}

捲動狀態變更時,Compose 會尋找最近的父項重新組合範圍,並使其無效。在這種情況下,最近的父項是 Box 可組合項。因此 Compose 會重新組合 Box,也會重新組合 Box 中的任意可組合項。如果您變更程式碼,只讀取實際使用的狀態,則可能會減少需要重組的元素數量。

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack) { scroll.value }
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    val offset = with(LocalDensity.current) { scrollProvider().toDp() }
    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
    // ...
    }
}

捲動參數現已改為 lambda。這表示 Title 仍可參照安全狀態,但該值只會在實際需要時在 Title 內部讀取。因此,當捲動值改變時,最近的重新組合範圍現在是 Title 可組合項,即 Compose 不再需要重新組合整個 Box

這是更好的改善,但是您可以做得更好!如果您只是為了重組或重新繪製可組合項,那麼就會感到困惑。在這種情況下,您只需變更 Title 可組合項的偏移值,即可在調整版面配置階段中完成設定。

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    Column(
        modifier = Modifier
            .offset { IntOffset(y = scrollProvider()) }
    ) {
      // ...
    }
}

先前,程式碼使用 Modifier.offset(x: Dp, y: Dp) 來將偏移值做為參數。改用輔助鍵的 lambda 版本後,您就能確保函式在版面配置階段讀取捲動狀態。因此,當捲動狀態變更時,Compose 可以完全略過組合階段,直接進入版面配置階段。將頻繁變更的變數傳送輔助鍵時,請盡可能使用輔助鍵的 lambda 版本。

以下是這個方法的另一個範例。此程式碼尚未最佳化:

// Here, assume animateColorBetween() is a function that swaps between
// two colors
val color by animateColorBetween(Color.Cyan, Color.Magenta)

Box(Modifier.fillMaxSize().background(color))

這裡,該方塊的背景顏色會在兩種顏色之間快速切換。因此,這個狀態會頻繁改變。然後,該可組合項會在背景輔助鍵中讀取此狀態。因此,方塊必須重新根據每個畫格而重新組合,因為每個畫格的顏色都會改變。

如要改善這種情形,我們可以使用 lambda 的輔助鍵,在本例中為 drawBehind。也就是說,只會在繪圖階段讀取顏色狀態。因此,Compose 可以完全略過可組成項和版面配置階段—當顏色改變時,Compose 會直接進入繪圖階段。

val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
   Modifier
      .fillMaxSize()
      .drawBehind {
         drawRect(color)
      }
)

避免向後寫入

Compose 的核心假設是永不寫入至已讀取的狀態。執行這項作業會稱為反向寫入,可能會導致在每個頁框中持續重新組合。

以下可組合項顯示這類錯誤的範例。

@Composable
fun BadComposable() {
    var count by remember { mutableStateOf(0) }

    // Causes recomposition on click
    Button(onClick = { count++ }, Modifier.wrapContentSize()) {
        Text("Recompose")
    }

    Text("$count")
    count++ // Backwards write, writing to state after it has been read
}

這段程式碼會在上述行上讀取可組合項之後在可組合項的末尾更新計數。如果執行這個程式碼,您將會看到按下按鈕後,系統會重新組合,計數器會在無限循環中快速增加,因為 Compose 會重新組合此可組合項,會看到狀態讀取已過時,因此請安排另一個重新組合。

您完全不需要在可組合項中寫入狀態,就能避免反向寫入。 如果可以,請一律寫入狀態以回應事件和 lambda(如前述 onClick 範例所示)。