Background demand
This is because the business lines need to be separated in the form of jsbundles
- Each service package is updated, rolled back, and version controlled independently
- Incremental loading, optimized startup speed
- Optimize incremental updates for a single business package
Case reference
Referred to ctrip and various network versions of the practice, roughly summed up as three
- Modify RN packaging script to support generating base packages and service packages during packaging, and allocate moduleID reasonably (Ctrip solution)
- Advantages: High customization, good performance optimization, and incremental loading
- Disadvantages: high maintenance cost, large invasion of RN source code, poor compatibility
- Without modifying the packaging script, the base package and the business package are split purely through the diff tool, glued together before loading and then loading
- Advantages: easy to maintain, small amount of development, no need to change RN source code
- Disadvantages: Weak customization, poor performance, cannot incremental loading
- Based on Metro configuration, the generated ModuleId is customized to achieve the purpose of splitting the base and service package
- Advantages: low maintenance cost, no need to change RN package source code, good compatibility
- Disadvantages: Not found yet
To sum up, the third solution is optimal for bundle splitting on the JS side
JSBundle split
Because Metro’s official documentation was too sketchy to read, some projects using Metro were borrowed
For example (thanks for original author’s contribution) : github.com/smallnew/re…
This project is relatively complete and can be used directly under brief configuration, so js side unpacking mainly refers to this project. By configuring Metro’s createModuleIdFactory and processModuleFilter callback, we can easily customize the generation of moduleId. As a matter of fact, the main work of splitting JsBundle is moduleId allocation and package filter configuration. We can observe the js code structure after packaging
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 The false command prints the base package (minify is set to false to facilitate viewing the source code)
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);
}
Copy the code
MetroRequire, define, require, import, export in js code, convert to __d and __r after compiling. To look at the native code Metro node_modules/Metro/SRC/lib/createModuleIdFactory js file, the code is:
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;
Copy the code
The logic is simple. If the module is not recorded in the map, the ID will be automatically increased, and then the module will be recorded in the map. Therefore, it can be seen from here that the rule for generating moduleId in the official code is auto-increment, so we need to replace it with our own configuration logic here, and we need to ensure that the ID cannot be repeated when unpacking. However, this ID is only generated during packaging, and the continuity of this ID will be lost if we separate the business package and the base package. Therefore, we can refer to the above open source project to deal with ID. Each package has the partition of 100,000 bit interval space, the base package increases from 0, and the business A increases from 1000000. Alternatively, each module can be assigned its own path or UUID to avoid collisions, but strings can increase the size of the package, which is not recommended. So summed up JS side unpacking or relatively easy, here will not repeat
CodePush (code for Android, similar for iOS)
Used CodePush classmates can feel its powerful function and stable performance, update, rollback, strong more, environmental control, version control, and so on function, with more and more sweet, but it does not support unpacking update, if you to achieve a set of function, the price of similar so I try to make it through reforming support multiple independent update package, To meet our business needs of unpacking, transformation principles:
- Try not to invade its process of individual package updates
- The ability to add multiple package updates based on existing logic does not change its original process
Through reading the source code, we can find that multiple packages can be updated independently as long as the path of package download and each package’s own status information file are isolated, and then do some synchronization processing when updating multiple packages concurrently
The app.json file holds information about the package, returned by the interface that detects updates, and written by the local logic, such as the hash value, download URL, update package version number, relative path of the bundle (written by local code), and so on
Codepush. json records the hash value of the current package and the hash value of the previous package for rollback, so normally there are two versions of a package. The previous version is used for backup rollback, and the current version will be deleted after the rollback
Native changes:
The main change is to add pathPrefix and bundleFileName to separate the bundle download path
Methods that add bundleFileName and pathPrefix parameters are
- 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) (this method is not used)
Methods that only add the pathPrefix parameter are
- 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) (this method not used)
Changes to initialize the CodePush.java class
Official code in instantiation CodePush. Java class called when initializeUpdateAfterRestart method, it can initialize some package information and then report to the server package is updated, there will be multiple packages after unpacking, So instead, you can iterate through the CodePush package cache folder to initialize the information for each package
Modify before: initializeUpdateAfterRestart (CodePushConstants CODE_PUSH_COMMON_BUNDLE_FOLDER_PREFIX) modified: 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);
}
Copy the code
Changes to update pack status management
Since the official code only manages a single package state, we will support multiple package states instead
- SIsRunningBinaryVersion: The initial package (not updated) that identifies whether it is currently running, instead using an array or map record
- SNeedToReportRollback: indicates whether the current package needs to be reported back
- For some persistent store keys, you need to add a pathPrefix field to identify which package key it is
Changes to the original ReactRootView
Since the package is incrementally loaded after unpacking, jsbundle of business A is incrementally loaded when ReactRootView of business scenario A is initialized. Similarly for other business scenarios, the modified CodePush method is required to obtain the jsbundle path of business A. By passing bundleFileName, pathPrefix
- CodePush.getJSBundleFile(“buz.android.bundle.js”, “Buz1”)
Changes to the package loading process during the update process
This method is not recommended after unpacking. If the service package is updated and the service package is reloaded and then the RN environment is rebuilt, the basic package code will be lost and an error will be reported. Therefore, a method that only loads jsBundle and does not rebuild the RN environment is added. Used when updating a business package
For example, the official update code is:
CodePushNativeModule# loadBundle method
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 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(); }}Copy the code
ReactContext is rebuilt by the base package after incremental loading by the business package
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 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); WritableMap map = arguments.createmap (); codepush.sync (); map.putString("pathPrefix", pathPrefix); map.putString("bundleFileName", bundleFileName); getReactApplicationContext().getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit(CODE_PUSH_RESTART_AP P, map); }Copy the code
The logic of incremental loading jsBundle when starting the service ReactRootView is the same as above. Note that incremental updating of the service package requires a notification to call CodePush. Sync on the JS side to simulate the correct reporting of update status after bridge reconstruction
Changes to the debug process
In RN debug mode, JSBundle is packaged and generated by the local nodeJS server, and then the RN framework gets it and loads it through HTTP request. Therefore, in RN debug mode, incremental loading of service packages produced by our Metro script should be prohibited. Otherwise, module IDS will conflict due to the loading of packages generated by two different packaging scripts. Therefore, when we start the RN page container, we will incrementally load the bundle. In this logic, we will not incrementally load the cache bundle downloaded from the nodeJS server in debug mode
if (BuildConfig.DEBUG && new File(mReactInstanceManager.getDevSupportManager().getDownloadedJSBundleFile()).exists()) {
return;
}
Copy the code
Changes to the JS side
- Codepush.sync (options): Options increases the bundleFileName, pathPrefix parameters, which are passed in by the business code and then passed to native
- Transform the method involved in the above parameters into one that can be transmitted to Native method
- Codepusher sync method officially does not support multi-packet concurrency, and duplicate sync requests will be discarded. Here, we need to use a queue to manage these duplicate tasks and queue them up for execution (for simplicity and safety, we do not do parallel update for the time being, and try to change to serial update).
CodePush# sync code
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);
returnsyncPromise; }; }) ();Copy the code
After transforming
const sync = (() => {
let syncInProgress = false; // Add a queue to manage concurrent taskslet syncQueue = [];
const setSyncCompleted = () => {
syncInProgress = false; Execute the tasks in the queue after the callback completesif (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."); // Concurrent tasks detected, In the queue 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);
returnsyncPromise; }; }) ();Copy the code
- NotifyApplicationReady: notifyApplicationReady: notifyApplicationReady: notifyApplicationReady: notifyApplicationReady: notifyApplicationReady: notifyApplicationReady: notifyApplicationReady: notifyApplicationReady: notifyApplicationReady: notifyApplicationReady
subsequent
The main process of the program has been OK, multi-package concurrent update, single package independent update basically no problem, now is still in the boundary scene and pressure test, to be robust after the program on the source code to do detailed analysis
The solution also meets the requirements of the self-built server. For details about the self-built server, see github.com/lisong/code…
Thanks again to open source authors for their contributions