1. 程式人生 > >攜程React Native實踐

攜程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 通過 JavaScript Core 解析 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 個月時間。

攜程 React Native 實踐與效能優化

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

二、遇到的問題和優化

RN 常見問題介紹

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

  1. 打包出來的 JSBundle 過大;

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

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

  4. 大資料量時 ListView 載入卡頓。

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

攜程 React Native 實踐與效能優化

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

JSBundle 檔案過大 & 頁面載入慢

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

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

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

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

攜程 React Native 實踐與效能優化

上述是一個 Hello World RN App 程式碼的結構,基本分為 3 部分:

  • 頭部:各依賴模組引用部分;

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

  • 尾部:入口模組註冊部分。

攜程 React Native 實踐與效能優化

上述是 Hello World RN App 打包之後 JSBundle 檔案的結構,基本分為 3 部分:

  • 頭部:全域性定義,主要是definerequire等全域性模組的定義;

  • 中間:模組定義,RN 框架和業務的各個模組定義;

  • 尾部:引擎初始化和入口函式執行;

\_\_d是 RN 自定義的define,符合 CommonJS規範,\_\_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.jsbusiness.js合併在一起,然後載入。

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

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

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

拆分方案二

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

按照這個思路,能後臺載入的 JS 檔案, 實際上是就是一個 RN App。因此我們設計了一個空白頁面的 Fake App,它做一件事情,就是監聽要顯示的真實業務 JS 模組,收到監聽之後,渲染業務模組,顯示頁面。

Fake App 設計如下:

攜程 React Native 實踐與效能優化

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

  1. 基於 Fake App 打common.js包時,需要記錄 RN 各個模組名和模組id之間的mapping關係;

  2. 打業務模組包時,判斷,如果已經在mapping檔案裡面的模組,不要打包到業務包中。

改造頁面載入流程:

  1. 因為要能夠後臺載入,所以需分離 UI 和 JS 載入引擎\<iOS-RCTBridge, Android-ReactInstanceManager\>;

  2. 進入業務 RN 頁面時候,獲取預載入好的 JS 引擎,然後傳送訊息給 Fake App,告知該渲染的業務 JS 模組;

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

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

拆分方案三

正在此時,我們研究 RN 在 Facebook App 裡面的使用情況,發現了Unbundle,簡單點說,就是將所有的 JS 模組都拆分成獨立的檔案。

下面截圖就是Unbundle打包的檔案格式:

攜程 React Native 實踐與效能優化
  1. entry.js就是 Global 部分定義 + RN App 入口;

  2. Unbundle檔案是用於標識這是一個Unbundle包的 flag;

  3. 12.js13.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 檔案做了簡單的合併。

攜程 React Native 實踐與效能優化

將 common 部分的 JS 檔案,合併成一個common\_ios(android).js

\_crn\_config記錄了這個 RN App 的入口模組id以及其他配置資訊,詳見下圖:

攜程 React Native 實踐與效能優化
  1. main\_module為當前業務模組入口模組id

  2. module\_path為業務模組 JS 檔案所在當前包的相對路徑;

  3. 666666=0.js,說明666666這個模組在0.js檔案裡面;

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

攜程 React Native 實踐與效能優化

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

這是自己單機測試的資料,那上線之後,資料如何呢?

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

攜程 React Native 實踐與效能優化

看到這個資料,發現和我們自己測試的基本一致,但是還有一個疑問,載入的時間分佈,是否服從正態分佈,會不會很離散,快的裝置很快,慢的裝置很慢呢?

然後我又進一步分析這一天的資料,按照頁面載入時間區間分佈統計。

攜程 React Native 實踐與效能優化

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

關於這個資料,補充一下,先前已看到一篇58同城同學分享的RN實踐的文章,裡面也曾提到他們業務頁面載入時間的資料,有興趣的同學可以去比較下。

頁面載入優化

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

攜程 React Native 實踐與效能優化

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

快取,還是快取,不要立即釋放,等符合一定條件之後,再釋放。

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

攜程 React Native 實踐與效能優化
  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 問題處理

攜程 React Native 實踐與效能優化

iOS 的 Crash,基本都來自RCTFatalException,都是RCTFatal丟擲錯誤資訊所知,處理也相對簡單,設定自己的Error Handler即可。

void RCTSetFatalHandler(RCTFatalHandler fatalHandler);

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

攜程 React Native 實踐與效能優化

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.javacreateReactContext丟擲來的,處理掉就可以了。

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

ListView 效能問題

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

攜程 React Native 實踐與效能優化

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

為適應大資料量 ListView 的場景,我們專門安排資源,開發了可重用 cell 的CRNListView,iOS 借鑑了第三方的ReactNativeTableView的實現,開發了可重用 cell 的 ListView,介面和官方原生的基本一致,Android 借鑑 iOS 的方案,採用RecyclerView實現了類似的可重用 cell 的 ListView,同時我們還做了一些擴充套件,把常用的下拉重新整理,載入更多,右側字母索引欄等功能,都增加了進去。

實際測試下來,資料量少時候,和 RN 提供的 ListView,效能基本一致,但當資料量大時候,CRNListView優勢明顯,下面這張圖,是我們在 Android 上的測試資料。

攜程 React Native 實踐與效能優化

三、下一階段的規劃

1. CRN-Web 的開發

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

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

2. 單JS執行引擎的實現

RN 還有一個比較大的效能瓶頸在於記憶體耗用大。做過這樣的測試,在一個 Hello World 的 RN 工程裡面,開啟一個 Native/RN/H5 Hybrid 的 Hello World 頁面,Native 顯示頁面記憶體佔用 0.2MB,RN 佔用 10MB,H5 Hybrid 佔用 20MB。如果大量業務都使用 RN 開發,JS 執行引擎大量建立,會耗費大量記憶體,但是從 JS 執行引擎的執行過程。執行邏輯來說,只要做好業務隔離,完全是可以在一個執行引擎裡面執行多個業務功能的 JS 程式碼的。我們正在做相關嘗試,相信在未來 1-2 個版本時間,可以完成線上驗證。

3. AMD模式的載入嘗試

RN 打包預設是CommonJS規範,整個 JSBundle 一次讀入記憶體,一次全部執行完成,所以耗費大量時間。如果能夠用 AMD 模式改造,JSBundle 讀取到記憶體,但是隻執行用到的模組,真正做到按需載入,相信對頁面載入效率,會有更近一步的提升。