CodePush优化之减小更新包体积

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/u013718120/article/details/85099974

 

还有 10 天就要迎来 2019 新年,感慨 18 年过的好快,恍恍惚惚。2018 年经历了很多,人生最重要的事情,很开心。闲余时间浏览了这一年写过的博客,9 篇相对 17 年少了很多。时间不等人,什么事还是要提前计划往前做。本来是要等新年再和大家分享新的内容,回头看看还是以整数结尾这一年,也算欣慰。

这篇博客内容是18年的最后一篇,也是关于 React Native 的最后一篇。从17年开RN专栏,到现在整整30篇。浏览访问量证实了大家对RN的认可,更多的还是对于跨平台技术的接受与热爱。怀着对RN以及跨平台技术的喜爱,今年年初在微信创建react-native技术交流群,短短的时间内就由稀疏零散变成了400多人的大家庭,这不仅仅是一个讨论组,更多的是大家技术生活的一部分。未来,共同进步。

很早之前,曾写过关于热更新的自实现解决方案 《React Native 实现热部署、差异化增量热更新》。其中详细描述了如何实现自定义热更新,以及增量更新。这种方案存在的缺点也是非常明显的,不够稳定,兼容性差等。微软出品的 react-native-code-push 帮助我们在RN中实现热部署变得非常容易,几句简单的配置代码,就可以让我们赞叹不已。围绕 codepush 热更新技术,在面试中也就成为了家常便饭。曾经群里有个朋友和我聊起热更新,提到 “ codepush 是差异化增量更新的吗?”  当时心想, codepush 作为一个成熟的热更新技术框架,差异化更新是必须的。其实,codepush 不完全是增量更新。之前的回答还是有点误己误人了。

分析

在 《CodePush热更新常用命令与注意事项》中简单介绍过关于codepush的一些常用操作及命令。在发布更新包时,我们一般会通过如下命令:

code-push release  <appName> <platform> ./bundle-images文件夹路径/ -d Production -t 1.1.1 --des "更新描述"

或者

code-push release-react <appName> <platform> -t 1.1.1 -d Production --des "更新描述" -m true (强制更新)

两种方式的区别在于第二条命令会自动打包生成bundle文件和图片资源。

假设当前热更新的版本为 1.1.0,内容如下:

资源文件夹/
├── drawable-mdpi
│   ├── a.png
│   └── b.png
└── index.iOS.bundle

即将要更新的版本为1.1.1,内容如下:

资源文件夹/
├── drawable-mdpi
│   ├── a.png
│   ├── b.png
│   └── c.png // 新增图片 c.png
└── index.iOS.bundle

如果当前 App 处于 1.1.0版本,并且在之前没有做过任何codepush相关的更新操作。从 1.1.0 更新到 1.1.1 时,按照我们所理解的 codepush 差异化更新的特点,在App检测到 1.1.1 的更新后,会将 c.png 图片和结构发生变化的 jsbundle 文件下载到本地,并在合适的时机进行加载,渲染。但真相并非如此!

1. 在 codepush 系统检测到有新版本,第一次做热更新加载时,会将所有的资源全部下载到本地。

2. 如果之前用户是1.0.0 版本通过 codepush 升级到 1.1.0 版本,再升级到 1.1.1 版本,这个时候 codepush 就会下载 diff 之后的 JSBundle 和新增的 c.png 图片两个文件。

所以在第一次做热更新操作时,是稍微消耗带宽流量的。

RN图片加载流程

造成这个现象的主要原因还要从 RN 框架 图片加载的方式说起,我们来看看 RN 图片加载流程的核心代码,打开 node_modules/react-native/Libraries/Image/AssetSourceResolver.js:

  /**
   * jsBundle 文件是否从packager服务加载
   */
  isLoadedFromServer(): boolean {
    return !!this.serverUrl;
  }

  /**
   * jsBundle 文件是否从本地文件目录加载
   */
  isLoadedFromFileSystem(): boolean {
    return !!(this.jsbundleUrl && this.jsbundleUrl.startsWith('file://'));
  }

  /**
   * 图片加载
   */
  defaultAsset(): ResolvedAssetSource {
    if (this.isLoadedFromServer()) {
      return this.assetServerURL();
    }

    if (Platform.OS === 'android') {
      return this.isLoadedFromFileSystem()
        ? this.drawableFolderInBundle()
        : this.resourceIdentifierWithoutScale();
    } else {
      return this.scaledAssetURLNearBundle();
    }
  }

  

在 defaultAsset() 方法中,首先判断JSBundle文件是否是从 packager 服务加载(调试模式),如果是会直接本地服务加载。

接着对 Android、iOS 两个不同不同处理:

(1)Android 平台下,判断是否是从手机本地目录下加载,如果是,则调用 drawableFolderInBundle() 方法;反之调用 resourceIdentifierWithoutScale() 方法。

(2)iOS 平台下直接调用 scaledAssetURLNearBundle() 方法。

我们首先 Android 平台下的  drawableFolderInBundle()、resourceIdentifierWithoutScale() 两个方法:

  /**
   * 如果jsbundle从本地文件目录下加载运行,则会解析相对于其位置的资产
   * E.g. 'file:///sdcard/AwesomeModule/drawable-mdpi/icon.png'
   */
  drawableFolderInBundle(): ResolvedAssetSource {
    const path = this.jsbundleUrl || 'file://';
    return this.fromSource(path + getAssetPathInDrawableFolder(this.asset));
  }

  /**
   * 与应用捆绑在一起的资产的默认位置,按资源标识符定位
   * Android资源系统选择正确的比例
   * E.g. 'assets_awesomemodule_icon'
   */
  resourceIdentifierWithoutScale(): ResolvedAssetSource {
    invariant(
      Platform.OS === 'android',
      'resource identifiers work on Android',
    );
    return this.fromSource(
      assetPathUtils.getAndroidResourceIdentifier(this.asset),
    );
  }

从上述源码我们不难发现:

(1)如果 JSBundle 文件是从本地文件目录(File)加载,例如(/sdcard/com.songlcy.myapp/...)之类的目录,不是从 assets 资源目录加载的情况下,会从相对该目录下的 drawable-xxx 目录下加载图片。

假设当前加载的 JSBundle 的文件路径是 /sdcard/com.songlcy.myapp/code-push/index.iOS.jsbundle,会从 /sdcard/com.songlcy.myapp/code-push/ 目录下查找图片。

(2)如果是从 assets 目录中加载的 JSBundle 文件,这个时候就会从apk包中的 drawable-xxx 目录中加载图片。

iOS 平台下并没有做任何区分,直接调用了 scaledAssetURLNearBundle() 方法:

  /**
   * 直接从 JSBundle 当前目录下查找 assets 目录
   * E.g. 'file:///sdcard/bundle/assets/AwesomeModule/[email protected]'
   */
  scaledAssetURLNearBundle(): ResolvedAssetSource {
    const path = this.jsbundleUrl || 'file://';
    return this.fromSource(path + getScaledAssetPath(this.asset));
  }

分析完图片的整个加载流程,我们再回到 codepush 更新。我们都知道,在当前APP检测到有更新时,codepush 会将服务器上的JSBundle 及 图片资源下载到手机本地目录,所以 JSBundle 文件是从手机系统文件目录加载,根据RN图片加载流程,更新的图片资源也需要放到 JSBundle 所在目录下。因此在 codepush 第一次更新的时候,需要把所有资源全部下载下来,否则会出现找不到资源的错误,加载失败。同样这样做也是为了方便统一管理,在第二次更新时, codepush 就会做一次 diff-patch,通过比对来实现差异化增量更新。

优化方案

了解了当前 codepush 在第一次更新时所带来的更新流量开销,那么我们如何优化第一次更新的包体积,使其也可以做到差异化增量更新呢?通过上面的分析,我们可以修改RN图片加载流程,通过 assets 和 本地目录结合,在更新后,判断当前JSBundle所在的本地目录下是否存在更新之前的资源,如果存在直接加载,不存在,则从apk包中的 drawable-xxx 目录中加载。此时,我们就不用上传所有的图片资源,只需要上传更新的资源即可。

修改RN图片加载流程,我们可以直接在源码中进行修改,也可以使用 hook 的方式进行修改,保证了项目与node_modules耦合度降低,核心代码如下:

import { NativeModules } from 'react-native';    
import AssetSourceResolver from "react-native/Libraries/Image/AssetSourceResolver";
import _ from 'lodash';

let iOSRelateMainBundlePath = '';
let _sourceCodeScriptURL = '';

// ios 平台下获取 jsbundle 默认路径
const defaultMainBundePath = AssetsLoad.DefaultMainBundlePath;

function getSourceCodeScriptURL() {
    if (_sourceCodeScriptURL) {
        return _sourceCodeScriptURL;
    }
    // 调用Native module获取 JSbundle 路径
    // RN允许开发者在Native端自定义JS的加载路径,在JS端可以调用SourceCode.scriptURL来获取 
    // 如果开发者未指定JSbundle的路径,则在离线环境下返回asset目录
    let sourceCode =
        global.nativeExtensions && global.nativeExtensions.SourceCode;
    if (!sourceCode) {
        sourceCode = NativeModules && NativeModules.SourceCode;
    }
    _sourceCodeScriptURL = sourceCode.scriptURL;
    return _sourceCodeScriptURL;
}

// 获取bundle目录下所有drawable 图片资源路径
let drawablePathInfos = [];
AssetsLoad.searchDrawableFile(getSourceCodeScriptURL(),
     (retArray)=>{
      drawablePathInfos = drawablePathInfos.concat(retArray);
});
// hook defaultAsset方法,自定义图片加载方式
AssetSourceResolver.prototype.defaultAsset = _.wrap(AssetSourceResolver.prototype.defaultAsset, function (func, ...args) {
     if (this.isLoadedFromServer()) {
         return this.assetServerURL();
     }
     if (Platform.OS === 'android') {
         if(this.isLoadedFromFileSystem()) {
             // 获取图片资源路径
             let resolvedAssetSource = this.drawableFolderInBundle();
             let resPath = resolvedAssetSource.uri;
             // 获取JSBundle文件所在目录下的所有drawable文件路径,并判断当前图片路径是否存在
             // 如果存在,直接返回
             if(drawablePathInfos.includes(resPath)) {
                 return resolvedAssetSource;
             }
             // 判断图片资源是否存在本地文件目录
             let isFileExist = AssetsLoad.isFileExist(resPath);
             // 存在直接返回
             if(isFileExist) {
                 return resolvedAssetSource;
             } else {
                 // 不存在,则根据资源 Id 从apk包下的drawable目录加载
                 return this.resourceIdentifierWithoutScale();
             }
         } else {
             // 则根据资源 Id 从apk包下的drawable目录加载
             return this.resourceIdentifierWithoutScale();
         }

     } else {
         let iOSAsset = this.scaledAssetURLNearBundle();
         let isFileExist =  AssetsLoad.isFileExist(iOSAsset.uri);
         isFileExist = false;
         if(isFileExist) {
             return iOSAsset;
         } else {
             let oriJsBundleUrl = 'file://'+ defaultMainBundePath +'/' + iOSRelateMainBundlePath;
             iOSAsset.uri = iOSAsset.uri.replace(this.jsbundleUrl, oriJsBundleUrl);
             return iOSAsset;
         }
     }
});

实现逻辑其实很简单:

(1)通过 hook 的方式重新定义 defaultAsset() 方法。

(2)如果是从手机系统文件目录加载JSBundle文件:

         1. 获取当前图片资源文件路径,判断当前JSBundle目录下是否存在。如果存在,则直接返回当前资源。

         2. 判断手机本地文件目录下是否存在该图片资源,如果存在,则直接返回当前资源。不存在,则从 apk 包中根据资源 Id 来加载图片资源。

(3)不是从手机系统文件目录加载JSBundle文件,则直接从 apk 包中根据资源 Id 来加载图片资源。

经过以上流程在 codepush 第一次更新时,实现资源的差异化增量更新。详细代码可以查看 react-native-code-push-assets

猜你喜欢

转载自blog.csdn.net/u013718120/article/details/85099974