遵循最佳做法

您可能會遇到 Compose 常見的陷阱。這些錯誤可能會導致程式碼看起來似乎執行得很好,但會對 UI 效能造成負面影響。請按照最佳做法,在 Compose 上最佳化應用程式。

使用 remember 盡量減少昂貴的計算作業

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

一項重要技巧是,使用 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, comparator) {
        contacts.sortedWith(comparator)
    }

    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 會在詳細資訊畫面上實作類似收合式工具列的效果。如要瞭解這項技巧的運作原理,請參閱網誌文章「Jetpack Compose:偵錯重組」。

為此,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 會使最接近的父項重組範圍失效。在這種情況下,最近的範圍是 SnackDetail 可組合項。請注意,Box 是內嵌函式,因此不是重組範圍。因此,Compose 會在 SnackDetail 中重組 SnackDetail 和任何可組合項。如果變更程式碼,只讀取實際使用的狀態,就能減少需要重組的元素數量。

@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(x = 0, 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</b>
}

此程式碼會在上一行讀取可組合項後,於可組合項的結尾更新計數。如果執行這個程式碼,您會在點選按鈕後看到重新組成,接著計數器會在 Compose 重組此可組合項時快速增加無限迴圈,而會看到狀態讀取已過時,因此請排定另一個重組時間。

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

其他資源