一種強大、可靠的React Native拆包以及熱更新方案,基於CodePush,Metro

背景需求

由於須要將各業務線經過劃分jsbundle的形式進行分離,以達到node

  • 各個業務包獨立更新、回滾以及版本管控
  • 增量加載,優化啓動速度
  • 優化增量更新,只對單獨某一業務包增量更新

案例參考

參考了攜程以及各類網絡版本的作法,大體總結爲三種react

  • 修改RN打包腳本,使其支持打包時生成基礎包以及業務包,併合理分配moduleID(攜程方案)
    • 優勢:定製化高,性能優化好,能夠作到增量加載
    • 缺點:維護成本高,對RN源碼侵入性大,兼容性差
  • 不修改打包腳本,純粹經過diff工具來拆分基礎包與業務包,加載前再粘合起來而後加載
    • 優勢:簡易便於維護,開發量小,不須要更改RN源碼
    • 缺點:定製化弱,對性能有必定影響,沒法增量加載
  • 基於Metro配置來自定義生成的ModuleId,以達到拆分基礎,業務包目的
    • 優勢:維護成本低,不須要更改RN打包源碼,兼容性好
    • 缺點:暫未發現

綜上所述,js端的bundle拆分用第三種方案最優android

JSBundle拆分

由於Metro官方文檔過於簡陋,實在看不懂,因此借鑑了一些使用Metro的項目git

好比(感謝開原做者的貢獻):github.com/smallnew/re…github

這個項目較爲完整,簡要配置下就能夠直接使用,因此js端拆包主要參考自這個項目,經過配置Metro的createModuleIdFactory,processModuleFilter回調,咱們能夠很容易的自定義生成moduleId,以及篩選基礎包內容,來達到基礎業務包分離的目的,由於實際上拆分jsbundle主要工做也就在於moduleId分配以及打包filter配置,咱們能夠觀察下打包後的js代碼結構json

經過react-native bundle --platform android --dev false --entry-file index.common.js --bundle-output ./CodePush/common.android.bundle.js --assets-dest ./CodePush --config common.bundle.js --minify false指令打出基礎包(minify設爲false便於查看源碼)react-native

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兩個變量,賦值了兩個方法metroRequiredefine,具體邏輯也很簡單,define至關於在表中註冊,require至關於在表中查找,js代碼中的importexport編譯後就就轉換成了__d__r,再觀察一下原生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的規則就是自增,因此這裏要替換成咱們本身的配置邏輯,咱們要作拆包就須要保證這個id不能重複,可是這個id只是在打包時生成,若是咱們單獨打業務包,基礎包,這個id的連續性就會丟失,因此對於id的處理,咱們仍是能夠參考上述開源項目,每一個包有十萬位間隔空間的劃分,基礎包從0開始自增,業務A從1000000開始自增,又或者經過每一個模塊本身的路徑或者uuid等去分配,來避免碰撞,可是字符串會增大包的體積,這裏不推薦這種作法。因此總結起來js端拆包仍是比較容易的,這裏就再也不贅述緩存

CodePush改造(代碼爲Android端,iOS端相似)

用過CodePush的同窗都能感覺到它強大的功能以及穩定的表現,更新,回滾,強更,環境管控,版本管控等等功能,越用越香,可是它不支持拆包更新,若是本身從新實現一套功能相似的代價較大,因此我嘗試經過改造來讓它支持多包獨立更新,來知足咱們拆包的業務需求,改造原則:安全

  • 儘可能不入侵其單個包更新的流程
  • 基於現有的邏輯基礎增長多包更新的能力,不會對其本來流程作更改

經過閱讀源碼,咱們能夠發現,只要隔離了包下載的路徑以及每一個包本身的狀態信息文件,而後對多包併發更新時,作一些同步處理,就能夠作到多包獨立更新

改造後的包存放路徑如上圖所示

app.json文件存放包的信息,由檢測更新的接口返回以及本地邏輯寫入的一些信息,好比hash值,下載url,更新包的版本號,bundle的相對路徑(本地代碼寫入)等等

codepush.json會記錄當前包的hash值以及上一個包的hash值,用於回滾,因此正常來說一個包會有兩個版本,上一版本用於備份回滾,回滾成功後會刪除掉當前版本,具體邏輯能夠自行閱讀了解,因此我這裏總結一下改動

Native改動:

主要改動爲增長pathPrefix和bundleFileName兩個傳參,用於分離bundle下載的路徑

增長了bundleFileName和pathPrefix參數的方法有

  • downloadUpdate(final ReadableMap updatePackage, final boolean notifyProgress, String pathPrefix, String bundleFileName)
  • getUpdateMetadata(String pathPrefix, String bundleFileName, final int updateState)
  • getNewStatusReport(String pathPrefix, String bundleFileName) {
  • installUpdate(final ReadableMap updatePackage, final int installMode, final int minimumBackgroundDuration, String pathPrefix, String bundleFileName)
  • restartApp(boolean onlyIfUpdateIsPending, String pathPrefix, String bundleFileName)
  • downloadAndReplaceCurrentBundle(String remoteBundleUrl, String pathPrefix, String bundleFileName) (該方法未使用)

只增長了pathPrefix參數的方法有

  • isFailedUpdate(String packageHash, String pathPrefix)
  • getLatestRollbackInfo(String pathPrefix)
  • setLatestRollbackInfo(String packageHash, String pathPrefix)
  • isFirstRun(String packageHash, String pathPrefix)
  • notifyApplicationReady(String pathPrefix)
  • recordStatusReported(ReadableMap statusReport, String pathPrefix)
  • saveStatusReportForRetry(ReadableMap statusReport, String pathPrefix)
  • clearUpdates(String pathPrefix) (該方法未使用)

對更新包狀態管理的改動

由於官方代碼只對單個包狀態作管理,因此這裏咱們要改成支持對多個包狀態作管理

  • sIsRunningBinaryVersion:標識當前是否運行的初始包(未更新),改爲用數組或者map記錄
  • sNeedToReportRollback:標識當前包是否須要彙報回滾,改動如上
  • 一些持久化存儲的key,須要增長pathPrefix字段來標識是哪個包的key

對初始ReactRootView的改動

由於拆包後,對包的加載是增量的,因此咱們在初始化業務場景A的ReactRootView時,增量加載業務A的jsbundle,其餘業務場景同理,獲取業務A jsbundle路徑須要藉助改造後的CodePush方法,經過傳入bundleFileName,pathPrefix

  • CodePush.getJSBundleFile("buz.android.bundle.js", "Buz1")

對更新過程當中包加載流程的改動

官方代碼爲更新到新的bundle後,加載完bundle即從新建立整個RN環境,拆包後此種方法不可取,若是業務包更新完後,從新加載業務包而後再重建RN環境,會致使基礎包代碼丟失而報錯,因此增長一個只加載jsbundle,不重建RN環境的方法,在更新業務包的時候使用

好比官方更新代碼爲:

CodePushNativeModule#loadBundle方法

private void loadBundle(String pathPrefix, String bundleFileName) {
    try {
        // #1) Get the ReactInstanceManager instance, which is what includes the
        //     logic to reload the current React context.
        final ReactInstanceManager instanceManager = resolveInstanceManager();
        if (instanceManager == null) {
            return;
        }
    
        String latestJSBundleFile = mCodePush.getJSBundleFileInternal(bundleFileName, pathPrefix);
    
        // #2) Update the locally stored JS bundle file path
        setJSBundle(instanceManager, latestJSBundleFile);
    
        // #3) Get the context creation method and fire it on the UI thread (which RN enforces)
        new Handler(Looper.getMainLooper()).post(new Runnable() {
            @Override
            public void run() {
                try {
                    // We don't need to resetReactRootViews anymore // due the issue https://github.com/facebook/react-native/issues/14533 // has been fixed in RN 0.46.0 //resetReactRootViews(instanceManager); instanceManager.recreateReactContextInBackground(); mCodePush.initializeUpdateAfterRestart(pathPrefix); } catch (Exception e) { // The recreation method threw an unknown exception // so just simply fallback to restarting the Activity (if it exists) loadBundleLegacy(); } } }); } catch (Exception e) { // Our reflection logic failed somewhere // so fall back to restarting the Activity (if it exists) CodePushUtils.log("Failed to load the bundle, falling back to restarting the Activity (if it exists). " + e.getMessage()); loadBundleLegacy(); } } 複製代碼

改造爲業務包增量加載,基礎包才重建ReactContext

if ("CommonBundle".equals(pathPrefix)) {
                new Handler(Looper.getMainLooper()).post(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            // We don't need to resetReactRootViews anymore // due the issue https://github.com/facebook/react-native/issues/14533 // has been fixed in RN 0.46.0 //resetReactRootViews(instanceManager); instanceManager.recreateReactContextInBackground(); mCodePush.initializeUpdateAfterRestart(pathPrefix); } catch (Exception e) { // The recreation method threw an unknown exception // so just simply fallback to restarting the Activity (if it exists) loadBundleLegacy(); } } }); } else { JSBundleLoader latestJSBundleLoader; if (latestJSBundleFile.toLowerCase().startsWith("assets://")) { latestJSBundleLoader = JSBundleLoader.createAssetLoader(getReactApplicationContext(), latestJSBundleFile, false); } else { latestJSBundleLoader = JSBundleLoader.createFileLoader(latestJSBundleFile); } CatalystInstance catalystInstance = resolveInstanceManager().getCurrentReactContext().getCatalystInstance(); latestJSBundleLoader.loadScript(catalystInstance); mCodePush.initializeUpdateAfterRestart(pathPrefix); } 複製代碼

啓動業務ReactRootView時增量加載jsbundle的邏輯同上

對JS端的改動

  • CodePush.sync(options): options增長bundleFileName,pathPrefix參數,由業務代碼傳遞進來而後傳遞給native
  • 將上述參數涉及到的方法,改形成可以傳遞給Native method
  • CodePush.sync方法官方不支持多包併發,碰到有重複的sync請求會將重複的丟棄,這裏咱們須要用一個隊列將這些重複的任務管理起來,排隊執行(爲了簡易安全,暫時不作並行更新,儘可能改形成串行更新)

CodePush#sync代碼

const sync = (() => {
  let syncInProgress = false;
  const setSyncCompleted = () => { syncInProgress = false; };
  return (options = {}, syncStatusChangeCallback, downloadProgressCallback, handleBinaryVersionMismatchCallback) => {
    let syncStatusCallbackWithTryCatch, downloadProgressCallbackWithTryCatch;
    if (typeof syncStatusChangeCallback === "function") {
      syncStatusCallbackWithTryCatch = (...args) => {
        try {
          syncStatusChangeCallback(...args);
        } catch (error) {
          log(`An error has occurred : ${error.stack}`);
        }
      }
    }

    if (typeof downloadProgressCallback === "function") {
      downloadProgressCallbackWithTryCatch = (...args) => {
        try {
          downloadProgressCallback(...args);
        } catch (error) {
          log(`An error has occurred: ${error.stack}`);
        }
      }
    }

    if (syncInProgress) {
      typeof syncStatusCallbackWithTryCatch === "function"
        ? syncStatusCallbackWithTryCatch(CodePush.SyncStatus.SYNC_IN_PROGRESS)
        : log("Sync already in progress.");
      return Promise.resolve(CodePush.SyncStatus.SYNC_IN_PROGRESS);
    }

    syncInProgress = true;
    const syncPromise = syncInternal(options, syncStatusCallbackWithTryCatch, downloadProgressCallbackWithTryCatch, handleBinaryVersionMismatchCallback);
    syncPromise
      .then(setSyncCompleted)
      .catch(setSyncCompleted);

    return syncPromise;
  };
})();
複製代碼

改造後

const sync = (() => {
  let syncInProgress = false;
  //增長一個管理併發任務的隊列
  let syncQueue = [];
  const setSyncCompleted = () => {
    syncInProgress = false;
    回調完成後執行隊列裏的任務
    if (syncQueue.length > 0) {
      log(`Execute queue task, current queue: ${syncQueue.length}`);
      let task = syncQueue.shift(1);
      sync(task.options, task.syncStatusChangeCallback, task.downloadProgressCallback, task.handleBinaryVersionMismatchCallback)
    }
  };

  return (options = {}, syncStatusChangeCallback, downloadProgressCallback, handleBinaryVersionMismatchCallback) => {
    let syncStatusCallbackWithTryCatch, downloadProgressCallbackWithTryCatch;
    if (typeof syncStatusChangeCallback === "function") {
      syncStatusCallbackWithTryCatch = (...args) => {
        try {
          syncStatusChangeCallback(...args);
        } catch (error) {
          log(`An error has occurred : ${error.stack}`);
        }
      }
    }

    if (typeof downloadProgressCallback === "function") {
      downloadProgressCallbackWithTryCatch = (...args) => {
        try {
          downloadProgressCallback(...args);
        } catch (error) {
          log(`An error has occurred: ${error.stack}`);
        }
      }
    }

    if (syncInProgress) {
      typeof syncStatusCallbackWithTryCatch === "function"
        ? syncStatusCallbackWithTryCatch(CodePush.SyncStatus.SYNC_IN_PROGRESS)
        : log("Sync already in progress.");
      //檢測到併發任務,放入隊列排隊
      syncQueue.push({
        options,
        syncStatusChangeCallback,
        downloadProgressCallback,
        handleBinaryVersionMismatchCallback
      });
      log(`Enqueue task, current queue: ${syncQueue.length}`);
      return Promise.resolve(CodePush.SyncStatus.SYNC_IN_PROGRESS);
    }

    syncInProgress = true;
    const syncPromise = syncInternal(options, syncStatusCallbackWithTryCatch, downloadProgressCallbackWithTryCatch, handleBinaryVersionMismatchCallback);
    syncPromise
      .then(setSyncCompleted)
      .catch(setSyncCompleted);

    return syncPromise;
  };
})();
複製代碼
  • notifyApplicationReady: 官方代碼這個方法只會執行一次,主要用於更新以前初始化一些參數,而後緩存結果,後續調用直接返回緩存結果,因此這裏咱們要改形成不緩存結果,每次都執行

後續

該方案主流程已經ok,多包併發更新,單包獨立更新基本沒有問題,如今還在邊界場景以及壓力測試當中,待方案健壯後再上源碼作詳細分析

該方案一樣知足自建server的需求,關於自建server能夠參考:github.com/lisong/code…

再次感謝開源做者的貢獻

相關文章
相關標籤/搜索