分析
在 《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/icon@2x.png'
*/
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