React Native(二):分包机制与动态下发

React Native(二):分包机制与动态下发

前言

随着 Flutter 的出现,React Native 的热度在逐渐下降,而 facebook 自己对于 React Native 的重构也在进行当中。以目前的状况来讲,React Native 要开发一个完整的应用程序,或者成为应用程序的一部分,都须要开发者可以了解两侧客户端的实现机制,由于不少的依赖都须要注入到客户端代码当中,更不要说桥接 native 和 React Native 的 bridge 了。java

在这种场景下,React Native 仍旧在不少大型 app 里面都有着本身的一席之地。热更新机制依旧是 React Native 最灵活,也是让人难以割舍的优势。node

对比

提到 React Native,就不得不提到 Flutter。可是于我看来,二者致力于解决的方向并不相同。Flutter 更但愿可以统一两端的开发体验,让一套代码能够不加修改的直接在两端运行。而 React Native 则是能够解决传统 Hybrid App 的劣势,那就是 H5 的体验。即便在高端移动端设备上,H5 都很难达到近似于 native 的体验,更别说大部分用户都还在使用中端、甚至低端机型了。python

为了保证这些用户的体验,就不得不牺牲掉部分灵活性,采用 React Native 的方案,来保证能够进行动态化更新以及向下兼容。毕竟大部分时候,客户端须要提供给 H5 或者是 React Native 的绝大部分功能在接入的开始都会肯定好。从开始接入 React Native 的 native 版本开始,就可让代码近乎无痛兼容。react

场景

React Native 的使用场景通常有如下几种:android

  • 完整的 app 都用 React Native 来进行开发:

这种方案比较适合我的开发者。移动端应用不可能放弃两个平台中的任何一个,对于我的开发者而言,同时学习 objective-c / swift 以及 kotlin / java 的成本过高,而且不可以保证迭代速度。因此,全站 React Native 便成为了一种可行的选择,也是比较好的选择。既可以给到用户较好的体验,也可以保证迭代效率。固然,在 Flutter 大行其道的今天,更多的开发者采用 Flutter 来代替 React Native 进行两端统一开发,Flutter 的坑更少,两端的体验更加统一,开发体验也要优于目前的 React Native 版本。ios

  • 部分动态化:

这种方案在不少大型 app 中都有实践,包括我曾经接触到的千万 DAU 的产品。在某些业务场景下,咱们既须要能够脱离客户端的版本进行独立更新,又须要较好的用户体验。这时就须要基于 React Native 来进行开发。通常都是在客户端的某个 view 上挂载 React Native 的 RCTRootView,将整个 view 经过 React Native 进行渲染。git

这种场景主要是某个业务模块,彻底使用 React Native 来进行开发,经过分包机制,将 React Native 的基础库打到一个 bundle 中,而后将业务代码打到另一个 bundle 中,两个 bundle 独立更新,由于基础库的更新并不频繁,而业务代码的更新可能会更加频繁一些。github

关于分包和下发会在后文中说到。objective-c

  • (极致的)动态化 -- 跟随数据下发:

这种方案并非一个常见的方案,也算是咱们根据本身的业务场景找到的一个解决方案,也是一个本身想到的解决思路。shell

和上一个解决方案相似,不过,有时候可能咱们不须要整个 view 都是 React Native 来进行管理,由于 React Native 的长列表性能并非很是理想。做为 feed 流这类场景会产生很大的问题,好比 android 机型内存消耗过大,或者是崩溃闪退等问题。

并且,有些时候,feed 流中的内容动态化会比较强,若是经过发版来知足要求,有极可能跟不上节奏。

好比在箭头所指的地方,插入一个奇形怪状的广告(以掘金为例,图片侵删)

因此咱们考虑将 feed 流中的部分动态化程度较高的 cell 经过 React Native 来进行渲染。最初的方案和上一个方案是一致的,咱们将业务 cell 的代码进行分包,打包在业务包中。若是有了新的 cell,经过更新业务包的方式,来让用户可以渲染新的 cell 内容。


若是这样就太平平无奇了~~


首先考虑这样下发会存在的问题,当咱们启动 app,进入 feed 的时候,咱们的 app 检测到业务包有变化,则会去 CDN 来拉取业务包,而后加载,再经过 JSCore 来执行业务包中的 JavaScript 代码,这样咱们才可以进行渲染。彷佛看起来没有什么问题,可是因为 feed 流的其余模块采用 native 进行渲染,一旦产生更新,业务包拉取速度比较慢的时候,React Native 的 cell 会白屏很长时间,而且可能会因为包中的某个 cell 模块的 error 致使其余 cell 渲染错误。

因而咱们采用了一种新的方案:将每一个 cell 的业务包分离,对于 bundle 进行 zip 压缩以后,进行 base64 编码,而后,跟随每一个 cell 的渲染数据一块儿下发。

这样作的好处在于:

  1. bundle 随着业务数据下发,因为每一个业务包都很小,因此解压以及加载的时间很短,基本能够保证数据加载完成,便可完成 cell 的渲染。

  2. 每一个 bundle 做用域隔离,一个 bundle 报错不会影响到其余 bundle 代码的执行。

  3. bundle 和 cell 的业务一一对应,若是某一个 cell 的样式或者功能须要更新,只须要配置一个新的 bundle 存起来就能够了,后台在下发新的数据的时候,就能够直接拉取到新的 bundle,不须要每一个都更新整个业务包。

固然,对于几乎全部问题来讲,都没有银弹,这个解决方案也存在本身的问题,我会在文章的最后,说一下我遇到的问题。

分包

React Native 的动态化方案,都难以脱离一个分包。React Native 的基础库不小,再加上咱们须要的一些依赖,好比 React-Native-Vector-Icons 这样的公共依赖,这些不常改变的依赖,须要和常常变化的业务代码分离,压缩业务代码的大小,保证业务包在进行更新时候的最优。

metro

React Native 很早就提供了 metro 来进行 bundle 的分包。使用 metro 来进行打包,须要配置一个 metro.config.js 文件来进行分包。

这里是官方的配置文档

文档看起来选项很是多,不过不少都是针对特定的场景的。

咱们分包须要用的选项主要是两个:

  • createModuleIdFactory:这个函数传入要打包的 module 文件的绝对路径,返回这个 module 在打包的时候生成的 id。
  • processModuleFilter:这个函数传入 module 信息,返回一个 boolean 值,false 则表示这个文件不打入当前的包。

分包策略

咱们的分包策略是这样的:

  • common.bundle:打入全部的公共依赖库,这个依赖库跟随客户端版本下发,不进行热更新;
  • business.bundle:大型业务模块的业务代码库,这个包的数量和业务模块数量相关,也就是第一节中说到的部分动态化场景中用到的业务包。
  • RN-xxx.bundle:feed 流中不一样 cell 的业务包,根据 cell 的种类不一样,能够有多个。也就是第一节中说到的跟随数据下发场景中用到的业务包。

其中,common.bundlebusiness.bundle 是预先打包在客户端代码中的,由于这两个包较大,而 business.bundle 支持动态化下发。common.bundle 体积过大,仍是安安心心放到客户端代码中吧,不然下发成本太大。而且,大部分时候,若是你须要增长一个新的 React Native 依赖,就不得不在客户端中增长相应的客户端依赖代码(就是你执行 react-native link 的时候,会在客户端中添加的客户端依赖),因此 common.bundle 跟着客户端发版也是很合理的事情。

分包

主包(common.bundle)

由于 metro 的配置能够将依赖进行分离,因此首先将须要打包到 common.bundle 中的代码引入到一个文件中:

// common.js
import {} from 'react';
import {} from 'react-native';
import {} from 'react-redux';
import Sentry from 'react-native-sentry';

// 还能够增长一些公共代码,好比统一监控之类的
Sentry.config('dsn').install();
复制代码

相应的,common.bundle 须要有一个配置文件:

'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
    }
};
复制代码

完成打包的配置以后,执行:

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
复制代码

这段命令很长,你能够根据本身的需求,写到 shellpython 或者 package.json 脚本中。

这样,咱们就获得了两个文件:

  • common.bundle:全部的 common.js 中引入的公共依赖,都会被打包到这个 bundle 里面,后续在客户端进行引入的时候,首先引入这个 bundle 就能够了。
  • common_manifest_ios(android).txt:保存了主包中的依赖信息,这样在打业务包的时候,经过读取这个文件的内容,就能够识别主包中已经打入的依赖,不进行重复打包了。
业务包

全部的业务包,包含跟随请求一块儿下发的业务包,以及跟随客户端版本,或者 patch 下发的业务包的打包流程都是一致的,能够根据本身的需求进行修改。

// 咱们这里用 charts 做为咱们须要打包的业务包名字,固然你能够根据需求来随便起名~
// charts.js

import React 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);
复制代码

打包配置:

// 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,
    }
};
复制代码

业务包的打包配置和 common.bundle 很是相似,有一点不一样在于,打包到 common.bundle 中的依赖须要在业务包打包的时候进行过滤,不然在进行业务包下发的时候会致使业务包体积过大。

咱们经过上面的 processModuleFilter 来进行过滤,返回当前的 path 是否位于刚才的 manifest 文件中,判断是否进行过滤。

完成配置后,执行:

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
复制代码

就能够获得一个很是精简的业务代码包了。上面的那一小段代码打包出来应该只有不到 1 KB,相对于下发一个 H5 动辄几 KB 到几十 KB 的大小来讲,能够说是很是节约网络资源了。

压缩前:

压缩后:

能够看到,在进行了 zip 压缩以后,包的大小基本能够忽略不计。

下发以及客户端加载

完成打包以后,就须要在业务层面进行处理了。这部分又能够分为两个部分,首先咱们须要将代码包进行存储,而后下发到客户端。以后客户端在对于这些包进行加载,就能够显示到用户的客户端里面了。

下发

根据上一节获得的分包结果,咱们获得了 common.bundle 这个包含了通用依赖的依赖包,也获得了一个 charts.bundle 的业务包,这个业务包不包含任何与通用依赖相关的代码。

common.bundle 因为更改的并很少,而且这个包的大小通常都会比较大,因此能够直接打包到客户端内部。固然若是你但愿可以保持动态更新功能的话也是能够的。

针对咱们应用的特殊场景:

在一条滚动的 feed 流中插入多条 React Native 的 cell,因此咱们采用了前文所述的方案,将这个 cell 的 bundle 跟着数据一块儿下发。

这样作的优势在于:

  1. feed 流极可能是用户进入的第一个界面,bundle 跟着数据一块儿下发,能够防止 bundle 在更新时产生的白屏问题,不存在其余 cell 已经渲染完成,而 React Native 的 cell 还在下载 bundle 更新的状况;
  2. 相比起原生来讲,咱们能够获得近似于客户端的用户体验,而且也拥有了动态更新的功能。feed 流做为承载用户阅读时长的载体,其中偶尔仍是须要动态化地插入活动或者是广告内容的。

固然也存在必定的缺点,也就是客户端相关的功能须要提早支持,若是增长了新的功能,可能须要从新发布 common.bundle 包。

若是采用跟随数据下发的方式来下发 bundle,最好的策略是将其 zip 压缩,减少 bundle 的大小,而后再将压缩后的 zip 压缩包进行 base64 编码,转换为字符串的形式,下发到客户端。

固然,你也能够设计实现一个平台,来进行包的上传和配置,配置完成后能够直接落库,让后端在数据分发的时候,遇到了 React Native 的内容,直接去取对应包的 base64。

客户端加载

客户端加载 React Native 的流程以及代码执行的过程在上一篇文章中已经有相关解释了。要作 React Native 的动态加载,不免要改动到客户端的代码。这里对于 iOS 客户端加载 React Native 的整个方案进行一下梳理:

加载

对于 React Native 来讲,native 和 JavaScript 代码的桥梁都是靠 RCTBridge 来进行桥接的。包括 JavaScript 代码执行一直到客户端渲染成为原生组件,以及 JavaScript 与 native 之间的相互通讯过程。固然,包的加载也是这样的。

首先,咱们须要一个对于包的加载进行管理的类,这个继承自 <React/RCTBridge.h>

NSString *const COMMON_BUNDLE = @"common.bundle";

// BundleLoader.m
@interface RCTBridge (PackageBundle)

- (RCTBridge *)batchedBridge;
- (void)executeSourceCode:(NSData *)sourceCode sync:(BOOL)sync;

@end

@interface BundleLoader()<RCTBridgeDelegate>
// 一些加载相关的变量
@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;
@end

@implementation BundleLoader

// 因为这个实例须要是惟一的,因此咱们实现一个单例
+ (instancetype)sharedInstance {
    static dispatch_once_t pred;
    static BundleLoader *instance;
    dispatch_once(&pred, ^{
        instance = [[BundleLoader alloc] init];
    });
    return instance;
}

// 进行类的初始化
- (instancetype)init {
    self = [super init];
    if (self) {
        // 这里还要初始化各类变量
        // 在 Native 中打印 React Native 中的日志,方便真机调试
        RCTSetLogThreshold(RCTLogLevelInfo);
        RCTAddLogFunction(^(RCTLogLevel level, RCTLogSource source, NSString *fileName, NSNumber *lineNumber, NSString *message) {
            NSLog(@"React Native log: %@, %@", @(source), message);
        });
        
        // 保证 React Native 的错误被静默
        RCTSetFatalHandler(^(NSError *error) {
            NSLog(@"React Native Fatal Error: %@", error.localizedDescription);
            // 将错误事件上报,进行统一处理
            [[NSNotificationCenter defaultCenter] postNotificationName:ReactNativeFatalErrorNotification object:nil];
        });
    }
    [self initBridge];
    return self;
}

// 进行 React Native 的初始化
- (void)initBridge {
    if (!self.bridge) {
        // 加载 common.bundle,而且将其标记为正在加载
        commonPath = [self loadCommonBundle];
        currentLoadingBundle = COMMON_BUNDLE;
        // 初始化 bridge,而且加载主包
        self.bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:nil];
        // 初始化全部事件监听
        [self addObservers];
    }
}

// 这个方法 override 了 RCTBridge 的同名方法,指定了主包所在的位置来让 RCTBridge 进行初始化
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {
    NSString *filePath = self.commonPath;
    NSURL *url = [NSURL fileURLWithPath:filePath];
    return url;
}

- (void)addObservers {
    @weakify(self)
    // JavaScript 包加载完成后触发
    [[NSNotificationCenter defaultCenter] addObserver:self name:RCTJavaScriptDidLoadNotification dispatchQueue:dispatch_get_main_queue() block:^(NSNotification *notification) {
        @strongify(self)
        [self handleJSDidLoadNotification:notification];
    }];
    // JavaScript 包加载失败触发
    [[NSNotificationCenter defaultCenter] addObserver:self name:RCTJavaScriptDidFailToLoadNotification dispatchQueue:dispatch_get_main_queue() block:^(NSNotification *notification) {
        @strongify(self)
        [self handleJSDidFailToLoadNotification:notification];
    }];
}

// 将沙盒中的 common.bundle 拷贝到目标应用程序目录当中,而且推入到 bundle 加载队列当中
- (void)loadCommonBundle {
    // 完成 common.bundle 的拷贝,获得文件所在的目录
    // 省略了拷贝沙盒文件的过程,获得文件的路径: path
    NSString *path = @"这里是 common ";
    return path;
}

// 加载当前队列的第一个包
- (void)loadBundle {
    // 取出队列中的第一个包
    NSDictionary *bundle = self.loadingQueue.firstObject;

    if (!bundle) {
        return;
    }

    NSString *bundleName = bundle.name;
    NSString *path = bundle.path;

    // 若是在加载业务包的时候,COMMON 包尚未加载,则将业务包暂存
    if (![self.loadedBundle containsObject:bundleName] && bundleName != COMMON_BUNDLE) {
        return;
    }
    
    // 标记当前正在加载的包
    self.currentLoadingBundle = bundleName;
    [self.loadingQueue removeFirstObject];

    // 若是须要加载的 bundle 不存在,则继续加载下一个 bundle
    if (![[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];

    // 加载而且执行对应的 bundle
    @weakify(self)
    [RCTJavaScriptLoader loadBundleAtURL:fileUrl onProgress:nil onComplete:^(NSError *error, RCTSource *source) {
        @strongify(self)
        if (!error && source.data) {
            // JavaScript 代码加载成功,而且成功获取到源代码 source.data,则执行这些代码
            dispatch_async(dispatch_get_main_queue(), ^{
                [self.bridge.batchedBridge executeSourceCode:source.data sync:YES];
                [self.loadedBundle addObject:bundleName];
                [[NSNotificationCenter defaultCenter] postNotificationName:ReactNativeDidExecutedNotification object:nil bundle:@{@"name": bundleName}];
                // 若是这个包加载完了就不须要了,能够进行移除
                // [[NSFileManager defaultManager] removeItemAtPath:path error:nil];
            });
        } else {
            // JavaScript 代码加载失败
            dispatch_async(dispatch_get_main_queue(), ^{
                [[NSNotificationCenter defaultCenter] postNotificationName:ReactNativeDidFailToLoad object:nil bundle:@{@"name": bundleName}];
                [self loadBundle];
            });
        }
    }];
}
复制代码

上面的代码是 React Native 包的核心加载代码,咱们来理一下 React Native 代码的加载流程:

  1. 首先,在 app 启动,或者其余你须要的时间点上来进行整个 React Native 的初始化;
  2. React Native 的初始化基于 RCTBridge 进行,RCTBridge 是整个 React Native 加载和执行的核心(上一篇文章中有过介绍);
  3. 实现 sourceURLForBridge 方法,返回从沙盒拷贝的 app 运行目录的 common.bundle 的路径;
  4. 实例化 RCTBridge

这样,和主包相关的内容就完成了加载。

在主包加载完成以后,会触发 RCTJavaScriptDidLoadNotification 事件,咱们能够在这个事件的处理函数当中,判断当前加载到了哪一个包,当 common.bundle 加载完成以后,就能够对于队列中的业务包进行加载了。

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

    if ([bundleName isEqualToString:COMMON_BUNDLE]) {
        [self loadBundle];
    }
}
复制代码

在须要使用 React Native 的 view 当中,能够监听上面 JavaScript 代码执行完成后发出的事件通知:ReactNativeDidExecutedNotification。在其后,将 RCTRootView 挂载到指定的 view 上,展现出来。

因为咱们在上传包的时候,进行了 zip 压缩来减小体积,以后进行了 base64 编码,因此须要先将拿到的代码包进行还原:

// BundleLoader.m
- (void)extractBundle:(NSString *bundle) {
    // 还原 bundle
    NSData *decodedBundle = [[NSData alloc] initWithBase64EncodedString:bundle options:0];
    // 将 zip 保存到指定路径
    [[NSFileManager defaultManager] createFileAtPath:zipPath contents:decodedBundle attributes:nil];
    // 将文件解压缩
    [zipArchive UnzipOpenFile:zipPath];
    [zipArchive UnzipFileTo:bundleDir overWrite:YES];
    [zipArchive UnzipCloseFile];
    
    // 而后将包推到待加载的队列当中,进行执行
}
复制代码

业务代码中监听 ReactNativeDidExecutedNotification 来进行 React Native 的挂载:

// 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 {
    // 进行 React Native 容器的初始化,而且进行挂载
    self.rctRootView = [[RCTRootView alloc] initWithBridge:bridge moduleName:moduleName initialProperties:initialProperties];
    [self.contentView addSubview:self.rctRootView];
}
复制代码

这样就完成了一个 React Native 组件的挂载。

总体打包、分发和加载流程,以下:

结论

目前,业务已经在线上稳定运行了一个多月,后续也加入了一些新的功能以及新的业务 cell 种类,让后端直接进行分发,脱离客户端开发版本。

其实不管是 feed 流,仍是其余场景,这种方案均可以让 Native 界面进行 “部分” 动态化,不须要动态化的地方,能够享受到原生的良好体验(虽然目前看来,React Native 的体验也是不错的)。

因为 React Native 还存在不少问题,好比长列表性能,内存消耗过大等问题。这些问题一直都是 React Native 的阿喀琉斯之踵,但愿此次 facebook 对于 React Native 的重构可以下降 React Native 的使用成本吧~

相关文章
相关标签/搜索