React Native (2) : Subcontracting mechanism and dynamic delivery

preface

With the advent of Flutter, the popularity of React Native is gradually decreasing, and Facebook itself is also in the process of reconstructing React Native. As it stands, React Native requires developers to be able to understand the implementation mechanism of both clients in order to develop a complete application or become a part of it. Many dependencies need to be injected into the client code. Not to mention Bridges that bridge native and React Native.

In this context, React Native still has its place in many large apps. The hot update mechanism is still the most flexible and difficult advantage of React Native.

contrast

React Native means a Flutter. But it seems to me that they are working in different directions. Flutter wants to unify the development experience at both ends, allowing a set of code to run directly on both ends without modification. React Native solves the disadvantage of traditional Hybrid apps, which is the EXPERIENCE of H5. Even on high-end mobile devices, the H5 is hard to come close to a Native experience, let alone that most users are still using mid-range or even low-end devices.

In order to ensure the experience of these users, some flexibility had to be sacrificed and React Native solution was adopted to ensure dynamic update and downward compatibility. After all, most of the time, most of the functionality that the client needs to provide to H5 or React Native will be determined at the beginning of the connection. From the start of plugging into the Native version of React Native, you can make your code nearly painless compatible.

scenario

React Native can be used in the following scenarios:

  • The entire app was developed with React Native:

This approach works best for individual developers. It is impossible for mobile apps to abandon either platform, and learning objective-C/Swift and Kotlin/Java at the same time is too expensive for individual developers and does not allow for rapid iteration. As a result, React Native becomes a viable option and a good one. It can not only give users a better experience, but also ensure the efficiency of iteration. Of course, today, with Flutter becoming popular, more developers adopt Flutter instead of React Native for unified development at both ends. There are fewer holes in Flutter, and the experience at both ends is more unified, and the development experience is better than the current React Native version.

  • Partially dynamic:

This scheme has been implemented in many large apps, including the one with tens of millions of DAUs THAT I have come into contact with. In some business scenarios, we need both a standalone update that can be done independently of the client version and a good user experience. In this case, React Native is needed for development. The React Native RCTRootView is usually mounted on a view of the client, and the entire view is rendered by React Native.

In this scenario, a certain business module is developed entirely using React Native. The basic libraries of React Native are put into one bundle through the subcontracting mechanism, and then the business code is put into another bundle. The two bundles are updated independently. Because the base library is not updated frequently, the business code may be updated more frequently.

Subcontracting and distribution will be discussed later.

  • (extreme) dynamic — follow the data down:

This solution is not a common one, but a solution we found according to our own business scenarios and a solution we came up with by ourselves.

This is similar to the previous solution, but sometimes you don’t need the entire view to be React Native to manage it, because React Native’s long list performance isn’t very good. As a feed stream, this kind of scenario can cause major problems, such as the Memory consumption of Android models, or crash and flash back.

Also, sometimes the content in the feed stream is very dynamic, and if you publish to meet the requirements, you may not be able to keep up with the pace.

For example, insert a bizarre AD where the arrow points (gold nuggets, for example, image deletion)

Therefore, we considered rendering some highly dynamic cells in the feed stream through React Native. The original scheme is consistent with the previous one. We subcontract the code of the business cell and package it in the business package. If a new cell is available, update the business package to enable the user to render the new cell content.


If this is too ordinary ~~


First of all, we should consider the problems existing in such delivery. When we start the app and enter the feed, our app detects changes in the business package, and then goes to CDN to pull the business package, load it, and execute the JavaScript code in the business package through JSCore. So we can render. There seems to be no problem, but since other modules of the feed stream use Native for rendering, the React Native cell will go blank for a long time when the service package pull speed is slow once updates are generated. Other cells may render incorrectly due to an error in one cell module of the package.

Therefore, we adopted a new scheme: separate the business package of each cell, zip the bundle, encode it in Base64, and then deliver it along with the rendering data of each cell.

The benefits of this are:

  1. Bundle As service data is delivered, the decompression and loading time of each service package is very short because each service package is very small. Data loading is basically guaranteed and cell rendering can be completed.

  2. Each bundle is scoped separately, and a bundle error does not affect the execution of other bundle code.

  3. Bundle and cell services correspond one by one. If the style or function of a cell needs to be updated, you only need to configure a new bundle and store it. When the background delivers new data, it can directly pull the new bundle, without updating the whole service package for each one.

Of course, there is no silver bullet for almost any problem, and this solution has its own problems, which I will describe at the end of the article.

The subcontract

React Native’s dynamic solution is difficult to separate from a subcontract. React Native has a large base library. In addition, we need some dependencies, such as the react-native vector-icons common dependencies, which do not change often. These dependencies need to be separated from the business code that changes frequently, and the size of the business code needs to be compressed. Ensure that the business package is optimally updated.

metro

React Native has long provided Metro for bundle subcontracting. To use metro for packaging, you need to configure a metro.config.js file for subcontracting.

Here is the official configuration document

The document seems to have a lot of options, but many are specific to a particular scenario.

There are two main options we need for subcontracting:

  • createModuleIdFactoryThis function passes in the absolute path of the module file to be packaged and returns the ID generated when the module was packaged.
  • processModuleFilterThis function passes module information and returns a Boolean,falseIt means that the file does not enter the current package.

The sub-contract strategy

Our subcontracting strategy is as follows:

  • common.bundle: Breaks into all public dependency libraries. This dependency library is delivered with the client version without hot update.
  • business.bundle: business code base for large business modules. The number of packages is related to the number of business modules, as described in section 1Partially dynamicThe business package used in the scenario.
  • RN-xxx.bundle: Service packages of different cells in the feed flow. There can be multiple service packages of different cells. That’s what we said in the first verseFollowing data DeliveryThe business package used in the scenario.

Common. bundle and Business. bundle are pre-packaged in the client code because these two packages are large, while Business. bundle supports dynamic delivery. Common. bundle is too large, so it’s better to put it in the client code, otherwise it’s too expensive to deliver. Also, most of the time, if you need to add a new React Native dependency, you will have to add the corresponding client dependency code to the client. So it makes sense for common.bundle to follow the client release.

The subcontract

Main package (common.bundle)

Because Metro is configured to separate dependencies, first introduce the code that needs to be packaged into common.bundle into a file:

// common.js
import {} from 'react';
import {} from 'react-native';
import {} from 'react-redux';
import Sentry from 'react-native-sentry'; // You can also add some common code, such as unified monitoring, sentry.config ('dsn').install();
Copy the code

Accordingly, common.bundle needs to have a configuration file:

'use strict';

const fs = require('fs');
const pathSep = require('path').sep;

function manifest (path) {
    if (path.length) {
        const manifestFile = `./dist/common_manifest_${process.env.PLATFORM}.txt`;
        if(! fs.existsSync(manifestFile)) { fs.writeFileSync(manifestFile, path); }else {
            fs.appendFileSync(manifestFile, '\n'+ path); }}}function processModuleFilter(module) {
    if (module['path'].indexOf('__prelude__') > = 0) {return false;
    }
    manifest(module['path']);
    return true;
}

function createModuleIdFactory () {
    return path => {
        let name = ' ';
        if (path.startsWith(__dirname)) {
            name = path.substr(__dirname.length + 1);
        }
        let regExp = pathSep == '\ \' ?
            new RegExp('\ \ \ \'."gm") :
            new RegExp(pathSep, "gm");
        return name.replace(regExp, '_');
    }
}

module.exports = {
    serializer: {
        createModuleIdFactory,
        processModuleFilter
    }
};
Copy the code

After completing the packaged configuration, execute:

node node_modules/react-native/local-cli/cli.js bundle --platform ios --dev false --entry-file ./common.js --bundle-output ./dist/common.bundle --config ./common.config.js
Copy the code

This command is long, and you can write it to a shell, Python, or package.json script, depending on your needs.

This gives us two files:

  • common.bundleAll:common.jsAll public dependencies that are introduced into the bundle are packaged into this bundle, which is then imported into the bundle when the client imports it.
  • common_manifest_ios(android).txt: Saves the dependency information in the main package. In this way, you can read the contents of the file to identify the dependencies that have been entered in the main package and do not pack them again.
The business package

The packaging process of all service packages, including service packages delivered with the request, client version, or patch, is the same, and you can modify the package based on your requirements.

/ / here we use charts as we need packaging business package name, of course you can according to demand to literally named ~ / / charts. Js import React the from'react';
import { AppRegistry, View } from 'react-native';

export default class Charts extends React.Component {
    render() {
        return( <View> <Text>Charts</Text> </View> ); }}; AppRegistry.registerComponent('charts', () => Charts);
Copy the code

Package configuration:

// business.config.js
'use strict'
const fs = require('fs');

const pathSep = require('path').sep;
var commonModules = null;

function isInManifest (path) {
    const manifestFile = `./dist/common_manifest_${process.env.PLATFORM}.txt`;

    if (commonModules === null && fs.existsSync(manifestFile)) {
        const lines = String(fs.readFileSync(manifestFile)).split('\n').filter(line => line.length > 0);
        commonModules = new Set(lines);
    } else if (commonModules === null) {
        commonModules = new Set();
    }

    if (commonModules.has(path)) {
        return true;
    }

    return false;
}

function processModuleFilter(module) {
    if (module['path'].indexOf('__prelude__') > = 0) {return false;
    }
    if (isInManifest(module['path']) {return false;
    }
    return true;
}

function createModuleIdFactory() {
    return path => {
        let name = ' ';
        if (path.startsWith(__dirname)) {
            name = path.substr(__dirname.length + 1);
        }
        let regExp = pathSep == '\ \' ?
            new RegExp('\ \ \ \'."gm") :
            new RegExp(pathSep,"gm");
        
        return name.replace(regExp,'_');
    };
}


module.exports = {
    serializer: {
        createModuleIdFactory,
        processModuleFilter,
    }
};
Copy the code

The package configuration of a service package is very similar to that of common.bundle, except that the dependencies packaged into common.bundle must be filtered during the package packaging. Otherwise, the volume of the service package will be too large when the service package is delivered.

We use the processModuleFilter above to filter and return whether the current path is in the manifest file to determine whether to filter.

After the configuration is complete, run the following command:

node node_modules/react-native/local-cli/cli.js bundle --platform ios --dev false --entry-file ./src/charts.js --bundle-output ./dist/charts.bundle --config ./business.config.js
Copy the code

The result is a very streamlined business code package. The above small code package should be less than 1 KB, compared to the size of delivering an H5 can be several KB to dozens of KB, can be said to be very saving network resources.

Before compression:

After the compression:

As you can see, the package size is negligible after zip compression.

Delivery and client loading

Once the packaging is complete, it’s time to deal with it at the business level. This part can be divided into two parts, first we need to store the code package, and then send to the client. The client then loads these packages and displays them in the user’s client.

Issued by the

Based on the subcontracting results from the previous section, we have common.bundle, a dependency package that contains common dependencies, and a chart.bundle business package that does not contain any code related to common dependencies.

Common.bundle because changes are minor and the package size is generally large, it can be packaged directly into the client. Of course, if you want to keep the dynamic update feature, that’s fine.

For the special scenario of our application:

Insert multiple React Native cells into a scrolling feed stream, so we use the scheme described above to deliver the bundle of this cell along with the data.

The advantages of this are:

  1. The Feed stream will probably be the first interface that users enter. The bundle will be delivered along with the data. This will prevent the problem of a blank screen when the Bundle is updated.
  2. Compared to native, we get a client-side user experience and dynamic updates. The feed stream serves as a carrier for the length of the user’s reading, which occasionally needs to be dynamically inserted with activity or advertising content.

Of course, there are some disadvantages, namely that client-specific functionality needs to be supported in advance, and if new functionality is added, you may need to republish the common.bundle package.

If the bundle is delivered following data, the best strategy is to zip compress the bundle, reduce the bundle size, and then base64 encode the compressed ZIP package into a string and deliver it to the client.

Of course, you can also design and implement a platform to upload and configure packages. After the configuration is complete, the database can be directly stored. When the backend encounters React Native content during data distribution, it can directly fetch the base64 of the corresponding package.

Client loading

The process of loading React Native and executing the code was explained in the previous article. React Native dynamic loading requires changes to the client code. Here is a summary of the entire solution for loading React Native on iOS clients:

loading

As for React Native, the bridge between Native and JavaScript code relies on the RCTBridge. Including JavaScript code execution until the client rendering into a native component, as well as the communication process between JavaScript and native. Of course, the same is true for package loading.

First, we need a class that manages the loading of packages. This class inherits from

.

NSString *const COMMON_BUNDLE = @"common.bundle"; // BundleLoader.m @interface RCTBridge (PackageBundle) - (RCTBridge *)batchedBridge; - (void)executeSourceCode:(NSData *)sourceCode sync:(BOOL)sync; @end @interface BundleLoader()<RCTBridgeDelegate> // Some load related variables @property (nonatomic, strong) RCTBridge *bridge; @property (nonatomic, strong) NSString *currentLoadingBundle; @property (nonatomic, strong) NSMutableArray *loadingQueue; @property (nonatomic, strong) NSMutableDictionary *bundles; @property (nonatomic, strong) NSMutableSet *loadedBundle; @property (nonatomic, copy) NSString *commonPath; // Since this instance needs to be unique, So we implement a singleton + (instanceType)sharedInstance {static dispatch_once_t pred; static BundleLoader *instance; dispatch_once(&pred, ^{ instance = [[BundleLoader alloc] init]; }); return instance; } // Initialize the class - (instanceType)init {self = [super init]; React Native RCTSetLogThreshold(RCTLogLevelInfo); RCTLogLevelInfo (RCTLogLevelInfo); RCTAddLogFunction(^(RCTLogLevel level, RCTLogSource source, NSString *fileName, NSNumber *lineNumber, NSString *message) { NSLog(@"React Native log: %@, %@", @(source), message); }); RCTSetFatalHandler(^(NSError *error) {NSLog(@"React Native Fatal error: %@", error.localizedDescription); // Report the error event. Unified processing [[NSNotificationCenter defaultCenter] postNotificationName: ReactNativeFatalErrorNotification object: nil]; }); } [self initBridge]; return self; } // Initialize React Native - (void)initBridge {if (! Self.bridge) {// Load common.bundle and mark it as loading commonPath = [self loadCommonBundle]; currentLoadingBundle = COMMON_BUNDLE; // Initialize bridge and load main package self.bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:nil]; // Initialize all events listener [self addObservers]; } // Override the RCTBridge method of the same name, - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {NSString *filePath = self.monpath; NSURL *url = [NSURL fileURLWithPath:filePath]; return url; } - (void)addObservers {@weakify(self) // Trigger [[NSNotificationCenter defaultCenter] addObserver:self when the JavaScript package is loaded name:RCTJavaScriptDidLoadNotification dispatchQueue:dispatch_get_main_queue() block:^(NSNotification *notification) { @strongify(self) [self handleJSDidLoadNotification:notification]; }]; / / JavaScript packages loaded failure triggered the [[NSNotificationCenter defaultCenter] addObserver: self name: RCTJavaScriptDidFailToLoadNotification  dispatchQueue:dispatch_get_main_queue() block:^(NSNotification *notification) { @strongify(self) [self handleJSDidFailToLoadNotification:notification]; }]; } // Copy common.bundle from the sandbox to the target application directory and push it into the bundle loading queue - (void) loadCommon. bundle {// Complete the copy of common.bundle. NSString *path = @" here is common "; return path; } / / load current queue first package - (void) loadBundle {/ / remove the queue first package NSDictionary * bundle = self loadingQueue. FirstObject; if (! bundle) { return; } NSString *bundleName = bundle.name; NSString *path = bundle.path; // If the COMMON package has not been loaded at the time of loading the business package, the business package is temporarily saved if (! [self.loadedBundle containsObject:bundleName] && bundleName ! = COMMON_BUNDLE) { return; } / / tags are currently loaded package self. CurrentLoadingBundle = bundleName; [self.loadingQueue removeFirstObject]; // If the bundle to be loaded does not exist, continue loading the next bundle. [[NSFileManager defaultManager] fileExistsAtPath:path]) { dispatch_async(dispatch_get_main_queue(), ^{ [[NSNotificationCenter defaultCenter] postNotificationName:ReactNativeDidFailToLoadNotification object:nil bundle:@{@"name": bundleName}]; [self loadBundle]; }); return; } NSURL *fileUrl = [NSURL fileURLWithPath:path]; // Load and execute the corresponding bund@Weakify (self) [RCTJavaScriptLoader loadBundleAtURL:fileUrl onProgress:nil onComplete:^(NSError) *error, RCTSource *source) { @strongify(self) if (! Error && source.data) {// JavaScript code loaded successfully, and successfully obtain source code source.data, Then execute the code dispatch_async (dispatch_get_main_queue (), ^ {[self. Bridge. BatchedBridge executeSourceCode: source. The data sync: YES];  [self.loadedBundle addObject:bundleName];  [[NSNotificationCenter defaultCenter] postNotificationName:ReactNativeDidExecutedNotification object:nil bundle:@{@"name": // [[NSFileManager defaultManager] removeItemAtPath:path Error :nil];}); } else {dispatch_async(dispatch_get_main_queue()), ^{ [[NSNotificationCenter defaultCenter] postNotificationName:ReactNativeDidFailToLoad object:nil bundle:@{@"name": bundleName}]; [self loadBundle]; }); } }]; }Copy the code

The React Native package is loaded with the following code:

  1. First, initialize the entire React Native app when it starts, or whenever you need it.
  2. React Native initialization is based on the RCTBridge, which is the core of the entire React Native load and execution (as described in the previous article).
  3. implementationsourceURLForBridgeMethod to return the path to common.bundle of the app run directory copied from the sandbox;
  4. Instantiation RCTBridge

The main package-related content is now loaded.

In the main package loading is completed, will trigger RCTJavaScriptDidLoadNotification events, we can in this event handler, which determine the current load to the package, when common. The bundle loading is completed, can be loaded for the queue business package.

// BundleLoader.m
- (void)handleJSDidLoadNotification:(NSNotification *)notification {
    NSString *bundleName = self.currentLoadingBundle;

    if([bundleName isEqualToString:COMMON_BUNDLE]) { [self loadBundle]; }}Copy the code

In need to use the React of Native view, can monitor the above JavaScript code execution after the completion of the event notice: ReactNativeDidExecutedNotification. After that, mount the RCTRootView to the specified view and display it.

Since we did zip compression to reduce the size when uploading the package, and then base64 encoding, we need to restore the code package we got first:

// bundleloader. m - (void)extractBundle:(NSString *bundle) {NSData *decodedBundle = [NSData alloc] initWithBase64EncodedString:bundle options:0]; // Save zip to the specified path [[NSFileManager defaultManager] createFileAtPath:zipPath contents:decodedBundle Attributes :nil]; // Unzip the file [zipArchive UnzipOpenFile:zipPath]; [zipArchive UnzipFileTo:bundleDir overWrite:YES]; [zipArchive UnzipCloseFile]; // Then push the package to the queue to be loaded, execute}Copy the code

Business code to monitor ReactNativeDidExecutedNotification to React Native mount:

// charts.m
- (void)addObservers {
    WeakifySelf
    [[NSNotificationCenter defaultCenter] addObserver:self name:ReactNativeDidExecutedNotification dispatchQueue:dispatch_get_main_queue() block:^(NSNotification *notification) {
        StrongifySelf
        NSString *loadedBundle = notification.bundle[@"name"];
        if([loadedBundle isEqualToString:self.bundle]) { [self _initRCTRootView]; }}]; } - (void)_initRCTRootView {// Initialize the React Native container. RctRootView = [[rctRootView alloc] initWithBridge:bridge moduleName:moduleName initialProperties:initialProperties]; [self.contentView addSubview:self.rctRootView]; }Copy the code

This completes mounting a React Native component.

The overall packaging, distribution and loading process is as follows:

conclusion

At present, the business has been running stably online for more than a month. Subsequently, some new functions and new types of business cells have been added, so that the backend can be directly distributed instead of the client development version.

In fact, whether it is feed flow or other scenes, this solution can make the Native interface “partially” dynamic. Places that don’t need to be dynamic can enjoy a good Native experience (although React Native experience is also good so far).

React Native has many problems, such as long list performance and high memory consumption. These issues have always been the Achilles heel of React Native. Let’s hope that Facebook’s Refactoring of React Native will reduce the cost of using React Native