Jetpack Compose布局優化實踐
01、前言
我們內部團隊使用 Jetpack Compose 開發項目已近一年,經歷了簡單布局到復雜布局的應用,對 Compose 的使用越來越成熟,構造了很多易用的基礎組合,提升了項目的開發效率,與此同時 Compose 布局的一些性能問題也慢慢凸顯出來,因此專門對 Compose 布局優化進行了調研工作,旨在減少重組提高性能,規避負面效應,提高應用穩定性。結合具體場景來具體分析。
02、使用 remember 減少計算
我們構造一個客戶列表,代碼如下:
@Composable
fun ClientList(list: MutableList<ClientInfo>, modifier: Modifier) {
LazyColumn(modifier = modifier) {
items(list) {
ClientItem(it)
}
}
}
接著增加一個需求,將客戶列表按照年齡排序,我們改動一下代碼:
@Composable
fun ClientList(list: MutableList<ClientInfo>, modifier: Modifier) {
LazyColumn(modifier = modifier) {
items(list.sortedBy { it.age }) {
ClientItem(it)
}
}
}
上面代碼能夠正確運行,只不過會有一點問題,就是每次重組都會對 list 執行排序操作。眾所周知在 Compose 中可組合項可能會非常頻繁的重組,也就意味著排序操作可能會非常頻繁的執行,這顯然是不行的,因為排序可能會占用較多的資源,導致布局卡頓。最理想的狀態應該是數據變動或者排序規則變動才會觸發排序,達到這種狀態我們可以使用 remember或者將排序操作放到 ViewModel 當中:
@Composable
fun ClientList(list: MutableList<ClientInfo>, modifier: Modifier) {
// 通過remember方法,將list的排序結果緩存起來,當list發生變化時,才會重新排序
val sortList = remember(key1 = list) {
list.sortedBy { it.age }
}
LazyColumn(modifier = modifier) {
items(sortList) {
ClientItem(it)
}
}
}
在開發過程中應該謹記一條規則:重組可能會頻繁的執行,因此盡量避免在組合內寫一些會引起副作用的代碼。
03、Lazy布局使用key
在項目開發中列表布局占多數,在 Compose 中實現列表使用延時布局它包含了 LazyColumn、LazyRow等布局,比如上一節使用 LazyColumn實現了一個客戶列表。
接上繼續以客戶列表布局為例,如果對客戶列表進行增加或者刪除,列表布局是如何重組的呢?為了探究這個問題,稍微改下代碼,增加一個添加客戶的按鈕:
Column {
Row(modifier = Modifier.fillMaxWidth()) {
Text(text = "添加新客戶", modifier = Modifier.clickable {
Log.d("compose demo", "添加新客戶")
//手動插入一條數據
list.add(5, ClientInfo("新添加客戶", 5))
})
}
ClientList(...)
}
然后在 LazyColumn作用域以及ClientItem中加上日志信息:
@Composable
fun ClientList(list: SnapshotStateList<ClientInfo>, modifier: Modifier) {
LazyColumn(modifier = modifier) {
Log.d("compose demo", "LazyColumn update")
itemsIndexed(list) { _, item ->
ClientItem(item)
}
}
}
@Composable
fun ClientItem(info: ClientInfo) {
Log.d("compose demo", "item name=${info.name} 重組")
Text(text = "${info.name} ${info.age}", modifier = Modifier.height(44.dp))
}
接下來運行一次,并點擊添加新客戶按鈕,控制臺輸出如下:
com.czx.demo D 添加新客戶
com.czx.demo D LazyColumn update
com.czx.demo D item name = 添加新客戶 重組
com.czx.demo D item name = name ---- 5 重組
com.czx.demo D item name = name ---- 6 重組
com.czx.demo D item name = name ---- 7 重組
com.czx.demo D item name = name ---- 8 重組
com.czx.demo D item name = name ---- 9 重組
com.czx.demo D item name = name ---- 10 重組
com.czx.demo D item name = name ---- 11 重組
com.czx.demo D item name = name ---- 12 重組
com.czx.demo D item name = name ---- 13 重組
com.czx.demo D item name = name ---- 14 重組
我們發現除了新添加的客戶項之外,在此位置之后的所有可見的客戶項都觸發了不必要的重組。如果想讓列表只重組新增項,那么這里就要使用 key參數來避免這些不必要的重組,key參數是一個任意類型的值,用于標識布局,并確保 Compose 框架在重新計算布局時正確地處理它們。改動代碼加上key參數:
@Composable
fun ClientList(list: SnapshotStateList<ClientInfo>, modifier: Modifier) {
LazyColumn(modifier = modifier) {
Log.d("compose demo", "LazyColumn update")
//key參數指定
itemsIndexed(list, key = { _, item -> item.id }) { _, item ->
ClientItem(item)
}
}
}
需要注意的是 key參數要保證唯一性這樣才能確保 Compose 框架能夠正確地計算和更新列表項,加上 key參數代碼運行后臺輸出如下:
com.czx.demo D 添加新客戶
com.czx.demo D LazyColumn update
com.czx.demo D item name = 添加新客戶 重組
之前的不必要重組沒有了,只重組了添加項,符合預期。
Tips: 這里一定要保證 key參數的唯一性,否則會出現不必要的重組,影響性能。
04、使用derivedStateOf限制重組
繼續使用上面的客戶列表,新增一個需求當第一個可見項大于0的時候,展示回到頂部的按鈕,按照需求我們對代碼做如下改動:
1.增加listState來監聽列表狀態:
val listState = rememberLazyListState()
2.通過listState獲取當前可見項,判斷是否展示回到頂部 button :
val showButton = listState.firstVisibleItemIndex > 0
3.回到頂部按鈕顯隱:
if (showButton){
ScrollToTopButton()
}
再將列表包裹一層布局整體代碼如下:
Box {
val listState = rememberLazyListState()
ClientList(...)
val showButton = listState.firstVisibleItemIndex > 0
if (showButton){
Log.d("compose demo", "button 重組")
ScrollToTopButton()
}
}
運行代碼并上下滑動列表,控制臺輸出:
com.czx.demo D item name = name ---- 17 重組
com.czx.demo D item name = name ---- 18 重組
com.czx.demo D item button 重組
com.czx.demo D item button 重組
可以看到觸發了多次重組,雖然 showButton只關心 firstVisibleItemIndex是否是從 0 變為非 0 ,但是這種寫法當 firstVisibleItemIndex大于 0 時會一直被觸發,從而引起了不必要的重組。要想規避這種情況可以使用 derivedStateOf()函數來處理頻繁變更的數據:
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
控制臺輸出:
com.czx.demo D item name = name ---- 17 重組
com.czx.demo D item name = name ---- 18 重組
com.czx.demo D item button 重組
com.czx.demo D item name = name ---- 19 重組
com.czx.demo D item name = name ---- 20 重組
com.czx.demo D item name = name ---- 21 重組
com.czx.demo D item name = name ---- 22 重組
連續滑動只會觸發一次重組。
05、延遲讀取
Compose 有三個階段 組合、布局和繪制 ,可以通過盡可能的跳過三個步驟中的一個或者多個來提高性能。
06、場景一
val color by animateColorBetween(Color.Red, Color.Blue)
Box(modifier = Modifier.fillMaxSize().background(color))
代碼能夠運行并且滿足我們的要求,如果足夠細心可以發現這里隱藏著一個優化點,上面提到 Compose 的三個階段組合、布局和繪制,對于示例代碼而言,僅僅是改變背景顏色,不需要重組和布局,那么我們對代碼進行優化。
val color by animateColorBetween(Color.Red, Color.Blue)
Box(modifier = Modifier.fillMaxSize().drawBehind {
drawRect(color = color)
})
我們使用了 drawBehind()函數,該函數發生在繪制時期,由于僅改變背景顏色,所以這里改變方框的背景顏色使用 drawRect達到一樣的效果,這樣繪制就成了唯一重復執行的階段,進而提高性能。
07、場景二
@Composable
fun SnackDetail() {
//...
Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
val scroll = rememberScrollState(0)
// ...
Title(snack, scroll.value) //1.狀態讀取
// ...
} //Recomposition Scope End
}
@Composable
private fun Title(snack: Snack, scroll: Int) {
//...
val offset = with(LocalDensity.current) { scroll.toDp() }
Column(
modifier = Modifier
.offset(y = offset) //2.狀態使用
) {
//...
}
}
對 scroll.value的讀取會使 Box()發生重組,但是 scroll的使用卻不是在 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) // 狀態讀取+使用
) {
// ...
}
}
這樣當 scroll.value()變化時不會觸發重組,也就是在滑動中唯二執行的階段只有布局和繪制。
08、避免向后寫入
Compose中有個核心 假設:您永遠不會向已被讀取的狀態寫入數據。如果破壞了這個假設也就是向后寫入,可能會造成一些不必要的重組。
舉個例子:
@Composable
fun BadComposable() {
var count by remember { mutableStateOf(0) }
// Causes recomposition on click
Button(onClick = { count++ }, Modifier.wrapContentSize()) {
Text("Recompose")
}
Text("$count") //1
count++ // Backwards write, writing to state after it has been read
}
點擊按鈕后會 count++執行,注釋1處讀取了 count因此會觸發重組,但是同時末尾處的 count++也會執行,最終導致之前狀態過期,注釋 1 繼續讀取,然后陷入循環,count++一直執行,每一幀都在重組。這會造成嚴重的性能問題,所以應該避免在組合中進行狀態寫入,盡量在響應事件中寫入狀態。
07、發布模式&R8優化
Compose并不是 Android 系統庫,而是作為獨立的庫進行引入。這樣做的好處就是可以兼容舊的安卓版本以及頻繁的更新功能,但是也會產生性能上的開銷,導致首次啟動或者首次使用一個庫功能時變得比較慢。
下圖是冷啟動耗時對比(單位:ms):
圖片
可以看到發布模式 +R8+Profile 下的冷啟動耗時是最短的。發布模式一般默認開啟了 R8 優化,具體優化細節,這里不做展開。另外值得一提的是Profile,它是 Compose 官方定義的基準配置文件,專門用來提高性能。
基準配置文件中定義關鍵用戶歷程所需的類和方法,并與應用的 APK 一起分發。在應用安裝期間,ART 會預先編譯該關鍵代碼,以確保在應用啟動時可供使用。要定義一個良好的基準配置文件并不容易,因而此 Compose 隨帶了一個默認的基準配置文件。您無需執行任何操作即可直接使用該配置文件。但是,如果選擇定義自己的配置文件,則可能會生成一個無法實際提升應用性能的配置文件。
10、總結
以上結合代碼示例介紹了 Jetpack Compose中的布局優化手段,總結下來就是在應用開發中,應盡量減少不必要的重組來提高性能。因此我們需要合理的使用 remember、 Lazy布局的key, derivedStateOf等手段,來遵循最佳性能實踐。
11、引用
本文轉載自微信公眾號「 搜狐技術產品」,作者「 蔡志學」,可以通過以下二維碼關注。
轉載本文請聯系「 搜狐技術產品」公眾號。