您可能會遇到常見的 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 { mutableIntStateOf(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 範例所示)。
其他資源
- 應用程式效能指南:瞭解改善 Android 效能的最佳做法、程式庫和工具。
- 檢查效能:檢查應用程式效能。
- 基準化:為應用程式效能設定基準。
- 應用程式啟動:改善應用程式啟動程序。
- 基準設定檔:瞭解基準設定檔。
為您推薦
- 注意:系統會在 JavaScript 關閉時顯示連結文字
- 狀態和 Jetpack Compose
- 圖形修飾符
- Compose 的程式設計概念