react-native-code-push进阶篇
之前写了一篇关于react-native-code-push的入门使用篇:微软的React Native热更新 - 使用篇,真的是很简单的使用,能热更新成功就行了。这一篇通过在项目中实战所遇到的问题,根据源码分析它的原理,来更深入的理解code-push。
这篇文章是在已经搭建好code-push环境(执行过
npm install --save react-native-code-push@latest、
react-native link react-native-code-push,并安装了code-push cli且成功登陆)为基础下写的,没有使用CRNA来创建App。
部署与配置
部署(deployment)Test,Staging和Production
在真正的项目中,我们一般会分为开发版(Test),灰度版(Staging)和发布版(Production),在Test中我一般是用来跟踪code-push的执行,在Staging中其实是和Production是同样的代码,但是当要热修复线上版本时,先会发布热更新到Staging版,在Staging测过后再通过promoting推到Production中去。
大致步骤:
- 通过
code-push app add MyAppIOS ios react-native来创建iOS端的App,或者通过code-push app add MyAppAndroid android react-native创建Android端的App。 - 使用
code-push app ls查看是否添加成功,默认会创建两个部署(deployment)环境:Staging和Production,可以通过code-push deployment ls MyAppIOS -k来查看当前App所有的部署,-k是用来查看部署的key,这个key是要方法原生项目中去的。 - 添加一个Test部署环境:
code-push deployment add MyAppIOS Test,添加成功后,就可以通过code-push deployment ls MyAppIOS -k来查看Test部署环境下的key了。
经常使用code-push --h来查看可以执行的操作
最后结果如下图所示:
image.png
image.png
在原生项目中动态部署
在上面有提过需要把部署的key添加到原生项目中,这样在不同的运行环境下动态的使用对应的部署key,例如在Staging下使用Staging的key,在Relase下使用Production的key,在Debug下不使用热更新(如需在debug环境下测试code-push,可以在codePush.sync里的option参数中动态修改部署key)。
在Android中动态部署key,并且在同一设备同时安装不同部署的Android包
有两种方式:
- 官方配置入口:https://github.com/Microsoft/react-native-code-push#multi-deployment-testing,通过添加
buildConfigFiled使用对应的key,同时在If you want to be able to install both debug and release builds simultaneously on the same device中有提到在同一设备同时安装不同部署的Android包。 - 第二种方式是通过资源文件
R.string来实现同样的效果,在app/src中分别添加staging/res/values和debug/res/values两个文件夹,然后复制app/src/main/res/value/strings.xml粘贴到刚新建的两个values目录下,最后在代码中获取key的方式为R.string.reactNativeCodePush_androidDeploymentKey。
配置好后可以使用
./gradlew assembleStaging来打包Staging下的apk,输出目录在./android/app/build/outputs/apk下,没有在gradle中配置签名安装(adb install app-staging.apk)会出现如下错误:Failure [INSTALL_PARSE_FAILED_NO_CERTIFICATES] React native,关于gradle的buildType的使用:http://tools.android.com/tech-docs/new-build-system/user-guide#TOC-Build-Types
在iOS中动态部署key
官方配置入口:https://github.com/Microsoft/react-native-code-push#multi-deployment-testing。
在iPhone上同时安装相同App的不同部署包
让你的iOS应用在不同状态(debug, release)有不同的图标和标题
应用中经常遇到的技巧
1、分清楚 Target binary version 和 Label
image.png
label代表发布的更新版本,Target binary version代表app的版本号。
2、使用patch打补丁,修改元数据属性。
使用场景:例如当你已经发布了一个更新,但是到有些情况下,比如--des需要修改,--targetBinaryVersion写错了,比如我的8.6.0写成了8.6,然后在我发布8.6.1新版的时候就会拉取8.6的版本更新,这个时候就可以code-push patch MyAppAndroid Production --label v4 --targetBinaryVersion 8.6.1。
3、使用promote将Staging推到Production
使用场景:当你在指定的部署环境下测试更新时,例如Staging,测试通过后,想把这个更新发布到正式生产环境Production中,则可以使用code-push promote MyAppAndroid Staging Production,这时可以修改一些元数据,例如--description、--targetBinaryVersion、--rollout等。
4、使用rollback回滚
使用场景:当你发布的更新测试没通过时,可以回滚到之前的某个版本。code-push rollback MyAppAndroid Production,当执行这个命令时它会在MyAppAndroid上的Production部署上再次发布一个release,这个release的代码和元属性与Production上倒数第二个版本一致。也可以通过可选参数--targetRelease来指定rollback到的版本,例如code-push rollback MyAppAndroid Production --targetRelase v2,则会新建一个release,这个release的代码和元属性与v2相同。
注意:这个回滚是主动回滚,与自动回滚不一样
5、使用debug查看是否使用了热更新版本
使用场景:当你想知道code-push的状态时,比如正在检查是否有更新包,正在下载,正在安装,当前加载的
bundle路径等,对于android可以使用code-push debug android,对于iOS可以使用code-push debug ios
注意:debug ios必须在模拟器下才可以使用
6、使用deployment h查看更新状态
使用场景:在发布更新后,需要查看安装情况,可以通过code-push deployment h MyAppAndroid Production来查看每一次更新的安装指标。
7、较难理解的发布参数
- Mandatory 代表是否强制性更新,这个属性只是简单的传递给客户端,具体要对这个属性如何处理是由客户端决定的,也就是说,如果在客户端使用
codePush.sync时,updateDialog为true的情况下,如果-mandatory为false,则更新提示框会弹出两个按钮,一个是【确认更新】,一个是【取消更新】,但是在-mandatory为true的情况下就只有一个按钮【确认更新】用户没法拒绝安装这个更新。在updateDialog为false的情况下,-mandatory就不起作用了,因为都会静默更新。注意:mandatory是服务器传给客户端的,它是一个“动态”属性,意思就是当你正在使用版本
v1的更新,然后现在服务器上有v2和v3的更新可用,v2的mandatory为true,v3的mandatory为false,此时去check update,服务器会返回v3的更新属性给客户端,这时服务返回的v3的mandatory为true,因为v3在v2之后发布的更新,它会被认为是包含v2的所有更新信息的,竟然v2有强制更新的需求,那跳过v2直接更新到v3的情况下,v3也被要求强制更新。但是如果你当前是在使用v2的更新包,check update时服务器返回v3的更新包属性,此时v3的mandatory为false,因为对于v2而言v3不是强制要更新的。 - Disabled 默认是为
false,顾名思义,这个参数的意思就是这个更新包是否让用户使用,如果为true,则不会让用户下载这个更新包,使用场景:- 当你想发布一个更新,但是却不想让这个更新立马生效,比如想对外公布一些信息后才让这个更新生效,这时候就可以使用
code-push promote MyAppAndroid Staging Production --disabled false来发布更新到正式环境,在对外公布信息后,使用code-push patch MyAppAndroid Production --disabled true来让用户可以使用这个更新。
- 当你想发布一个更新,但是却不想让这个更新立马生效,比如想对外公布一些信息后才让这个更新生效,这时候就可以使用
- Rollout 用来指定可以接收到这个更新的用户的百分比,取值范围为0-100,不指定时默认为100。如果你希望部分用户体验这个新的更新,然后在观察它的崩溃率和反馈后,在将这个更新发布给所有用户时,这个属性就非常有用。当部署中的最后一个更新包的
rollout值小于100,有三点要注意:- 不能发布新的更新包,除非最后一个更新包的
rollout值被patch为100。 - 当
rollback时,rollout值会被置空(为100)。 - 当
promote去其他部署时,rollout会被置空(为100),可以重新指定--rollout。
- 不能发布新的更新包,除非最后一个更新包的
8、理解安装指标(Install Metrics)数据
先来看下试用过程,现在有两个机子,分别为A和B
第一步:发了一个更新包,Install Metrics中提示No install recorded表示没有安装记录
image.png
第二步:A安装了这个更新包,并且现在正在使用这个更新包
image.png
第三步:给
v1打了个patch,把App Version改为1.0.0,并且把元属性Disabled改为true
image.png
第四步:A卸掉App,发现
Install Metrics中的Activite为0%了(0 of 1),证明在of左边的数是会增降的,of右边的数是只会增不会降的,of左边的数代表当前install或者receive的总人数,当有用户卸载App,或者使用了更新的更新包时,这个数就会降低。因此它很好的解释了当前更新包有多少活跃用户,多少用户接收过这个安装包。Install Metrics中的total并没有改变,还是为1,代表有多少个用户install过这个更新包,这个数字只增不降,注意total与active的区别。
image.png
第五步:分别在A、B上安装这个App。发现图中数据和上图没有任何区别,那是因为
disabled为true,因此不会接收这个更新包。
image.png
第六步:给v1打了个patch,把元属性
Disabled改为true,让Bcheck update,发现下图中active中of右边的数增加了1,代表多了一个用户receivedv1,但是of左边的数字为0,代表v1没有活跃用户,total的改变是多了(1 pending),代表有一个用户receivedv1,但是还没有install(也就是notifyApplicationReady没被调用)
image.png
第七步:让A
check update,发现Active没有任何改变,因为B以前就接收过v1。total中pending数为2了,代表有两个用户receivedv1。
image.png
第八步:让B
installv1,active变为50%,可以看出installed/received为50%。total增加了1,代表v1多了一次installed,一共经历了2次installed,(1 pending)代表还有一个received。
image.png
第九步:让A
installv1,active变为100%。total增加了1,代表v1多了一次installed,一共经历了3次installed,没有pending代表没有received。
image.png
第十步:发一个可以触发
rollback的更新。在
App.js的构造函数中添加如下代码:
constructor() {
super(...arguments)
throw new Error('roll back')
}
然后发个更新出去:code-push release-react MyAppIOS ios -d Staging --dev false --des rollBackTest
此时code-push deployment h MyAppIOS Staging为:
image.png
这时我们让A去
check update,并且把code-push debug ios打开(注意debug必须使用模拟器)。发现v2的total直接从v1total中读下来,也就是说所有的v1用户都会receivedv2,pending为1代表Areceviedv2,但没有installed。
image.png
这时,我们让A
installedv2,发现A会闪退,然后再次进入App,发现pending没有了,但是total并没有增加,active也没有改变,pending的加到rollbacks去了。
image.png
此时
code-push debug ios会打印Update did not finish loading the last time, rolling back to a previous version.第十一步:发布个修订版,修复v2产生的bug。然后让B安装。
image.png
哈哈,这个图看懂了吗,看懂了就代表了解它的意思了O(∩_∩)O哈哈~
第十二步:发布一个强制更新的更新包。
image.png
经过上面的测试,大致了解了Install metrics中各个参数的意思,这里大概总结一下:
- Active 成功安装并运行当前release的用户的数量(当用户打开你的App就会运行这个release),这个数字会根据用户成功
installed这个release或者离开这个release(installed了别的更新包,或者卸载了App),总之有它就知道当前release的活跃用户量 - Total 成功
installed这个release的用户的数量,这个数量只会增不会减。 - Pending 当前这个release被下载的数量,但是还没有被
installed,因此这一个数值会在release被下载时增长,在installed时降低。这个指标主要是适配于没有为更新配置立马安装(mandatory)。如果你为更新配置了立马安装但是还是有pending,很有可能是你的App启动时没有调用notifyApplicationReady。 - Rollbacks 这个数字代表在客户端自动回滚的数量,理想状态下,它应该为0,如果你发布了一个更新包,在
installing中发生crash,code-push将会把它回滚到之前的一个更新包中。
源码解读
检查、下载、使用以及rollback更新包
js模块:
code-push中Javascript API并不多,可以在JavaScript API查阅。
而快速接入的方法也就两种,一种是sync,一种是root-level HOC。现在来看HOC的源码:
//CodePush.js 456行
componentDidMount() {
if (options.checkFrequency === CodePush.CheckFrequency.MANUAL) {
//如果是手动检查更新,直接installed
CodePush.notifyAppReady();
} else {
...
//如果不是手动更新,则每次start app都会去sync
CodePush.sync(options, syncStatusCallback, downloadProgressCallback, handleBinaryVersionMismatchCallback);
if (options.checkFrequency === CodePush.CheckFrequency.ON_APP_RESUME) {
//每次从后台恢复时sync
ReactNative.AppState.addEventListener("change", (newState) => {
newState === "active" && CodePush.sync(options, syncStatusCallback, downloadProgressCallback);
});
}
}
}
可以看出更新的代码是sync:
//CodePush.js 344行
syncStatusChangeCallback(CodePush.SyncStatus.CHECKING_FOR_UPDATE);
const remotePackage = await checkForUpdate(syncOptions.deploymentKey, handleBinaryVersionMismatchCallback);
在checkForUpdate中会去拿App的版本号,部署key和当前更新包的hash值,确保服务器传过来对应的更新包,有几种情况拿不到更新包,第一种是服务端没有更新包,第二种是服务端的更新包要求的版本号与当前App版本不符,第三种是服务端的更新包和App当前正在使用的更新包Hash值相同。
//CodePush.js 85行
//PackageMixins.remote(...)执行后返回一个对象包含两属性,分别是download和isPending。
//download是一个异步方法用来下载更新包,isPending初始值为false,表示没有installed。
const remotePackage = { ...update, ...PackageMixins.remote(sdk.reportStatusDownload) };
//会去判断这个包是否是已经安装失败的包(rollback过)
remotePackage.failedInstall = await NativeCodePush.isFailedUpdate(remotePackage.packageHash);
remotePackage.deploymentKey = deploymentKey || nativeConfig.deploymentKey;
拿到remotePackage后判断这个更新包是否能使用,能使用就去下载:
//CodePush.js 362行
//如果有拿个更新包,但是这个更新包是安装失败的包,并且设置中配置忽略安装失败的包,则这个更新包会被忽略
const updateShouldBeIgnored = remotePackage && (remotePackage.failedInstall && syncOptions.ignoreFailedUpdates);
if (!remotePackage || updateShouldBeIgnored) {
if (updateShouldBeIgnored) {
log("An update is available, but it is being ignored due to having been previously rolled back.");
}
//会去原生端拿当前下载的更新包,如果这个更新包没有installed,又更新包可以安装,如果已经installed就会提示已经是最新版本。
const currentPackage = await CodePush.getCurrentPackage();
if (currentPackage && currentPackage.isPending) {
syncStatusChangeCallback(CodePush.SyncStatus.UPDATE_INSTALLED);
return CodePush.SyncStatus.UPDATE_INSTALLED;
} else {
syncStatusChangeCallback(CodePush.SyncStatus.UP_TO_DATE);
return CodePush.SyncStatus.UP_TO_DATE;
}
} else{
//如果设置中配置弹提示框,则根据mandatory弹出不同的提示框,根据用户的选择决定是否下载更新包。
//如果没有配置弹提示框,则直接下载更新包
...
}
下载的代码:
const doDownloadAndInstall = async () => {
syncStatusChangeCallback(CodePush.SyncStatus.DOWNLOADING_PACKAGE);
//使用之前提到的download方法来下载更新包。
const localPackage = await remotePackage.download(downloadProgressCallback);
//检查安装方式
resolvedInstallMode = localPackage.isMandatory ? syncOptions.mandatoryInstallMode : syncOptions.installMode;
syncStatusChangeCallback(CodePush.SyncStatus.INSTALLING_UPDATE);
//安装更新
await localPackage.install(resolvedInstallMode, syncOptions.minimumBackgroundDuration, () => {
syncStatusChangeCallback(CodePush.SyncStatus.UPDATE_INSTALLED);
});
return CodePush.SyncStatus.UPDATE_INSTALLED;
};
原生模块(以Android端为例):
首先寻找jsbundle路径,getJSBundleFile中返回了CodePush.getJSBundleFile(),在这里面会判断是否有新下载的更新包,如果比本地新则加载这个更新包,否则加载本地包,
//CodePush.java 143行
public String getJSBundleFileInternal(String assetsBundleFileName) {
this.mAssetsBundleFileName = assetsBundleFileName;
String binaryJsBundleUrl = CodePushConstants.ASSETS_BUNDLE_PREFIX + assetsBundleFileName;
//获取当前可以使用的更新包的路径
String packageFilePath = mUpdateManager.getCurrentPackageBundlePath(this.mAssetsBundleFileName);
if (packageFilePath == null) {
// 当前没有任何更新包可以使用
CodePushUtils.logBundleUrl(binaryJsBundleUrl);
sIsRunningBinaryVersion = true;
return binaryJsBundleUrl;
}
//获取当前可以使用的更新包的配置文件
JSONObject packageMetadata = this.mUpdateManager.getCurrentPackage();
if (isPackageBundleLatest(packageMetadata)) {
//如果当前更新包是最新可用的(版本号相符),使用当前更新包
CodePushUtils.logBundleUrl(packageFilePath);
sIsRunningBinaryVersion = false;
return packageFilePath;
} else {
// 当前App的版本是新的(比如更新包是8.6.0的,现在App是8.6.1)
this.mDidUpdate = false;
if (!this.mIsDebugMode || hasBinaryVersionChanged(packageMetadata)) {
//当App版本号有改变的时候清除所有更新包
this.clearUpdates();
}
//使用本地bundle
CodePushUtils.logBundleUrl(binaryJsBundleUrl);
sIsRunningBinaryVersion = true;
return binaryJsBundleUrl;
}
}
在js端的remotePackage.download中会调用原生的downloadUpdate方法:
//CodePushNativeModule.java 203行
public void downloadUpdate(final ReadableMap updatePackage, final boolean notifyProgress, final Promise promise) {
//后台下载任务
AsyncTask<Void, Void, Void> asyncTask = new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
try {
JSONObject mutableUpdatePackage = CodePushUtils.convertReadableToJsonObject(updatePackage);
CodePushUtils.setJSONValueForKey(mutableUpdatePackage, CodePushConstants.BINARY_MODIFIED_TIME_KEY, "" + mCodePush.getBinaryResourcesModifiedTime());
//开始下载remotePackage
mUpdateManager.downloadPackage(mutableUpdatePackage, mCodePush.getAssetsBundleFileName(), new DownloadProgressCallback() {
//下载进度回调
...
});
//获取remotePackage的信息并返回给js
JSONObject newPackage = mUpdateManager.getPackage(CodePushUtils.tryGetString(updatePackage, CodePushConstants.PACKAGE_HASH_KEY));
promise.resolve(CodePushUtils.convertJsonObjectToWritable(newPackage));
} catch (IOException e) {
e.printStackTrace();
promise.reject(e);
} catch (CodePushInvalidUpdateException e) {
e.printStackTrace();
mSettingsManager.saveFailedUpdate(CodePushUtils.convertReadableToJsonObject(updatePackage));
promise.reject(e);
}
return null;
}
};
asyncTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
在js端调用installUpdate,一共会出现三个hash值,分别是刚下载的更新包的hash值(packageHash),当前使用的hash值(currentPackageHash),以前使用的hash值(previousPackageHash),现在要把prevousPackageHash = currentPackageHash,currentPackageHash = packageHash
//CodePushUpdateManager.java
public void installPackage(JSONObject updatePackage, boolean removePendingUpdate) {
//获取更新包的hash值
String packageHash = updatePackage.optString(CodePushConstants.PACKAGE_HASH_KEY, null);
JSONObject info = getCurrentPackageInfo();
//获取当前使用的更新包的hash值
String currentPackageHash = info.optString(CodePushConstants.CURRENT_PACKAGE_KEY, null);
if (packageHash != null && packageHash.equals(currentPackageHash)) {
// 如果下载的更新包和当前使用的是同一个更新包,不做处理
return;
}
if (removePendingUpdate) {
//如果当前使用的更新包是下载好但没有installed的更新包,则把这个更新包移除
String currentPackageFolderPath = getCurrentPackageFolderPath();
if (currentPackageFolderPath != null) {
FileUtils.deleteDirectoryAtPath(currentPackageFolderPath);
}
} else {
//获取之前的更新包,并移除
String previousPackageHash = getPreviousPackageHash();
if (previousPackageHash != null && !previousPackageHash.equals(packageHash)) {
FileUtils.deleteDirectoryAtPath(getPackageFolderPath(previousPackageHash));
}
//将上一个更新包指向当前更新包
CodePushUtils.setJSONValueForKey(info, CodePushConstants.PREVIOUS_PACKAGE_KEY, info.optString(CodePushConstants.CURRENT_PACKAGE_KEY, null));
}
//设置当前可使用的更新包为update package
CodePushUtils.setJSONValueForKey(info, CodePushConstants.CURRENT_PACKAGE_KEY, packageHash);
updateCurrentPackageInfo(info);
}
将刚下载的更新包标记为pending package,isloading为false:
//CodePushNativeModule.java 411行,
//标记为pending,并且isLoading为false
mSettingsManager.savePendingUpdate(pendingHash, /* isLoading */false);
App第一次进入和重新加载bundle时会调用initializeUpdateAfterRestart,用来判断是否有pending package,如果有并且isloading为true(被init过),代表这个pending package在notifyApplicationReady前崩溃了,因此需要rollback,如果isloading为false则代表是第一次加载更新包,会将isloading(init)置为true,用来判断下次进入时需不需要rollback:
//CodePush.js 177行
void initializeUpdateAfterRestart() {
...
JSONObject pendingUpdate = mSettingsManager.getPendingUpdate();
if (pendingUpdate != null) {
//有新的更新包可用
JSONObject packageMetadata = this.mUpdateManager.getCurrentPackage();
if (!isPackageBundleLatest(packageMetadata) && hasBinaryVersionChanged(packageMetadata)) {
//版本不符
CodePushUtils.log("Skipping initializeUpdateAfterRestart(), binary version is newer");
return;
}
try {
boolean updateIsLoading = pendingUpdate.getBoolean(CodePushConstants.PENDING_UPDATE_IS_LOADING_KEY);
if (updateIsLoading) {
// Pending package已经被init过, 但是 notifyApplicationReady 没有被调用.
// 因此认为这是个无效的更新并且rollback.
CodePushUtils.log("Update did not finish loading the last time, rolling back to a previous version.");
sNeedToReportRollback = true;
rollbackPackage();
} else {
// 现在有个新的更新包可以运行,开始init这个更新包
//如果它崩溃了,需要在下一次启动时rollback
mSettingsManager.savePendingUpdate(pendingUpdate.getString(CodePushConstants.PENDING_UPDATE_HASH_KEY),
/* isLoading */true);
}
} catch (JSONException e) {
// Should not happen.
throw new CodePushUnknownException("Unable to read pending update metadata stored in SharedPreferences", e);
}
}
}
rollback的代码:
//CodePush.java 257行
private void rollbackPackage() {
//将当前使用的更新包标记为失败的包
JSONObject failedPackage = mUpdateManager.getCurrentPackage();
mSettingsManager.saveFailedUpdate(failedPackage);
//用之前使用的更新包替换当前使用的更新包
mUpdateManager.rollbackPackage();
//移除pending package
mSettingsManager.removePendingUpdate();
}
notifyApplicationReady的代码:
//CodePushNativeModule.java 498行
public void notifyApplicationReady(Promise promise) {
//移除pending package
mSettingsManager.removePendingUpdate();
promise.resolve("");
}
总结:
js端使用checkupdate用App当前的版本号,当时使用的更新包信息以及部署key传递给原生,原生调用codu-push服务器查询是否有更新包可以使用,如果不存在更新包,或者更新包与当前使用的更新包一致,或者版本号不符都不会产生remotePackage。拿到remotePackage后会去原生的本地存储查询这个remotePackage的hash是否为failedPackage,如果是failedPackage则会选择忽略这个更新包,否则就download这个更新包。
下载好更新包后,将这个更新包标志位pending package,并且isloading为false,将previousPacakge置为currentPackage,currentPackage置为下载的更新包。
在加载更新包时会判断这个更新包是否是pending package,如果是则判断isloading是否为false,如果为false则代表这个pending package是第一次加载,如果为true则代表这个pending被加载后调用notifyApplicationReady前发生崩溃,需要回滚。
如果发生回滚会将pending package置空,将previouPackage赋值给currentPackage。
在正确加载更新包后,应该手动触发notifyApplicationReady将pending package置空,代表这个更新包被正确installed。
示例:
hash包的管理:
failed package:崩溃的package
pending package:下载好的没有被installed的package
previous package: 之前使用的package
current package:当前正在使用package
第一步:下载更新包A
pending pacakge = A
isloding = false
previous package = current package
current package = pending package
第二步:第一次使用A
pending isloading = true
如果在notifyApplicationReady之前发生崩溃走第三步,否则走第四步。
第三步:再次加载bundle,发现pending package还存在,并且isloading为true,回滚
第四步:pending package不存在,不做任何处理
Demo
地址:https://github.com/lyxia/CodePushExp
image.png
image.png
image.png
image.png