React Native 拆包及实践「iOS&Android」

7,651 阅读10分钟

一.拆包

拆包的方式一般有三种,分别为Facebook的Metro、携程的moles-packer和diff patch(可以使用Google的diff-match-patch)。但目前最好的方式可能还是Metro。在调研的过程中,接触最早的,也是最全的例子为react-native-multibundler,这个例子甚至开发了可视化工具,进行拆包打包。

bundle代码拆分类型:基础包与业务包。
基础包:将一些重复的js代码与第三方依赖库打成一个包。
业务包:根据应用内的不同业务逻辑,拆分出一个或多个包。

1.Metro安装

实际在运行npm install时React Native已经安装Metro了,只不过可能并不是最新版(跟React Native版本有关),想使用最新版Metro,需要单独安装。

npm install --save-dev metro metro-core

yarn add --dev metro metro-core

2.Metro配置

配置Metro有三种方法,分别为metro.config.jsmetro.config.jsonpackage.json中添加metro字段,常用的方式为 metro.config.js

Metro配置内部结构大致像这样:

module.exports = {
  resolver: {
    /* resolver options */
  },
  transformer: {
    /* transformer options */
  },
  serializer: {
    /* serializer options */
  },
  server: {
    /* server options */
  }

  /* general options */
};

每个optoins内都有很多配置选项,而对于我们这些初学者来说,最重要的是serializer选项内的createModuleIdFactoryprocessModuleFilter

如图:

createModuleIdFactory :在v0.24.1后,Metro支持了通过此方法配置自定义模块ID,同样支持字符串类型ID,用于生成require语句的模块ID,其类型为() => (path: string) => number(带有返回参数的返回函数的函数),其中path为各个module的完整路径。此方法的另一个用途就是多次打包时,对于同一个模块生成相同的ID,下次更新发版时,不会因ID不同找不到Module。

processModuleFilter:根据给出的条件,对Module进行过滤,将不需要的模块过滤掉。其类型为(module: Array<Module>) => boolean,其中module为输出的模块,里面带着相应的参数,根据返回的波尔值判断是否过滤当前模块。返回false为过滤,不打入bundle。

接下来上代码:

function createModuleIdFactory() {
  //获取命令行执行的目录,__dirname是nodejs提供的变量
  const projectRootPath = __dirname;
  return (path) => {
    let name = '';
    // 如果需要去除react-native/Libraries路径去除可以放开下面代码
    // if (path.indexOf('node_modules' + pathSep + 'react-native' + pathSep + 'Libraries' + pathSep) > 0) {
    //   //这里是react native 自带的库,因其一般不会改变路径,所以可直接截取最后的文件名称
    //   name = path.substr(path.lastIndexOf(pathSep) + 1);
    // }
    if (path.indexOf(projectRootPath) == 0) {
      /*
        这里是react native 自带库以外的其他库,因是绝对路径,带有设备信息,
        为了避免重复名称,可以保留node_modules直至结尾
        如/{User}/{username}/{userdir}/node_modules/xxx.js 需要将设备信息截掉
      */
      name = path.substr(projectRootPath.length + 1);
    }
    //js png字符串 文件的后缀名可以去掉
    // name = name.replace('.js', '');
    // name = name.replace('.png', '');
    //最后在将斜杠替换为下划线
    let regExp = pathSep == '\\' ? new RegExp('\\\\', "gm") : new RegExp(pathSep, "gm");
    name = name.replace(regExp, '_');
    //名称加密
    if (isEncrypt) {
      name = md5(name);
    }
    return name;
  };
}

需要生成什么样的模块ID,可以根据自己的情况与喜好而定,无论是加密,拼接,甚至可以直接将获取到的path返回,唯一注意的是规则要统一,否则会无法找到相应的模块,当然模块ID定的越长,最终的bundle文件就越大,ID长短还是要适中,不过通过MD5加密后,长短已经无所谓了。

在打业务包时,可以使用filter对基础包内已有模块进行过滤,减小bundle文件大小。

function processModuleFilter(module) {
  //过滤掉path为__prelude__的一些模块(基础包内已有)
  if (module['path'].indexOf('__prelude__') >= 0) {
    return false;
  }
  //过滤掉node_modules内的模块(基础包内已有)
  if (module['path'].indexOf(pathSep + 'node_modules' + pathSep) > 0) {
    /*
      但输出类型为js/script/virtual的模块不能过滤,一般此类型的文件为核心文件,
      如InitializeCore.js。每次加载bundle文件时都需要用到。
    */
    if ('js' + pathSep + 'script' + pathSep + 'virtual' == module['output'][0]['type']) {
      return true;
    }
    return false;
  }
  //其他就是应用代码
  return true;
}

在xxx.config.js文件内添加上述两个方法后,将方法引入到module.exports内的serializeroptions内。

module.exports = {
  serializer: {
    createModuleIdFactory: config.createModuleIdFactory,
    processModuleFilter: config.processModuleFilter
    /* serializer options */
  }
}

3.Metro使用

根据基础包业务包的不同,添加 --config <path/to/config> 参数对相应入口文件打包。Metro官文虽然标明支持其他路径的配置文件,但至今没有成功过,只能在项目根目录添加配置文件,可能是我添加路径的方式不对,如果你知道如何添加其他路径config.js,请在issue中偷偷告诉我:sweat_smile:。

基础包:
将需要的第三方依赖包与React Native的包、js文件等,可以通过import方式引入到一个js文件内,如basics.js,再使用basics.config.js当做参数传入到--confg后。

使用终端切换到项目根目录,执行命令:

react-native bundle --platform android --dev false --entry-file src/basics/basics.js --bundle-output ./android/app/src/main/assets/basics.android.bundle --assets-dest android/app/src/main/res/ --config basics.config.js

业务包:
根据自己应用的业务逻辑,分出不同的业务入口,并使用AppRegistry注册业务的主Component,如index1.js,使用business.config.js传入到--config后。

命令如下:

react-native bundle --platform android --dev false --entry-file src/index/index1.js --bundle-output ./android/app/src/main/assets/business1.android.bundle --assets-dest android/app/src/main/res/ --config business.config.js

将上述两种命令中的路径,替换为自己的路径,分了几个业务包就需要执行几次命令,可以将命令使用&&连接,写入到脚本文件内,如Linux的.sh或Windows的.bat文件,执行脚本文件即可。

通过react-native bundle -h命令可以查看相应的参数配置选项,其中--entry-file为加载的入口文件,如图:

接下来看下createModuleIdFactory的log输出结果:

应用内的js:

react native的js:
三方依赖库的js:

第一行为方法内的path路径
第二行为根据是React Native自带文件还是三方库文件截取名称
第三行是去除后缀的的名称
第四行是替换斜杠的名称
第五行是加密后的字符串

如果不加密的话,可以去除项目的目录,否则bundle文件会将项目结构暴露。

加密前:

加密后:

二.Android 原生加载

:sparkles:目前Demo中使用的是Koltin语言,如果需要Java语言,可以切换build.gradleisUseKotlin的值为false后点击Sync按钮进行同步。

1.源码浅析

React Native 加载bundle文件有三种方式,分别是从assets目录,本地File目录与Metro本地Server的delta bundle 。而平时用模拟器开发运行,更新文件双击R键时,使用的就是delta bundle。接下来就需要寻找加载bundle的接口文件,调用接口完成对不同业务包的加载,而基础包会在调用createReactContextInBackground时加载。

每个React Native页面都会继承ReactActivity,在onCreate方法内,会调用mDelegate.onCreate,在此方法内创建RootView,并设置到ContentView上。

看下源码逻辑:

Delegate.loadApp->ReactRootView.startReactApplication->
attachToReactInstanceManager->ReactInstanceManager.attachRootView->
attachRootViewToInstance->ReactRootView.runApplication->
catalystInstance.getJSModule(AppRegistry.class).runApplication

最后会通过CatalystInstance调用runApplication方法进行页面的呈现,如果在没有加载对应的bundle文件时,会报Application xxx has not been registered.之类的错误,只需在调用runApplication前将bundle文件加载即可。而接口CatalystInstance继承了一个名为JSBundleLoaderDelegate的接口,此接口中的三个方法分别为loadScriptFromAssetsloadScriptFromFileloadScriptFromDeltaBundle,通过名称可看出是用来load不同位置的bundle的。

在ReactRootView的runApplication内,CatalystInstance是调用ReactContext.getCatalystInstance方法获取,而ReactContext内的CatalystInstance是在其创建时从ReactInstanceManager.createReactContext方法内由CatalystInstanceImpl的Builder新建。

ReactContext可以通过ReactApplication.getReactNativeHost.getReactInstanceManager.getCurrentReactContext获取,因此可以直接自己写一个工具类,在工具类内将需要加载的bundle文件提前加载好即可。

2.功能实现

实现此功能,Demo中用了两种方式,两种方式都需要使用工具类JsLoaderUtil

一种是新建一个类作为基类,它继承ReactActivity,并重写了createReactActivityDelegategetMainComponentName两个方法,在createReactActivityDelegate方法内新建ReactActivityDelegate时的onCreate方法调用super前,通过工具类将约定的组件加载好。这种方式的好处是,在子类内或进入子类前不用关心加载bundle过程的代码,基类中已经写好了,只需要告诉基类加载哪个业务的bundle文件,如Business1Activity。这种方式的另一个用法就是在进入子类前直接告诉工具类需要加载的bundle文件,而在子类中则无需增加任何代码,仅仅继承BaseReactActivity,如Business2Activity

public class BaseReactActivity extends ReactActivity {
  @Override
  protected ReactActivityDelegate createReactActivityDelegate() {
      String localBundleName = getBundleName();
      if (!TextUtils.isEmpty(localBundleName)) {
          JsLoaderUtil.jsState.bundleName = localBundleName;
      }
      return new ReactActivityDelegate(this, getMainComponentName()) {
          @Override
          protected void onCreate(Bundle savedInstanceState) {
              JsLoaderUtil.load(getApplication(), 
                  () -> super.onCreate(savedInstanceState));
          }
      };
  }

  @Nullable
  @Override
  protected String getMainComponentName() {
      return JsLoaderUtil.jsState.componentName;
  }

  protected String getBundleName() {
      return "";
  }

}

Demo中另一种方式是,让子类直接继承ReactActivity,而在进入子类前就用工具类加载好需要的业务bundle文件。这种方式的好处是不用拘泥于继承的父类,但需要注意是在进入页面前,一定要对业务包加载,否则会报错。 如Business3ActivityBusiness4Activity

3.Double Tap R

到此我们的bundle文件已经加载好了,但不可能总是进行打包调试,平时开发时还是需要双击R进行热更新加载的。但JS代码都已经进行了业务拆分,并且Application中只对React Native返回了基础包的bundle,业务包分散在各个业务逻辑上。这时就需要一个开关来控制到底是加载文件bundle还是delta bundle,这大致分为三步或四步完成。

第一步,在index.js文件内将拆分出来的业务包导入,相当于一次性将业务模块全部注册。

import './src/index/index1';
import './src/index/index2';
import './src/index/index3';
import './src/index/index4';

第二步,在JsLoaderUtil工具类内增加判断,如果是Dev模式,直接返回,不加载bundle并且不调用createReactContextInBackground

第三步,在MainApplicationReactNativeHostgetUseDeveloperSupport方法内返回是否为Dev模式标志,并在getJSMainModuleName方法内返回之前的index.js名称,告诉React Native此为入口文件。

这时就可以进入一个业务页面后,双击R更新页面内容了,但在切换开关时重启应用,会无法正常reload,就算进入页面,也会报错崩溃致使被杀掉进程,再进入应用就可以了。与其让它崩溃,不如要么将应用进程杀掉重启,要么增加第四步内容。

第四步,在MainActivityonDestroy内,调用System.exit(0),切换开关后重启应用就可以正常使用了。

4.特殊说明

每一个js文件都相当于一个Module,而React Native对加载过的Module不会再次加载,也就是说,如果先加载assets内的bundle再加载本地File的bundle文件,呈现的还会是assets内的bundle文件,除非杀掉进程重启后,先加载本地File的bundle文件,才会生效,并没找到很好的解决方法。如果你知道如何解决请在issue中告诉我。

assets目录下的bundle.zip压缩包为带有File文字的业务包,用来测试从本地File加载功能。而assets内其他的业务bundle文件,如business1.android.bundle,是带有Assets文字的bundle包,用来测试从assets加载功能。JS代码中,如Business1.js,是带有Runtime文字的业务,用来测试开发过程中双击R键热更新功能。

5.效果演示:

三.iOS 原生加载

1.源码接入

相对于Android,iOS加载多个bundle文件较简单,只需要对RCTBridge扩展暴露以下接口即可:

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

2.实践(以下是以一个基础包和一个业务包测试)

1.将打包好的基础包和业务包导入项目中

图1:

2.在App启动时加载基础包 图2:

3.在详情页或者加载基础包之后预加载业务包

图3:

4.输出信息:先加载了基础包,后成功加载业务包,且页面&逻辑正常

图4:

四. 功能展望

以上就是Demo中的全部内容了,对于下一步的功能展望就是,通过向工具类中传递不同的与服务器定好的模块Key,去下载不同的bundle内容,同样可以根据Key的不同,下载需要更新的图片资源,由工具类拷贝到指定的本地目录,供应用进行更新加载。

GitHub地址:github.com/yxyhail/Met…