一、业务流程
1.正常更新流程
1)默认情况下APP如果是第一次启动,则更新完成后立刻重启bridge,否则等到下次启动才重启bridge(可以设置强制更新,但重启会白屏)
2)如果下载过程中因断网、APP退出而导致下载失败的,已下载的数据作废,需要重新下载
3)下载过程中不能连代理,否则会下载失败(https证书校验通不过)
4)可以分包下载,不过需要修改源码,更新逻辑比较复杂
1.1 检查更新:这是codepush的第一步,也是最重要的一步!!
主要代码在JS这边,从RN包加载后,就开始调用 CodePushUtil 的init方法开始检查更新,然后调用RN自带的网路组件去请求codepush服务器。 客户端把本地RN包的版本号(label)、hash值和APP版本传给服务端, 检查是否有新的包可以下载。 如果有,则会返回对应的地址和其他相应字段,如果没有,则地址为空字符串
返回数据(有新版本):
{
"updateInfo": {
"downloadURL": "https://cstatic.xxx.net/code-push-download/lh9RADUHUusZEaLk9T1T5j6OnLV_",
"downloadUrl": "https://cstatic.xxx.net/code-push-download/lh9RADUHUusZEaLk9T1T5j6OnLV_",
"description": "",
"isAvailable": true,
"isDisabled": false,
"isMandatory": false,
"appVersion": "4.2.3",
"targetBinaryRange": ">=3.5.9",
"packageHash": "bfa5e47bf38cceeaee107d390dd9e004fc38ce54558b53ee4596b2fad0e838fe",
"label": "v1315",
"packageSize": 5806711,
"updateAppVersion": false,
"shouldRunBinaryVersion": false
}
}
返回数据(无新版本):
{
"updateInfo": {
"downloadUrl": "",
"description": "",
"isAvailable": false,
"isDisabled": true,
"isMandatory": false,
"appVersion": "4.2.3",
"targetBinaryRange": "",
"packageHash": "",
"label": "",
"packageSize": 0,
"updateAppVersion": false,
"shouldRunBinaryVersion": false
}
}
返回的结果会保存在本地对应版本的 的 app.json文件中,路径为 沙盒的文档目录(不会被系统自动清理) /CodePush/ 本地包的hash值/app.json。
请求逻辑代码主要在CodePush.js的sync->syncInternal-> checkForUpdate方法内,实际请求的代码是放在 acquisition-sdk 的queryUpdateWithCurrentPackage 方法内。
这里简单看一下syncInternal方法的实现,这个方法比较长,拆成三段来看:
首先是初始化syncOptions和申明syncStatusChangeCallback,这个syncStatusChangeCallback是通过参数外部传入的,后面会多次调用
/*
* The syncInternal method provides a simple, one-line experience for
* incorporating the check, download and installation of an update.
*
* It simply composes the existing API methods together and adds additional
* support for respecting mandatory updates, ignoring previously failed
* releases, and displaying a standard confirmation UI to the end-user
* when an update is available.
*/
async function syncInternal(options = {}, syncStatusChangeCallback, downloadProgressCallback, downloadFailedCallback, handleBinaryVersionMismatchCallback, syncCallbackOptions = {}) {
let resolvedInstallMode;
const syncOptions = {
deploymentKey: null,
ignoreFailedUpdates: true,
rollbackRetryOptions: null,
installMode: CodePush.InstallMode.ON_NEXT_RESTART,
mandatoryInstallMode: CodePush.InstallMode.IMMEDIATE,
minimumBackgroundDuration: 0,
updateDialog: null,
...options
};
const { checkForUpdateOptions = {}, downloadOptions = {}, oneSelfAchieveOptions = {} } = syncCallbackOptions
syncStatusChangeCallback = typeof syncStatusChangeCallback === "function"
? syncStatusChangeCallback
: (syncStatus) => {
switch (syncStatus) {
case CodePush.SyncStatus.CHECKING_FOR_UPDATE:
log("Checking for update.");
break;
case CodePush.SyncStatus.AWAITING_USER_ACTION:
log("Awaiting user action.");
break;
case CodePush.SyncStatus.DOWNLOADING_PACKAGE:
log("Downloading package.");
break;
case CodePush.SyncStatus.INSTALLING_UPDATE:
log("Installing update.");
break;
case CodePush.SyncStatus.UP_TO_DATE:
log("App is up to date.");
break;
case CodePush.SyncStatus.UPDATE_IGNORED:
log("User cancelled the update.");
break;
case CodePush.SyncStatus.UPDATE_INSTALLED:
if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESTART) {
log("Update is installed and will be run on the next app restart.");
} else if (resolvedInstallMode == CodePush.InstallMode.ON_NEXT_RESUME) {
if (syncOptions.minimumBackgroundDuration > 0) {
log(`Update is installed and will be run after the app has been in the background for at least ${syncOptions.minimumBackgroundDuration} seconds.`);
} else {
log("Update is installed and will be run when the app next resumes.");
}
}
break;
case CodePush.SyncStatus.UNKNOWN_ERROR:
log("An unknown error occurred.");
break;
}
};
.......
}
第二部分是从try 开始一直到doDownloadAndInstall这个方法的定义结束,这里先是把syncStatus改为CHECKING_FOR_UPDATE,调用checkForUpdate。
然后定义了doDownloadAndInstall,这里面是下载和安装的实际操作,包括调用了 remotePackage.download 下载RN包,调用localPackage.install进行安装,
......
try {
await CodePush.notifyApplicationReady();
syncStatusChangeCallback(CodePush.SyncStatus.CHECKING_FOR_UPDATE);
const remotePackage = await checkForUpdate(syncOptions.deploymentKey, handleBinaryVersionMismatchCallback, checkForUpdateOptions);
const doDownloadAndInstall = async () => {
syncStatusChangeCallback(CodePush.SyncStatus.DOWNLOADING_PACKAGE);
let localPackage
downloadOptions.downloadStartHandler && downloadOptions.downloadStartHandler()
let isSuccess = true
try {
localPackage = await remotePackage.download(downloadProgressCallback, downloadFailedCallback);
// 连了代理会下载不到
// if (!localPackage) {
// isSuccess = false
// }
} catch (e) {
isSuccess = false
}
downloadOptions.downloadEndHandler && downloadOptions.downloadEndHandler(isSuccess);
// Determine the correct install mode based on whether the update is mandatory or not.
resolvedInstallMode = localPackage.isMandatory ? syncOptions.mandatoryInstallMode : syncOptions.installMode;
syncStatusChangeCallback(CodePush.SyncStatus.INSTALLING_UPDATE);
await localPackage.install(resolvedInstallMode, syncOptions.minimumBackgroundDuration, syncOptions.isOneSelfAchieve, () => {
syncStatusChangeCallback(CodePush.SyncStatus.UPDATE_INSTALLED);
}, () => {
//后加 下载完成 还未重新加载
oneSelfAchieveOptions.oneSelfAchieveCallback && oneSelfAchieveOptions.oneSelfAchieveCallback()
});
return CodePush.SyncStatus.UPDATE_INSTALLED;
};
......
第三段是从 updateShouldBeIgnored 一直到方法结束,这里根据 checkForUpdate获取到的remotePackage和 shouldUpdateBeIgnored的结果分为三种情况:
一是没有远程包或者之前这个包回滚过,这时候不会更新,而是把状态改为UPDATE_INSTALLED或者UP_TO_DATE;
二是需要用户确认更新,这时候会有弹窗出来,当用户点击之后再更新(我们没有用到,可以忽略)
三是默认情况,调用doDownloadAndInstall更新
......
const updateShouldBeIgnored = await shouldUpdateBeIgnored(remotePackage, syncOptions);
if (!remotePackage || updateShouldBeIgnored) {
if (updateShouldBeIgnored) {
log("An update is available, but it is being ignored due to having been previously rolled back.");
}
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 if (syncOptions.updateDialog) {
// updateDialog supports any truthy value (e.g. true, "goo", 12),
// but we should treat a non-object value as just the default dialog
if (typeof syncOptions.updateDialog !== "object") {
syncOptions.updateDialog = CodePush.DEFAULT_UPDATE_DIALOG;
} else {
syncOptions.updateDialog = { ...CodePush.DEFAULT_UPDATE_DIALOG, ...syncOptions.updateDialog };
}
return await new Promise((resolve, reject) => {
let message = null;
let installButtonText = null;
const dialogButtons = [];
if (remotePackage.isMandatory) {
message = syncOptions.updateDialog.mandatoryUpdateMessage;
installButtonText = syncOptions.updateDialog.mandatoryContinueButtonLabel;
} else {
message = syncOptions.updateDialog.optionalUpdateMessage;
installButtonText = syncOptions.updateDialog.optionalInstallButtonLabel;
// Since this is an optional update, add a button
// to allow the end-user to ignore it
dialogButtons.push({
text: syncOptions.updateDialog.optionalIgnoreButtonLabel,
onPress: () => {
syncStatusChangeCallback(CodePush.SyncStatus.UPDATE_IGNORED);
resolve(CodePush.SyncStatus.UPDATE_IGNORED);
}
});
}
// Since the install button should be placed to the
// right of any other button, add it last
dialogButtons.push({
text: installButtonText,
onPress: () => {
doDownloadAndInstall()
.then(resolve, reject);
}
})
// If the update has a description, and the developer
// explicitly chose to display it, then set that as the message
if (syncOptions.updateDialog.appendReleaseDescription && remotePackage.description) {
message += `${syncOptions.updateDialog.descriptionPrefix} ${remotePackage.description}`;
}
syncStatusChangeCallback(CodePush.SyncStatus.AWAITING_USER_ACTION);
Alert.alert(syncOptions.updateDialog.title, message, dialogButtons);
});
} else {
return await doDownloadAndInstall();
}
} catch (error) {
syncStatusChangeCallback(CodePush.SyncStatus.UNKNOWN_ERROR);
log(error.message);
throw error;
}
1.2 下载bundle包
根据步骤(1)返回的downloadUrl进行下载,这个过程主要是在原生这边做的,JS只是触发原生的downloadUpdate方法,并监听下载完成的回调,在回调里面触发RN包的安装和状态上报。
代码在package-mixins.js 的 remote.download方法内
1.3 安装bundle包
把步骤(2)得到的bundle包进行解压并拷贝到指定目录。这个过程也是以原生为主,JS这边触发了原生的installUpdate方法,并在这个方法执行完成后调用restartApp重启 bridge。
代码在package-mixins.js 的 local.install方法内
1.4 上报下载状态
上报下载成功的状态,主要是用于统计,失败或者无更新时不会上报。这个接口会修改到服务端的数据库,是引发服务器性能瓶颈的一个点。
接口地址: https://codepush.xx.net/reportStatus/download
请求参数
{
"clientUniqueId": "A25E48C6-1766-431B-B979-6F1CD2607579",
"deploymentKey": "57w3cawU4Q93X95zdP84FVY3YIxQ4ksvOXqog",
"label": "v1323",
}
具体代码在 acquisition-sdk 的 reportStatusDownload 方法内
1.5 上报安装状态
上报安装成功还是失败的状态,主要是用于统计,如果是安装失败,还把这个包标记为不可更新,下次RN包出问题时会根据这个标记来决定是否回滚。这个接口会修改到服务端的数据库,是引发服务器性能瓶颈的一个点。
接口地址: https://codepush.xx.net/reportStatus/deploy
请求参数
{
"appVersion": "4.2.3",
"deploymentKey": "57w3cawU4Q93X95zdP84FVY3YIxQ4ksvOXqog",
"clientUniqueId": "A25E48C6-1766-431B-B979-6F1CD2607579",
"label": "v1323",
"status": "DeploymentSucceeded",
"previousLabelOrAppVersion": "v1315",
"previousDeploymentKey": "57w3cawU4Q93X95zdP84FVY3YIxQ4ksvOXqog",
}
主要代码在Codepush.js 的 getNewStatusReport 方法(从原生获取上报状态)和 acquisition-sdk 的 reportStatusDeploy 方法内(发请求), 设置回滚标记的写在原生的 setLatestRollbackInfo 方法内
下面简单分析下getNewStatusReport在iOS端的实现:
/*
* This method is checks if a new status update exists (new version was installed,
* or an update failed) and return its details (version label, status).
*/
RCT_EXPORT_METHOD(getNewStatusReport:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
{
if (needToReportRollback) { //如果当前包需要上报回滚(更新后发生严重错误导致无法运.行),则通过NSUserDefaults获取到最近的更新失败的包信息,返回给JS
needToReportRollback = NO;
NSUserDefaults *preferences = [NSUserDefaults standardUserDefaults];
NSMutableArray *failedUpdates = [preferences objectForKey:FailedUpdatesKey];
if (failedUpdates) {
NSDictionary *lastFailedPackage = [failedUpdates lastObject];
if (lastFailedPackage) {
resolve([CodePushTelemetryManager getRollbackReport:lastFailedPackage]);
return;
}
}
} else if (_isFirstRunAfterUpdate) { //如果是更新后第一次运行(即更新成功),则返回这个包的信息给JS
NSError *error;
NSDictionary *currentPackage = [CodePushPackage getCurrentPackage:&error];
if (!error && currentPackage) {
resolve([CodePushTelemetryManager getUpdateReport:currentPackage]);
return;
}
} else if (isRunningBinaryVersion) { //如果运行的是本地包,则把本地包信息给JS (APP版本之类的信息)
NSString *appVersion = [[CodePushConfig current] appVersion];
resolve([CodePushTelemetryManager getBinaryUpdateReport:appVersion]);
return;
} else { //如果有重新上报的标记,则把重新上报的状态给JS(因为超时或其他原因导致需重新上报)
NSDictionary *retryStatusReport = [CodePushTelemetryManager getRetryStatusReport];
if (retryStatusReport) {
resolve(retryStatusReport);
return;
}
}
//默认什么都不返回
resolve(nil);
}
这里回滚的情况比较复杂,下面异常更新流程会详细展开
2. 异常更新流程
1)本地有两到三个包,一个跟随APP发布的默认包,另外一到两个通过codepush下载的最新包。如果最新包出错,则回滚到上一个包,如果还是出错,则加载默认包。
2)本地包加载出错时,默认会崩溃,可以加拦截让其不崩溃,但会白屏。
3)一个用户出现回滚后会把出错的信息上报服务端,服务端标记这个RN包版本有问题,后续这个用户再也不会下载这个包,除非手动修改服务端标记。
2.1 本地有两到三个包,一个跟随APP发布的默认包,另外一到两个是通过codepush下载的RN包,其中一个是最近更新的包,另一个是上一次更新的包(如果第一次更新,则没有这个包)。
如果最新包出错,则回滚到上一个包,如果上一个包不存在或者还是出错, 到回滚到默认包。
2.2 本地包出错时,默认会崩溃,我们加了拦截,让其不崩溃,但会白屏,并自动重启bridge。
2.3 一个用户出现回滚后会把出错的信息上报服务端,服务端标记这个RN包版本有问题,后续这个用户再也不会下载这个包,除非手动修改服务端标记。
针对回滚的流程, 我们来看下具体的代码,首先是needToReportRollback这个bool变量,前面提到在getNewStatusReport 这个方法内会用到这个标记,那这个标记什么赋值的呢?
先看下initializeUpdateAfterRestart 这个方法,这是是在CodePush启动之后就立即调用,在updateIsLoading为true的时候将这个标记改为YES(也就是true)。
这个updateIsLoading变量在codepush的一个生命周期内只会修改一次,默认是true,当安装成功后会改为false(见installUpdate方法)。
在标记需要上报回滚之后,就调用rollbackPackage进行本地回滚了
/*
* This method is used when the app is started to either
* initialize a pending update or rollback a faulty update
* to the previous version.
*/
- (void)initializeUpdateAfterRestart
{
#ifdef DEBUG
[self clearDebugUpdates];
#endif
self.paused = YES;
NSUserDefaults *preferences = [NSUserDefaults standardUserDefaults];
NSDictionary *pendingUpdate = [preferences objectForKey:PendingUpdateKey];
if (pendingUpdate) {
_isFirstRunAfterUpdate = YES;//标记是更新后第一次运行
BOOL updateIsLoading = [pendingUpdate[PendingUpdateIsLoadingKey] boolValue];
if (updateIsLoading) {
NSDictionary *installUpdate = [preferences objectForKey:PendingInstallKey];
if (installUpdate) {
BOOL updateIsLoading = [installUpdate[PendingUpdateIsInstallKey] boolValue];
if (!updateIsLoading) {
// Pending update was initialized, but notifyApplicationReady was not called.
// Therefore, deduce that it is a broken update and rollback.
CPLog(@"Update did not finish loading the last time, rolling back to a previous version.");
needToReportRollback = YES;//标记需要上报回滚
[self rollbackPackage];//进行本地回滚
}
}else{
[CodePush removePendingUpdate];
[CodePush removePendingInstall];
}
} else {
// Mark that we tried to initialize the new update, so that if it crashes,
// we will know that we need to rollback when the app next starts.
//默认updateIsLoading设置为true
[self savePendingUpdate:pendingUpdate[PendingUpdateHashKey]
isLoading:YES];
}
}
}
/*
* This method is the native side of the LocalPackage.install method.
*/
RCT_EXPORT_METHOD(installUpdate:(NSDictionary*)updatePackage
installMode:(CodePushInstallMode)installMode
minimumBackgroundDuration:(int)minimumBackgroundDuration
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
{
[[NSNotificationCenter defaultCenter] postNotificationName:CodePushWillInstallUpdateNotification object:nil userInfo:updatePackage];
NSError *error;
//这一步进行安装,如果出错会通过error参数把错误返回出来
[CodePushPackage installPackage:updatePackage
removePendingUpdate:[[self class] isPendingUpdate:nil]
error:&error];
[[NSNotificationCenter defaultCenter] postNotificationName:CodePushDidInstallUpdateNotification object:nil userInfo:updatePackage];
//如果安装出错,则返回异常,否则修改updateIsLoading的状态和installMode,并监听APP重新返回前台的通知(收到通知后重启bundle)
if (error) {
[self saveInstallFailUpdate:updatePackage[PackageHashKey] isInstall:NO];
reject([NSString stringWithFormat: @"%lu", (long)error.code], error.localizedDescription, error);
} else {
//修改updateIsLoading为false.
[self savePendingUpdate:updatePackage[PackageHashKey]
isLoading:NO];
_installMode = installMode;
if (_installMode == CodePushInstallModeOnNextResume || _installMode == CodePushInstallModeOnNextSuspend) {
_minimumBackgroundDuration = minimumBackgroundDuration;
if (!_hasResumeListener) {
// Ensure we do not add the listener twice.
// Register for app resume notifications so that we
// can check for pending updates which support "restart on resume"
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(applicationDidBecomeActive)
name:UIApplicationDidBecomeActiveNotification
object:RCTSharedApplication()];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(applicationWillEnterForeground)
name:UIApplicationWillEnterForegroundNotification
object:RCTSharedApplication()];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(applicationWillResignActive)
name:UIApplicationWillResignActiveNotification
object:RCTSharedApplication()];
_hasResumeListener = YES;
}
}
// Signal to JS that the update has been applied.
resolve(nil);
}
}
回滚的具体实现 , 注意一下这里 failedPackage 的信息被写到了本地,在上报安装状态时会通过 getNewStatusReport拿到,同步给服务端,以确保有问题的包不会再次下发。
CodePush.m
/*
* This method is used when an update has failed installation
* and the app needs to be rolled back to the previous bundle.
* This method is automatically called when the rollback timer
* expires without the app indicating whether the update succeeded,
* and therefore, it shouldn't be called directly.
*/
- (void)rollbackPackage
{
NSError *error;
NSDictionary *failedPackage = [CodePushPackage getCurrentPackage:&error];
if (!failedPackage) {
if (error) {
CPLog(@"Error getting current update metadata during rollback: %@", error);
} else {
CPLog(@"Attempted to perform a rollback when there is no current update");
}
} else {
// Write the current package's metadata to the "failed list"
//本地记录更新失败的包,以便后续通过状态上报接口同步给服务端
[self saveFailedUpdate:failedPackage];
}
// Rollback to the previous version and de-register the new update
[CodePushPackage rollbackPackage]; //清除本地bundle 及所在文件夹(包括app.json),并把上一个包设置为当前包
[CodePush removePendingUpdate];
[CodePush removePendingInstall];
[self loadBundle]; //重启bundle
}
/*
* This method updates the React Native bridge's bundle URL
* to point at the latest CodePush update, and then restarts
* the bridge. This isn't meant to be called directly.
*/
- (void)loadBundle
{
// This needs to be async dispatched because the bridge is not set on init
// when the app first starts, therefore rollbacks will not take effect.
dispatch_async(dispatch_get_main_queue(), ^{
// If the current bundle URL is using http(s), then assume the dev
// is debugging and therefore, shouldn't be redirected to a local
// file (since Chrome wouldn't support it). Otherwise, update
// the current bundle URL to point at the latest update
if ([CodePush isUsingTestConfiguration] || ![super.bridge.bundleURL.scheme hasPrefix:@"http"]) {
[super.bridge setValue:[CodePush bundleURL] forKey:@"bundleURL"];
}
//重启bridge,重新运行bundle
[super.bridge reload];
});
}
CodePushPackage.m
+ (void)rollbackPackage
{
NSError *error;
NSMutableDictionary *info = [self getCurrentPackageInfo:&error];
if (!info) {
CPLog(@"Error getting current package info: %@", error);
return;
}
NSString *currentPackageFolderPath = [self getCurrentPackageFolderPath:&error];
if (!currentPackageFolderPath) {
CPLog(@"Error getting current package folder path: %@", error);
return;
}
//清除本地bundle 及所在文件夹
NSError *deleteError;
BOOL result = [[NSFileManager defaultManager] removeItemAtPath:currentPackageFolderPath
error:&deleteError];
if (!result) {
CPLog(@"Error deleting current package contents at %@ error %@", currentPackageFolderPath, deleteError);
}
//把上一个包设置为当前包,清除上一个包的信息
[info setValue:info[@"previousPackage"] forKey:@"currentPackage"];
[info removeObjectForKey:@"previousPackage"];
[self updateCurrentPackageInfo:info error:&error];
[[CodePushReport shared] reportRollback];
}
网友评论