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