React Native如何做效能優化

xiangzhihong 發表於 2022-06-22
React

和原生開發相比,React Native 最明顯的不足就是頁面的渲染速度,比如頁面載入慢,渲染的效率低等。對於這些問題,都是開發中常見的問題,也是使用React Native 開發跨平臺應用時必須優化的點,由此引入一個問題,React Native的效能優化究竟應該如何做?

相信對於這個問題,大多數人第一眼看到後都是很懵逼的。因為大多數人除了業務開發之外,對於React Native原理性的東西都瞭解甚少。其實,經過我們多年的經驗,一個未經優化的 React Native 應用,從大體上講可以分為 3 個瓶頸:
在這裡插入圖片描述

在這裡插入圖片描述

當然,RN的效能優化包括JavaScript 側和原生容器的優化。不過,我們今天我們主要站從客戶端角度進行優化。

一、React Native 環境預建立

在 最新的React Native 架構中,Turbo Module (新架構下的通訊方式)是按需載入,而舊框架則是在初始化的時候把Native Modules一股腦的載入進來,同時 Hermes 引擎放棄了 JIT,在啟動速度方面也有明顯提升。如果對React Native新架構感興趣的同學,可以參考:React Native新架構。

拋開這兩個版本在框架方面的優化,在啟動速度方面,我們還能做些什麼呢?首先,我們看一下React Native 環境預建立。在混合工程中,React Native 環境與載入頁面的關係如下圖。
在這裡插入圖片描述

從上圖中可以看到,在混合應用中,獨立的 React Native 載體頁都有自己獨立的執行環境。Native 域包括 React View、Native Modules;JavaScript 域澤包括 JavaScript 引擎、JS Modules、業務程式碼;中間通訊使用的是Bridge/JSI(新版本)。

當然,業內也有多個頁面複用一個引擎的優化。但是多頁面複用一個引擎存在一些問題,比如 JavaScript 上下文隔離、多頁面渲染錯亂、JavaScript 引擎不可逆異常等。而且複用的效能不穩定,考慮到投入產出比、維護成本等方面,通常在混合開發中,採用的是一個載體頁一個引擎。

通常,一個 React Native 頁面從載入渲染到展示大致分為以下幾步:【React Native 環境初始化】 -> 【下載/載入 bundle】 -> 【執行JavaScript 程式碼】。

環境初始化這一步主要包含的工作包括:建立 JavaScript 引擎、Bridge、載入 Native Modules(舊版)。根據我們的測試,初始化這一步在 Android 環境中是特別耗時的。所以,我們想到的第一個優化點就是提前將 React Native 環境建立好,流程如下。
在這裡插入圖片描述

涉及的程式碼如下:
RNFactory.java

public class RNFactory {
    // 單例
    private static class Holder {
        private static RNFactory INSTANCE = new RNFactory();
    }

    public static RNFactory getInstance() {
        return Holder.INSTANCE;
    }

    private RNFactory() {
    }

    private RNEnv mRNEnv;
    
    //App啟動時呼叫init方法,提前建立RN所需的環境
    public void init(Context context) {
        mRNEnv = new RNEnv(context);
    }
    
    //獲取RN環境物件
    public RNEnv getRNEnv(Context context) {
        RNEnv rnEnv = mRNEnv;
        mRNEnv = createRNEnv(context);
        return rnEnv;
    }
}

RNEnv.java

public class RNEnv {
   private ReactInstanceManager mReactInstanceManager;
   private ReactContext mReactContext;
   
   public RNEnv(Context context) {
       // 構建 ReactInstanceManager
       buildReactInstanceManager(context);
       // 其他初始化
       ...
   }
   
   private void buildReactInstanceManager(Context context) {
      // ...
      mReactInstanceManager = ...
   }
   
   public void startLoadBundle(ReactRootView reactRootView, String moduleName, String bundleid) {
      // ...
   }
}

在做預建立時,我們需要注意執行緒同步問題。在混合應用中,React Native 由應用級變成頁面級使用,所以線上程安全這方面有不少的問題,預建立時會併發建立多個 React Native 環境,而 React Native 環境內部構建存在非同步處理,一些全域性的變數,如 ViewManagersPropertyCache。

class ViewManagersPropertyCache {
    private static final Map<Class, Map<String, ViewManagersPropertyCache.PropSetter>> CLASS_PROPS_CACHE;
    private static final Map<String, ViewManagersPropertyCache.PropSetter> EMPTY_PROPS_MAP;

    ViewManagersPropertyCache() {
    }
    ...
}

內部的 CLASS_PROPS_CACHE、EMPTY_PROPS_MAP 都是非執行緒安全的資料結構,併發時可能會存在 Map 擴容轉換問題 ,又比如 DynmicFromMap 也有此問題。
在這裡插入圖片描述

二、非同步更新

原先我們進入 React Native 載體頁後需要先下載最新的 JavaScript 程式碼包版本,若有更新,就要下載最新的包並載入。在這個過程中,我們會經歷兩次網路請求,即獲取是否有更新,如果有下載熱更新的bundle包。如果使用者網路比較差,下載bundle包就會很慢,最終等待時間也會較長。

所以我們針對部分特殊的頁面,採取了非同步更新的策略。非同步更新策略的主要思路為在進入頁面之前選擇性地提前下載 JavaScript 程式碼包,進入載體頁後再看 JavaScript 程式碼包是否有快取,如果有,我們就優先載入快取並渲染;然後再非同步檢測是否有最新版本的 JavaScript 程式碼包,如果有,下載到本地並進行快取,再等下次進入載體頁時生效。
在這裡插入圖片描述

上圖展示了我們開啟一個RN頁面所需要經歷的一些流程。流程圖中可以看出,我們從進入載體頁到渲染頁面,需要兩次網路請求,不管網速快還是慢,這個流程算是比較漫長的,但在進行非同步更新後,我們的流程就會變成下圖這樣
在這裡插入圖片描述

在業務頁面中,我們可以對 JavaScript 程式碼包進行提前下載並快取,在使用者跳轉到 React Native 頁面後,檢測是否有快取的 JavaScript 程式碼包,如果有我們就直接渲染頁面。這樣就不需要等待版本號檢測網路介面以及下載最新包的網路介面,也不依賴於使用者的網路情況,減少了使用者等待時間。

在渲染頁面的同時,我們通過非同步檢測 JavaScript 程式碼包的版本,若有新版本就進行更新並快取,下次生效。當然,業務也可以選擇更新完最新包之後,提示使用者有新版本頁面,以及是否選擇重新整理並載入最新頁面。

三、介面預快取

在經過React Native 環境初始化、bundle 載入流程進行優化後,我們的 React Native 頁面基本就可以達到秒開級別了。不過,React Native 頁面載入後,進入 JavaScript 業務執行區間,大部分業務都不可避免地會進行網路互動,請求伺服器資料進行渲染,這部分其實也有很大的優化空間。

首先,我們來看下具備熱更新能力的 React Native 載入流程。
在這裡插入圖片描述

可以看到,整個流程是從 React Native 環境初始化到熱更新 ,再到 JavaScript 業務程式碼執行,最後到業務介面展示。鏈路比較長,而且每一個步驟都依賴前一個步驟的結果。特別是熱更新流程,最長可涉及兩次網路呼叫,分別是檢測是否需要更新與下載最新 bundle 檔案。

針對這種場景,我們想到一個優化點,在等待網路返回的過程中,Native 能不能把閒置的 CPU 資源利用起來呢?

在純客戶端開發中,我們經常使用介面資料快取策略來提升使用者體驗,在最新資料返回前,先使用快取資料進行頁面渲染。那麼在 React Native 中,我們也可以參考這一思路,對整個流程進行優化。
在這裡插入圖片描述

下面我們來看一下具體如何實現。首先,當我們開啟載體頁時,解析對應 bundle 快取中的預請求介面配置資料,發起請求快取資料,並在請求成功之後快取請求。

public class RNApiPreloadUtils {
    public static void preloadData(String bundleId) {
       //根據bundle id解析對應的預請求介面配置,可存在多個介面
       List<PrefetchBean> prefetchBeans = parsePrefetchBeans(bundleId);
       //請求介面,成功後快取到本地儲存
       requestDatas(prefetchBeans);
    }
    
    public static String prefetchData(String url) {
       //從本地快取中,根據url獲取對應的介面資料
    }
}

然後,根據 url 獲取對應的快取資料。

public class PreFetchBusinessModule extends ReactContextBaseJavaModule 
    implements ReactModuleWithSpec, TurboModule {
    public PreFetchBusinessModule(ReactApplicationContext reactContext) {
       super(reactContext.real());
    }

    @ReactMethod
    public void prefetchData(String url, Callback callback) {
        String data = RNApiPreloadUtils.prefetchData(url);
        // 回傳資料給 JS
        WritableMap resultMap = new WritableNativeMap();
        map.putInt("code", 1);
        map.putString("data", data);
        callback.invoke(resultMap);
    }
}

接下來,就可以在JavaScript端呼叫上面的方法了,呼叫的程式碼如下:

NativeModules.PreFetchBusinessModule.prefetchData(url, (result)=>{
    //獲取到結果後,判斷是否為空,不為空解析資料後渲染頁面
    console.info(result);
  }
);

四、拆包

React Native 頁面的 JavaScript 程式碼包是熱更新平臺根據版本號進行下發的,每次有業務改動,我們都需要通過網路請求更新程式碼包。不過,只要 React Native 官方版本沒有發生變化,JavaScript 程式碼包中 React Native 原始碼相關的部分是不會發生變化的,所以我們不需要在每次業務包更新的時候都進行下發,在工程中內建一份就好了。

因此,我們在對JavaScript 程式碼進行打包的時候,需要講包拆分成兩個部分: 一個是Common 部分,也就是 React Native 原始碼部分;另一個是業務程式碼部分,也就是我們需要動態下載的部分。
在這裡插入圖片描述

經過上面的拆分後,Common 包內建到工程中(至少為幾百 kb 的大小),業務程式碼包進行動態下載。然後我們利用 JSContext 環境,在進入載體頁後在環境中先載入 Common 包,再載入業務程式碼包就可以完整的渲染出 React Native 頁面,下面是iOS原生部分的載入邏輯。

//載體頁
- (void)loadSourceForBridge:(RCTBridge *)bridge
                 onProgress:(RCTSourceLoadProgressBlock)onProgress
                 onComplete:(RCTSourceLoadBlock)loadCallback{
    if (!bridge.bundleURL) return;
    //載入新資源
    //開始載入bundle,先執行common bundle
    [RCTJavaScriptLoader loadCommonBundleOnComplete:^(NSError *error, RCTSource *source){
        loadCallback(error,newSource);
    }];
}

//common執行完畢
+ (void)commonBundleFinished{
    //開始執行buz bundle程式碼
     [RCTJavaScriptLoader loadBuzBundle:self.bridge.bundleURL onComplete:^(NSError *error, RCTSource *source){
        loadCallback(error,newSource);
    }];
}

//RCTJavaScriptLoader.mm
+ (void)loadBuzBundle:(NSURL *)buzURL
           onComplete:(WBSourceLoadBlock)onComplete{
    //執行buz包程式碼
    [self executeSource:buzURL onComplete:^(NSError *error){
      //執行完畢        
      onComplete(error);
    }];
}

五、按需載入

其實我們通過前面拆包的方案,已經減少了動態下載的業務程式碼包的大小。但是還會存在部分業務非常龐大,拆包後業務程式碼包的大小依然很大的情況,依然會導致下載速度較慢,並且還會受網路情況的影響。

因此,我們可以再次針對業務程式碼包進行拆分,將一個業務程式碼包拆分為一個主包和多個子包的方式。在進入頁面後優先請求主包的 JavaScript 程式碼資源,能夠快速地渲染首屏頁面,緊接著使用者點選某一個模組時,再繼續下載對應模組的程式碼包並進行渲染,就能再進一步減少載入時間。
在這裡插入圖片描述

那麼,什麼時候需要把業務程式碼包拆分成一個主包和多個子包呢?把什麼模組作為主包,什麼模組作為子包比較合適呢?

其實,當業務邏輯比較簡單的時候,我們並不需要對業務程式碼包進行拆分,當時當業務比較複雜的時候,特別是一些大型的專案就有可能需要進行拆包,而拆包的邏輯,通常是按照業務進行拆分的。舉個例子,我們有一下這個包含 Tab 的業務頁面。
在這裡插入圖片描述

可以看到,頁面的首頁包含三個 Tab,分別表示三個不同的業務模組。如果這三個 Tab 中的內容相似,我們當然就不需要對業務程式碼包進行拆分了。但是如果這三個 Tab 中的內容差異化較大,頁面模版完全不相同,我們就可以對業務程式碼包進行拆分。

六、其他優化

在 React Native 移動端的效能優化中,除了 React Native 環境建立、bundle 檔案、介面資料等方面的優化外,還有一個大的優化點,就是 React Native 執行時優化。

眾所周知,React Native 舊版本的執行效率有兩大痛點:一是 JSC 引擎解釋執行 JavaScript 程式碼效率低,引擎啟動速度慢;二是 JavaScript 與 Native 通訊效率低,特別是涉及批量地 UI 互動更是如此。

所以,React Native 新架構採用了 JSI 進行通訊,替換了 JSBridge,無非同步地序列化與反序列化操作、無記憶體拷貝,可以做到同步通訊。

除此之外,React Native 0.60 及以後的版本開始支援 Hermes 引擎。對比 JSC 引擎,Hermes 引擎在啟動速度、程式碼執行效率上都有大幅提升,所以接下來我們就來重點講解 Hermes 引擎的特點、它的優化手段以及如何在移動端啟用。

6.1 開啟Hermes 引擎

Facebook 在 ChainReact 2019 大會上正式推出了新一代 JavaScript 執行引擎 Hermes。Hermes 是個輕量級的 JavaScript 引擎,專門對移動端上執行 React Native 進行了優化,Hermes 可執行位元組碼,也可以執行 JavaScript。

在分析效能資料時,Facebook 團隊發現 JavaScript 引擎是影響啟動效能和應用包體積的重要因素。JavaScriptCore 最初是為桌面瀏覽器端設計,相較於桌面端,移動端能力有太多的限制。所以,為了能從底層對移動端進行效能優化,Facebook 團隊選擇自建 JavaScript 引擎 Hermes。

依據Chain React 大會上官方給出了 Hermes 引擎一組資料,可以看出Hermes確實強大:
從頁面啟動到使用者可操作的時間長短(Time To Interact:TTI),從 4.3s 減少到 2.01s;
App 的下載大小,從 41MB 減少到 22MB;
記憶體佔用,從 185MB 減少到 136MB。

Hermes 的優化主要體現在位元組碼預編譯和放棄 JIT 這兩點上。首先,來看下位元組碼預編譯。現代主流的 JavaScript 引執行一段 JavaScript 程式碼的大概流程是:【讀取原始碼檔案】 ->【 解析轉換成位元組碼】 ->【 執行位元組碼】。

不過,在執行時解析原始碼轉換位元組碼是一種時間浪費,所以 Hermes 選擇預編譯的方式在編譯期間生成位元組碼。這樣做,一方面避免了不必要的轉換時間;另一方面,多出的時間可以用來優化位元組碼,從而提高執行效率。
在這裡插入圖片描述

第二點是放棄了 JIT。為了加快執行效率,現在主流的 JavaScript 引擎都會使用一個 JIT 編譯器,在執行時通過轉換成機器碼的方式優化 JavaScript 程式碼。Faceback 團隊認為 JIT 編譯器主要有兩個問題:
要在啟動時候預熱,對啟動時間有影響;
會增加引擎 size 大小和執行時記憶體消耗。

但是這裡需要注意一點,放棄了 JIT,純文字 JavaScript 程式碼執行效率會降低。放棄 JIT,是指放棄執行時 Hermes 引擎對純文字 JavaScript 程式碼的編譯優化。當然,Hermes 也會帶來一些問題,首先就是 Hermes 編譯的位元組碼檔案比純文字 JavaScript 檔案增大不少,第二點就是執行純文字 JavaScript 耗時長。

那我們如何開啟 Hermes 呢,除了可以參考官方文件快速開啟 Hermes,下面我們重點看一下如何在混合工程中開啟 Hermes 引擎,以 Android 為例。

1,第一步,獲取 hermes.aar 檔案 (目錄node_modules/hermes-engine)。
在這裡插入圖片描述

2,第二步,將 hermes-cppruntime-release.aar 與 hermes-release.aar 放到工程的 libs 目錄總,然後在模組的 build.gradle 中新增依賴,這兩個 aar 中主要是 hermes 和 libc++_shared的so檔案。

dependencies {
    implementation(name:'hermes-cppruntime-release', ext:'aar')
    implementation(name:'hermes-release', ext:'aar')
}

3,第三步,設定 JavaScript 引擎。

ReactInstanceManagerBuilder builder = ReactInstanceManager.builder()
    .setApplication((Application) context.getApplicationContext())
    .addPackage(new MainReactPackage()) 
    .setRedBoxHandler(mExceptionHandler)
    .setUseDeveloperSupport(RNDebugSwitcher.getInstance().isDebug())
    .setInitialLifecycleState(LifecycleState.BEFORE_CREATE)
    .setJavaScriptExecutorFactory(new HermesExecutorFactory()); // 設定為 hermes

最後,執行 hermes 編譯出的位元組碼 bundle 檔案即可。而這一步又分為了幾個小步驟:
將 JavaScript 打包成 bundle 檔案。

 react-native bundle --platform android --entry-file index.android.js 
--bundle-output ./bundles/index.android.bundle --assets-dest ./bundles 
--dev false

使用 hermes-engine 將 bundle 檔案轉換成位元組碼檔案。下載 hermes-engine,使用 hermesc 命名進行轉換。

 ./hermesc -emit-binary -out index.android.bundle.hbc 
xxx/react-native/app/bundles/index.android.bundle

最後,還需要重新命名 bundle 檔案。做法是,將之前 bundle 目錄下的 index.android.bundle 刪掉,然後將當前的 index.android.bundle.hbc 重新命名為 index.android.bundle。

6.2 引擎複用

在混合應用中,React Native 由應用級的使用變更為頁面級,每一個頁面都使用一個 React Native 引擎 (包括 JSC/Hermes、Bridge/JSI),除了記憶體佔用高以外,React Native 引擎的建立耗時也是比較嚴重的。因此React Native另一個常見的優化就是引擎複用優化。

以 Android 為例,React Native 引擎的直接表現就是 ReactInstanceManager,內部會初始化 React Native 相關的環境。而在混合應用中,一般會配合熱更新策略進行頁面載入,所以使用的是 JSC/Hermes 動態載入指令碼的能力。從這個場景來看,似乎一個引擎可以執行不同的 bundle 檔案,即可達到複用的目的。引擎複用的坑也非常多,比如常見的有如下幾個:

  • 建立和複用引擎的成本可能會導致不少頁面,第一次進入和後續進入的速度,表現不一致,因此這類體驗問題還需要專項排查並優化;
  • 在多頁面同時在前臺的狀態下,比如首頁 TAB 不同頁面使用的都是 React Native 頁面,會存在莫名的同步問題;
  • 複用 React Native 容器內容時,會保持上一次會話的全域性變數,容易造成業務邏輯錯誤。同一個引擎載入不同 bundle,JavaScript 上下文與新載入進去的程式碼能否實現 100% 隔離無汙染可能是未知數。同時多頁面 JavaScript 上下文隔離。目前引起復用的一大坑其實來源於 JavaScript 上下文多個頁面混在一起,容易出錯;
  • JSC/Hermes 隨時有可能發生不可逆轉的異常,因此引擎維護的過程中異常狀態識別也是一個問題。

以上就是今天講的React Native優化的一些常見點,包括環境預建立、非同步更新、介面預快取、拆包、按需載入、Hermes 引擎、引擎複用等。這些手段在實際業務中非常實用,當然 React Native 框架也在從自身上不斷優化、迭代,追求效能的更高水平。