在一次項(xiàng)目開(kāi)發(fā)中接觸到了jetpack Compose,并且還項(xiàng)目中在邏輯簡(jiǎn)單的頁(yè)面,使用了compose去實(shí)現(xiàn)。當(dāng)時(shí)覺(jué)得很新穎,實(shí)踐中也感覺(jué)到,這種響應(yīng)式的,與當(dāng)時(shí)的Vue/微信小程序/Flutter中思想大同小異,可能是未來(lái)的一種原生寫(xiě)UI的趨勢(shì)。在現(xiàn)在的每記和腳印項(xiàng)目中,新實(shí)現(xiàn)的頁(yè)面,都會(huì)優(yōu)先考慮用Compose去實(shí)現(xiàn)。然而,Compose的一些性能優(yōu)化點(diǎn)及注意點(diǎn),也是做為開(kāi)發(fā)人員需要熟悉的,今天將做一個(gè)小的總結(jié)。
一、聲明式 vs 指令式編程
1、定義
無(wú)論是官網(wǎng)文檔還是介紹Compose的優(yōu)點(diǎn)時(shí),都會(huì)說(shuō)到Compose是聲明式的。我們來(lái)回顧下,在wiki上有著如下定義:
聲明式編程(英語(yǔ):Declarative programming)或譯為聲明式編程,是對(duì)與命令式編程不同的編程范型的一種合稱。它們建造計(jì)算機(jī)程序的結(jié)構(gòu)和元素,表達(dá)計(jì)算的邏輯而不用描述它的控制流程。
指令式編程(英語(yǔ):Imperative programming);是一種描述電腦所需作出的行為的編程范型。幾乎所有電腦的硬件都是指令式工作;幾乎所有電腦的硬件都是能執(zhí)行機(jī)器語(yǔ)言,而機(jī)器代碼是使用指令式的風(fēng)格來(lái)寫(xiě)的。
通俗的來(lái)說(shuō)就是:聲明式編程是一種把程序?qū)懗擅枋鼋Y(jié)果的形式,而不是如何獲得結(jié)果的形式。它主要關(guān)注結(jié)果,而不是實(shí)現(xiàn)細(xì)節(jié)。聲明式編程的代碼通常更簡(jiǎn)潔,更容易理解和維護(hù)。
命令式編程則是一種把程序?qū)懗芍噶畹男问?,告訴計(jì)算機(jī)如何實(shí)現(xiàn)結(jié)果。它更加關(guān)注細(xì)節(jié),如何實(shí)現(xiàn)任務(wù)。命令式編程的代碼通常更長(zhǎng),更難理解和維護(hù)。
2、個(gè)人理解
Compose其實(shí)就是UI框架,它最主要的功能就是讓開(kāi)發(fā)人員更加快速的實(shí)現(xiàn) 頁(yè)面邏輯&交互效果 這是目的。
對(duì)于傳統(tǒng)的XML來(lái)說(shuō),我們通過(guò)請(qǐng)求去服務(wù)器獲取數(shù)據(jù),請(qǐng)求成功后,我們需要findViewById找到頁(yè)面元素View,再設(shè)置View的屬性,更新頁(yè)面展示狀態(tài)。整個(gè)過(guò)程是按 http請(qǐng)求 -> 響應(yīng) -> 尋找對(duì)應(yīng)View -> 更新對(duì)應(yīng)View按部就班就地執(zhí)行,這種思想就是命令式編程。
但是Compose描述為 http請(qǐng)求 -> 響應(yīng) -> 更新mutableData -> 引用對(duì)應(yīng)數(shù)據(jù)的View自動(dòng)重組,整個(gè)過(guò)程不需要我們開(kāi)發(fā)去寫(xiě)更新UI的代碼(發(fā)出命令),而是數(shù)據(jù)發(fā)生改變,UI界面自動(dòng)更新,可以理解為聲明式。
二、Compose優(yōu)勢(shì)
目前對(duì)于我的體驗(yàn)感受來(lái)說(shuō),Compose的優(yōu)勢(shì)體現(xiàn)在以下幾個(gè)點(diǎn):
頁(yè)面架構(gòu)清晰。對(duì)比以前mvp,mvvm或結(jié)合viewbinding,少去了很多接口及編寫(xiě)填充數(shù)據(jù)相關(guān)的代碼
動(dòng)畫(huà)API簡(jiǎn)單好用。強(qiáng)大的動(dòng)畫(huà)支持,使得寫(xiě)動(dòng)畫(huà)非常簡(jiǎn)單。
開(kāi)發(fā)效率高,寫(xiě)UI速度快,style、shape等樣式使用簡(jiǎn)單。
另外、還有一些官方優(yōu)勢(shì)介紹
三、Compose 的重組作用域
雖然Compose 編譯器在背后做了大量工作來(lái)保證 recomposition 范圍盡可能小,我們還是需要對(duì)哪些情況發(fā)生了重組以及重組的范圍有一定的了解 。
假設(shè)有如下代碼:
@Composable
fun Foo() {
var text by remember { mutableStateOf("") }
Log.d(TAG, "Foo")
Button(onClick = {
text = "$text $text"
}.also { Log.d(TAG, "Button") }) {
Log.d(TAG, "Button content lambda")
Text(text).also { Log.d(TAG, "Text") }
}
}
其打印結(jié)果為:
D/Compose: Button content lambda
D/Compose: Text
按照開(kāi)發(fā)經(jīng)驗(yàn),第一感覺(jué)會(huì)是,text變量只被Text控件用到了。
分析一下,Button控件的定義為:

參數(shù) text 作為表達(dá)式執(zhí)行的調(diào)用處是 Button 的尾lambda,而后才作為參數(shù)傳入 Text()。 所以此時(shí)最小重組范圍是 Button 的 尾lambda 而非 Text()
另外還有兩點(diǎn)需要關(guān)注:
Compose 關(guān)心的是代碼塊中是否有對(duì) state 的 read,而不是 write。
text 指向的 MutableState 實(shí)例是永遠(yuǎn)不會(huì)變的,變的只是內(nèi)部的 value
重組中的 Inline 陷阱!
非inline函數(shù) 才有資格成為重組的最小范圍,理解這點(diǎn)特別重要!
我們將代碼稍作改動(dòng),為 Text() 包裹一個(gè) Box{...}
@Composable
fun Foo() {
var text by remember { mutableStateOf("") }
Button(onClick = { text = "$text $text" }) {
Log.d(TAG, "Button content lambda")
Box {
Log.d(TAG, "Box")
Text(text).also { Log.d(TAG, "Text") }
}
}
}
日志如下:
D/Compose: Button content lambda
D/Compose: Box
D/Compose: Text
要點(diǎn):
-
Column、Row、Box乃至Layout這種容器類 Composable 都是inline函數(shù),因此它們只能共享調(diào)用方的重組范圍,也就是 Button 的 尾lambda
如果你希望通過(guò)縮小重組范圍提高性能怎么辦?
@Composable
fun Foo() {
var text by remember { mutableStateOf("") }
Button(onClick = { text = "$text $text" }) {
Log.d(TAG, "Button content lambda")
Wrapper {
Text(text).also { Log.d(TAG, "Text") }
}
}
}
@Composable
fun Wrapper(content: @Composable () -> Unit) {
Log.d(TAG, "Wrapper recomposing")
Box {
Log.d(TAG, "Box")
content()
}
}
- 自定義非 inline 函數(shù),使之滿足 Compose 重組范圍最小化條件。
四、Compose開(kāi)發(fā)時(shí),提高性能的關(guān)注點(diǎn)
當(dāng) Compose 更新重組時(shí),它會(huì)經(jīng)歷三個(gè)階段(跟傳統(tǒng)View比較類似):
組合:Compose 確定要顯示的內(nèi)容 - 運(yùn)行可組合函數(shù)并構(gòu)建界面樹(shù)。
布局:Compose 確定界面樹(shù)中每個(gè)元素的尺寸和位置。
繪圖:Compose 實(shí)際渲染各個(gè)界面元素。
基于這3個(gè)階段, 盡可能從可組合函數(shù)中移除計(jì)算。每當(dāng)界面發(fā)生變化時(shí),都可能需要重新運(yùn)行可組合函數(shù);可能對(duì)于動(dòng)畫(huà)的每一幀,都會(huì)重新執(zhí)行您在可組合函數(shù)中放置的所有代碼。
1、合理使用 remember
它的作用是:
- 保存重組時(shí)的狀態(tài),并可以有重組后取出之前的狀態(tài)
引用官方的栗子??:
@Composable
fun ContactList(
contacts: List<Contact>,
comparator: Comparator<Contact>,
modifier: Modifier = Modifier
) {
LazyColumn(modifier) {
// DON’T DO THIS
items(contacts.sortedWith(comparator)) { contact ->
// ...
}
}
}
-
LazyColumn在滑動(dòng)時(shí),會(huì)使自身狀態(tài)發(fā)生改變導(dǎo)致ContactList重組,從而contacts.sortedWith(comparator)也會(huì)重復(fù)執(zhí)行。而排序是一個(gè)占用CPU算力的函數(shù),對(duì)性能產(chǎn)生了較大的影響。
正確做法:
@Composable
fun ContactList(
contacts: List<Contact>,
comparator: Comparator<Contact>,
modifier: Modifier = Modifier
) {
val sortedContacts = remember(contacts, sortComparator) {
contacts.sortedWith(sortComparator)
}
LazyColumn(modifier) {
items(sortedContacts) {
// ...
}
}
}
使用
remember會(huì)對(duì)排序的結(jié)果進(jìn)行保存,使得下次重組時(shí),只要contacts不發(fā)生變化 ,其值可以重復(fù)使用。也就是說(shuō),它只進(jìn)行了一次排序操作,避免了每次重組時(shí)都進(jìn)行了計(jì)算。
提示:
- 更優(yōu)的做法是將這類計(jì)算的操作移出Compose方法,放到ViewModel中,再使用
collectAsState或LanchEffect等方式進(jìn)行觀測(cè)自動(dòng)重組。
2、使用LazyColumn、LazyRow等列表組件時(shí),指定key
如下一段代碼,是一個(gè)很常見(jiàn)的需求(from官網(wǎng)):
??NoteRow記錄每項(xiàng)記錄的簡(jiǎn)要信息,當(dāng)我們進(jìn)入編輯頁(yè)進(jìn)行修改后,需要將最近修改的一條按修改時(shí)間放到列表最前面。這時(shí),假若不指定每項(xiàng)Item的Key,其中一項(xiàng)發(fā)生了位置變化,都會(huì)導(dǎo)致其他的NoteRow發(fā)生重組,然而我們修改的只是其中一項(xiàng),進(jìn)行了不必要的渲染。
@Composable
fun NotesList(notes: List<Note>) {
LazyColumn {
items(
items = notes
) { note ->
NoteRow(note)
}
}
}
正確的做法:
- 為每項(xiàng)Item提供 項(xiàng)鍵,就可避免其他未修改的NoteRow只需挪動(dòng)位置,避免發(fā)生重組
@Composable
fun NotesList(notes: List<Note>) {
LazyColumn {
items(
items = notes,
key = { note ->
// 為每項(xiàng)Item提供穩(wěn)定的、不會(huì)發(fā)生改變的唯一值(通常為項(xiàng)ID)
note.id
}
) { note ->
NoteRow(note)
}
}
}
3、使用 derivedStateOf 限制重組
??假設(shè)我們需要根據(jù)列表的第一項(xiàng)是否可見(jiàn)來(lái)決定劃到頂部的按鈕是否可見(jiàn),代碼如下:
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
val showButton = listState.firstVisibleItemIndex > 0
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
- 由于列表的滑動(dòng)會(huì)使
listState狀態(tài)改變,而使用showButton的AnimatedVisibility會(huì)不斷重組,導(dǎo)致性能下降。
??解決方案是使用派生狀態(tài)。如下 :
val listState = rememberLazyListState()
LazyColumn(state = listState) {
// ...
}
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}
- 派生狀態(tài),可以這樣理解,只有在
derivedStateOf里的狀態(tài)發(fā)生改變時(shí),只關(guān)注和派發(fā)對(duì)UI界面產(chǎn)生了影響的狀態(tài)。這樣AnimatedVisibility只會(huì)在改變時(shí)發(fā)生重組。對(duì)應(yīng)的應(yīng)用場(chǎng)景是,狀態(tài)發(fā)生了改變,但是我們只關(guān)注對(duì)界面產(chǎn)生了影響的狀態(tài)進(jìn)行分發(fā),這種情況下,就可以考慮使用。
4、盡可能延遲State的讀行為
之前我們提到,對(duì)于一個(gè)Compose頁(yè)面來(lái)說(shuō),它會(huì)經(jīng)歷以下步驟:
第一步,Composition,這其實(shí)就代表了我們的Composable函數(shù)執(zhí)行的過(guò)程。
第二步,Layout,這跟我們View體系的Layout類似,但總體的分發(fā)流程是存在一些差異的。
第三步,Draw,也就是繪制,Compose的UI元素最終會(huì)繪制在Android的Canvas上。由此可見(jiàn),Jetpack Compose雖然是全新的UI框架,但它的底層并沒(méi)有脫離Android的范疇。
最后,Recomposition,也就是重組,并且重復(fù)1、2、3步驟。
盡可能推遲狀態(tài)讀取的原因,其實(shí)還是希望我們可以在某些場(chǎng)景下直接跳過(guò)Recomposition的階段、甚至Layout的階段,只影響到Draw。
??分析如下代碼:
@Composable
fun SnackDetail() {
// Recomposition Scope
// ...
Box(Modifier.fillMaxSize()) { Start
val scroll = rememberScrollState(0)
// ...
Title(snack, scroll.value) // 1,狀態(tài)讀取
// ...
}
// 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,狀態(tài)使用
) {
// ...
}
}
上面的代碼有兩個(gè)注釋,注釋1,代表了狀態(tài)的讀??;注釋2,代表了狀態(tài)的使用。這種“狀態(tài)讀取與使用位置不一致”的現(xiàn)象,其實(shí)就為Compose提供了性能優(yōu)化的空間。
那么,具體我們?cè)撊绾蝺?yōu)化呢?簡(jiǎn)單來(lái)說(shuō),就是讓:“狀態(tài)讀取與使用位置一致”。
改為如下 :
// 代碼段12
@Composable
fun SnackDetail() {
// Recomposition Scope
// ...
Box(Modifier.fillMaxSize()) {
val scroll = rememberScrollState(0)
// ...
Title(snack) { scroll.value } // 1,Laziness
// ...
}
// Recomposition Scope End
}
@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
// ...
val offset = with(LocalDensity.current) { scrollProvider().toDp() }
Column(
modifier = Modifier
.offset(y = offset) // 2,狀態(tài)讀取+使用
) {
// ...
}
}
理解: 由于我們將scroll.value變成了Lambda,所以,它并不會(huì)在composition期間產(chǎn)生狀態(tài)讀取行為,這樣,當(dāng)scroll.value發(fā)生變化的時(shí)候,就不會(huì)觸發(fā)「重組」,這就是 延遲 的意義。

五、小結(jié)
其實(shí)以上案例優(yōu)化的點(diǎn)在本質(zhì)上,都是在踐行:狀態(tài)讀取與使用位置一致的原則。但是需要我們對(duì)Compose的底層原理,快照系統(tǒng),還有ScopeUpdateScope有一定的了解。這樣才會(huì)讓我們有著深刻的理解,代碼為什么要這么寫(xiě)。