Google I/O : Jetpack Compose 中常見的性能問題

前言

Jetpack Compose的應用也逐漸廣泛起來即彪,由于聲明式UI的特點烤低,Compose在開發(fā)的易用性方面有較大優(yōu)勢刑顺,但相信很多人對于Compose的性能問題有一些疑問柔纵。
這些問題有些是因為Compose還是個新生事物缔杉,不夠成熟導致的,有些則是因為開發(fā)者的使用不當導致的搁料。
本文主要介紹如何編寫和配置應用程序以獲得最佳性能或详,并指出了一些要避免的問題。

正確配置應用

如果您的Compose應用性能不佳郭计,則可能意味著存在配置問題霸琴。 首先應該檢查以下配置項

使用Release模式構(gòu)建并且使用R8

如果您發(fā)現(xiàn)性能問題,請確保嘗試在Release模式下運行您的應用昭伸。Debug模式對于發(fā)現(xiàn)許多問題很有用梧乘,但它會帶來顯著的性能成本,并且很難發(fā)現(xiàn)可能影響性能的其他代碼的問題。
同時您還應該使用 R8 編譯器從您的應用程序中刪除不必要的代碼选调。 默認情況下夹供,在Release模式下構(gòu)建會自動使用 R8 編譯器。

使用baseline profile

Compose 作為一個單獨的庫分發(fā)仁堪,而不是作為 Android 平臺的一部分哮洽。這種方法讓我們可以經(jīng)常更新 Compose 并支持較舊的 Android 版本。但是枝笨,將 Compose 作為庫分發(fā)也會產(chǎn)生一定的成本袁铐。 Android 平臺代碼已編譯并安裝在設備上。另一方面横浑,庫需要在應用程序啟動時加載剔桨,并在需要功能時及時解釋(即JIT)。這可能會在啟動時減慢應用程序的速度徙融,并且每當它首次使用庫功能時洒缀。

您可以通過定義baseline profile來提高性能。這些配置文件定義了用戶主流程所需的類和方法欺冀,并與您應用的 APK 一起分發(fā)树绩。在應用程序安裝期間,ART 會提前編譯該關鍵代碼(即AOT)隐轩,以便在應用程序啟動時準備好使用饺饭。

定義一個好的baseline profile并不總是那么容易,因此 Compose 默認附帶一個职车。因此默認情況下你不需要做任何額外工作瘫俊。
同時,如果您選擇定義自己的配置文件悴灵,您可能會生成一個實際上不會提高應用程序性能的配置文件扛芽。您應該測試配置文件以驗證它是否有幫助。一個很好的方法是為您的應用程序編寫 Macrobenchmark測試积瞒,并在您編寫和修改baseline profile時檢查測試結(jié)果川尖。有關如何為 Compose UI 編寫 Macrobenchmark 測試的示例,請參閱 Macrobenchmark Compose示例茫孔。

總得來說叮喳,使用baseline profile即通過AOT取代JIT,加快Compose首次運行的速度缰贝。
在默認情況下Compose已經(jīng)自帶了一個默認的baseline profile嘲更,你不需要做什么額外工作就可以支持。
但如果你要自定義baseline profile的話揩瞪,需要做好測試用例,驗證自定義的配置是否有效篓冲。

自定義baseline profile比較麻煩李破,不過根據(jù)Google I/O上給出的數(shù)據(jù)宠哄,可以達到20%到30%的啟動性能提升,大家可以根據(jù)情況決定是否使用

關于Compose的一些最佳實踐

在編寫Compose代碼時你可能會碰到一些常見的錯誤嗤攻。這些錯誤不影響運行毛嫉,但會損害您的 UI 性能。本節(jié)列出了一些最佳實踐來幫助您避免它們妇菱。

使用remember減少計算

Compose函數(shù)可以非常頻繁地運行承粤,就像動畫的每一幀一樣頻繁。 出于這個原因闯团,你應該盡可能少地在Compose中做計算辛臊。
最常見的就是使用remember, 這樣房交,計算只運行一次彻舰,并且可以在需要時獲取結(jié)果。
例如候味,這里有一些代碼顯示排序的名稱列表刃唤,其中排序操作比較耗時

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

問題在于,每次重組 ContactsList 時白群,整個聯(lián)系人列表都會重新排序尚胞,即使列表沒有更改。 如果用戶滾動列表帜慢,只要出現(xiàn)新行笼裳,Composable 就會重新組合。
為了解決這個問題崖堤,在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) {
          // ...
        }
    }
}

如上,當?shù)谝淮谓M成 ContactList 時密幔,列表被排序一次楔脯。 如果聯(lián)系人或比較器更改,則重新生成排序列表胯甩。 否則昧廷,可組合項可以繼續(xù)使用緩存的排序列表。
當然:如果可能偎箫,最好將計算完全移到Compose之外木柬,比如ViewModel

Lazy Layout使用Key

Lazy Layout使用智能重組,僅在必要時才會發(fā)生重組淹办。 同時眉枕,我們可以幫助它做出最佳決策。
假設用戶操作導致item在列表中移動。 例如速挑,假設您顯示按修改時間排序的筆記列表谤牡,最近修改的筆記在最上面。

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

不過姥宝,這段代碼存在一定的問題翅萤。 假設底部的note發(fā)生了變化。 它現(xiàn)在是最近修改的note腊满,所以它應該排在列表的頂部套么,而其他每個note都向下移動一個位置。

這里的問題是碳蛋,沒有您的幫助胚泌,Compose 不會意識到未更改的項目只是在列表中移動。 相反疮蹦,Compose 認為舊的“第 2 項”被刪除并創(chuàng)建了一個新的诸迟,依此類推,第 3 項愕乎、第 4 項一直如此阵苇。 結(jié)果是,Compose 會重新組合列表中的每一項感论,即使其中只有一項實際發(fā)生了變化绅项。

解決方案是提供item key。 為每個item提供一個穩(wěn)定的key可以讓 Compose 避免不必要的重組比肄。 在這種情況下快耿,Compose 可以看到現(xiàn)在位于第 3 項和item過去位于第 2 的item相同。由于該項目的數(shù)據(jù)都沒有更改芳绩,因此 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 限制重組

在重組中使用狀態(tài)的一個風險是,如果狀態(tài)快速變化妥色,你的 UI 可能會比你預期的發(fā)生更多的重組搪花。
例如,假設您正在顯示一個可滾動的列表嘹害。 您檢查列表的狀態(tài)以查看哪個item是列表中的第一個可見item

val listState = rememberLazyListState()

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

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

這里的問題在于撮竿,如果用戶滾動列表,listState 會隨著用戶拖動手指而不斷變化笔呀。 這意味著該列表不斷被重新組合幢踏,而showButton的結(jié)果也會被不斷計算。
但是许师,您只有在firstVisibleItemIndex發(fā)生變化時才需要計算showButton房蝉。 所以僚匆,這里多了很多額外的計算,會影響您的UI性能

解決方案是使用derivedStateOf搭幻。 derivedStateOf告訴Compose只有當我們關心的狀態(tài)發(fā)生變化時白热,才需要重組。
在這種情況下粗卜,當firstVisibleItemIndex發(fā)生變化時才需要重組。但如果用戶還沒有滾動到足以將新item帶到頂部的程度纳击,則不需要重新組合续扔。

val listState = rememberLazyListState()

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

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

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

注意,如果把狀態(tài)都放在ViewModel里焕数,也就不用考慮這個了

盡可能延遲讀取State

您應該盡可能推遲讀取State纱昧。 延遲讀取State有助于確保 Compose 在重組時重新運行盡可能少的代碼。 例如堡赔,如果您的 UI 具有在composable樹中高高提升的狀態(tài)识脆,并且您在子composable中讀取狀態(tài),則可以將讀取的狀態(tài)包裝在 lambda 函數(shù)中善已。 這樣做會使讀取僅在實際需要時發(fā)生灼捂。

我們來看一段Jetsnack在列表滾動時實現(xiàn)Title折疊展開的代碼,為了達到這個效果换团,Title composable需要知道滾動偏移量悉稠,以便使用修飾符來偏移自己。 在進行優(yōu)化之前艘包,這是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)
    ) {
        // ...
    }
}

當滾動狀態(tài)發(fā)生變化時的猛,Compose 會尋找最近的父重組作用域并使其無效。 在這種情況下想虎,最近的父級是可組合的是Box卦尊。 因此 Compose 重組了 Box,并且還重組了 Box 內(nèi)的任何可組合項舌厨。 如果您將代碼重構(gòu)為僅讀取您需要的State岂却,那么您可以減少需要重組的元素數(shù)量。

@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)
    ) {
    // ...
    }
}

滾動參數(shù)現(xiàn)在是一個 lambda邓线。 這意味著 Title 仍然可以引用被提升的狀態(tài)淌友,但該值只能在 Title 內(nèi)部讀取,也就是實際需要的地方骇陈。 這樣一來震庭,當滾動值發(fā)生變化時,最近的重組范圍現(xiàn)在是 Title composable你雌,因此Compose 不再需要重組整個 Box器联。

這是一個很好的改進二汛,但我們還可以做得更好。 因為我們所做的只是更改可組合標題的偏移量拨拓,這可以在布局階段完成肴颊,而不必經(jīng)過組合階段。

Compose的階段

與大多數(shù)其他界面工具包一樣渣磷,Compose 會通過幾個不同的“階段”來渲染幀婿着。如果我們觀察一下 Android View 系統(tǒng),就會發(fā)現(xiàn)它有 3 個主要階段:測量醋界、布局和繪制竟宋。Compose 和它非常相似,但開頭多了一個叫做“組合”的重要階段形纺。

Compose 有 3 個主要階段:

  • 組合:要顯示什么樣的界面丘侠。Compose 運行可組合函數(shù)并創(chuàng)建界面說明。
  • 布局:要放置界面的位置逐样。該階段包含兩個步驟:測量和放置蜗字。對于布局樹中的每個節(jié)點,布局元素都會根據(jù) 2D 坐標來測量并放置自己及其所有子元素脂新。
  • 繪制:渲染的方式挪捕。界面元素會繪制到畫布(通常是設備屏幕)中。

優(yōu)化狀態(tài)讀取

知道了Compose的3個階段戏羽,Compose 會執(zhí)行局部狀態(tài)讀取跟蹤担神,因此我們可以在適當階段讀取每個狀態(tài),從而盡可能降低需要執(zhí)行的工作量
如果我們在布局階段讀取狀態(tài)始花,就可以跳過組合階段妄讯,如果我們在繪制階段讀取狀態(tài),就可以跳過組合和布局

因此我們可以將offset的讀取推遲到布局階段酷宵,這樣可以避免組合階段重新執(zhí)行

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

之前代碼使用了Modifier.offset(x: Dp, y: Dp)亥贸,它以偏移量為參數(shù)。 通過切換到修飾符的 lambda 版本浇垦,您可以確保函數(shù)在布局階段讀取滾動狀態(tài)炕置。 因此,當滾動狀態(tài)發(fā)生變化時男韧,Compose 可以完全跳過組合階段朴摊,直接進入布局階段。 當您將頻繁更改的狀態(tài)變量傳遞給modifier時此虑,應盡可能使用modifierlambda 版本甚纲。

繪制階段讀取狀態(tài)的一個例子

上面我們看了一個在布局階段讀取狀態(tài)的例子,下面來看一下繪制階段讀取狀態(tài)的一個例子

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

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

這里盒子的背景顏色在兩種顏色之間快速切換朦前。 因此介杆,這種狀態(tài)變化非常頻繁鹃操。 然后可組合項在背景modifier中讀取此狀態(tài)。 結(jié)果春哨,盒子必須在每一幀上重新組合荆隘,因為每一幀的顏色都在變化。

為了改善這一點赴背,我們可以使用基于 lambdamodifier:在本例中為 drawBehind椰拒。這意味著僅在繪制階段讀取顏色狀態(tài)。
因此凰荚,Compose 可以完全跳過組合和布局階段耸三,當顏色發(fā)生變化時,Compose 會直接進入繪制階段浇揩。

避免反向?qū)懭?/h3>

Compose 有一個核心假設,即您永遠不會寫入已讀取的狀態(tài)憨颠。 當你這樣做時胳徽,它被稱為反向?qū)懭耄鼤е轮亟M在每一幀上發(fā)生爽彤,無休止养盗。
以下代碼顯示了此類錯誤的示例。

@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
}

這段代碼在讀取了狀態(tài)之后在可組合項末尾的更新了狀態(tài)适篙。 如果您運行此代碼往核,您會看到在單擊導致重組的按鈕后,隨著 Compose 重組此 Composable嚷节,計數(shù)器會在無限循環(huán)中迅速增加聂儒,看到讀取的狀態(tài)已過期,因此會安排另一個重組 .

您可以通過從不在 Composition 中寫入狀態(tài)來完全避免向后寫入硫痰。 如果可能衩婚,請始終響應事件來更新狀態(tài),并使用 lambda 表達式效斑,就像前面的 onClick 示例一樣非春。

總結(jié)

本文主要介紹如何編寫和配置Compose以獲得最佳性能,并指出了一些要避免的問題缓屠。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末奇昙,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子敌完,更是在濱河造成了極大的恐慌储耐,老刑警劉巖,帶你破解...
    沈念sama閱讀 219,270評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件蠢挡,死亡現(xiàn)場離奇詭異衡瓶,居然都是意外死亡晓避,警方通過查閱死者的電腦和手機宫补,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,489評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來涧卵,“玉大人,你說我怎么就攤上這事腹尖×郑” “怎么了?”我有些...
    開封第一講書人閱讀 165,630評論 0 356
  • 文/不壞的土叔 我叫張陵热幔,是天一觀的道長乐设。 經(jīng)常有香客問我,道長绎巨,這世上最難降的妖魔是什么近尚? 我笑而不...
    開封第一講書人閱讀 58,906評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮场勤,結(jié)果婚禮上戈锻,老公的妹妹穿的比我還像新娘。我一直安慰自己和媳,他們只是感情好格遭,可當我...
    茶點故事閱讀 67,928評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著留瞳,像睡著了一般拒迅。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上她倘,一...
    開封第一講書人閱讀 51,718評論 1 305
  • 那天璧微,我揣著相機與錄音,去河邊找鬼硬梁。 笑死往毡,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的靶溜。 我是一名探鬼主播开瞭,決...
    沈念sama閱讀 40,442評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼罩息!你這毒婦竟也來了嗤详?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,345評論 0 276
  • 序言:老撾萬榮一對情侶失蹤瓷炮,失蹤者是張志新(化名)和其女友劉穎葱色,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體娘香,經(jīng)...
    沈念sama閱讀 45,802評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡苍狰,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,984評論 3 337
  • 正文 我和宋清朗相戀三年办龄,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片淋昭。...
    茶點故事閱讀 40,117評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡俐填,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出翔忽,到底是詐尸還是另有隱情英融,我是刑警寧澤,帶...
    沈念sama閱讀 35,810評論 5 346
  • 正文 年R本政府宣布歇式,位于F島的核電站驶悟,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏材失。R本人自食惡果不足惜痕鳍,卻給世界環(huán)境...
    茶點故事閱讀 41,462評論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望龙巨。 院中可真熱鬧额获,春花似錦、人聲如沸恭应。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,011評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽昼榛。三九已至,卻和暖如春剔难,著一層夾襖步出監(jiān)牢的瞬間胆屿,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,139評論 1 272
  • 我被黑心中介騙來泰國打工偶宫, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留非迹,地道東北人。 一個月前我還...
    沈念sama閱讀 48,377評論 3 373
  • 正文 我出身青樓纯趋,卻偏偏與公主長得像憎兽,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子吵冒,可洞房花燭夜當晚...
    茶點故事閱讀 45,060評論 2 355

推薦閱讀更多精彩內(nèi)容