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

在一次項(xiàng)目開發(fā)中接觸到了jetpack Compose投放,并且還項(xiàng)目中在邏輯簡單的頁面,使用了compose去實(shí)現(xiàn)适贸。當(dāng)時(shí)覺得很新穎灸芳,實(shí)踐中也感覺到,這種響應(yīng)式的拜姿,與當(dāng)時(shí)的Vue/微信小程序/Flutter中思想大同小異烙样,可能是未來的一種原生寫UI的趨勢。在現(xiàn)在的每記和腳印項(xiàng)目中蕊肥,新實(shí)現(xiàn)的頁面谒获,都會優(yōu)先考慮用Compose去實(shí)現(xiàn)蛤肌。然而,Compose的一些性能優(yōu)化點(diǎn)及注意點(diǎn)批狱,也是做為開發(fā)人員需要熟悉的裸准,今天將做一個(gè)小的總結(jié)。

一赔硫、聲明式 vs 指令式編程

1炒俱、定義

無論是官網(wǎng)文檔還是介紹Compose的優(yōu)點(diǎn)時(shí),都會說到Compose是聲明式的爪膊。我們來回顧下权悟,在wiki上有著如下定義:

聲明式編程(英語:Declarative programming)或譯為聲明式編程,是對與命令式編程不同的編程范型的一種合稱推盛。它們建造計(jì)算機(jī)程序的結(jié)構(gòu)和元素峦阁,表達(dá)計(jì)算的邏輯而不用描述它的控制流程。

指令式編程(英語:Imperative programming)耘成;是一種描述電腦所需作出的行為的編程范型拇派。幾乎所有電腦的硬件都是指令式工作;幾乎所有電腦的硬件都是能執(zhí)行機(jī)器語言凿跳,而機(jī)器代碼是使用指令式的風(fēng)格來寫的。

通俗的來說就是:聲明式編程是一種把程序?qū)懗擅枋鼋Y(jié)果的形式疮方,而不是如何獲得結(jié)果的形式控嗜。它主要關(guān)注結(jié)果,而不是實(shí)現(xiàn)細(xì)節(jié)骡显。聲明式編程的代碼通常更簡潔疆栏,更容易理解和維護(hù)。

命令式編程則是一種把程序?qū)懗芍噶畹男问奖拱嬖V計(jì)算機(jī)如何實(shí)現(xiàn)結(jié)果壁顶。它更加關(guān)注細(xì)節(jié),如何實(shí)現(xiàn)任務(wù)溜歪。命令式編程的代碼通常更長若专,更難理解和維護(hù)。

2蝴猪、個(gè)人理解

Compose其實(shí)就是UI框架调衰,它最主要的功能就是讓開發(fā)人員更加快速的實(shí)現(xiàn) 頁面邏輯&交互效果 這是目的。

對于傳統(tǒng)的XML來說自阱,我們通過請求去服務(wù)器獲取數(shù)據(jù)嚎莉,請求成功后,我們需要findViewById找到頁面元素View沛豌,再設(shè)置View的屬性趋箩,更新頁面展示狀態(tài)。整個(gè)過程是按 http請求 -> 響應(yīng) -> 尋找對應(yīng)View -> 更新對應(yīng)View按部就班就地執(zhí)行,這種思想就是命令式編程叫确。

但是Compose描述為 http請求 -> 響應(yīng) -> 更新mutableData -> 引用對應(yīng)數(shù)據(jù)的View自動(dòng)重組跳芳,整個(gè)過程不需要我們開發(fā)去寫更新UI的代碼(發(fā)出命令),而是數(shù)據(jù)發(fā)生改變启妹,UI界面自動(dòng)更新筛严,可以理解為聲明式。

二饶米、Compose優(yōu)勢

目前對于我的體驗(yàn)感受來說桨啃,Compose的優(yōu)勢體現(xiàn)在以下幾個(gè)點(diǎn):

  • 頁面架構(gòu)清晰。對比以前mvp檬输,mvvm或結(jié)合viewbinding照瘾,少去了很多接口及編寫填充數(shù)據(jù)相關(guān)的代碼

  • 動(dòng)畫API簡單好用。強(qiáng)大的動(dòng)畫支持丧慈,使得寫動(dòng)畫非常簡單析命。

  • 開發(fā)效率高,寫UI速度快逃默,style鹃愤、shape等樣式使用簡單。

  • 另外完域、還有一些官方優(yōu)勢介紹

三软吐、Compose 的重組作用域

雖然Compose 編譯器在背后做了大量工作來保證 recomposition 范圍盡可能小,我們還是需要對哪些情況發(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

按照開發(fā)經(jīng)驗(yàn)凹耙,第一感覺會是,text變量只被Text控件用到了肠仪。

分析一下肖抱,Button控件的定義為:

參數(shù) text 作為表達(dá)式執(zhí)行的調(diào)用處是 Button 的尾lambda,而后才作為參數(shù)傳入 Text()异旧。 所以此時(shí)最小重組范圍是 Button 的 尾lambda 而非 Text()

另外還有兩點(diǎn)需要關(guān)注:

  • Compose 關(guān)心的是代碼塊中是否有對 state 的 read意述,而不是 write。

  • text 指向的 MutableState 實(shí)例是永遠(yuǎn)不會變的吮蛹,變的只是內(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)

  • ColumnRow然低、Box 乃至 Layout 這種容器類 Composable 都是 inline 函數(shù)喜每,因此它們只能共享調(diào)用方的重組范圍务唐,也就是 Button 的 尾lambda

如果你希望通過縮小重組范圍提高性能怎么辦?

@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開發(fā)時(shí)刚照,提高性能的關(guān)注點(diǎn)

當(dāng) Compose 更新重組時(shí)刑巧,它會經(jīng)歷三個(gè)階段(跟傳統(tǒng)View比較類似):

  • 組合:Compose 確定要顯示的內(nèi)容 - 運(yùn)行可組合函數(shù)并構(gòu)建界面樹。

  • 布局:Compose 確定界面樹中每個(gè)元素的尺寸和位置无畔。

  • 繪圖:Compose 實(shí)際渲染各個(gè)界面元素啊楚。

基于這3個(gè)階段, 盡可能從可組合函數(shù)中移除計(jì)算浑彰。每當(dāng)界面發(fā)生變化時(shí)恭理,都可能需要重新運(yùn)行可組合函數(shù);可能對于動(dòng)畫的每一幀郭变,都會重新執(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í)周伦,會使自身狀態(tài)發(fā)生改變導(dǎo)致ContactList重組,從而contacts.sortedWith(comparator)也會重復(fù)執(zhí)行未荒。而排序是一個(gè)占用CPU算力的函數(shù)横辆,對性能產(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會對排序的結(jié)果進(jìn)行保存茄猫,使得下次重組時(shí),只要contacts不發(fā)生變化 困肩,其值可以重復(fù)使用划纽。

  • 也就是說,它只進(jìn)行了一次排序操作锌畸,避免了每次重組時(shí)都進(jìn)行了計(jì)算勇劣。

提示:

  • 更優(yōu)的做法是將這類計(jì)算的操作移出Compose方法,放到ViewModel中潭枣,再使用collectAsStateLanchEffect等方式進(jìn)行觀測自動(dòng)重組比默。
2、使用LazyColumn盆犁、LazyRow等列表組件時(shí)命咐,指定key

如下一段代碼,是一個(gè)很常見的需求(from官網(wǎng)):

??NoteRow記錄每項(xiàng)記錄的簡要信息谐岁,當(dāng)我們進(jìn)入編輯頁進(jìn)行修改后醋奠,需要將最近修改的一條按修改時(shí)間放到列表最前面榛臼。這時(shí),假若不指定每項(xiàng)Item的Key窜司,其中一項(xiàng)發(fā)生了位置變化沛善,都會導(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)定的尤蛮、不會發(fā)生改變的唯一值(通常為項(xiàng)ID)
                note.id
            }
        ) { note ->
            NoteRow(note)
        }
    }
}
3、使用 derivedStateOf 限制重組

??假設(shè)我們需要根據(jù)列表的第一項(xiàng)是否可見來決定劃到頂部的按鈕是否可見笙蒙,代碼如下:

val listState = rememberLazyListState()

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

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}
  • 由于列表的滑動(dòng)會使listState狀態(tài)改變抵屿,而使用showButtonAnimatedVisibility會不斷重組,導(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ā)對UI界面產(chǎn)生了影響的狀態(tài)。這樣AnimatedVisibility只會在改變時(shí)發(fā)生重組焰雕。對應(yīng)的應(yīng)用場景是衷笋,狀態(tài)發(fā)生了改變,但是我們只關(guān)注對界面產(chǎn)生了影響的狀態(tài)進(jìn)行分發(fā)矩屁,這種情況下辟宗,就可以考慮使用。
4吝秕、盡可能延遲State的讀行為

之前我們提到泊脐,對于一個(gè)Compose頁面來說,它會經(jīng)歷以下步驟:

  • 第一步烁峭,Composition容客,這其實(shí)就代表了我們的Composable函數(shù)執(zhí)行的過程。

  • 第二步约郁,Layout缩挑,這跟我們View體系的Layout類似,但總體的分發(fā)流程是存在一些差異的鬓梅。

  • 第三步供置,Draw,也就是繪制绽快,Compose的UI元素最終會繪制在Android的Canvas上士袄。由此可見悲关,Jetpack Compose雖然是全新的UI框架,但它的底層并沒有脫離Android的范疇娄柳。

  • 最后寓辱,Recomposition,也就是重組赤拒,并且重復(fù)1秫筏、2、3步驟挎挖。

盡可能推遲狀態(tài)讀取的原因这敬,其實(shí)還是希望我們可以在某些場景下直接跳過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)化的空間。

那么别伏,具體我們該如何優(yōu)化呢蹄衷?簡單來說,就是讓:“狀態(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轴脐,所以,它并不會在composition期間產(chǎn)生狀態(tài)讀取行為抡砂,這樣大咱,當(dāng)scroll.value發(fā)生變化的時(shí)候,就不會觸發(fā)「重組」注益,這就是 延遲 的意義碴巾。

五、小結(jié)

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末浙垫,一起剝皮案震驚了整個(gè)濱河市刨仑,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌夹姥,老刑警劉巖杉武,帶你破解...
    沈念sama閱讀 206,214評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異辙售,居然都是意外死亡轻抱,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,307評論 2 382
  • 文/潘曉璐 我一進(jìn)店門旦部,熙熙樓的掌柜王于貴愁眉苦臉地迎上來祈搜,“玉大人,你說我怎么就攤上這事志鹃∝参剩” “怎么了?”我有些...
    開封第一講書人閱讀 152,543評論 0 341
  • 文/不壞的土叔 我叫張陵曹铃,是天一觀的道長缰趋。 經(jīng)常有香客問我,道長陕见,這世上最難降的妖魔是什么秘血? 我笑而不...
    開封第一講書人閱讀 55,221評論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮评甜,結(jié)果婚禮上灰粮,老公的妹妹穿的比我還像新娘。我一直安慰自己忍坷,他們只是感情好粘舟,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,224評論 5 371
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著佩研,像睡著了一般柑肴。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上旬薯,一...
    開封第一講書人閱讀 49,007評論 1 284
  • 那天晰骑,我揣著相機(jī)與錄音,去河邊找鬼绊序。 笑死硕舆,一個(gè)胖子當(dāng)著我的面吹牛秽荞,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播抚官,決...
    沈念sama閱讀 38,313評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼扬跋,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了耗式?” 一聲冷哼從身側(cè)響起胁住,我...
    開封第一講書人閱讀 36,956評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎刊咳,沒想到半個(gè)月后彪见,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,441評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡娱挨,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,925評論 2 323
  • 正文 我和宋清朗相戀三年余指,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片跷坝。...
    茶點(diǎn)故事閱讀 38,018評論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡酵镜,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出柴钻,到底是詐尸還是另有隱情淮韭,我是刑警寧澤,帶...
    沈念sama閱讀 33,685評論 4 322
  • 正文 年R本政府宣布贴届,位于F島的核電站靠粪,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏毫蚓。R本人自食惡果不足惜占键,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,234評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望元潘。 院中可真熱鬧畔乙,春花似錦、人聲如沸翩概。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,240評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽钥庇。三九已至牍鞠,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間上沐,已是汗流浹背皮服。 一陣腳步聲響...
    開封第一講書人閱讀 31,464評論 1 261
  • 我被黑心中介騙來泰國打工楞艾, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留参咙,地道東北人龄广。 一個(gè)月前我還...
    沈念sama閱讀 45,467評論 2 352
  • 正文 我出身青樓,卻偏偏與公主長得像蕴侧,于是被迫代替她去往敵國和親择同。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,762評論 2 345

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