攜程是如何做 React Native 優化的

bnxj5550 8年前發布 | 18K 次閱讀 ReactNative 移動開發 React Native

React Native(下文簡稱RN)開源已經一年多時間,國內各大互聯網公司都在使用,攜程也在今年5月份投入資源開始引入,并推廣給多個業務團隊使用,本文將會分享我們遇到的一些問題以及我們的優化方案。

一、背景和使用情況介紹

為什么會引入React Native?

1. AppSize占用

攜程旅行App從11年開始開發,至今已有5年多時間,隨著各項業務功能的全面移動化,以及公司mobile first策略的指引下,App功能越來越多,越來越臃腫,Size達到將近100MB,而同樣功能,使用RN開發,Size遠遠小于Native開發,RN的引入,可以支持我們App的可持續健康的發展。

2、 用戶體驗佳

RN通過JavascriptCore解析Javascript模塊,轉換成原生native組件渲染,相比H5頁面不再局限于WebView、渲染性能長足提升,運行用戶體驗可以媲美native。

3. 相對成熟

Android和iOS的RN都已經開源,原生提供的組件和API相對豐富,且跨平臺基本一致,對外接口也趨于穩定,適合業務開發。

4. 支持動態更新

純原生的開發,android上通過插件化框架,可以實現動態加載遠端代碼。但是在iOS上,因為系統限制,不能動態執行遠端下載的Native代碼,而RN完全滿足該需求。

5. 跨平臺

RN提供的API和組件,大多能跨平臺使用,對少數不支持的組件,我們再做二次封裝抹平,可以讓業務開發人員開發一份代碼,運行在iOS&Android 兩個平臺上。這樣能夠大大提供開發效率,降低開發維護成本。

如何引入?

基于RN 0.30版本,開發了支持攜程業務團隊快速便捷開發的CRN框架,框架主要從以下幾個方面著手。

1. 工具

  • cli工具,負責CRN工程創建,運行;

  • pack工具,負責打包;

2. 控件

  • 對RN官方提供的API和組件,實現跨平臺支持;

  • 新增攜程業務相關的API和組件,方便業務接入;

3. 穩定性、性能優化

  • RN頁面加載提速,實現秒開;

  • 穩定性提升,消除RN導致的crash;

4. 發布

  • 統一管理所有RN業務的相關發布;

  • 差分增量支持,盡可能減小文件大小;

除此之外,我們還從文檔以及技術支持等方面,支撐其作為一個完整的產品開發框架。

業務的使用

下面一幅圖說明了RN在攜程業務中的使用情況,總共4個版本的開發時間,每個版本大約1個月時間。

前面2個版本主要是CRN基礎功能完成和線上驗證,后面2個版本穩定性優化和API跨平臺抹平基本完成,業務數和頁面數量猛增。

二、遇到的問題和優化

RN常見問題介紹

所有做ReactNative開發的團隊,或多或少都面臨著以下4個問題需要解決。

1. 打包出來的JSBundle過大;

2. 首次進入RN頁面加載緩慢;

3. 穩定性不夠,有大量因為RN導致的crash;

4. 大數據量時候listview加載卡頓;

接下來,我們就這四個問題來一一探討。

從這張圖中可以看出,最大的瓶頸在JS init+Require,這塊時間就是JSBundle的執行時間,為了提升頁面加載速度,這塊時間我們需要想辦法優化。

JSBundle文件過大&頁面加載慢

先來說一組數據,一個Helloorld的App,如果使用0.30RN 官方命令react-native bundle打包出來的JSBundle文件大小大約為531KB,RN框架JavaScript本身占了530KB, zip壓縮之后也有148KB。

如果只有一兩個業務使用,這點大小算不了什么,但是對于我們這種動輒幾十個業務的場景,如果每個業務的JSBundle都需要這么大的一個RN框架本身,那將是不可接受的。

因此,我們需要對RN官方的打包腳本做改造,將框架代碼拆分出來,讓所有業務使用一份框架代碼。

開始拆分之前, 我們先以HelloWorld的RNApp為基礎介紹幾個背景知識。

上述是一個HelloWorld RNApp代碼的結構,基本分為3部分:

頭部:各依賴模塊引用部分;

中間:入口模塊和各業務模塊定義部分;

尾部:入口模塊注冊部分;

上述是HelloWorld RNApp打包之后JSBundle文件的結構,基本分為3部分 頭部:全局定義,主要是define,require等全局模塊的定義; 中間:模塊定義,RN框架和業務的各個模塊定義; 尾部:引擎初始化和入口函數執行;

__d是RN自定義的define,符合 ,__d后面的數字是模塊的id,是在RN打包過程中,解析依賴關系,自增長生成的。

如果所有業務代碼,都遵照一個規則:入口JS文件首先require的都是react/react-native, 則打包生成的JSBundle里面react/react-native相關的模塊id都是固定的。

拆分方案一

基于上面2點背景知識介紹,我們很容易發現,如果將打包之后的JSBundle文件,拆分成2部分(框架部分+業務模塊部分),使用的時候合并起來,然后去加載,即可實現拆分功能。

具體實現步驟:

1、創建一個空工程,入口文件只需要2行代碼,require react/react-native即可;

2、使用react-native bundle命令,打包該入口文件,生成common.js;

3、使用react-native bundle打包業務工程(有一點要保證,業務工程入口文件前面2行代碼也是require react/react-native), 生成business_all.js;

4、開發工具,從business_all.js里面刪除common.js的內容,剩下的就是business.js;

5、App加載的時候將common.js和business.js合并在一起,然后加載;

貌似功能完成,可是回到Dive into React Nativeperformance, 這么做還是優化不了JSBundle的執行時間,因為我們不能把拆分開的2個文件分別執行,因為加載common.js會提示找不到RNApp的入口,先執行business.js,會提示一堆依賴的RN模塊找不到。

顯然,這種拆分方式不能滿足我們這種需要。

那這個方案就完全沒有價值嗎?不是的,如果你做的是一個純RNApp,native只是一個殼,里面業務全是RN開發的,完全可以使用這種方式做拆分,這種方案簡單,無侵入,實現成本低,不需要修改任何RN打包代碼和RN Runtime代碼。

拆分方案二

RN 框架部分文件(common.js)大小530KB,如此大的js文件,占用了絕大部分的JS執行時間,這塊時間如果能放到后臺預先做完,進入業務也只需執行業務頁面的幾個JS文件,將可以大大提升頁面加載速度,參考上面的RN性能瓶頸圖,預估可以提升100%。

按照這個思路,能后臺加載的JS文件, 實際上是就是一個RNApp,因此 我們設計了一個空白頁面的FakeApp,這個FakeApp做一件事情,就是監聽要顯示的真實的業務JS模塊,收到監聽之后,渲染業務模塊,顯示頁面。

FakeApp 設計如下:

為了實現該拆包方案,需要改造react-native的打包命令;

1、基于FakeApp打common.js包的時候, 需要記錄RN各個模塊名和模塊id之間的mapping關系;

2、打業務模塊包的時候,判斷,如果已經在mapping文件里面的模塊,不要打包到業務包中

改造頁面加載流程:

1、因為要能夠后臺加載,所以需分離UI和JS加載引擎<iOS-RCTBridge, Android-ReactInstanceManager>;

2、進入業務RN頁面時候,獲取預加載好的JS引擎,然后發送消息給FakeApp,告知該渲染的業務JS模塊;

通過后臺預加載,省去了絕大部分的JS加載時間,似乎問題已經完美解決。

但是,如果隨著業務不斷膨脹,一個RN業務JS代碼也達到500KB,進入這個業務頁面,500多KB JS文件讀取出來,執行,整個JS執行的時間瓶頸會再次出現。

拆分方案三

正在此時,我們研究RN在非死book App里面的使用情況,發現了Unbundle,簡單點說,就是將所有的JS模塊都拆分成獨立的文件。

下面截圖就是unbundle打包的文件格式:

1、entry.js就是global部分定義+RNApp入口;

2、UNBUNDLE文件是用于標識這是一個unbundle包的flag;

3、12.js,13.js就是各個模塊,文件名就是模塊id;

4、在業務執行,需要加載模塊(require)的時候,就去磁盤查找該文件,讀取、執行。

RN里面加載模塊流程說明,以require(66666)模塊為例:

1、首先從__d<就是前文提到的define>的緩存列表里面查找是否有定義過模塊66666,如果有,直接返回,如果沒有走到下面第二步的nativeRequire;

2、nativeRequire根據模塊id,查找文件所在路徑,讀取文件內容;

3、定義模塊,_d(66666)=eval(JS文件內容),會將這個模塊ID和JS代碼執行結果記錄在define的緩存列表里面;

打包通過react-native unbundle 命令,可以給android平臺打出這樣的unbundle包。

順便提一下,這個unbundle方案,只在android上有效,打ios平臺的unbundle包,是打不出來的,在RN的打包腳本上有一行注釋,大致意思是在iOS上眾多小文件讀取,文件IO效率不夠高,android上沒這樣的問題,然后判斷如果是打iOS的unbundle包的時候,直接return了。

相對應的,iOS開發了一個prepack的打包模式,簡單點說,就是把所有的JS模塊打包到一個文件里面,打包成一個二進制文件,并固定0xFB0BD1E5為文件開始,這個二進制文件里面有個meta-table,記錄各個模塊在文件中的相對位置,在加載模塊(require)的時候,通過fseek,找到相應的文件開始,讀取,執行。

在Unbundle的啟發下,我們修改打包工具,開發了CRNUnbunle,做了簡單的優化,把眾多零散的JS文件做了簡單的合并。

將common部分的JS文件,合并成一個common_ios(android).js.

_crn_config記錄了這個RNApp的入口模塊ID以及其他配置信息,詳見下圖:

1、main_module為當前業務模塊入口模塊ID;

2、module_path為業務模塊JS文件所在當前包的相對路徑;

3、666666=0.js,說明666666這個模塊在0.js文件里面;

做完這個拆包和加載優化之后,我們用自己的幾個業務做了下測試,下圖是當時的測試驗證數據。

可以看出,iOS和android基本都比官方打包方式的加載時間,減少了50%。

這是自己單機測試的數據,那上線之后,數據如何呢?

下圖,是我們分析一天的數據,得出的平均值<排除掉了5s以上的異常數據,后面實測下來5s以上數據極少>;

看到這個數據,發現和我們自己測試的基本一致,但是還有一個疑問,加載的時間分布,是否服從正態分布,會不會很離散,快的設備很快,慢的設備很慢呢?

然后我又進一步分析這一天的數據,按照頁面加載時間區間分布統計。

看圖上數據,很明顯,iOS&Android基本一致,將近98%的用戶都能在1s內加載完成頁面,符合我們期望的正態分布,所以bundle拆分到此基本完成。

關于這個數據,補充一下,先前看業到一篇58同城同學分享的 的文章,里面也曾提到他們業務頁面加載時間的數據,有興趣的同學可以去比較下。

頁面加載優化

按照上述的拆包方案實現后,我們的RN頁面加載流程大致是這樣的。

從上文的優化可以看出,緩存了common.js部分的JS執行引擎(iOS RCTBridge, Android ReactInstanceManager),頁面加載可以大大提速,那對于已經被業務使用過的JS執行引擎,該如何處理呢?

緩存,還是緩存,不要立即釋放,等符合一定條件之后,再釋放。

對JS執行引擎,我們定義了以下的一些生命周期狀態。

1、JS執行引擎加載common.js的時候,處于loading狀態,如果加載出錯,處于Error狀態;

2、框架common.js加載結束,JS執行引擎狀態設置為Ready;

3、Ready狀態的JS執行引擎被使用,則修改狀態為Dirty;

4、Dirty狀態的JS執行引擎達到一定條件<比如Dirty的JS執行引擎總數達到2個時候>,開始回收;

5、回收過程很簡單,就是將加載(require)的業務代碼,從__d<前文提到的define>的緩存模塊數組里面刪除掉就可以了,回收完成之后,又變成還原狀態;

錯誤處理

RN剛上線的前2個版本,我們發現有大量因為RN導致的crash,常見的錯誤有以下幾種。

iOS的crash問題處理

iOS的crash,基本都來自RCTFatalException,都是RCTFatal拋出錯誤信息所知, 處理也相對簡單, 設置自己的Error Handler即可。

void RCTSetFatalHandler(RCTFatalHandler fatalHandler);

一般初次開發RN應用的開發人員,都沒有留意這一點,其實查閱下RN的源代碼,RCTFatal的注釋寫的還比較清楚,分析源碼也可以發現在生產環境的時候,RCTFatal會直接Raise Exception,然后crash。

Android的crash問題處理

Android的crash點相對較多,大致會出現在以下幾個場景。

1、bundle加載過程中的RuntimeException;

2、JS執行過程中的,處理NativeExceptionsManagerModule;

3、native模塊執行出錯, 處理NativeModuleCallExceptionHandler

4、so lib加載失敗,經典的java.lang.UnsatisfiedLinkError, 這種問題,解決方案很簡單,給System.load添加try catch,并且在catch里面做補償,可以大大降低由此導致的crash;

對于第一點提到的RuntimeException,我們收集到的日志如下:

不能連接到dev server,看到之后很不明白,明明是生產環境,怎么會報這樣的錯誤呢? 

偶現的Javascript執行出錯,怎么會走到RuntimeException呢? 

問題的解決很簡單,這些RuntimeException,都是從ReactInstanceManagerImp.java的createReactContext拋出來的, 處理掉就可以了。

再補充一點,這些錯誤處理之后,都需要一層一層的傳遞到最上層的UI界面,這樣才能友好的給用戶提示。

ListView性能問題

先來看一張截圖,是從RN提供的UIExplore demo跑出來的。

可以清楚的看到,超出屏幕的條目,依然被渲染了。沒有實現cell重用,導致數據量大時候,卡頓。

為適應大數據量listview的場景,我們專門安排資源,開發了可重用cell的CRNListView,iOS借鑒了第三方的 的實現,開發了可重用cell的listview,接口和官方原生的基本一致,Android借鑒iOS的方案,采用RecyclerView實現了類似的可重用cell的listview,同時我們還做了一些擴展,把常用的下拉刷新,載入更多,右側字母索引欄等功能,都增加了進去。

實際測試下來,數據量少時候,和RN提供的listview,性能基本一致,但當數據量大時候,CRNListView優勢明顯,下面這張圖,是我們在android上的測試數據。

三、下一階段的規劃

1. CRN-Web的開發

同樣的功能,CRN一套代碼可以在iOS和android 2個平臺運行。但對于業務開發團隊,他們還需要維護H5平臺同樣的功能,如果我們能夠將CRN代碼,通過類似webpack這樣的工具,直接轉換過去就能在H5平臺上運行起來,就可以做到一套代碼,三端運行,可以大大降低業務團隊的開發維護成本。

目前,我們已經再拿一些業務的CRN代碼做轉換驗證,初步驗證可行,還在持續優化完善中。

2. 單JS執行引擎的實現

RN還有一個比較大的性能瓶頸在于內存耗用大.做過這樣的測試,在一個HelloWorld的RN工程里面,打開一個Native/RN/H5 Hybrid的HelloWorld頁面,native顯示頁面內存占用0.2MB,RN占用10MB,H5 Hybrid占用20MB。如果大量業務都使用RN開發,JS執行引擎大量創建,會耗費大量內存,但是從JS執行引擎的執行過程,運行邏輯來說,只要做好業務隔離,完全是可以在一個執行引擎里面運行多個業務功能的JS代碼的。我們正在做相關嘗試,相信在未來1-2個版本時間,可以完成線上驗證。

3. AMD模式的加載嘗試

RN打包默認是 ,整個JSBundle一次讀入內存,一次全部執行完成,所以耗費大量時間。如果能夠用AMD模式改造,JSBundle讀取到內存,但是只執行用到的模塊,真正做到按需加載,相信對頁面加載效率,會有更近一步的提升。

 

 

來自:https://mp.weixin.qq.com/s?__biz=MjM5MDI3MjA5MQ==&mid=2697265573&idx=2&sn=3a560b14da608cea5bdff193e9d7cbdf&chksm=8376fe91b4017787c46fc854dd9a96e6ece6b2e14c00d1aded4b741cbeac0ca29884fe1cabc7&mpshare=1&scene=1&srcid=1111QGSJAxeh3Mu0vmRF78wn&pass_ticket=GrN3ApiPKLBLUCaaxhFTb57AI0k64rlufcHG3leafl4=

 

 本文由用戶 bnxj5550 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!