背景需求
因為需要將各業務線通過劃分jsbundle的形式進行分離,以達到
- 各個業務包獨立更新、回滾以及版本管控
- 增量載入,優化啟動速度
- 優化增量更新,只對單獨某一業務包增量更新
案例參考
參考了攜程以及各種網路版本的做法,大致總結為三種
- 修改RN打包指令碼,使其支援打包時生成基礎包以及業務包,併合理分配moduleID(攜程方案)
- 優點:定製化高,效能優化好,可以做到增量載入
- 缺點:維護成本高,對RN原始碼侵入性大,相容性差
- 不修改打包指令碼,純粹通過diff工具來拆分基礎包與業務包,載入前再粘合起來然後載入
- 優點:簡易便於維護,開發量小,不需要更改RN原始碼
- 缺點:定製化弱,對效能有一定影響,無法增量載入
- 基於Metro配置來自定義生成的ModuleId,以達到拆分基礎,業務包目的
- 優點:維護成本低,不需要更改RN打包原始碼,相容性好
- 缺點:暫未發現
綜上所述,js端的bundle拆分用第三種方案最優
JSBundle拆分
因為Metro官方文件過於簡陋,實在看不懂,所以借鑑了一些使用Metro的專案
比如(感謝開原作者的貢獻):github.com/smallnew/re…
這個專案較為完整,簡要配置下就可以直接使用,所以js端拆包主要參考自這個專案,通過配置Metro的createModuleIdFactory,processModuleFilter回撥,我們可以很容易的自定義生成moduleId,以及篩選基礎包內容,來達到基礎業務包分離的目的,因為實際上拆分jsbundle主要工作也就在於moduleId分配以及打包filter配置,我們可以觀察下打包後的js程式碼結構
通過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便於檢視原始碼)
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
編譯後就就轉換成了__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) (該方法未使用)
對初始化CodePush.java類的更改
官方程式碼在例項化CodePush.java類的時候呼叫了initializeUpdateAfterRestart方法,裡面會初始化一些包資訊然後上報給伺服器包是否更新成功,拆包後會有多個包,所以這裡可以改成遍歷CodePush包快取的資料夾,來初始化每一個包的資訊
修改前:
initializeUpdateAfterRestart(CodePushConstants.CODE_PUSH_COMMON_BUNDLE_FOLDER_PREFIX)
修改後:
File codePushRoot = new File(context.getFilesDir().getAbsolutePath(), CodePushConstants.CODE_PUSH_FOLDER_PREFIX);
if (codePushRoot.exists()) {
for (String path : codePushRoot.list()) {
initializeUpdateAfterRestart(path);
}
} else {
initializeUpdateAfterRestart(CodePushConstants.CODE_PUSH_COMMON_BUNDLE_FOLDER_PREFIX);
}
複製程式碼
對更新包狀態管理的改動
因為官方程式碼只對單個包狀態做管理,所以這裡我們要改為支援對多個包狀態做管理
- 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);
// 因為業務包只增量載入未重建bridge,所以更新業務包時要通知JS端重新執行CodePush.sync方法,以正確上報更新狀態
WritableMap map = Arguments.createMap();
map.putString("pathPrefix", pathPrefix);
map.putString("bundleFileName", bundleFileName);
getReactApplicationContext().getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit(CODE_PUSH_RESTART_APP, map);
}
複製程式碼
啟動業務ReactRootView時增量載入jsbundle的邏輯同上,注意增量更新業務包時,這裡需要加上一個通知,通知給JS端呼叫CodePush.sync方法,以模擬重建bridge後,正確上報更新狀態的場景
對debug流程的改動
RN debug模式下,JSBundle由本地nodeJS伺服器打包生成,然後RN框架通過http請求拿到並載入,所以在debug模式下,需要禁止增量載入我們metro指令碼打出來的業務包,否則會因為載入兩種不同打包指令碼生成的包而導致模組id衝突,所以我們在開啟RN頁面容器時,增量載入bundle這裡邏輯做一個判斷,判斷debug模式下,有nodeJS伺服器下載下來的快取bundle,就不增量載入
if (BuildConfig.DEBUG && new File(mReactInstanceManager.getDevSupportManager().getDownloadedJSBundleFile()).exists()) {
return;
}
複製程式碼
對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…
再次感謝開源作者的貢獻