React Native拆包及热更新方案

holden688 8年前
   <p>随着 React Native 的不断发展完善,越来越多的公司选择使用 React Native 替代 iOS/Android 进行部分业务线的开发,也有不少使用 Hybrid 技术的公司转向了 React Native 。要说 React Native 最能吸引开发者的地方那就是其拥有前端的开发速度以及原生的体验。</p>    <h2>1、概述</h2>    <p>今天要跟大家探讨的是 React Native 的拆包及热更新方案,官方并没有很好的支持这一企业十分看中的热更新能力,因此也催生了第三方的热更新方案,如 CodePush 、 react-native-pushy 。由于公司内部有不同的业务线,所以在采用第三方的热更新方案灵活度不够,在调研的初期,我们参考了携程的提到的 jsbundle 拆分和加载优化方案 ,但这个方案需要改变 React Native 的打包代码及 Runtime 代码,实施难度上非常大,暂无精力深入研究,但这个方案对加载速度提升也是显而易见的。我们暂时放弃了携程的方案,我们前期需要一套相对简单稳定且可行度高的方案,在经过调研及讨论后定下了这样一套热更方案,今天我们就来聊聊这个方案。</p>    <h2>2、流程梳理</h2>    <p>整体流程图其实非常简单,不过内部一些细节规则需要仔细推敲。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/2d7173f82867617066a3ac77e0d1e564.png"></p>    <h2>3、热更新模块的实现方案</h2>    <p>当下选择使用 React Native 的项目大都是基于原有项目的基础上进行接入,所以要达到上线的项目的状态自然要各方面都准备就绪,热更新就作为基建工程之一。</p>    <p>2.1 jsbundle 的拆分</p>    <p>对 React Native 的代码打包编译后会生成一个 bundle 文件,这里要说明一下, jsbundle 的拆分是基于生成的 bundle 文件可以看成两部分构成(如下图):一是 React Native 包含的的基础类库,一是开发的业务代码。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/51affb2fa3f3a4afe2e540831e77553b.png"></p>    <p>了解了这一点,我们就可以基于此将完整的 bundle 文件进行拆分:</p>    <p>首先需要做的就是生成 common.bundle ,新建一个 blank.android.js 文件,在文件中仅引入 react 及 react native :</p>    <pre>  <code class="language-javascript">import React from 'react';  import {} from 'react-native';  </code></pre>    <p>通过打包命令编译成 common.bundle :</p>    <pre>  <code class="language-javascript">react-native bundle --entry-file blank.android.js --bundle-output ~/Desktop/common.bundle --platform android --dev false  </code></pre>    <p>其次,打包完整的 jsbundle ,这将会包含所有的基础类库及业务代码。提醒一句保持 import 的公共模块一致:</p>    <pre>  <code class="language-javascript">import React from 'react';  import { AppRegistry } from 'react-native';  //其他导入  ...  </code></pre>    <p>最后根据 diff 算法将两个文件进行 diff 拆分,由此会生成一个 index.diff 的二进制文件。如有多个业务代码,相应的生成多个 diff 文件即可。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/e4027b8322068189a083d816b003e5ad.png"></p>    <p>记得有几篇文章中推荐 google-diff-match-patch ,虽然 Google 这个开源版本包含多种语言的实现,但由于是基于纯文本的 diff 所以在当下这个场景下并不十分合适,我还是推荐大家使用基于二进制的 diff ,再此也推荐另一种 java 版本的 bsdiff 的实现: jbdiff 。</p>    <p>2.2 bundle 文件的拷贝及合成</p>    <p>在完成拆分以后,我们需要将 common.bundle 及拆分的 *.diff 文件进行 zip 压缩,放入 assets 目录下,为了方便版本管理,我们将其文件名中写入版本号 jsbundle_<版本号>.zip ,例如: jsbundle_1.zip ,每次改 zip 文件包跟随发版时更新,并自动升级版本号。</p>    <p>接下来我们要做的就是将内置于 assets 目录下的 jsbundle_*.zip 拷贝至内部存储,这里推荐使用应用内部存储。</p>    <p>在拷贝过程中根据历史记录的版本号,进行判断是否需要执行拷贝,拷贝完成后将 common.bundle 及 *.diff 文件进行 patch 合并,合并后的文件即为一个完整的 bundle 文件,文件名规定为 *.diff.bundle ,例如: index.diff.bundle ,在加载时根据模块名进行加载即可。</p>    <p>2.3 diff 文件的更新</p>    <p>说到热更新,反而在关于 *.diff 文件的更新本身并没有什么复杂度,简单来说就是下载替换 *.diff 文件,并合成新的完整 bundle 文件,其他需要注意的则是关于 diff 文件版本的控制。</p>    <p>其他主要工作量在于 diff 文件的生成及上传,这部分是我们编写 shell 脚本自动完成的,以下摘录部分 packer.sh 的打包代码。</p>    <pre>  <code class="language-javascript">if [ $platform == "android" ]; then   react-native bundle \          --entry-file $commonFile.js \          --bundle-output $androidModuleDir/common.bundle \          --platform android \          --dev false        echo "common.bundle packed!!!"        react-native bundle \          --entry-file $module.js \          --bundle-output $androidModuleDir/$module.android.bundle \          --platform android \          --dev false        echo "$module.android.bundle packed!!!"        # 对 jbdiff 打成的 jar 执行文件      chmod +x dmp.jar         echo "diff start =========>>>"      java -jar ./dmp.jar $androidModuleDir/common.bundle \          $androidModuleDir/$module.android.bundle $androidModuleDir/$module.diff      # 进行二次 zip 压缩      zip -j $androidModuleDir/$module.diff.zip $androidModuleDir/$module.diff  elfi ...  </code></pre>    <p>2.4 对于容器 Activity 的改造</p>    <p>由于对于 React Native 的 bundle 文件加载做了更改,我们就不能直接使用 sdk 提供的 ReactActivity 了,对此我们需要对容器 Activity 进行改造。</p>    <p>而改造的最终落脚点其实是 ReactInstanceManager 的构建,由于我们需要按业务模块加载,所以最终将其进行了部分改造:</p>    <pre>  <code class="language-javascript">public class MyReactNativeHost extends ReactNativeHost{      ...      protected MyReactNativeHost(Application application, String moduleName) {          super(application);          mApplication = application;          mModuleName = moduleName;      }      ...      @Override      protected ReactInstanceManager createReactInstanceManager() {          if(getUseDeveloperSupport()){ //为了保留 debug 的能力              return super.createReactInstanceManager();          }          String path = JSBundleManager.getJSBundleDirPath(mApplication)                  .concat(mModuleName).concat(".diff.bundle");          ReactInstanceManager.Builder builder = ReactInstanceManager.builder()                  .setApplication(mApplication)                  .setJSBundleLoader(JSBundleLoader.createFileLoader(path))                  .setUseDeveloperSupport(false)                  .setInitialLifecycleState(LifecycleState.BEFORE_RESUME);          ...          return builder.build();      }      ...  }  </code></pre>    <p>将改造后的 Activity 容器也要接入原有项目的路由框架(如果项目本身有的话),至此,整个更新加载就可以串起来了。</p>    <h2>4、热更新改造的后遗症</h2>    <p>由于采用加载文件系统下的 bundle 文件的形式,在测试过程中发现通过此形式加载的 bundle 文件,图片加载时不能读取到 res 目录下的资源文件,带着这个问题看了相关的 js 源码,发现了一个有意思的地方:</p>    <pre>  <code class="language-javascript">...  class AssetSourceResolver {    isLoadedFromFileSystem(): boolean {      return !!this.bundlePath;    }      defaultAsset(): ResolvedAssetSource {      if (this.isLoadedFromServer()) { //如果是从服务器下发的bundle,资源从服务器读取,对应debug模式        return this.assetServerURL();      }        if (Platform.OS === 'android') { //在android平台        return this.isLoadedFromFileSystem() ?          this.drawableFolderInBundle() ://如果是从文件系统读取的bundle则从文件系统取资源          this.resourceIdentifierWithoutScale();//否则从res读取资源      } else {        return this.scaledAssetPathInBundle();      }    }    ...    resourceIdentifierWithoutScale(): ResolvedAssetSource {      invariant(Platform.OS === 'android', 'resource identifiers work on Android');      return this.fromSource(assetPathUtils.getAndroidResourceIdentifier(this.asset));    }        drawableFolderInBundle(): ResolvedAssetSource {      const path = this.bundlePath || '';      return this.fromSource(        'file://' + path + getAssetPathInDrawableFolder(this.asset)      );    }  }  </code></pre>    <p>看到这里就明白了,源码中对资源的加载保持了跟 bundle 文件同源。要解决这个问题有两个方案:1、将 js 源码中的逻辑进行修改,都从 res 中读取资源;2、将 React Native 使用到的资源打包到本地,跟随 jsbundle_*.zip 发布。我个人比较倾向于第二个方案,我主要考虑两点:一是后续 React Native 版本升级的成本,一是对于 React Native 的资源单独管理,同时也意外的获得了一个 React Native 资源热更的能力。</p>    <p>最后,吐槽下 React Native 的一个坑,目前最新的 0.41 版的 Android 端的占位图通过 <Image /> 的 loadingIndicatorSource 属性来指定无效,15 年的一个 issues #5017 到现在没有修复,实在匪夷所思,感觉我是用了假的 RN !</p>    <p> </p>    <p>来自:http://solart.cc/2017/02/22/react-native-jsbundle_patch/</p>    <p> </p>