Android Jetpack Compose使用及性能優(yōu)化小結(jié)

在一次項(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、RowBox 乃至 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中,再使用collectAsStateLanchEffect等方式進(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)改變,而使用showButtonAnimatedVisibility會(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ě)。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容