RN框架在攜程旅行鴻蒙應(yīng)用的全業(yè)務(wù)適配實(shí)踐
一、RN在攜程業(yè)務(wù)使用現(xiàn)狀
2019年,攜程開始在線上使用RN框架,并結(jié)合自身的業(yè)場(chǎng)景,對(duì)RN框架進(jìn)行了開發(fā)和改造,研發(fā)了CRN框架(以下簡(jiǎn)稱CRN)。2021年,CRN成為攜程主流的開發(fā)框架。集團(tuán)內(nèi)有20+個(gè)App接入CRN框架,其中核心的App都已接入。攜程旅行App中,200+個(gè)業(yè)務(wù)Bundle在線上運(yùn)行,業(yè)務(wù)頁(yè)面數(shù)量超過(guò)2000個(gè),超過(guò)80%的業(yè)務(wù)使用CRN。
二、技術(shù)選型(為什么選擇CRN)
從新技術(shù)的選擇到落地的實(shí)踐上看,業(yè)務(wù)對(duì)技術(shù)的要求往往是以下幾個(gè)方面:
1)功能全,全量業(yè)務(wù)都能快速的適配上線
2)性能好,用戶體驗(yàn)多端一致
3)成本低,復(fù)用現(xiàn)有在其他平臺(tái)的運(yùn)行的代碼
為了滿足業(yè)務(wù)需求,鴻蒙的實(shí)現(xiàn)技術(shù)上我們選擇了CRN,主要考慮:
1)基建成熟度高:有配套研發(fā)/測(cè)試/發(fā)布/運(yùn)營(yíng)監(jiān)控系統(tǒng),內(nèi)部交流活躍,知識(shí)沉淀深
2)業(yè)務(wù)適配成本?。簶I(yè)務(wù)不需要重新再開發(fā)一遍,可以使用現(xiàn)有的業(yè)務(wù)代碼
3)開發(fā)能快速上手:業(yè)務(wù)開發(fā)還是使用原有的技術(shù)進(jìn)行開發(fā),在鴻蒙上運(yùn)行
4)產(chǎn)品迭代效率:支持每個(gè)周期的產(chǎn)品迭代,快速在鴻蒙系統(tǒng)的手機(jī)上線
三、CRN適配實(shí)踐
3.1 版本升級(jí)
線上攜程旅行App使用的React Native(RN)版本是0.70.1,而鴻蒙RN版本是0.72.5。因此,適配鴻蒙的第一步是將RN版本從0.70.1升級(jí)到0.72.5。
版本升級(jí)包含了如下幾個(gè)方面:
3.1.1 RN版本差異分析
我們對(duì)比RN 0.70.1 和 0.72.5 框架庫(kù)的差異,整體改動(dòng)點(diǎn)不多。為了降低業(yè)務(wù)方升級(jí)成本,我們?cè)诳蚣艿讓訉?duì)廢棄的組件和API變更做了兼容,盡可能減少業(yè)務(wù)使用方的改動(dòng)。
3.1.2 CRN框架改造
CRN框架覆蓋了文檔、工具、開發(fā)框架、發(fā)布、監(jiān)控、排障全鏈路。對(duì)應(yīng)框架的改造也從這幾個(gè)方面進(jìn)行。
1)在文檔方面,我們編寫了詳細(xì)的業(yè)務(wù)升級(jí)文檔,列出業(yè)務(wù)方需要關(guān)注的點(diǎn)和常見問(wèn)題。
2)在工具方面,提供了一鍵式CLI升級(jí)工具,只需在業(yè)務(wù)工程執(zhí)行一行升級(jí)命令,即可完成工程升級(jí)改造。
3)在開發(fā)框架方面,改造涉及點(diǎn)比較多,包括:
- 對(duì)Native運(yùn)行時(shí)升級(jí),升級(jí)RN 0.72.5 核心庫(kù),合并對(duì)官方RN庫(kù)的自定義改動(dòng)點(diǎn)。
- 對(duì)JS打包工具升級(jí),支持現(xiàn)有的拆包邏輯,合并對(duì)官方RN庫(kù)的自定義改動(dòng)點(diǎn)。
- 梳理使用到的社區(qū)三方庫(kù),統(tǒng)一三方庫(kù)版本升級(jí)至鴻蒙RN三方庫(kù)要求版本。
- 對(duì)Hermes引擎進(jìn)行升級(jí),合并自定義改動(dòng)點(diǎn)。
- 對(duì)RN自定義組件和API進(jìn)行新架構(gòu)改造。
4)在發(fā)布方面,對(duì)現(xiàn)有的CRN發(fā)布系統(tǒng)進(jìn)行改造,支持選擇鴻蒙平臺(tái)進(jìn)行單獨(dú)發(fā)布。發(fā)布的產(chǎn)物下發(fā)和線上IOS/Android進(jìn)行隔離,保證測(cè)試上線階段,不影響已經(jīng)上架的IOS/Android應(yīng)用。
待后續(xù)鴻蒙應(yīng)用穩(wěn)定,再支持一鍵同時(shí)發(fā)布IOS/Android/HarmonyOS Next平臺(tái)。
又考慮到業(yè)務(wù)場(chǎng)景存在一套代碼,跨RN版本發(fā)布。發(fā)布系統(tǒng)改造,支持了發(fā)布時(shí)根據(jù)發(fā)布單選擇的RN版本,自動(dòng)選擇依賴配置進(jìn)行打包發(fā)布。提升業(yè)務(wù)發(fā)布效率。
5)在監(jiān)控方面,實(shí)現(xiàn)鴻蒙端的監(jiān)控?cái)?shù)據(jù)上報(bào),接入到現(xiàn)有的監(jiān)控系統(tǒng),方便線上監(jiān)控。
6)在排障方面,實(shí)現(xiàn)鴻蒙端的異常數(shù)據(jù)上報(bào),接入現(xiàn)有排障系統(tǒng),方便線上排障。
3.1.3 業(yè)務(wù)工程改造
1)業(yè)務(wù)方按照提供升級(jí)文檔和工具進(jìn)行具體業(yè)務(wù)工程改造。
2)升級(jí)改造后,進(jìn)行本地開發(fā)環(huán)境測(cè)試,發(fā)現(xiàn)問(wèn)題,解決問(wèn)題。
3)本地測(cè)試通過(guò)后,進(jìn)行打包發(fā)布,進(jìn)入集成測(cè)試階段。
在升級(jí)過(guò)程中,工作量最大的部分是“RN自定義組件和API實(shí)現(xiàn)新架構(gòu)改造”。
這里先介紹下RN新架構(gòu)。RN新架構(gòu)是指從0.68版本開始后的架構(gòu)。主要包括:
- Turo Modules 模塊系統(tǒng),替換老架構(gòu)中的Native Modules,用于JS到Native的API同步調(diào)用。
- Farbic 組件系統(tǒng),替換老架構(gòu)中Native Component,支持同步渲染。
由于鴻蒙RN只支持新架構(gòu),所以需要將RN自定義組件和API實(shí)現(xiàn)進(jìn)行新架構(gòu)改造。在攜程旅行App中,我們使用有100+的自定義組件和API。這部分的改造工作量非常大,建議在做適配時(shí)優(yōu)先處理這部分工作。
3.2 差異化工作
在RN版本升級(jí)到0.72.5后,開始鴻蒙端特有的適配。
鴻蒙RN框架特點(diǎn):
- 已實(shí)現(xiàn)了官方RN大部分組件、API
- 已實(shí)現(xiàn)社區(qū)常用的三方庫(kù)
- 自定義組件和API需要應(yīng)用開發(fā)自行實(shí)現(xiàn)
差異化工作:
1)自定義組件和API實(shí)現(xiàn)
- 100+自定義組件和API,基于鴻蒙原生開發(fā)實(shí)現(xiàn),再封裝提供給RN調(diào)用
- 按優(yōu)先級(jí)分階段實(shí)現(xiàn)這些自定義組件和API,保持上層JS接口不變
2)RN工程改造
- 添加react-native-harmony和react-native-harmony-cli依賴庫(kù)
- 適配Platform.OS,Platform.select等API
- 實(shí)現(xiàn)xxx.harmony.js文件,邏輯與IOS保持一致
- 升級(jí)三方庫(kù)版本,如react-native-gesture-handler,從1.X版本升級(jí)到2.X版本
- 三方庫(kù)版本升級(jí)后,對(duì)不兼容的地方做適配
3.3 原生組件開發(fā)
攜程CRN框架經(jīng)過(guò)近8年的迭代,業(yè)務(wù)線非常復(fù)雜,自定義的組件、turboModule有100多個(gè)。
在鴻蒙中適配CRN,首先面臨的工作就是將這些自定義組件、turboModule在鴻蒙原生端用ArkTS重新實(shí)現(xiàn)。
我們面臨以下幾個(gè)挑戰(zhàn):
1)工作量
這些組件經(jīng)過(guò)了近8年的迭代,開發(fā)負(fù)責(zé)人可能幾經(jīng)易手。有些復(fù)雜組件,如信息流組件、自定義地圖、日歷組件、多媒體組件等,邏輯異常復(fù)雜,經(jīng)過(guò)跟原開發(fā)負(fù)責(zé)人、產(chǎn)品等初步討論,工作量都超過(guò)單人一個(gè)半月。而我們面臨的是100多個(gè)組件、turboModule的重實(shí)現(xiàn)。
2)HarmonyOS Next逐步完善,與Android、iOS在某些特性上有差異
開發(fā)過(guò)程中發(fā)現(xiàn)了很多HarmonyOS Next功能不完善、存在若干Bug的地方,畢竟是一個(gè)新系統(tǒng),我們與華為同學(xué)緊密合作,一一解決了問(wèn)題,這個(gè)過(guò)程見證了鴻蒙系統(tǒng)的愈發(fā)成熟。
出于安全考慮,鴻蒙系統(tǒng)有一些新特性,比如選取圖片視頻進(jìn)行編輯的場(chǎng)景,在Android、iOS中,申請(qǐng)用戶權(quán)限之后便可以拿到整個(gè)系統(tǒng)相冊(cè)的圖片視頻,這確實(shí)可能存在一些安全隱患。鴻蒙在最開始就切割了這一操作,即使App經(jīng)用戶同意申請(qǐng)了讀相冊(cè)權(quán)限,也無(wú)法拿到系統(tǒng)主相冊(cè)的圖片視頻,本意是讓App直接跳到系統(tǒng)相冊(cè)選取圖片之后返回,只提供當(dāng)次選中的圖片信息給App,從而徹底斷絕了App侵犯用戶隱私的可能。
但我們的多媒體場(chǎng)景比較復(fù)雜,用戶選取圖片、視頻后會(huì)跳入編輯頁(yè),且可以重回相冊(cè)頁(yè)選擇其他圖片,也就是說(shuō)我們的圖片視頻選擇頁(yè)與編輯頁(yè)存在聯(lián)動(dòng),鴻蒙提供的這種跳入系統(tǒng)相冊(cè)的方式顯示無(wú)法滿足我們的需求。
后續(xù)經(jīng)過(guò)討論,鴻蒙提供了相冊(cè)Picker的方案,將系統(tǒng)相冊(cè)頁(yè)封裝為組件提供給開發(fā)者,我們的圖片視頻選擇頁(yè)可以內(nèi)嵌相冊(cè)Picker,從而解決了聯(lián)動(dòng)的問(wèn)題。但這個(gè)需求從開始評(píng)審、開發(fā)、測(cè)試到最終實(shí)現(xiàn),花費(fèi)了幾個(gè)月的時(shí)間。
3)RN組件C化
在接入RN的過(guò)程中,發(fā)現(xiàn)鴻蒙中RN關(guān)鍵性能指標(biāo)與Android、iOS有差距,華為鴻蒙RN團(tuán)隊(duì)為了解決性能問(wèn)題,提出了組件C化的方案。
簡(jiǎn)單來(lái)講,就是將ArkTS實(shí)現(xiàn)的組件用C-Api重新實(shí)現(xiàn)一遍,華為方面給出的要求是容器結(jié)點(diǎn)(RN代碼中存在標(biāo)簽<></>嵌套的組件)需強(qiáng)制C化。雖然攜程中這種必須C化的組件并不多,但也帶來(lái)了非常多的適配工作。具體可參考下篇-組件C化。
部分組件圖如下:
Fabric、TurboModule
最開始,我們?cè)趯?shí)現(xiàn)相關(guān)Fabric、TurboModule的時(shí)候,鴻蒙RN框架還沒(méi)有提供Spec文件CodeGen工具,全靠手寫。不過(guò)現(xiàn)在已經(jīng)提供了相關(guān)工具,具體操作步驟可以參考相關(guān)文檔。
Spec文件生成之后,剩下的工作就是相關(guān)組件、TurboModule的功能橋接實(shí)現(xiàn),邏輯較為簡(jiǎn)單,實(shí)現(xiàn)相關(guān)功能就好。
需要注意的是:
- RN代碼中存在標(biāo)簽<></>嵌套的組件被視為容器結(jié)點(diǎn),此類型組件需使用C-API實(shí)現(xiàn)。
- 可以通過(guò)this.ctx獲取RNOHContext,進(jìn)而獲取RNInstance,從而獲取一系列RN端JS傳入的信息,如View寬高、style等,也可執(zhí)行發(fā)送事件、接收事件、獲取TurboModule進(jìn)行其他操作等等。
- 在RNInstanceImpl構(gòu)造函數(shù)中有一個(gè)arkTsComponentNames字段,可以傳入所有我們自定義葉子結(jié)點(diǎn)Fabric組件的名稱,用于在RNOH SDK內(nèi)部進(jìn)行指令分發(fā)優(yōu)化。實(shí)現(xiàn)ArkTS端Fabric組件后,需要將Fabric組件的名稱加入此列表中。
- 假設(shè)存在實(shí)現(xiàn)過(guò)于復(fù)雜或者其他原因無(wú)法C化的容器組件,RNOH SDK內(nèi)部指令優(yōu)化代碼需修改(這也意味著RNOH SDK需重新打包編譯),關(guān)鍵代碼見下文‘性能優(yōu)化-5.3 RN 指令精簡(jiǎn)章節(jié)。
3.4 組件C化
經(jīng)過(guò)與華為的詳細(xì)溝通,RN代碼中存在標(biāo)簽<></>嵌套的組件被視為容器結(jié)點(diǎn),此類型組件需強(qiáng)制C化。
也就是此類型的組件:
<RNComponent>
<Text/>
<Image/>
</RNComponent>
RNComponent算為容器結(jié)點(diǎn)
經(jīng)確認(rèn),攜程端存在四個(gè)需強(qiáng)制C化的容器結(jié)點(diǎn)組件,分別為:
名稱 | 描述 |
SwipeoutView | 可滑動(dòng)組件 |
ScrollView | 滾動(dòng)組件 |
CustomScrollView | 自定義列表組件 |
CRNModal | modal容器 |
簡(jiǎn)而言之,需要把ArkTS端實(shí)現(xiàn)的組件用C-Api再次實(shí)現(xiàn)。
3.4.1 CRNModal C化
CRNModal 在開發(fā)測(cè)試過(guò)程中一步步探索了實(shí)現(xiàn)方案,經(jīng)過(guò)多輪測(cè)試、方案討論調(diào)整,最終確定了C化方案。
方案一:嘗試使用系統(tǒng)Modal實(shí)現(xiàn)這個(gè)組件
后續(xù)測(cè)試過(guò)程中發(fā)現(xiàn)系統(tǒng)Modal的實(shí)現(xiàn)方案為系統(tǒng)Dialog,層級(jí)很高,攜程業(yè)務(wù)線會(huì)出現(xiàn)這樣一種場(chǎng)景,RN頁(yè)面打開Modal后點(diǎn)擊跳轉(zhuǎn)一個(gè)其他頁(yè)面,新打開的頁(yè)面會(huì)出現(xiàn)在Modal的下方,不符合需求,方案淘汰。
方案二:嘗試通過(guò)新跳轉(zhuǎn)一個(gè)透明頁(yè)面的方式實(shí)現(xiàn)modal
測(cè)試發(fā)現(xiàn)新跳轉(zhuǎn)一個(gè)頁(yè)面后,RN的點(diǎn)擊事件分發(fā)出現(xiàn)問(wèn)題,無(wú)法響應(yīng)任何事件。且我們App的路由方案為Navigation,經(jīng)與華為方面溝通,Navigation C化難度非常巨大,短時(shí)間不可行。此方案淘汰。
方案三:嘗試在RN JS端創(chuàng)建modal
JS端創(chuàng)建一個(gè)style為position: 'absolute', zIndex: 999的容器,層級(jí)提高,顯示在其他組件上方來(lái)實(shí)現(xiàn)modal。測(cè)試發(fā)現(xiàn)調(diào)用顯示Modal的地方很多,可能會(huì)在一個(gè)嵌套很深的層級(jí)中,如果在這里嘗試展示這個(gè)zIndex: 999的容器,還是會(huì)有被遮擋的情況,最終此方案也被淘汰。
方案四:C++層進(jìn)行插入
經(jīng)過(guò)內(nèi)部討論,這個(gè)modal應(yīng)該展示在整個(gè)RN頁(yè)面層級(jí)的最上方,這在Android、iOS中都很好實(shí)現(xiàn),但鴻蒙是一個(gè)聲明式的語(yǔ)言,無(wú)法拿到頁(yè)面實(shí)例,無(wú)法拿到父組件,也就無(wú)法進(jìn)行插入。
但研究RNOH SDK之后發(fā)現(xiàn),C化后的RNInstance實(shí)例在C++端持有一個(gè)XComponentSurface,所有RN頁(yè)面對(duì)應(yīng)的Native組件都被添加顯示在這里,而XComponentSurface可以獲取rootView實(shí)例ComponentInstance,這是一個(gè)根控件,將這個(gè)根控件強(qiáng)轉(zhuǎn)為ViewComponentInstance之后,在ViewComponentInstance.cpp代碼中可以類似Android,獲取childCount,通過(guò)index添加child等等,進(jìn)而可以實(shí)現(xiàn)在RN頁(yè)面層級(jí)最上方添加modal,最終也是依據(jù)此方案,實(shí)現(xiàn)了CRNModal組件。
流程如下:
3.4.2 開發(fā)注意事項(xiàng)
1)在CAPI instance中聲明的Node節(jié)點(diǎn),必須在全局聲明,否則會(huì)導(dǎo)致node節(jié)點(diǎn)不能收到node_event等消息;
2)設(shè)置node屬性構(gòu)建ArkUI_AttributeItem的時(shí)候,如果設(shè)置的值是一個(gè)ArkUI_NumberValue類型,需要指定size,這個(gè)size的計(jì)算必須除去類型的長(zhǎng)度,如下:
ArkUI\_NumberValue value\[] = {{.i32 = alignItem}};
ArkUI\_AttributeItem item = {value, sizeof(value) / sizeof(ArkUI\_NumberValue)};
3)animateTo執(zhí)行動(dòng)畫,在組件析構(gòu)之后還是會(huì)回調(diào),需要控制好生命周期避免crash;
4)設(shè)置Stack背景,導(dǎo)致子組件布局錯(cuò)誤,是因?yàn)镾tack被作為同級(jí)組件從而導(dǎo)致子組件的postion參數(shù)異常,需要手動(dòng)處理好position問(wèn)題;
5)可以通過(guò)以下方式在C++層調(diào)用arkTS方法,獲取相關(guān)數(shù)據(jù):
方案1:在ArkTS里實(shí)現(xiàn)一個(gè)TurboModule方法,然后通過(guò)rnInstance->getTurboModule<XXTurboModule>獲取對(duì)應(yīng)的TurboModule,調(diào)用方法,獲取返回值。但此方案涉及C++與ArkTS的跨端調(diào)用,性能會(huì)差一些,優(yōu)點(diǎn)是實(shí)現(xiàn)簡(jiǎn)單。
方案2:通過(guò)ArkTSBridge,添加一個(gè)ArkTS方法的橋,然后就可以在C++里直接調(diào)用這個(gè)ArkTS方法。具體實(shí)現(xiàn)可以參考NapiBridget.ArkTSBridgeHandler里任意方法。此方案性能好,但實(shí)現(xiàn)起來(lái)稍微麻煩一點(diǎn)。
6)可以通過(guò)以下Api獲取設(shè)備的高寬
auto displayMetrics = ArkTSBridge::getInstance()->getDisplayMetrics();
displayMetrics.screenPhysicalPixels.width / displayMetrics.screenPhysicalPixels.scale //直接獲取到是px單位,需要進(jìn)行轉(zhuǎn)換,也可以自行修改TurboModle的初始化值:
四、遇到的問(wèn)題和解決辦法
在升級(jí)適配過(guò)程中,我們遇到了一些RN新架構(gòu)問(wèn)題,還有一些鴻蒙RN特有的問(wèn)題。
RN 新架構(gòu)問(wèn)題:
- IOS Animated.timing 設(shè)置 useNativeDriver:true 后,內(nèi)嵌按鈕無(wú)法點(diǎn)擊
- IOS TouchableOpacity 內(nèi)嵌 Aminated.View ,Aminated.View 開啟動(dòng)畫變更位置后,無(wú)法點(diǎn)擊
- IOS Image樣式設(shè)置 borderRadius 顯示不全
- IOS minimumFontScale maxFontSizeMultiplier 不生效
- Aminated.View 內(nèi)嵌Modal組件,內(nèi)部TouchableOpacity點(diǎn)擊不響應(yīng)
- FlatList、ScrollView stickyHeaderIndices 吸頂功能多次滑動(dòng)后失效
- Aminated.View 、Animated.ScrollView、layoutAnimation 動(dòng)畫卡頓
- 樣式中使用了zIndex屬性層級(jí)可能不生效,嘗試添加 position:relative屬性后生效
- 組件需要設(shè)置默認(rèn)高寬,不然布局展示可能發(fā)生截?cái)?/span>
由于動(dòng)畫、樣式、性能影響較大,最終決定在RN 0.72.5版本(iOS/Andriod)中只使用Turbo Modules,不開啟Fabric模式,來(lái)規(guī)避掉這些問(wèn)題。
但鴻蒙RN只支持新架構(gòu),新架構(gòu)存在問(wèn)題有些在鴻蒙端同樣存在。我們和華為伙伴緊密溝通來(lái)處理這些問(wèn)題。對(duì)于無(wú)法規(guī)避問(wèn)題只能業(yè)務(wù)側(cè)做兼容處理。
鴻蒙RN特有的問(wèn)題:
問(wèn)題:RN Modal彈窗顯示時(shí),再打開一個(gè)H5頁(yè)面會(huì)顯示在Modal下面
解決辦法:實(shí)現(xiàn)一個(gè)View層級(jí)的CRNModal替代RN Modal
問(wèn)題:絕對(duì)定位中添加top:“auto”導(dǎo)致元素不顯示
解決辦法:去除top:“auto”設(shè)置
問(wèn)題:zIndex:-1元素不顯示
解決辦法:在最外層的View添加collapsable={false}屬性
問(wèn)題:position:absolute樣式漂移
解決辦法:在外層的View添加collapsable={true}屬性
問(wèn)題:react-native-harmony/metro.config 和現(xiàn)有的自定義metro配置沖突
解決辦法:提取react-native-harmony/metro.config中harmony平臺(tái)相關(guān)處理,合并到自定義metro插件中
五、性能優(yōu)化
華為內(nèi)部對(duì)鴻蒙系統(tǒng)寄予厚望,為了追求更好的用戶體驗(yàn),希望鴻蒙APP核心業(yè)務(wù)場(chǎng)景性能指標(biāo)達(dá)成業(yè)內(nèi)最佳水平。對(duì)攜程來(lái)說(shuō),大多數(shù)業(yè)務(wù)頁(yè)面都是RN,RN技術(shù)棧對(duì)性能指標(biāo)非常敏感,很小的性能優(yōu)化或劣化,都會(huì)大幅影響用戶體驗(yàn)。
5.1 CRN預(yù)加載
默認(rèn)情況下,我們會(huì)在頁(yè)面的生命周期中去加載rn_bundle,因?yàn)轫?yè)面已經(jīng)進(jìn)入生命周期開始展示了,加載bundle又會(huì)有一定的耗時(shí),這種情況下,就會(huì)產(chǎn)生白屏現(xiàn)象。
攜程也存在某些頁(yè)面依賴接口數(shù)據(jù)且接口返回比較慢的情況,比如機(jī)票列表頁(yè),在進(jìn)入頁(yè)面白屏之后又會(huì)有長(zhǎng)時(shí)間的骨架屏,用戶體驗(yàn)差。
經(jīng)過(guò)調(diào)研,攜程端基于系統(tǒng)的FrameNode能力,實(shí)現(xiàn)了CRN預(yù)加載方案,解決了上述問(wèn)題。
5.1.1 FrameNode
鴻蒙中FrameNode是一個(gè)非常強(qiáng)大的能力,不光是各大廠商在用,官方ArkUI中也大量使用了FrameNode進(jìn)行性能優(yōu)化。
它的特點(diǎn)用一句話可以描述:后臺(tái)離屏渲染,前臺(tái)上樹展示。
利用這個(gè)特點(diǎn),可以實(shí)現(xiàn)組件渲染與頁(yè)面展示的完全分離。也就是說(shuō)組件的創(chuàng)建渲染不再依賴頁(yè)面的生命周期,這樣我們就可以做很多事情了。
但正因?yàn)镕rameNode組件會(huì)在后臺(tái)真實(shí)渲染,它使用起來(lái)會(huì)有一定的風(fēng)險(xiǎn),后臺(tái)渲染的組件可能會(huì)影響前臺(tái)行為,比如改變狀態(tài)欄顏色、彈Toast、彈Dialog等,這些都需要人為進(jìn)行規(guī)避。
在RNSDK中我們對(duì)Toast、Dialog、狀態(tài)欄等行為的TurboModule調(diào)用,根據(jù)頁(yè)面狀態(tài)進(jìn)行了攔截,頁(yè)面不可見時(shí),上述這些TurboModule的調(diào)用都不會(huì)生效,而且會(huì)記錄最后一次攔截的行為及參數(shù),在頁(yè)面變?yōu)榭梢姇r(shí),恢復(fù)最后一次被攔截的行為。
基于FrameNode能力,實(shí)現(xiàn)了鴻蒙中CRN預(yù)加載的1.0和2.0方案,下文會(huì)詳細(xì)介紹這兩個(gè)方案。
另外需要注意的一點(diǎn)是,攜程在RN中使用FrameNode過(guò)程中,遇過(guò)一個(gè)困擾許久的問(wèn)題:使用FrameNode加載RN頁(yè)面時(shí),在某些比較復(fù)雜的頁(yè)面,會(huì)發(fā)生非常嚴(yán)重的JS阻塞現(xiàn)象,用戶的點(diǎn)擊、返回等操作行為被頁(yè)面渲染指令阻塞,遲遲得不到響應(yīng),極度影響用戶體驗(yàn)。
經(jīng)過(guò)與華為方面的聯(lián)合排查,發(fā)現(xiàn)是因?yàn)镽NInstance初始化時(shí)傳入的參數(shù):disableCnotallow=true導(dǎo)致。此參數(shù)會(huì)關(guān)閉React18一個(gè)性能優(yōu)化的功能:微任務(wù)指令批量提交,從而導(dǎo)致在JS代碼setTimeout中進(jìn)行setState時(shí),指令立即提交,總指令數(shù)大幅增加,進(jìn)而大幅影響RN指令處理效率。
大家如果也會(huì)在項(xiàng)目中用到FrameNode進(jìn)行RN頁(yè)面的性能優(yōu)化,在初始化RNInstance時(shí),disableConcurrentRoot參數(shù)一定要傳false。
5.1.2 CRN分包
要理解我們CRN的預(yù)加載方案,首先要了解我們的分包邏輯,具體可參考文章:《近萬(wàn)字長(zhǎng)文詳述攜程大規(guī)模應(yīng)用RN的工程化實(shí)踐》。
總體而言,將業(yè)務(wù)bundle的加載分為兩部分: rn_common & rn_business。其中rn_common包含完整的基礎(chǔ)框架能力,rn_business則是具體的業(yè)務(wù)邏輯代碼。通過(guò)nativeRequire的方式,分行加載rn_business中的業(yè)務(wù)代碼,然后在加載了rn_common的空白頁(yè)面上進(jìn)行渲染。
可以發(fā)現(xiàn),CRN這種分包模式完美契合FrameNode,我們使用FrameNode預(yù)渲染一個(gè)加載了rn_common的空白頁(yè)面,這個(gè)空白頁(yè)面不會(huì)渲染UI元素且具備完整的框架能力,不會(huì)有任何影響前臺(tái)頁(yè)面的行為,等到頁(yè)面真正展示時(shí),才去加載業(yè)務(wù)代碼rn_business,進(jìn)行UI渲染,從而完美規(guī)避FrameNode的使用風(fēng)險(xiǎn)。
也正是基于此,我們實(shí)現(xiàn)了CRN預(yù)加載1.0的方案。
5.1.3 CRN預(yù)加載1.0
預(yù)加載1.0方案:
- 在前置頁(yè)面通過(guò)FrameNode預(yù)加載一個(gè)RNSurface,利用這個(gè)RNSurface去加載rn_common,完成后可以理解為后臺(tái)存在了一個(gè)具備所有框架能力的空白頁(yè)面。
- 用戶點(diǎn)擊跳轉(zhuǎn)RN頁(yè)面時(shí),添加一個(gè)用戶幾乎不可感知的延時(shí)去加載rn_business。
- 充分利用這個(gè)跳轉(zhuǎn)延時(shí) + 頁(yè)面創(chuàng)建 + 頁(yè)面切換的動(dòng)畫時(shí)間去加載業(yè)務(wù)bundle、渲染等。
- 業(yè)務(wù)Bundle加載完成后,動(dòng)態(tài)替換業(yè)務(wù)自定義的intialProps
- 做到了rn bundle加載、 渲染與頁(yè)面生命周期的完全隔離。
- 目前預(yù)加載1.0方案在全業(yè)務(wù)默認(rèn)使用,基本解決了RN頁(yè)面首幀白屏問(wèn)題。
在攜程的某些業(yè)務(wù)線中,頁(yè)面UI依賴網(wǎng)絡(luò)接口數(shù)據(jù),且受外部接口影響,響應(yīng)較慢。這時(shí)候,打開頁(yè)面會(huì)有較長(zhǎng)時(shí)間的骨架屏loading,也非常影響用戶體驗(yàn)。
如果在前置頁(yè)面中,我們可以大概率猜到用戶下一步跳入的目標(biāo)頁(yè)面,那是不是可以利用FrameNode將目標(biāo)頁(yè)面提前加載,且根據(jù)前置頁(yè)面的參數(shù)進(jìn)行動(dòng)態(tài)刷新,這樣用戶真正跳入目標(biāo)頁(yè)面的時(shí)候,就可以直接上屏,達(dá)到秒開的效果。
基于此,我們實(shí)現(xiàn)了CRN預(yù)加載2.0。
5.1.4 CRN預(yù)加載2.0
預(yù)加載2.0方案:
- 在前置頁(yè)面通過(guò)FrameNode預(yù)加載了一個(gè)真實(shí)的RN頁(yè)面,完成了加載rn_commom、rn_business、接口請(qǐng)求、渲染等一系列流程。
- 前置頁(yè)面中影響下一個(gè)頁(yè)面關(guān)鍵參數(shù)發(fā)生改變時(shí),發(fā)消息給后臺(tái)預(yù)加載的的RN頁(yè)面,RN頁(yè)面接收到事件,拿到關(guān)鍵參數(shù)后進(jìn)行網(wǎng)絡(luò)請(qǐng)求,得到數(shù)據(jù)后對(duì)頁(yè)面進(jìn)行刷新。
- 用戶點(diǎn)擊跳轉(zhuǎn)到目標(biāo)頁(yè)面時(shí),直接將后臺(tái)已經(jīng)預(yù)渲染好的頁(yè)面上屏展示。
- 因?yàn)轫?yè)面已經(jīng)在后臺(tái)被真實(shí)渲染,有影響前置頁(yè)面的風(fēng)險(xiǎn),雖然我們?cè)赗N SDK層面已經(jīng)做了一層攔截,但這種攔截不可能cover所有場(chǎng)景,所有接入了預(yù)加載2.0方案的業(yè)務(wù)都必須在上線前經(jīng)過(guò)完整回歸測(cè)試。
- 目前,我們?cè)跈C(jī)票列表頁(yè)及火車票詳情頁(yè)使用了預(yù)加載2.0方案。
對(duì)比視頻:
性能優(yōu)化關(guān)閉:
性能優(yōu)化開啟:
5.2 RN TurboModule運(yùn)行在Worker線程
前段時(shí)間我們?cè)赗N JS端對(duì)TurboModule調(diào)用加了一個(gè)埋點(diǎn),統(tǒng)計(jì)TurboModule方法調(diào)用的耗時(shí),后續(xù)也是根據(jù)這個(gè)埋點(diǎn)生成了一個(gè)報(bào)表,發(fā)現(xiàn)在鴻蒙中,TurboModule同步方法調(diào)用耗時(shí)比Android、iOS耗時(shí)長(zhǎng)10倍,某些方法甚至慢100倍。
經(jīng)過(guò)分析,在Android、iOS中TurboModule都是運(yùn)行在單獨(dú)的子線程中,而在鴻蒙中,TurboModule都運(yùn)行在主線程,主線程要承載一些別的任務(wù)比如頁(yè)面渲染、用戶操作行為響應(yīng)等,這些行為會(huì)導(dǎo)致鴻蒙中TurboModule的調(diào)用被阻塞,耗時(shí)就長(zhǎng)。比如下圖的Trace,如果TurboModule在UI線程運(yùn)行,那就可能會(huì)被阻塞,阻塞的這段時(shí)間,js線程只能等待,而這段等待是毫無(wú)意義的。
前段時(shí)間,鴻蒙RN SDK也是加入了TurboModule運(yùn)行在Worker線程這個(gè)能力。RNInstance在創(chuàng)建時(shí)會(huì)同步創(chuàng)建一個(gè)worker線程,專門用于TurboModule運(yùn)行。我們要做的是對(duì)工程中TurboModule代碼進(jìn)行適配改造,使之可以運(yùn)行到worker線程中。
整個(gè)適配過(guò)程也存在一系列的問(wèn)題。
首先,鴻蒙的ArkTS衍生自TS語(yǔ)言,基于Actor線程模型,內(nèi)存不共享,線程間數(shù)據(jù)通信非常麻煩。
為了解決線程間通信流程繁瑣的問(wèn)題,鴻蒙提供了Sendable注解,可以理解為被這個(gè)注解修飾的對(duì)象會(huì)在共享內(nèi)存創(chuàng)建。但Sendable存在一個(gè)問(wèn)題,Sendable對(duì)象的成員變量只能是Sendable對(duì)象或其他特定的數(shù)據(jù)類型,也就是說(shuō)我們?nèi)绻麑?duì)一個(gè)對(duì)象進(jìn)行Sendable改造,就必須對(duì)他的所有成員變量進(jìn)行Sendable改造,也需要對(duì)成員變量的成員變量進(jìn)行Sendable改造,那這個(gè)改造過(guò)程就存在指數(shù)級(jí)擴(kuò)散的問(wèn)題。
另外,Sendable注解提供的時(shí)候,我們大部分代碼都已經(jīng)完成了,在這種成熟的大型項(xiàng)目中再重新進(jìn)行Sendable改造的成本非常高,大家各自App如果還沒(méi)開始或者剛開始開發(fā),一定要考慮Sendable適配的問(wèn)題,比如數(shù)據(jù)類型默認(rèn)使用collection下屬map、array,class默認(rèn)添加Sendable注解等。
目前,我們適配完成了7個(gè)TurboModule,其他TurboModule做Sendable適配的成本非常高,正在逐步進(jìn)行中。
5.3 RN 指令精簡(jiǎn)
在鴻蒙中,RN支持兩套組件:C-API實(shí)現(xiàn)的組件以及原生ArkTS實(shí)現(xiàn)的組件。
C-API實(shí)現(xiàn)的組件性能更好,華為的支持力度更大,出于性能考慮,大多數(shù)廠商使用RN時(shí),都會(huì)選擇C-API實(shí)現(xiàn)的組件。
C-API組件的Create、Insert、Update、Remove等指令不再需要傳遞給ArkTS側(cè)。僅僅幾個(gè)自定義的ArkTS組件需要將指令傳遞給ArkTS側(cè)。如下圖中綠色節(jié)點(diǎn)的指令。
鴻蒙RNOH SDK中有默認(rèn)算法,可以保留葉子節(jié)點(diǎn)ArkTS組件的指令,如果項(xiàng)目?jī)?nèi)沒(méi)有ArkTS容器組件,RNInstance初始化時(shí)添加配置arkTsComponentNames就好。
但在攜程的業(yè)務(wù)中存在AdatpterMap這個(gè)容器組件,依賴系統(tǒng)花瓣地圖,這個(gè)地圖C化難度巨大,所以我們的AdatpterMap暫時(shí)也只能由ArkTS實(shí)現(xiàn)。
這就需要我們自己設(shè)計(jì)算法。在保證性能的情況下,完整保留AdatpterMap及其子組件的相關(guān)指令。
以下是關(guān)鍵代碼:
RNOHSDK/src/main/cpp/RNOH/MountingManagerCAPI.cpp::getValidMutations:
facebook::react::ShadowViewMutationList MountingManagerCAPI::getValidMutations(
facebook::react::ShadowViewMutationList const& mutations) {
...
//需要特殊處理,保留容器組件及其子組件所有指令的容器組件名稱
std::unordered_set<std::string> whiteListArkTsComponentNames = {
"AdapterMap", "AdapterMapMarkersContainer", "AdapterMapMarker"};
//第一次遍歷:只遍歷create,從前到后找到混合組件名稱,只保存tag
for (auto mutation : mutations) {
if (mutation.type == facebook::react::ShadowViewMutation::Create) {
...
//特殊保留地圖容器組件tag
if (whiteListArkTsComponentNames
.count(newChild.componentName)) {
arkTsComponentTags.push(newChild.tag);
}
}
}
if (!arkTsComponentTags.empty()) {
// 第二次遍歷:只遍歷insert,找到混合組件tag和它的子組件的tag。采用廣度遍歷方式,這里也只保存tag
for (auto mutation : mutations) {
if (mutation.type == facebook::react::ShadowViewMutation::Insert) {
...
//保存地圖容器組件tag和它的子組件的tag
}
}
}
//第三次遍歷:根據(jù)2中齊全的tag,重新過(guò)濾所有指令,保留這些tag的create、insert、update、remove指令。
for (auto mutation : mutations) {
...
//根據(jù)組件Tag,保留需要傳遞給ArkTS的所有指令
}
return validMutations;
}
再來(lái)看下成果,測(cè)試RN頁(yè)面中,算法過(guò)濾的不需要傳遞到ArkTS側(cè)的指令數(shù)超過(guò)99.9%。
頁(yè)面 | 優(yōu)化前指令數(shù) | 優(yōu)化后指令數(shù) |
酒店首頁(yè) | 3092 | 11 |
酒店套餐 | 2181 | 0 |
機(jī)票+酒店 | 496 | 2 |
酒店 | 3838 | 3 |
美食/購(gòu)物 | 1542 | 3 |
5.4 分幀渲染
分幀渲染主要用在App的啟動(dòng)優(yōu)化中。首頁(yè)宮格存在兩屏,二屏在剛開始是不可見的,可以在首頁(yè)加載完畢之后再去加載宮格二屏。
分幀渲染可以監(jiān)聽到幀渲染的回調(diào),這樣就可以對(duì)頁(yè)面元素的加載優(yōu)先級(jí)進(jìn)行定制,將重要的元素優(yōu)先加載,不重要或者不可見的元素后續(xù)加載。進(jìn)而提升頁(yè)面的性能。
關(guān)鍵代碼:
private myDisplaySync?: displaySync.DisplaySync
updateStage() {
if (this.stages == 0) {
this.myDisplaySync = displaySync.create();
this.myDisplaySync.start();
this.myDisplaySync.on('frame', (frameInfo: displaySync.IntervalInfo) => {
this.updateStage();
});
}
this.stages++;
if (this.stages == 3) {
this.myDisplaySync?.stop();
}
}
...
build() {
Column() {
Scroll(this.scroller) {
Row() {
//默認(rèn)加載宮格首屏
if (this.stages > 0){
this.genFirstCell(0)
}
//三個(gè)渲染幀之后,加載宮格二屏
if (this.stages > 2){
this.genFirstCell(1)
}
}
...
}
接入分幀渲染,控制宮格二屏的渲染時(shí)機(jī)后,首頁(yè)的啟動(dòng)耗時(shí)減少了20ms。
5.5 后續(xù)性能優(yōu)化
華為鴻蒙RN團(tuán)隊(duì)規(guī)劃有一個(gè)性能優(yōu)化的feature,在這里簡(jiǎn)單介紹下。
5.5.1 更換RN JS執(zhí)行引擎:JSVM(基于V8)
JSVM相較hermers,預(yù)計(jì)可以提升20%的JS解析性能。前段時(shí)間華為提供了一個(gè)rn sdk,我們新建了一個(gè)分支驗(yàn)證了一下這個(gè)JSVM,js的加載速度確實(shí)比hermes要快一些。但RN產(chǎn)物jsbundle的加載比hermes要慢,這意味著頁(yè)面的首屏性能會(huì)受到一定影響。這是我們非常關(guān)注的一個(gè)性能指標(biāo),問(wèn)題得到解決之后,我們也會(huì)進(jìn)行切換。
六、成果和未來(lái)規(guī)劃
經(jīng)過(guò)4個(gè)月鴻蒙版本的開發(fā)和適配,2024年6月18日攜程在鴻蒙應(yīng)用商店上架了首個(gè)全業(yè)務(wù)全場(chǎng)景的攜程旅行鴻蒙版應(yīng)用。業(yè)務(wù)方在Android/iOS上的一套CRN代碼,只需經(jīng)過(guò)簡(jiǎn)單的適配,就能正常在鴻蒙系統(tǒng)上運(yùn)行,甚至有些業(yè)務(wù)不需要修改,現(xiàn)有的代碼直接在鴻蒙系統(tǒng)上能完整的跑完業(yè)務(wù)流程。
未來(lái),我們還會(huì)在以下兩個(gè)方面持續(xù)對(duì)鴻蒙CRN框架進(jìn)行優(yōu)化:
用戶體驗(yàn)
用戶體驗(yàn)和性能一直是我們關(guān)注的重點(diǎn),CRN在鴻蒙系統(tǒng)上還有很大的優(yōu)化空間。我們會(huì)持續(xù)在性能上繼續(xù)打磨和提升。
技術(shù)布局
為了追求高效率、低成本的研發(fā)模式,未來(lái)攜程業(yè)務(wù)開發(fā)會(huì)大量使用一碼多端的框架xTaro。后續(xù)xTaro會(huì)支持鴻蒙系統(tǒng),真正實(shí)現(xiàn)讓業(yè)務(wù)的一套代碼能在多端多平臺(tái)多應(yīng)用場(chǎng)景上全矩陣運(yùn)行。
鴻蒙生態(tài)的發(fā)展是一個(gè)持續(xù)且快速的過(guò)程。隨著鴻蒙系統(tǒng)的不斷迭代升級(jí)和生態(tài)的逐步完善,我們會(huì)持續(xù)為用戶提供更加智能、安全、便捷的一站式的旅行應(yīng)用。