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

10,538 阅读16分钟

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

前言

随着 Flutter 的出现,React Native 的热度在逐渐降低,而 facebook 本身对于 React Native 的重构也在进行当中。以目前的情况来说,React Native 要开发一个完整的应用程序,或者成为应用程序的一部分,都需要开发者能够了解两侧客户端的实现机制,因为很多的依赖都需要注入到客户端代码当中,更不要说桥接 native 和 React Native 的 bridge 了。

在这种场景下,React Native 仍旧在很多大型 app 里面都有着自己的一席之地。热更新机制依旧是 React Native 最灵活,也是让人难以割舍的优点。

对比

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

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

场景

React Native 的使用场景一般有以下几种:

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

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

  • 部分动态化:

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

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

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

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

这种方案并不是一个常见的方案,也算是我们根据自己的业务场景找到的一个解决方案,也是一个自己想到的解决思路。

和上一个解决方案类似,不过,有时候可能我们不需要整个 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 的使用成本吧~