這是我參加創(chuàng)作者計(jì)劃的第一篇文章。
前言
長(zhǎng)列表是前端和客戶端應(yīng)用中最常見(jiàn)的業(yè)務(wù)場(chǎng)景,比如商品瀑布流等,有成千上萬(wàn)條數(shù)據(jù),因此長(zhǎng)列表的渲染性能在iOS,Android,Harmony,Web等各大平臺(tái)都非常重要。HarmonyOS和iOS類似也提供了自己的解決方案。Roma(羅碼)作為跨端平臺(tái),在此基礎(chǔ)上進(jìn)行了具體的實(shí)踐。在實(shí)踐過(guò)程中,遇到了各種問(wèn)題和挑戰(zhàn),經(jīng)歷了ArkTS+C++架構(gòu)向純C++架構(gòu)的轉(zhuǎn)變,本文將圍繞實(shí)踐中的各種問(wèn)題和挑戰(zhàn),探討Roma的具體解決方案和優(yōu)化思路。
一、鴻蒙長(zhǎng)列表解決方案及原理
鴻蒙系統(tǒng)為L(zhǎng)ist,WaterFlow,Grid等容器組件的數(shù)據(jù)加載和渲染提供了一次性加載方案(ForEach)和按需加載方案(LazyForEach)兩種方式。
1. 一次性加載方案(ForEach)
?ForEach:一次性加載全量數(shù)據(jù)并循環(huán)渲染。原理如下:
(圖片來(lái)自鴻蒙官網(wǎng))
缺點(diǎn):
1) 因?yàn)橐淮涡约虞d所有的列表數(shù)據(jù),創(chuàng)建所有組件節(jié)點(diǎn)并完成組件樹(shù)的構(gòu)建,在數(shù)據(jù)量大時(shí)會(huì)非常耗時(shí),從而導(dǎo)致頁(yè)面加載渲染時(shí)間過(guò)長(zhǎng)
2) 屏幕可視區(qū)外的組件雖然不會(huì)顯示在屏幕上,但是仍然會(huì)占用內(nèi)存。在系統(tǒng)處于高負(fù)載的情況下,更容易出現(xiàn)性能問(wèn)題,極限情況下甚至?xí)?dǎo)致應(yīng)用異常退出。
實(shí)際業(yè)務(wù)中數(shù)據(jù)條數(shù)非常多,該方案存在很嚴(yán)重的性能問(wèn)題。為了解決這個(gè)性能問(wèn)題,HarmonyOS提供了性能更好的解決方案
2. 按需加載方案(LazyForEach)
?LazyForEach: 實(shí)現(xiàn)延遲加載數(shù)據(jù)并按需渲染。原理如下:
1) 根據(jù)屏幕可視區(qū)能夠容納顯示的組件數(shù)量按需加載數(shù)據(jù)。
2) 根據(jù)加載的數(shù)據(jù)量創(chuàng)建組件,掛載在組件樹(shù)上,屏幕可以展示多少列表項(xiàng)組件,就按需創(chuàng)建多少個(gè)ListItem組件節(jié)點(diǎn)掛載在List組件樹(shù)根節(jié)點(diǎn)上。
3) 當(dāng)組件滑出可視區(qū)域外時(shí),框架會(huì)進(jìn)行組件銷毀以降低內(nèi)存占用;當(dāng)組件滑入可視區(qū)域時(shí),需要從頭完成數(shù)據(jù)加載、組件創(chuàng)建、掛載組件樹(shù)這一過(guò)程,直至渲染到屏幕上。
(圖片來(lái)自鴻蒙官網(wǎng))
LazyForEach實(shí)現(xiàn)了按需加載,針對(duì)列表數(shù)據(jù)量大、列表組件復(fù)雜的場(chǎng)景,減少了頁(yè)面首次啟動(dòng)時(shí)一次性加載數(shù)據(jù)的時(shí)間消耗,減少了內(nèi)存峰值。可以顯著提升頁(yè)面的能效比和用戶體驗(yàn)。提升性能,HarmonyOS又給出了兩種優(yōu)化手段:緩存列表項(xiàng)(CacheCount)+組件復(fù)用(@Reusable)。
2.1 緩存列表項(xiàng)CacheCount
如果只有懶加載,滑動(dòng)速度過(guò)快時(shí),則會(huì)導(dǎo)致數(shù)據(jù)來(lái)不及加載而出現(xiàn)“白塊現(xiàn)象”。為了解決這一問(wèn)題,LazyForEach懶加載可以通過(guò)設(shè)置cachedCount屬性來(lái)指定緩存數(shù)量。在設(shè)置cachedCount后,除屏幕內(nèi)顯示的ListItem組件外,還會(huì)預(yù)先將屏幕可視區(qū)外指定數(shù)量的列表項(xiàng)數(shù)據(jù)緩存起來(lái)。這樣當(dāng)緩存列表項(xiàng)需要從屏幕可視區(qū)外進(jìn)入可視區(qū)內(nèi)時(shí),只用創(chuàng)建、渲染組件即可,相比不設(shè)置cachedCount提升了顯示效率。(cacheCount具體設(shè)置多少,這里依然不詳細(xì)展開(kāi),詳見(jiàn)后續(xù)文章。)
原理如下:
(圖片來(lái)自鴻蒙官網(wǎng))
2.2 組件復(fù)用@Reusable
由上文可知LazyForEach+cacheCount方案中,當(dāng)組件滑出可視區(qū)域外時(shí),框架會(huì)進(jìn)行組件銷毀以降低內(nèi)存占用;當(dāng)組件滑入可視區(qū)域時(shí),需要從頭完成組件創(chuàng)建、掛載組件樹(shù)這一過(guò)程,直至渲染到屏幕上。而且列表頁(yè)面很多列表項(xiàng)的UI樣式完全相同,只有數(shù)據(jù)上的差異,如果能組件復(fù)用,就能節(jié)省組件創(chuàng)建的時(shí)間,因此就可以進(jìn)一步提高列表頁(yè)面的加載速度和響應(yīng)速度。
框架為我們提供了組件復(fù)用的能力,機(jī)制如下:
1)標(biāo)記為@Reusable的組件從組件樹(shù)上被移除時(shí),組件和其對(duì)應(yīng)的JSView對(duì)象都會(huì)被放入復(fù)用緩存中,復(fù)用緩存可以通過(guò)reuseId標(biāo)記為不同的緩存池。
2)當(dāng)列表滑動(dòng)新的ListItem將要被顯示,List組件樹(shù)上需要新建節(jié)點(diǎn)時(shí),將會(huì)從相應(yīng)的復(fù)用緩存池中查找可復(fù)用的組件節(jié)點(diǎn)。
3)找到可復(fù)用節(jié)點(diǎn)并對(duì)其進(jìn)行更新后添加到組件樹(shù)中。從而節(jié)省了組件節(jié)點(diǎn)和JSView對(duì)象的創(chuàng)建時(shí)間。
(圖片來(lái)自鴻蒙官網(wǎng))
二、動(dòng)態(tài)化的長(zhǎng)列表解決方案
結(jié)合上文HarmonyOS提供的解決方案,開(kāi)始考慮動(dòng)態(tài)化的長(zhǎng)列表方案。通過(guò)前面鴻蒙跨端方案介紹文章,我們知道,跨平臺(tái)框架的核心原理是通過(guò)JavaScript在JS引擎上執(zhí)行時(shí),對(duì)虛擬DOM進(jìn)行操作,通過(guò)橋接或JSI與原生端進(jìn)行通信,同時(shí)通過(guò)組件抽象,這些組件在不同平臺(tái)上映射到相應(yīng)的原生組件。運(yùn)行時(shí)我們會(huì)有相應(yīng)的節(jié)點(diǎn)樹(shù):JS虛擬DOM節(jié)點(diǎn)樹(shù)->原生端組件節(jié)點(diǎn)樹(shù)->原生端渲染節(jié)點(diǎn)樹(shù)。長(zhǎng)列表的渲染同樣會(huì)涉及這三棵樹(shù),并且過(guò)程比較復(fù)雜。
1. 移植iOS、Android方案到鴻蒙
1.1 其他兩端的方案原理
?緩存池大小設(shè)置為最大N頁(yè),每個(gè)方向N/2頁(yè)(這里的N和摩擦系數(shù)等因素有關(guān),這里暫時(shí)不詳細(xì)展開(kāi),后面有機(jī)會(huì)專門寫文章分享)
?當(dāng)組件滑出緩存區(qū)域外時(shí),操作虛擬DOM樹(shù)刪除列表項(xiàng)節(jié)點(diǎn),同時(shí)通過(guò)bridge在原生端進(jìn)行相應(yīng)列表項(xiàng)組件的銷毀以降低內(nèi)存占用;當(dāng)組件滑入緩存區(qū)域時(shí),操作虛擬DOM樹(shù)添加列表項(xiàng)節(jié)點(diǎn),同時(shí)通過(guò)bridge在原生端進(jìn)行相應(yīng)列表項(xiàng)組件的添加,這里從虛擬DOM節(jié)點(diǎn)到原生端的組件,都需要從頭完成組件創(chuàng)建、掛載組件樹(shù)這一過(guò)程,直至渲染到屏幕上。
?原生端列表的reuseId是一個(gè)不會(huì)重復(fù)的唯一值
該方案已經(jīng)被京東金融業(yè)務(wù)100+頁(yè)面使用,在復(fù)雜的列表頁(yè)面性能表現(xiàn)也非常好。優(yōu)點(diǎn)也是顯而易見(jiàn),由于跨端的核心原理決定了我們必須操作VDOM節(jié)點(diǎn)樹(shù)和組件樹(shù),過(guò)程中涉及JS線程和UI線程的頻繁通信,最終行為是否一致,是否能達(dá)到我們想要的結(jié)果,這個(gè)過(guò)程涉及的細(xì)節(jié)非常多,因此一個(gè)簡(jiǎn)單的邏輯是保證正確性的比較好的手段。這當(dāng)然也得益于iOS和Android系統(tǒng)本身性能的優(yōu)越。從上文可知我們其實(shí)無(wú)論在VDOM節(jié)點(diǎn)樹(shù)中,還是原生端組件樹(shù)中,新的VDOM節(jié)點(diǎn)/列表項(xiàng)組件創(chuàng)建或刪除的時(shí)候,都沒(méi)有復(fù)用節(jié)點(diǎn)或者利用系統(tǒng)本身的組件復(fù)用的能力,只有新創(chuàng)建和真刪除,這種邏輯就非常簡(jiǎn)單明了,不容易產(chǎn)生bug。但是從頭創(chuàng)建的過(guò)程會(huì)依賴系統(tǒng)本身的性能。
1.2 移植后存在的問(wèn)題
然而,當(dāng)我們把同樣的方案移植到HarmonyOS上之后,使用ArkUI框架開(kāi)發(fā),發(fā)現(xiàn)肉眼可見(jiàn)的卡頓,抖動(dòng)等掉幀現(xiàn)象非常嚴(yán)重,因此我們開(kāi)始排查原因。并與iOS和Android系統(tǒng)進(jìn)行對(duì)比分析,經(jīng)過(guò)分析我們發(fā)現(xiàn)主要存在以下3個(gè)問(wèn)題:
?UI層級(jí)過(guò)多。在ArkUI框架實(shí)現(xiàn)下,自定義組件本身必須增加一個(gè)包裹的容器,比如一個(gè)類似RomaDiv這樣的業(yè)務(wù)里最常使用的,數(shù)量最多的自定義容器組件,里面必須有個(gè)類似Stack/Flex這樣的容器組件才合法,因此這個(gè)組件本身就已經(jīng)是兩層了,比其他系統(tǒng)就多了一層。另外有些容器組件還有系統(tǒng)本身生成的類似__common__ 這種層級(jí),也會(huì)導(dǎo)致層級(jí)變多。層級(jí)過(guò)多,每次創(chuàng)建,渲染過(guò)程中的計(jì)算就更多,耗時(shí)自然就更長(zhǎng)。
?跨語(yǔ)言通信鏈路長(zhǎng)。原生組件的UI是基于ArkUI實(shí)現(xiàn)的,運(yùn)行在方舟虛擬機(jī)中。JS代碼運(yùn)行在系統(tǒng)的JSVM中,在C++端,兩種語(yǔ)言通過(guò)系統(tǒng)提供的NAPI通信,其中涉及各種數(shù)據(jù)類型轉(zhuǎn)換,成本自然比其他系統(tǒng)要高。尤其在UI層級(jí)多的情況下,成本就更高了。
?系統(tǒng)二次布局的問(wèn)題。動(dòng)態(tài)化系統(tǒng)架構(gòu)中有三個(gè)核心線程:UI主線程,JS線程和布局計(jì)算的線程。布局方案采用的是yoga布局,可以高效地進(jìn)行組件的大小,位置的計(jì)算。但是系統(tǒng)在此布局之后還會(huì)重新進(jìn)行布局一次,這個(gè)開(kāi)銷就完全沒(méi)有必要,但是卻增加了耗時(shí),影響了性能。
針對(duì)這幾個(gè)問(wèn)題,經(jīng)過(guò)和華為專家溝通以后,建議我們直接使用C-API開(kāi)發(fā),但是經(jīng)過(guò)深入開(kāi)發(fā)和溝通之后,發(fā)現(xiàn)C-API目前尚有功能欠缺,而且文檔不完善,不能滿足我們當(dāng)下的所有需求,因此我們決定支持ArkTS版本和C-API版本兩個(gè)版本,Q3先上線ArkTS版本,同時(shí)開(kāi)發(fā)完CAPI版本,待華為進(jìn)一步完善C-API后,Q4上線。
2. ArkTS版本解決方案
在已經(jīng)存在以上問(wèn)題的前提下,我們需要盡可能的提高列表性能,創(chuàng)建慢的問(wèn)題,首先考慮到的就是reuse的思路。
2.1 ArkTS方案原理
?原生端UI完全依賴系統(tǒng)提供的懶加載LazyForEach+緩存列表項(xiàng)CacheCount+組件復(fù)用@Reusable,其中復(fù)用的reuseId設(shè)置為具體緩存池的類別。
?虛擬DOM節(jié)點(diǎn)的創(chuàng)建,復(fù)用,回收和銷毀的時(shí)機(jī)完全與原生端UI相對(duì)應(yīng)的時(shí)機(jī)同步。由于ArkUI是聲明式語(yǔ)法,因此整個(gè)過(guò)程是先由原生端觸發(fā)UI占位,然后在對(duì)應(yīng)的生命周期上相應(yīng)的操作VDOM,再通過(guò)JSI&NAPI與原生端通信,更新原生端組件。
這個(gè)方案是真正做到了reuse/recycle的長(zhǎng)列表,做到了比較絲滑的體驗(yàn)。但是由于有了recycle/reuse的過(guò)程,也增加了更多的復(fù)雜性,有很多細(xì)節(jié)需要處理。
2.2 重點(diǎn)優(yōu)化點(diǎn)
1)更新數(shù)據(jù)后UI“閃”的問(wèn)題 - 不要改變鍵值key + @ObjectLink + @Observed
這個(gè)問(wèn)題的根本原因是lazyForEach的迭代器key generator的鍵值key發(fā)生了變化。如果鍵值key發(fā)生了變化,框架會(huì)將這個(gè)變化的組件整體先回收,然后再重新創(chuàng)建。經(jīng)歷這一個(gè)過(guò)程就會(huì)出現(xiàn)“閃”的問(wèn)題。
而且,改變鍵值key去刷新UI的方式代價(jià)很大,同一類別的列表項(xiàng)的結(jié)構(gòu)非常類似,只是顯示的文本和圖片等不一樣,不變化的組件不需要重新創(chuàng)建,只需要更新變化的部分即可。這種情況框架提供了裝飾器@Observed和@ObjectLink,可以監(jiān)聽(tīng)變化的部進(jìn)行局部更新。同時(shí),復(fù)雜列表情況下,數(shù)據(jù)源大多都是多層嵌套的對(duì)象結(jié)構(gòu),建議使用@ObjectLink而不要用@Prop,因?yàn)锧Prop會(huì)進(jìn)行深拷貝,會(huì)增加創(chuàng)建時(shí)間及內(nèi)存的消耗,開(kāi)銷較大,而@ObjectLink指向數(shù)據(jù)源的指針,雙向同步數(shù)據(jù),因此這種情況下性能更優(yōu)。
2)刷新/更新數(shù)據(jù)后,數(shù)據(jù)先展示其他的數(shù)據(jù)然后快速再刷成最終結(jié)果
?不要更新(可見(jiàn)+cacheCount)范圍內(nèi)的組件的鍵值key,此范圍外的部分改變鍵值key
?手動(dòng)調(diào)用列表組件的方法只更新(可見(jiàn)+cacheCount)范圍內(nèi)的組件和對(duì)應(yīng)的VDOM節(jié)點(diǎn)
首先產(chǎn)生這個(gè)問(wèn)題的原因還是由于key發(fā)生了變化,每次重新創(chuàng)建的時(shí)候,如果當(dāng)前類型的緩存池有數(shù)據(jù),就從緩存池取出復(fù)用,然后再更新變化的部分。這個(gè)從緩存池取出的組件仍然帶有原來(lái)的數(shù)據(jù)信息,因此我們會(huì)看到先展示其他數(shù)據(jù)然后再被刷成最終結(jié)果。為了避免這個(gè)現(xiàn)象,首先還是不要改變key。在UI上就是已經(jīng)渲染了的那些組件,也即可視加上cacheCount范圍內(nèi)的組件。同時(shí)對(duì)此范圍內(nèi)的組件手動(dòng)調(diào)用組件的更新方法,更新組件,這時(shí)JS引擎會(huì)對(duì)這個(gè)節(jié)點(diǎn)進(jìn)行diff,把變化的部分通過(guò)JSI與原生端通信,原生端完成最終UI的更新。范圍外的部分就按需更新key和數(shù)據(jù)源。
3)有些列表滑動(dòng)過(guò)程中仍有卡頓現(xiàn)象
?沒(méi)有正確使用組件復(fù)用 - 使用了組件復(fù)用,實(shí)際上是無(wú)效的復(fù)用,reuseId設(shè)置一定要正確,且必須為字符串類型
復(fù)用類型 | 描述 | 復(fù)用思路 |
---|---|---|
標(biāo)準(zhǔn)型 | 復(fù)用組件之間布局完全相同 | 標(biāo)準(zhǔn)復(fù)用 |
有限變化型 | 復(fù)用組件之間有不同,但是類型有限 | 使用reuseId或者獨(dú)立成兩個(gè)自定義組件 |
組合型 | 復(fù)用組件之間有不同,情況非常多,但是擁有共同的子組件 | 將復(fù)用組件改為Builder,讓內(nèi)部子組件相互之間復(fù)用 |
全局型 | 組件可在不同的父組件中復(fù)用,并且不適合使用@Builder | 使用BuilderNode自定義復(fù)用組件池,在整個(gè)應(yīng)用中自由流轉(zhuǎn) |
嵌套型 | 復(fù)用組件的子組件的子組件存在差異 | 采用化歸思想將嵌套問(wèn)題轉(zhuǎn)化為上面四種標(biāo)準(zhǔn)類型來(lái)解決 |
無(wú)法復(fù)用型 | 組件之間差別很大,規(guī)律性不強(qiáng),子組件也不相同 | 不建議使用組件復(fù)用 |
標(biāo)準(zhǔn)型
有限變化型
組合型
全局型
嵌套型
此外,如果使用if/else條件語(yǔ)句來(lái)控制布局的結(jié)構(gòu),會(huì)導(dǎo)致在不同邏輯創(chuàng)建不同布局結(jié)構(gòu)嵌套的組件,此時(shí)我們應(yīng)該使用reuseId將if/else條件語(yǔ)句拆分為不同結(jié)構(gòu)的組件
?優(yōu)先使用@Builder替代自定義組件@Component,減少嵌套層級(jí)
ArkUI中使用自定義組件時(shí),在build階段將在在后端FrameNode樹(shù)創(chuàng)建一個(gè)相應(yīng)的CustomNode節(jié)點(diǎn),在渲染階段時(shí)也會(huì)創(chuàng)建對(duì)應(yīng)的RenderNode節(jié)點(diǎn)。會(huì)造成組件復(fù)用下,CustomNode創(chuàng)建和和RenderNod渲染的耗時(shí),因此應(yīng)該優(yōu)先使用@Builder。同時(shí)減少一個(gè)自定義組件,也就是減少一次aboutToReuse的回調(diào),也會(huì)節(jié)省耗時(shí)。
?避免不必要的狀態(tài)變量刷新,使用AttributeUpdater更新組件屬性
?避免對(duì)@Link/@ObjectLink/@Prop等自動(dòng)更新的狀態(tài)變量,在aboutToReuse方法中再進(jìn)行更新
?避免使用函數(shù)/方法作為復(fù)用組件創(chuàng)建時(shí)的入?yún)?/p>
?避免在列表滑動(dòng)過(guò)程中做大量計(jì)算或者耗時(shí)長(zhǎng)的操作
?可以結(jié)合列表預(yù)加載,布局優(yōu)化等其他常規(guī)手段進(jìn)一步優(yōu)化體驗(yàn)
3. C-API版本解決方案
上文中我們已經(jīng)提到CAPI的方案能解決UI層級(jí)過(guò)多,跨語(yǔ)言通信鏈路長(zhǎng)兩個(gè)核心問(wèn)題,同時(shí)也減少了狀態(tài)變量維護(hù)相應(yīng)的耗時(shí),是我們最終的解決方案。C++端我們還是采用了recycle/reuse的方案,C-API實(shí)現(xiàn)上我們需要自己實(shí)現(xiàn)類似lazyForEach的能力。
3.1 C-API方案原理
?系統(tǒng)提供了一個(gè)ArkUI_NodeAdapter對(duì)象來(lái)管理容器的子組件,這個(gè)對(duì)象類似事件的機(jī)制,通過(guò)相關(guān)事件通知按需生成組件。
(圖片來(lái)自鴻蒙官網(wǎng))
?在監(jiān)聽(tīng)事件的回調(diào)中處理創(chuàng)建,回收,復(fù)用,刪除等邏輯。
3.2 部分核心代碼
有興趣的同學(xué)可以私下聯(lián)系我。
4. 性能對(duì)比分析
使用JR APP購(gòu)物車頁(yè)面(頁(yè)面結(jié)構(gòu)較復(fù)雜),400條數(shù)據(jù),分別用三種方案以及優(yōu)化后測(cè)試,測(cè)試結(jié)果如下:
方案 | ArkTS Create | ArkTS Reuse | C++ Reuse |
---|---|---|---|
完全顯示所用時(shí)間 | 1s 804ms | 1s 321ms | 977ms |
丟幀率 | 12.1% | 0.0% | 0.0% |
獨(dú)占內(nèi)存 | 45.1M | 42.3M | 40.2M |
測(cè)試結(jié)果表明,lazyForEach,組件復(fù)用,cacheCount,預(yù)加載等等這些方法的確提高了性能,尤其是滑動(dòng)過(guò)程中出現(xiàn)的明顯卡頓現(xiàn)象,同時(shí)減少UI層級(jí),不跨語(yǔ)言通信能進(jìn)一步提高性能,帶來(lái)更好的體驗(yàn)。
三、總結(jié)
本文通過(guò)圖文的方式介紹了HarmonyOS的長(zhǎng)列表ArkTS解決方案以及原理,同時(shí)結(jié)合實(shí)際的實(shí)現(xiàn)過(guò)程介紹了ROMA動(dòng)態(tài)化長(zhǎng)列表的ArkTS和C++解決方案,相應(yīng)的重點(diǎn)優(yōu)化細(xì)節(jié)以及部分核心源碼,最后對(duì)兩者進(jìn)行了性能對(duì)比分析。
如果大家覺(jué)得有幫助,千萬(wàn)別忘了點(diǎn)贊+收藏,方便以后隨時(shí)閱讀!
動(dòng)態(tài)化是一個(gè)涉及JavaScript、C++、iOS、Android、Java、Harmony、Vue、Node、Webpack、Shell等眾多領(lǐng)域的綜合解決方案,我們有各個(gè)領(lǐng)域優(yōu)秀的小伙伴共同前行,大家如果想深入了解某個(gè)領(lǐng)域的具體實(shí)現(xiàn)或者提出寶貴意見(jiàn),可以在評(píng)論中給我留言,隨時(shí)交流~!
審核編輯 黃宇
-
JS
+關(guān)注
關(guān)注
0文章
78瀏覽量
18076 -
鴻蒙系統(tǒng)
+關(guān)注
關(guān)注
183文章
2634瀏覽量
66224 -
鴻蒙
+關(guān)注
關(guān)注
57文章
2321瀏覽量
42749 -
HarmonyOS
+關(guān)注
關(guān)注
79文章
1967瀏覽量
30035
發(fā)布評(píng)論請(qǐng)先 登錄
相關(guān)推薦
評(píng)論