React Native 拆包原理和實踐

持續(xù)完善中...

一、拆包關(guān)鍵之bridge

1、bridge原理

RCTBridge是對JavaScriptCore中Bridge的封裝,每個bridge都是一個獨立的js環(huán)境。

RN的啟動流程可以簡單概括為:

  • Native編譯并啟動
  • 創(chuàng)建js虛擬機環(huán)境
  • 創(chuàng)建 bridge,擁有獨立的context js運行環(huán)境,并負責原生和js線程的通信(通過不同bridge加載的js代碼,可以存在相同的全局變量,不會沖突)
  • 通過 bridge 獲取js線程來解析js代碼(可以是遠程包和離線包)
  • 運行js代碼,并根據(jù)參數(shù)創(chuàng)建 RootView

bridge在RN中起到承上啟下的作用,在做RN拆包的時候是重點考慮的對象。目前RN拆包針對brdige有兩種主流方案,分別是單bridge和多bridge。

2、單bridge和多bridge的選擇

優(yōu)勢 劣勢
不用管理bridge的緩存和復(fù)用問題 不重啟APP的情況下想要更新bundle需要做更多的配置,比較繁瑣,且更新bundle并不會清除bridge中的舊bundle,存在少量內(nèi)存浪費
占用內(nèi)存更少 由于不同模塊都是運行在同一個bridge環(huán)境中,如果存在相同的全局變量會造成代碼污染
優(yōu)勢 劣勢
不同模塊之間使用了bridge隔離,不用擔心全局變量污染的問題 由于bridge很占用內(nèi)存,所以需要手動維護bridge的緩存和復(fù)用問題,避免APP內(nèi)存溢出(CRN維護了5個上限的bridge)
不重啟APP的情況下更新bundle很方便,只需要重新指定路徑加載或者執(zhí)行reload 占用內(nèi)存多

二、基礎(chǔ)包和業(yè)務(wù)包的拆分

1、metro 介紹和打包流程

react-native metro 分析
metro是一種支持ReactNative的打包工具,我們現(xiàn)在也是基于他來進行拆包的,metro打包流程分為以下幾個步驟:

  • Resolution:Metro需要從入口點構(gòu)建所需的所有模塊的圖,要從另一個文件中找到所需的文件,需要使用Metro解析器。在現(xiàn)實開發(fā)中,這個階段與Transformation階段是并行的。
  • Transformation:所有模塊都要經(jīng)過Transformation階段,Transformation負責將模塊轉(zhuǎn)換成目標平臺可以理解的格式(如React Naitve)。模塊的轉(zhuǎn)換是基于擁有的核心數(shù)量來進行的。
  • Serialization:所有模塊一經(jīng)轉(zhuǎn)換就會被序列化,Serialization會組合這些模塊來生成一個或多個包,包就是將模塊組合成一個JavaScript文件的包,序列化的時候提供了一些列的方法讓開發(fā)者自定義一些內(nèi)容,比如模塊id,模塊過濾等。

觀察一下原生Metro代碼的node_modules/metro/src/lib/createModuleIdFactory.js文件,代碼為:

function createModuleIdFactory() {
  const fileToIdMap = new Map();
  let nextId = 0;
  return path => {
    let id = fileToIdMap.get(path);

    if (typeof id !== "number") {
      id = nextId++;
      fileToIdMap.set(path, id);
    }

    return id;
  };
}

module.exports = createModuleIdFactory;

邏輯比較簡單,如果查到map里沒有記錄這個模塊則id自增,然后將該模塊記錄到map中,所以從這里可以看出,官方代碼生成moduleId的規(guī)則就是自增,所以這里要替換成我們自己的配置邏輯,我們要做拆包就需要保證這個id不能重復(fù),但是這個id只是在打包時生成,如果我們單獨打業(yè)務(wù)包,基礎(chǔ)包,這個id的連續(xù)性就會丟失,所以對于id的處理,我們還是可以參考上述開源項目,每個包有十萬位間隔空間的劃分,基礎(chǔ)包從0開始自增,業(yè)務(wù)A從1000000開始自增,又或者通過每個模塊自己的路徑或者uuid等去分配,來避免碰撞,但是字符串會增大包的體積,這里不推薦這種做法。所以總結(jié)起來js端拆包還是比較容易的,這里就不再贅述

2、Plain Bundle 分析

通過react-native bundle --platform android --dev false --entry-file index.common.js --bundle-output {輸出bundle的路徑} --assets-dest {資源路徑} --config {自定義打包配置} --minify false 打出基礎(chǔ)包(minify設(shè)為false便于查看源碼)

function (global) {
  "use strict";

  global.__r = metroRequire;
  global.__d = define;
  global.__c = clear;
  global.__registerSegment = registerSegment;
  var modules = clear();
  var EMPTY = {};
  var _ref = {},
      hasOwnProperty = _ref.hasOwnProperty;

  function clear() {
    modules = Object.create(null);
    return modules;
  }

  function define(factory, moduleId, dependencyMap) {
    if (modules[moduleId] != null) {
      return;
    }

    modules[moduleId] = {
      dependencyMap: dependencyMap,
      factory: factory,
      hasError: false,
      importedAll: EMPTY,
      importedDefault: EMPTY,
      isInitialized: false,
      publicModule: {
        exports: {}
      }
    };
  }

  function metroRequire(moduleId) {
    var moduleIdReallyIsNumber = moduleId;
    var module = modules[moduleIdReallyIsNumber];
    return module && module.isInitialized ? module.publicModule.exports : guardedLoadModule(moduleIdReallyIsNumber, module);
  }

這里主要看__r,__d兩個變量,賦值了兩個方法metroRequire,define,具體邏輯也很簡單,define相當于在表中注冊,require相當于在表中查找,js代碼中的import,export編譯后就就轉(zhuǎn)換成了__d與__r

三、拆包的后遺癥

1、按序加載基礎(chǔ)包和業(yè)務(wù)包

將RN的js業(yè)務(wù)拆出了公共模塊之后,在bridge加載bundle的時候需要優(yōu)先加載common包。這里需要考慮兩個問題:

  • RCTBridge需要疊加加載bundle
    由于RCTBridge并沒有提供多次加載bunlde的方法,但是其內(nèi)部又一個私有方法實現(xiàn)了該功能(- (void)executeSourceCode:(NSData *)sourceCode sync:(BOOL)sync;),在iOS中我們可以通過Category的方式將該方法暴露出來
  • bundle加載完成獲取回調(diào)
    我們必須要在common bunlde加載完成之后再去加載業(yè)務(wù)模塊,所以我們需要獲取到bundle加載完成的回調(diào)。然而RCTBridge并沒有提供回調(diào)入口,但是其有一個loading屬性,我們可以使用一個do while循環(huán)阻塞線程,直到loading為false代碼再往下走

如果是多bridge方案,每個bridge都得先加載common包,再加載具體業(yè)務(wù)包,這樣會很浪費內(nèi)存。

2、熱更新改造

  • 單bridge熱更新
    單bridge的疊加加載問題已經(jīng)解決了,但是疊加加載并不會覆蓋已經(jīng)加載過的bundle包,如果在不重啟APP的情況下,單bridge將無法實現(xiàn)熱更新。解決辦法是在打更新包的時候,得更新需要熱更的bundle包的模塊ID,具體可參考:react-native實現(xiàn)不重啟App的情況下更新分包
    第二個問題是熱更之后資源路徑發(fā)生變化。需要制定熱更之后的bundle從沙盒加載資源,否則會出現(xiàn)資源文件找不到的問題。

  • 多bridge熱更新
    多bridge方案進行熱更時,無需考慮單bridge reload影響全局的問題,只需要reload當前需要更新的bridge就行,如果模塊劃分比較細,這樣做通常更有優(yōu)勢。

如果使用靜默升級,那么可以在下載完bundle包之后先不做替換或者reload,而是等到下一次進入APP的時候從新的路徑加載bundle,這樣做可以使用戶進行無感知的更新。

3、混合開發(fā)的路由方案

  • 純RN路由
    適用于純RN,使用react-navigation即可,僅需使用AppRegistry.registerComponent注冊一個根組件,只會存在一個VC或activity,所有的路由跳轉(zhuǎn)其實都是在同一個VC或activity內(nèi)跳轉(zhuǎn)。如果后期要擴展混合路由,純RN改造會比較大

  • 純Native路由
    每個RN頁面,都使用AppRegistry.registerComponent單獨注冊,然后在Native端利用注冊的組件創(chuàng)建的單獨的RootView,并最終創(chuàng)建單獨的VC承載。由于都使用Native路由,所以可以很方便的進行Native和RN路由的統(tǒng)一,管理一套路由表即可。但是如果項目中需要引入其他團隊開發(fā)的RN bundle包,其他團隊如果使用的是純RN路由,那么這個時候就不兼容了,所以純Native路由方式不太適合需要引入其他團隊開發(fā)的bundle的場景

  • 混合路由
    混合路由指的是有一部分Native路由,有一部分RN路由,攜程CRN目前走的就是混合路由路線。如果有些模塊需要在其他App內(nèi)復(fù)用,建議采用攜程的模式,他們對路由進行了優(yōu)化(沒開源),管理起來應(yīng)該會方便些。

4、路由表的調(diào)整

拆包之后路由表怎么維護呢?由于拆分成了多個bundle,路由表散落在了多個bundle中,不同bundle之間如何跳轉(zhuǎn)。如果路由名產(chǎn)生了沖突,就會導(dǎo)致跳轉(zhuǎn)異常和錯亂,所以這里就需要給每個路由加上一個所屬bundle標識。

5、多bundle的debug

各種操作拆完包后,突然有個問題,怎么調(diào)試呢?起初還想著怎么讓Native在初始化時直接加載全部bundle。但后來突然想明白,拆包的本質(zhì)就是通過設(shè)置多個入口文件將代碼給分割,那調(diào)試的時候我們直接將入口文件都在放在index.js里不就行了么。這樣就實現(xiàn)了跟RN單包一樣的調(diào)試。這個操作需要再js端提供一個引用所有模塊入口的文件,然后Native端設(shè)置debug標識來做bundle加載區(qū)分。

多bundle的情況下還嘗試過區(qū)分端口來獨立啟動和調(diào)試不同模塊,暫時不調(diào)試的模塊就加載本地一個提前打包好的bundle。但是實踐過程發(fā)現(xiàn)當開啟 Remote JS Debug 的時候,所有的bridge都會重新調(diào)用reload,那么這會導(dǎo)致什么問題嗎?

這里要說下Remote JS Debug的原理和command + Rcommand + D + Reload 的區(qū)別。

這是command + R 的源代碼

#if RCT_DEV
  RCTExecuteOnMainQueue(^{
    RCTRegisterReloadCommandListener(self);
  });
  #endif

void RCTRegisterReloadCommandListener(id<RCTReloadListener> listener)
{
  RCTAssertMainQueue(); // because registerKeyCommandWithInput: must be called on the main thread
  static dispatch_once_t onceToken;
  dispatch_once(&onceToken, ^{
    listeners = [NSHashTable weakObjectsHashTable];
    [[RCTKeyCommands sharedInstance] registerKeyCommandWithInput:@"r"
                                                   modifierFlags:UIKeyModifierCommand
                                                          action:
     ^(__unused UIKeyCommand *command) {
       RCTTriggerReloadCommandListeners();
     }];
  });
  [listeners addObject:listener];
}

void RCTTriggerReloadCommandListeners(void)
{
  RCTAssertMainQueue();
  // Copy to protect against mutation-during-enumeration.
  // If listeners hasn't been initialized yet we get nil, which works just fine.
  NSArray<id<RCTReloadListener>> *copiedListeners = [listeners allObjects];
  for (id<RCTReloadListener> l in copiedListeners) {
    [l didReceiveReloadCommand];
  }
}

開發(fā)環(huán)境會監(jiān)聽command + R鍵盤事件,一旦監(jiān)聽到指令就會遍歷所有注冊過得bridge,并執(zhí)行其didReceiveReloadCommand方法,最后調(diào)用reload方法。所以如果當前初始化了多個bridge,就會將注冊的bridge全都reload一遍,即使加載的是離線包的bridge,也會觸發(fā)一個8081端口的bridge,由于此時可能沒有開啟8081端口服務(wù),那么屏幕就會爆紅。

所以在多bridge方案中,如果要方便調(diào)試,要么在底層做改造,要么區(qū)分開發(fā)和正式場景,在開發(fā)場景使用單bridge方案。但這又造成了開發(fā)和正式環(huán)境的不一致問題,可能會出現(xiàn)開發(fā)環(huán)境正常,正式環(huán)境報錯的問題,很難定位。

參考文章

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容