列表性能優(yōu)化:從渲染原理到工程實(shí)踐)
1. 長(zhǎng)列表為什么會(huì)卡從渲染鏈路找瓶頸先說(shuō)一個(gè)很多初學(xué)者容易忽略的事實(shí)ArkUI 里L(fēng)ist組件本身并不慢真正拖垮性能的往往是我們自己寫(xiě)業(yè)務(wù)代碼時(shí)埋下的雷。我見(jiàn)過(guò)不少項(xiàng)目數(shù)據(jù)量撐死幾百條滑動(dòng)起來(lái)卻像在拖著一塊巨石打開(kāi) DevEco Studio 的 Profiler 一看滿屏都是自定義組件的 build 調(diào)用和 Image 解碼耗時(shí)。鴻蒙的 ArkUI 渲染鏈路大致是這樣走的狀態(tài)變量變化后框架會(huì)觸發(fā)組件樹(shù) diff然后生成渲染指令交給 JS 引擎和渲染引擎執(zhí)行。只要這個(gè)鏈路里任何一環(huán)出現(xiàn)重復(fù)勞動(dòng)性能賬單就會(huì)算到你頭上。長(zhǎng)列表場(chǎng)景下最常見(jiàn)的三類重復(fù)勞動(dòng)是不必要的子組件重建、圖片重復(fù)解碼、以及布局計(jì)算沒(méi)有復(fù)用。先看組件重建。默認(rèn)情況下List的ListItem在滑出屏幕后會(huì)被回收滑回來(lái)時(shí)會(huì)重新創(chuàng)建并執(zhí)行 build。如果每個(gè) Item 里塞了復(fù)雜的自定義組件尤其是那種在aboutToAppear里寫(xiě)一堆初始化邏輯的組件重新創(chuàng)建的成本會(huì)非常高。更隱蔽的問(wèn)題是父組件一旦發(fā)生狀態(tài)更新即便子組件的數(shù)據(jù)沒(méi)變子組件的 build 依然會(huì)被調(diào)用——這就是所謂的“無(wú)效渲染”。再看圖片解碼。長(zhǎng)列表是圖片加載的重災(zāi)區(qū)。鴻蒙的Image組件如果直接給一個(gè)網(wǎng)絡(luò) URL內(nèi)部雖然會(huì)有緩存但首次滾動(dòng)到可見(jiàn)區(qū)域時(shí)解碼大圖的速度足夠讓列表明顯掉幀。如果用pixelMap或者base64字符串問(wèn)題更嚴(yán)重因?yàn)檫@些數(shù)據(jù)從 JS 層走到渲染層中間還要做一次數(shù)據(jù)拷貝。最后是布局計(jì)算。每個(gè) Item 如果高度不是確定的框架需要?jiǎng)討B(tài)測(cè)量。測(cè)量本身有成本而如果你在ListItem里用了Flex、RelativeContainer這類相對(duì)布局每幀滑動(dòng)過(guò)程中都可能觸發(fā)重新測(cè)量這比固定高度的列表要貴一個(gè)數(shù)量級(jí)。所以我個(gè)人在做性能優(yōu)化時(shí)第一步永遠(yuǎn)是先跑 Profiler 看調(diào)用棧搞清楚瓶頸到底是 JS 邏輯、布局、還是渲染。優(yōu)化這事怕的不是問(wèn)題多而是你不知道問(wèn)題在哪。2. 先做減法把列表項(xiàng)拆到最薄2.1 減少不必要的組件嵌套很多同學(xué)寫(xiě)列表項(xiàng)時(shí)習(xí)慣從設(shè)計(jì)稿里照搬結(jié)構(gòu)一個(gè) Item 里塞四五個(gè)嵌套容器。從 UI 效果看確實(shí)沒(méi)差別但從渲染性能看每一層嵌套都是在給 diff 算法增加負(fù)擔(dān)。ArkUI 的 diff 是逐層遞歸的節(jié)點(diǎn)層級(jí)越深遞歸成本越高。尤其當(dāng) Item 數(shù)量上百時(shí)這個(gè)成本會(huì)被放大到肉眼可見(jiàn)的程度。我的習(xí)慣是能用布局容器解決的不用自定義組件能用簡(jiǎn)單屬性完成的不用額外封裝。比如一個(gè)典型的商品卡片如果只有圖片、標(biāo)題、價(jià)格三要素直接用Row和Column內(nèi)聯(lián)寫(xiě)在ListItem里就夠了沒(méi)必要單獨(dú)抽一個(gè)GoodsCard組件。組件抽離是為了代碼復(fù)用但如果這個(gè)組件只在一個(gè)地方用抽出來(lái)就是在給渲染層增加無(wú)謂的節(jié)點(diǎn)。這里有個(gè)折中方案如果確實(shí)需要抽組件把組件內(nèi)部的樣式盡量靜態(tài)化。所謂靜態(tài)化就是那些不會(huì)隨數(shù)據(jù)變化而改變的屬性比如圓角、背景色、固定寬高不要綁定狀態(tài)變量直接寫(xiě)死或?qū)懗沙A?。?dòng)態(tài)屬性越少diff 階段需要比對(duì)的內(nèi)容就越少。2.2 狀態(tài)變量的粒度控制狀態(tài)變量是 ArkUI 性能的另一大坑。很多開(kāi)發(fā)者習(xí)慣在頁(yè)面頂層定義一個(gè)巨型數(shù)據(jù)對(duì)象比如State private goodsList: GoodsItem[] [];然后每個(gè)列表項(xiàng)的圖片、標(biāo)題、價(jià)格都從goodsList里讀取。這個(gè)寫(xiě)法在數(shù)據(jù)量小的時(shí)候沒(méi)毛病但如果某個(gè)子組件內(nèi)部修改了goodsList中某一項(xiàng)的字段頂層狀態(tài)會(huì)通知所有依賴它的組件去刷新哪怕這些組件根本不關(guān)心那個(gè)字段。這就像辦公室的廣播系統(tǒng)任何一個(gè)人說(shuō)話全公司的人都得放下手頭工作聽(tīng)一遍效率能不低嗎更合理的做法是把狀態(tài)變量的粒度縮小到組件級(jí)別。具體到長(zhǎng)列表場(chǎng)景讓每個(gè)ListItem自己持有獨(dú)立的GoodsItem數(shù)據(jù)通過(guò)構(gòu)造參數(shù)傳進(jìn)去而不是統(tǒng)一從頂層讀取。這樣單個(gè) Item 的數(shù)據(jù)變化只觸發(fā)自身刷新不會(huì)波及兄弟節(jié)點(diǎn)。另外如果你用的是Observed和ObjectLink做數(shù)據(jù)聯(lián)動(dòng)切記不要在整個(gè)對(duì)象上做深拷貝式的賦值盡量只修改具體字段。深拷貝會(huì)生成新對(duì)象新對(duì)象會(huì)迫使ObjectLink重新建立關(guān)聯(lián)之前的優(yōu)化就白做了。3. 懶加載與緩存讓列表學(xué)會(huì)“偷懶”3.1 使用 LazyForEach 替代 ForEachForEach 是全量渲染LazyForEach 是懶加載渲染這個(gè)基礎(chǔ)概念大多數(shù)人都知道但真正到了代碼層面很多人的用法是錯(cuò)的。正確用法是提供一個(gè)繼承IDataSource的數(shù)據(jù)源類實(shí)現(xiàn)totalCount、getData、registerDataChangeListener這些方法。框架會(huì)按需調(diào)用getData來(lái)獲取當(dāng)前可見(jiàn)區(qū)域的 Item 數(shù)據(jù)滾出屏幕的數(shù)據(jù)則會(huì)被回收。但這里有個(gè)關(guān)鍵細(xì)節(jié)IDataSource 的 getData 返回的數(shù)據(jù)對(duì)象最好是從緩存池里取出的復(fù)用實(shí)例而不是每次 new 一個(gè)新對(duì)象。如果每次滾動(dòng)都 new列表就會(huì)頻繁觸發(fā)垃圾回收GCGC 一多滑動(dòng)卡頓就肉眼可見(jiàn)了。我自己寫(xiě)過(guò)一個(gè)簡(jiǎn)單的對(duì)象池用Map緩存已經(jīng)創(chuàng)建過(guò)的 Item 數(shù)據(jù)key 是 itemIdvalue 是數(shù)據(jù)實(shí)體。新的列表項(xiàng)需要展示時(shí)先從池里取取不到再新建。實(shí)測(cè)下來(lái)GC 頻率有明顯下降。3.2 緩存列表項(xiàng)組件實(shí)例LazyForEach 負(fù)責(zé)的是“數(shù)據(jù)懶加載”但組件實(shí)例的復(fù)用還得靠框架底層的緩存機(jī)制。ArkUI 的ListItem在滑出可視區(qū)域后其實(shí)不會(huì)立刻銷毀而是進(jìn)入一個(gè)回收池如果短時(shí)間內(nèi)滑回來(lái)可以直接復(fù)用。不過(guò)這個(gè)復(fù)用是有條件的你在 build 里不能有大量的動(dòng)態(tài)分支邏輯。比如根據(jù) index 決定不同的布局結(jié)構(gòu)這種情況下組件復(fù)用回來(lái)的還得重新走 build 分支判斷等于沒(méi)省多少。我的建議是列表項(xiàng)內(nèi)部的 UI 結(jié)構(gòu)盡量一致不同形態(tài)通過(guò)屬性差異去表達(dá)而不是通過(guò)結(jié)構(gòu)分支??ㄆ斜砭褪强ㄆ斜聿灰粫?huì)兒卡片一會(huì)兒通欄。如果真的需要混合布局盡量在數(shù)據(jù)層提前分好組讓相鄰的 Item 結(jié)構(gòu)相同減少切換成本。3.3 圖片加載的三種優(yōu)化手段長(zhǎng)列表里的圖片優(yōu)化我把它拆成三個(gè)層級(jí)從易到難第一層尺寸裁剪。很多后端返回的原圖是 1080P 甚至 2K 的但列表里展示的寬度只有 200 到 300 像素。直接用原圖加載解碼時(shí)長(zhǎng)和內(nèi)存占用都白白浪費(fèi)。鴻蒙的 Image 組件支持objectFit和alt等屬性但更關(guān)鍵的還是讓后端裁剪一份合適尺寸的圖或者在前端用ImageDecoder做一次下采樣。實(shí)測(cè)下來(lái)把 1080P 的圖降到 320 寬內(nèi)存占用能少 70% 以上。第二層內(nèi)存緩存。Image 組件默認(rèn)有 LRU 緩存但緩存策略是透明的你沒(méi)法精細(xì)控制。如果列表頁(yè)面對(duì)圖片即時(shí)性要求高可以自建一個(gè)LruCache管理最近使用的圖片數(shù)據(jù)。需要注意的是緩存 key 建議用圖片 URL 加上尺寸信息避免同一個(gè) URL 因尺寸不同導(dǎo)致緩存擊穿。第三層懶加載配合預(yù)加載。LazyForEach 只渲染可見(jiàn)區(qū)域但圖片的加載有個(gè)延遲用戶快速滑動(dòng)時(shí)會(huì)看到空白占位??梢栽诹斜砘瑒?dòng)快到尾部時(shí)提前加載下一屏的圖片 URL。ArkUI 沒(méi)有提供內(nèi)置的預(yù)加載接口我一般是在onScrollIndex回調(diào)里判斷當(dāng)前 index 是否接近末尾然后手動(dòng)觸發(fā)圖片緩存預(yù)熱。效果挺明顯尤其是列表滾動(dòng)速度比較快的時(shí)候。4. 布局與渲染屬性優(yōu)化細(xì)節(jié)決定絲滑程度4.1 固定高度比動(dòng)態(tài)測(cè)量省得多這句話聽(tīng)起來(lái)像廢話但真正做到的人不多。很多列表項(xiàng)的布局用了Row和Column的默認(rèn) wrap 行為或者依賴Flex的彈性布局這會(huì)導(dǎo)致每個(gè) Item 的高度在渲染前無(wú)法確定框架必須動(dòng)態(tài)測(cè)量?jī)纱蜗葴y(cè)量子組件再確定父組件高度。如果列表項(xiàng)內(nèi)容比較規(guī)整直接給ListItem設(shè)置一個(gè)預(yù)估高度或者用constraintSize約束最小高度渲染引擎就能少做一輪測(cè)量。尤其當(dāng) Item 內(nèi)部有圖片時(shí)給圖片設(shè)置固定寬高比比讓圖片自適應(yīng)要穩(wěn)定得多。有人會(huì)擔(dān)心固定高度會(huì)不會(huì)導(dǎo)致不同設(shè)備上顯示不一致實(shí)際上你可以在ListItem上設(shè)置minHeight而不是height這樣既給了渲染引擎一個(gè)參考值又保留了內(nèi)容撐高的彈性。4.2 善用visibility與條件渲染列表項(xiàng)內(nèi)部如果有不常展示的模塊比如“查看詳情”的折疊區(qū)不要用條件渲染去控制顯示隱藏而應(yīng)該用visibility屬性。條件渲染意味著銷毀和重建代價(jià)很大visibility只是控制是否繪制組件實(shí)例和布局信息都還在。當(dāng)然如果折疊區(qū)里的子組件特別重用條件渲染在展開(kāi)時(shí)才創(chuàng)建反而更劃算這個(gè)取舍要看實(shí)際情況。我的一般原則是頻繁切換的狀態(tài)用 visibility低頻切換的狀態(tài)用 if/else。還有一個(gè)容易踩的坑在build里寫(xiě)復(fù)雜的函數(shù)調(diào)用。比如build() { Column() { Text(this.formatPrice(this.item.price)) } }這個(gè)寫(xiě)法的問(wèn)題在于每次 build 都會(huì)執(zhí)行formatPrice函數(shù)。如果函數(shù)內(nèi)部有正則匹配、字符串拼接等操作疊加到上百個(gè) Item 上就是不小的開(kāi)銷。正確的做法是在數(shù)據(jù)源里提前格式化好把價(jià)格字符串直接存到數(shù)據(jù)對(duì)象里。4.3 避免不必要的透明與陰影效果ArkUI 的渲染引擎對(duì)透明度和陰影的處理成本比較高。列表滑動(dòng)過(guò)程中如果 Item 上疊加了多層 transparency 或者 blur 效果GPU 的 fillrate 壓力會(huì)上升幀率就容易掉下來(lái)。不是說(shuō)不能用這些特效而是盡量把它們用在靜態(tài)頁(yè)面上不要用在頻繁滾動(dòng)的列表項(xiàng)里。如果設(shè)計(jì)稿里確實(shí)有陰影效果優(yōu)先用圖片資源代替代碼生成的陰影或者用elevation屬性讓系統(tǒng)幫忙處理而不是人為疊加多層。5. 實(shí)戰(zhàn)案例優(yōu)化一個(gè) 500 條數(shù)據(jù)的商品列表5.1 優(yōu)化前的效果與問(wèn)題定位接手一個(gè)商城項(xiàng)目的商品列表頁(yè)數(shù)據(jù)量約 500 條每屏展示 5 到 6 個(gè)商品卡片。優(yōu)化前用 DevEco Profiler 跑了一下列表滑動(dòng)時(shí)幀率大概在 25 到 38 幀之間波動(dòng)肉眼可見(jiàn)的掉幀。Profile 的結(jié)果顯示JS 線程占用率跑到了 70% 以上其中大部分時(shí)間花在了自定義組件的 build 上。抽絲剝繭之后發(fā)現(xiàn)代碼里存在幾個(gè)典型問(wèn)題第一個(gè)是商品卡片被封裝成了一個(gè)超大的自定義組件里面包含了價(jià)格格式化、促銷標(biāo)簽計(jì)算、好評(píng)率拼接等業(yè)務(wù)邏輯每個(gè) Item 創(chuàng)建時(shí)都要執(zhí)行一遍。第二個(gè)是列表用的是 ForEach而不是 LazyForEach導(dǎo)致 500 條數(shù)據(jù)一次性全部渲染。第三個(gè)是圖片直接加載后端原圖沒(méi)做尺寸適配內(nèi)存占用高峰期到了 400MB 以上系統(tǒng)頻繁觸發(fā) GC。這三個(gè)問(wèn)題疊加在一起列表不卡才怪。5.2 具體的優(yōu)化改動(dòng)清單優(yōu)化過(guò)程分了四步走第一步切換到 LazyForEach。自定義了一個(gè)繼承IDataSource的商品數(shù)據(jù)源類把 500 條數(shù)據(jù)全部交給它管理。這樣框架只在滾動(dòng)到對(duì)應(yīng)位置時(shí)才創(chuàng)建 Item內(nèi)存占用從 400MB 降到了 80MB 左右。第二步精簡(jiǎn)組件層級(jí)。把商品卡片從六層嵌套壓縮到三層ListItem→Column→ 圖片和文字。價(jià)格、標(biāo)題這些文本直接綁定數(shù)據(jù)源中的預(yù)格式化字段不再調(diào)用函數(shù)實(shí)時(shí)計(jì)算。第三步圖片尺寸適配。改造了圖片加載工具請(qǐng)求時(shí)帶上?imageMogr2/thumbnail/!320x320r這類裁剪參數(shù)讓后端返回適配列表尺寸的小圖。同時(shí)給Image設(shè)置了alt占位和objectFit: ImageFit.Cover避免加載失敗時(shí)出現(xiàn)空白格。第四步加緩存預(yù)熱。在onScrollIndex回調(diào)里判斷當(dāng)前 index 是否接近總條數(shù)末尾的 10 條是則提前加載后面 10 張圖片的 URL 到緩存確??焖倩瑒?dòng)時(shí)圖片能及時(shí)出現(xiàn)。5.3 優(yōu)化后的效果對(duì)比最終壓測(cè)結(jié)果滑動(dòng)時(shí)幀率穩(wěn)定在 58 到 60 幀JS 線程占用率降到 25% 左右GC 間隔從每 2 秒一次延長(zhǎng)到每 12 秒一次。整個(gè)優(yōu)化過(guò)程花了一個(gè)下午沒(méi)動(dòng)任何 UI 設(shè)計(jì)稿所有改動(dòng)都在數(shù)據(jù)和渲染邏輯層面。6. 常見(jiàn)性能陷阱自查清單實(shí)踐出真知我把這些年踩過(guò)的坑整理成了一張自查清單每次做長(zhǎng)列表優(yōu)化前先過(guò)一遍能省不少排查時(shí)間。檢查項(xiàng)問(wèn)題表現(xiàn)解決方案ForEach 全量渲染數(shù)據(jù)量一大列表創(chuàng)建超慢切換為 LazyForEach自定義組件過(guò)大Profiler 顯示 build 時(shí)間占比高拆分組件減少嵌套層級(jí)動(dòng)態(tài)函數(shù)計(jì)算每次 build 都執(zhí)行格式化邏輯數(shù)據(jù)源中預(yù)格式化原圖直接加載內(nèi)存飆高GC 頻繁圖片裁剪 內(nèi)存緩存無(wú)固定高度滑動(dòng)時(shí)布局抖動(dòng)設(shè)置 minHeight頂層狀態(tài)過(guò)大單個(gè) Item 更新觸發(fā)全局刷新縮小狀態(tài)變量粒度條件渲染頻繁切換折疊區(qū)展開(kāi)收起有明顯卡頓改用 visibility陰影/透明疊加GPU 負(fù)載高掉幀減少特效或改用靜態(tài)圖片注意性能優(yōu)化不是一步到位的先跑 Profiler 定位瓶頸再針對(duì)性地做改造最后用真機(jī)驗(yàn)證效果。模擬器上的表現(xiàn)和真機(jī)差距很大尤其是 GPU 相關(guān)的優(yōu)化測(cè)試時(shí)建議用中低端機(jī)型更接近真實(shí)用戶環(huán)境。7. 幾個(gè)值得留意的工具與調(diào)試技巧DevEco Studio 的 Profiler 是我日常做性能優(yōu)化的主力工具。它能展示 JS 線程、渲染線程、GPU 線程的時(shí)間線還能定位到具體的函數(shù)調(diào)用棧。新手剛開(kāi)始看這個(gè)工具可能有點(diǎn)懵我建議抓重點(diǎn)關(guān)注三塊JS 線程的 CPU 占用、渲染指令的數(shù)量、以及 GC 發(fā)生的頻率。另外HiLog也是排查問(wèn)題的重要手段。在關(guān)鍵業(yè)務(wù)邏輯里打上HiLog.info日志標(biāo)記函數(shù)的開(kāi)始和結(jié)束可以很方便地量出每個(gè)操作耗時(shí)多久。比如圖片加載從發(fā)出請(qǐng)求到圖片顯示分幾個(gè)階段打日志就能知道瓶頸是網(wǎng)絡(luò)、解碼還是渲染。還有一個(gè)容易被忽略的點(diǎn)狀態(tài)變量調(diào)試。ArkUI DevEco Studio 支持查看組件樹(shù)中的狀態(tài)變量值但如果你把狀態(tài)定義得太亂調(diào)試時(shí)根本理不清。我最后建議每個(gè)頁(yè)面只保留一個(gè)核心狀態(tài)源其他派生狀態(tài)用計(jì)算屬性或事件去刷新這樣優(yōu)化和排查都更有條理。8. 我的經(jīng)驗(yàn)復(fù)盤與建議做了這么久的長(zhǎng)列表優(yōu)化我最大的感受是性能優(yōu)化不是魔法而是基本功。它考驗(yàn)的不是你記了多少 API 屬性而是你能不能一眼看穿什么操作是多余的什么是必要的。很多時(shí)候優(yōu)化的空間不在框架層面而在業(yè)務(wù)代碼里藏著的大量不合理的封裝和重復(fù)計(jì)算。如果你是從零開(kāi)始做鴻蒙應(yīng)用建議在寫(xiě)列表頁(yè)之前先想清楚三個(gè)問(wèn)題這個(gè)列表最大會(huì)有多少條數(shù)據(jù)每個(gè) Item 的復(fù)雜度到底有多高圖片是否是性能的關(guān)鍵因素把這三個(gè)問(wèn)題想透了再?zèng)Q定需要用哪些優(yōu)化手段能避免不少無(wú)用的 work。最后分享一個(gè)小技巧優(yōu)化完記得用低端機(jī)比如幾年前的千元機(jī)做回歸測(cè)試。很多優(yōu)化在中高端機(jī)上根本看不出區(qū)別但低端機(jī)對(duì)性能的敏感度極高一卡一頓都會(huì)暴露問(wèn)題。真機(jī)跑過(guò)一遍心里才踏實(shí)。