rn-relates
rn-relates copied to clipboard
XPCodePush 热更新体系
最近在托哥的带领下,参与了小鹏 App 的技术专项开发,主要也是因为公司上市后,部门和业务都有相应的变化。为了提供更高的交付效率,提出了服务营销效能交付的技术专项。主要包括:
- 提供 ReactNative 热更新
- UI动态化布局
- App 组件化
目前已经启动的是在搭建热更新体系,现已进入与 OTA 平台联调阶段。本篇主要针对搭建过程中,对 JavaScript 和 iOS 端改造的一些简单记录。
CodePush热更流程简述
CodePush 在 JavaScript 端暴露的接入方式很简单,只需要在入口文件调用 CodePush.sync(syncOptions)(App) 即可,实际该函数是一个高阶函数,其内部最终调用的是 syncInternal 函数,因此可以从该入口函数开始源码阅读之旅。进入 syncInternal 函数可以发现,整个 CodePush 的流程状态就是在这里定义的,不过可以先忽略,重点关注主要流程。以下为流程简单摘要:
async function syncInternal(options = {}, syncStatusChangeCallback, downloadProgressCallback, handleBinaryVersionMismatchCallback) {
const syncOptions = ...
// 流程监听处理
syncStatusChangeCallback = ...
try {
// 通知 Native 端已就绪,清理 pending package
await CodePush.notifyApplicationReady();
// 获取更新
const remotePackage = await checkForUpdate(syncOptions.deploymentKey, handleBinaryVersionMismatchCallback);
// 定义下载安装
const doDownloadAndInstall = async { ... }
// 是否应该忽略当前 package
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.");
}
...
} else {
// 开始下载安装
return await doDownloadAndInstall();
}
} catch (error) {
...
}
};
简单梳理:
notifyApplicationReady:用来清理 Native 端的 pending package,这里涉及到 CodePush 的回滚逻辑:- 前提1:回滚只在 App 下次启动时进行
- 前提2:installUpdate 成功之后,本地将基于 NSUserDefault,缓存 CODE_PUSH_PENDING_UPDATE 信息,其对应一个字典:{ isLoading: false }
- 下次启动:在 CodePush Native 端初始化方法中,获取本地 pending package,如果该 package 不为空,进入回滚判断:
- 当 isLoading 为 true,则回滚,会调用
loadBundle方法,重新进入[CodePush bundleURL]流程(有 update package 则加载,无则加载 binary version ) - 当 isLoading 为 false,会更新 isLoading 为 true。后续有两个不同走向,如果 JavaScript 端正常触发 notifyApplicationReady,则代表安装的 package 可以正常生效,此时会删除本地保存的 pending package 信息;否则本地依然保存该信息,会走回滚流程
- 当 isLoading 为 true,则回滚,会调用
checkForUpdate:开始检查更新,会获取本地配置信息,比如版本号、deployment key 和 package hash,作为请求参数传到服务端。其返回的 remotePackage 是与本地 package (如有)合并的结果,其中包含了 isFailedPackage 的关键信息,会作为是否忽略当前 package 的依据shouldUpdateBeIgnored:是否忽略当前 package,里面的判断涉及到 isFailedPackagedoDownloadAndInstall:检查更新返回的 package 符合要求,进入下载和安装流程,主要工作在 Native 端:- CodePushDownloadHandler 完成下载,回调下载结果
- CodePushPackage 进行下载包的解压和安装,主要分全量和差异两种更新方式。全量更新比较简单,直接文件拷贝覆盖操作。如果解压结果中有 hotcodepush.json 文件,代表差异更新。具体操作:
- 拷贝当前本地 package 或是 binary 版本资源到新目录
- hotcodepush.json 中 deletedFiles 保存着需要删除的文件名,在新目录删除这些文件
- 把新 package 中其他文件(如有新增)拷贝到新目录,完成差异更新
- 全量或差异更新之后,进行验签。验签成功,代表 package 正式安装成功;失败则当成 failed package,保存到本地的 failed updates 数组中
当一个 package 安装成功之后,沙盒中会存在 /Library/Application Support/CodePush/ 目录,其完整内容:
|--CodePush/
|--5d4e7c7a9e90f3d80f32a529739ea735efc172df935fa0b33f1bc0550d357172/ // package hash
|--app.json
|--CodePush/ // 这里后续自定义替换成 react-native/
|--main.unbundle
|--bbs.unbundle
|--...
|--codepush.json // { currentPackage: 5d4e7c7a9e90f3d80f32a529739ea735efc172df935fa0b33f1bc0550d357172, previousPackage: ... }
如上,每个 package 对应一个 hash 目录,codepush.json 中保存了当前以及上一个(如有) package 的信息。package 内的 app.json 文件,存有该 package 所有的信息:
{
"downloadUrl": "http://127.0.0.1:3000/download/fi/Fi5ixtwLUKxrq5KzTHuR6QAYNfNc",
"description": "",
"isAvailable": true,
"isDisabled": false,
"isMandatory": false,
"appVersion": "2.19.0",
"targetBinaryRange": "2.19.0",
"packageHash": "29be78e048e2fabeeaa27c0d27fa056431f12bfdeb499cbad903d6c31bb30e59",
"label": "v13",
"packageSize": 188251,
"updateAppVersion": false,
"shouldRunBinaryVersion": false
}
下一步,就是 package 加载的环节。小鹏 App 的热更策略是下次启动生效,所以 package 安装成功之后是不做处理的。当 App 下次启动时,会进入 [CodePush bundleURL] 的逻辑,其主要是判断沙盒中是否有新 package 的信息,如有则返回该文件路径;否则返回 main bundle 的路径,即当前运行的是 binary version。
整个热更流程基本如上所述,更详尽的内容可以直接参考源码。
App背景
小鹏 App 属于混编 App,支持 bundle 分包和按需加载。由于 CodePush 并不支持分包热更,经过一番技术预研后,需要对其进行针对性改造,涉及 JavaScript 和 Native 两端。改造之前,需要明确的一些关键点:
- package 安装成功后,只在下次启动生效
- 整个流程的关键环节,都需要进行事件上报,主要包括检查更新、下载更新、签名验证、安装更新,以及是否生效。事件上报,统一在 JavaScript 端触发
- package 下载失败后,将不列入回滚名单,下次启动 App 继续检查更新并下载 package
- OTA 平台在运营人员停止或撤销某次更新活动之后,App 端如已完成更新流程,下次启动将继续生效,不做回滚
- 弱化了 package hash 的作用,主要根据 build version 来判断是否更新
- 由于 XPeng RN 版本基于 0.59.x 进行改造,最终选用的 CodePush 为 5.6.0 版本。另为了以后升级考虑,改造源码都加以 XPENG_BUILD_CODE_PUSH 宏标记
以上基本规则在改造过程中具备一定的指导意义,最终源码也只针对这些场景,所以会与官方流程有比较大的出入。总结下来,会有以下几个改造点:
- unbundle 注册:App 原始打包流程拆包后,需要独立注册每个业务 unbundle;所以为了正常运行热更包,需支持从 CodePush 文件目录注册 unbundle
- 热更功能开关:OTA 增加一个开关配置,当置为 true 时,才代表热更功能可用,否则不可用
- 验签过程抽离:官方流程 JavaScript 端只能感知下载是否结束、成功与否,无法感知粒度更细的验签过程。相较于官方流程,一个比较关键的点,是需要把下载完成后的验签过程,抽离开来
- 下载失败处理:官方在下载过程失败的时候,会直接忽略该 package,当成 failed package 记录到本地。为了确保 package 的检查与下载,改造后的流程在下载失败的时候,将不在本地记录该 package 为 failed
- 增加 build version:在判断当前 package 是否为 failed package 的时候,补充一个 build versions 校验的逻辑,即 current package 的 build version 与 failed package 的相等时,才认为其是无效的;否则具备更大 build version 的包,会被当成有效的最新包
技术改造
注册unbundle
小鹏 App 之前已经支持了分包,可以按需加载不同业务 bundle,其中比较关键的流程是:
- @xpeng/rn-cli 进行不同业务 unbundle 拆分,并一同打到一个 zip
- Native App 的打包过程中,下载该 zip 包,并解压到 main bundle 目录
- App 启动,基于 main bundle 目录注册所有的 unbundle 文件,注册之后在
- sourceURLForBridge:中返回 main.unbundle 的路径
上面已经提到,当前 package 信息会存在于沙盒中的某个目录,unbundle 注册的首要任务其实就是获取正确的路径,然后沿用现有的逻辑进行注册。CodePush 内部已经有获取当前 package 路径的方法,可以增加接口将其暴露出来:
// CodePush.m
#ifdef XPENG_BUILD_CODE_PUSH
+ (NSString *)unbundleFileFullPathPrefix {
NSError *error;
NSString *packageFile = [CodePushPackage getCurrentPackageBundlePath:&error];
if (error || !packageFile) {
CPLog(@"-unbundleFileFullPathPrefix: 当前无更新package,返回mainBundle路径");
return [self getUnbundleFilePathPrefix:[self binaryBundleURL].path];
}
NSString *bundleFilePath = [self getUnbundleFilePathPrefix:packageFile];
if (!bundleFilePath || bundleFilePath.length == 0) {
CPLog(@"-unbundleFileFullPathPrefix: 当前无更新package,返回mainBundle路径");
return [self getUnbundleFilePathPrefix:[self binaryBundleURL].path];
} else {
CPLog(@"-unbundleFileFullPathPrefix: unbundle文件路径前缀为 %@", bundleFilePath);
return bundleFilePath;
}
}
+ (NSString *)getUnbundleFilePathPrefix:(NSString *)filePath {
// 基于 main.unbundle 切分
NSArray *pathComps = [filePath componentsSeparatedByString:[@"main." stringByAppendingString:@"unbundle"]];
return [pathComps objectAtIndex:0];
}
#endif
上面 packageFile 返回的是 main.unbundle 的路径,而我们需要的文件所在的目录,用于拼接其他业务 unbundle,所以这里只要返回目录即可。最终现有 RNBundleLoader.m 修改:
// RNBundleLoader.m
~ + (NSURL *)bundleFileFullPathWithName:(NSString *)name {
- NSString *reactNativeDirPath = RNBundleFileAppFullPath; // binary version
+ NSString *reactNativeDirPath = [self getBinaryOrCodePushBundlePathPrefix];
~ NSString *filePath = [reactNativeDirPath stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.%@", name, kReactNativeBundleFileSuffix]];
~ DLogTagInfo(kReactNativeTag, @"bundleFileFullPathWithName: filePath: %@", filePath);
~ return [NSURL fileURLWithPath:filePath];
~ }
+ + (NSString *)getBinaryOrCodePushBundlePathPrefix {
+ if (SystemConfig.currentConfig.enableCodePush) {
+ return [CodePush unbundleFileFullPathPrefix];
+ } else {
+ return RNBundleFileAppFullPath;
+ }
+ }
+ - (NSURL *)getBinaryOrCodePushBundleURL {
+ if (SystemConfig.currentConfig.enableCodePush) {
+ return [CodePush bundleURL];
+ }
+ // binary version
+ NSString *filePath = [RNBundleFileAppFullPath stringByAppendingPathComponent:[NSString stringWithFormat:@"main.%@", kReactNativeBundleFileSuffix]];
+ return [NSURL fileURLWithPath:filePath];
+ }
~ - (NSURL *)commonBundleURL {
- // binary version
- NSString *filePath = [RNBundleFileAppFullPath stringByAppendingPathComponent:[NSString stringWithFormat:@"main.%@", kReactNativeBundleFileSuffix]];
- return [NSURL fileURLWithPath:filePath];
+ return [self getBinaryOrCodePushBundleURL];
~ }
// ReactNativeManager.m
// 在 sourceURLForBridge: 中返回 main.unbundle 路径URL
~ - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {
~ return [self.bundleLoader commonBundleURL];
~ }
独立验签过程
验签抽离的思路其实很简单,不过会涉及 JavaScript 和 Native 两端的改造:
- Native 端下载 package 并完成增量/差异更新,回传验签需要的信息到 JavaScript 端
- 增加桥接方法,在 JavaScript 端调用,将相关信息传至 Native 端进行验签,返回最终安装成功的 package 信息
首先是 Native 端,原有方法的下载结束回调中没有回传信息,与改造后的流程不大相符,我们是需要拿到验签用的信息,然后回传给 JavaScript 端的,故弃用原有的下载方法:
// CodePush.h
@interface CodePushPackage : NSObject
+ #ifdef XPENG_BUILD_CODE_PUSH
+ + (void)downloadPackage:(NSDictionary *)updatePackage
+ expectedBundleFileName:(NSString *)expectedBundleFileName
+ publicKey:(NSString *)publicKey
+ operationQueue:(dispatch_queue_t)operationQueue
+ progressCallback:(void (^)(long long, long long))progressCallback
+ doneCallback:(void (^)(NSDictionary *signatureInfo))doneCallback // 新增回调签名信息
+ failCallback:(void (^)(NSError *err))failCallback;
+ #else
~ + (void)downloadPackage:(NSDictionary *)updatePackage
~ expectedBundleFileName:(NSString *)expectedBundleFileName
~ publicKey:(NSString *)publicKey
~ operationQueue:(dispatch_queue_t)operationQueue
~ progressCallback:(void (^)(long long, long long))progressCallback
~ doneCallback:(void (^)())doneCallback
~ failCallback:(void (^)(NSError *err))failCallback;
+ #endif
@end
// CodePushPackage.m
+ #ifdef XPENG_BUILD_CODE_PUSH
+ + (void)downloadPackage:(NSDictionary *)updatePackage
+ expectedBundleFileName:(NSString *)expectedBundleFileName
+ publicKey:(NSString *)publicKey
+ operationQueue:(dispatch_queue_t)operationQueue
+ progressCallback:(void (^)(long long, long long))progressCallback
+ doneCallback:(void (^)(NSDictionary *))doneCallback // 新增回调签名信息
+ failCallback:(void (^)(NSError *err))failCallback
+ {
+ NSString *newUpdateHash = updatePackage[@"packageHash"];
+ NSString *newUpdateFolderPath = [self getPackageFolderPath:newUpdateHash];
+ NSString *newUpdateMetadataPath = [newUpdateFolderPath stringByAppendingPathComponent:UpdateMetadataFileName];
+ NSError *error;
+ // 基本流程与先前一致,改动点在更新完成之后
+ ...
- NSData *updateSerializedData = [NSJSONSerialization dataWithJSONObject:mutableUpdatePackage
- options:0
- error:&error];
- NSString *packageJsonString = [[NSString alloc] initWithData:updateSerializedData
- encoding:NSUTF8StringEncoding];
- [packageJsonString writeToFile:newUpdateMetadataPath
- atomically:YES
- encoding:NSUTF8StringEncoding
- error:&error];
- if (error) {
- failCallback(error);
- } else {
- doneCallback();
- }
+ // 下载结束,回调js,通知可以验签
+ NSDictionary *signatureInfo = @{
+ @"newUpdateFolderPath": newUpdateFolderPath,
+ @"newUpdateHash": newUpdateHash,
+ @"mutableUpdatePackage": mutableUpdatePackage,
+ @"newUpdateMetadataPath": newUpdateMetadataPath
+ };
+ doneCallback(signatureInfo);
+ }
+ #endif
JavaScript 端也比较简单,在拿到更新之后的验签信息后,手动调用 Native 端的验签处理。这里略去了一些异常情况的处理,只列出最主要的相关改动:
// CodePush.js
async function syncInternal() {
const doDownloadAndInstall = async () => {
...
syncStatusChangeCallback(CodePush.SyncStatus.DOWNLOADING_PACKAGE);
- const localPackage = await remotePackage.download(downloadProgressCallback);
+ const signatureInfo = await remotePackage.download(downloadProgressCallback);
+ syncStatusChangeCallback(CodePush.SyncStatus.DOWNLOAD_PACKAGE_SUCCESS);
+ syncStatusChangeCallback(CodePush.SyncStatus.SIGNATURE_START);
+ let localPackage = await NativeCodePush.signatureVerification(signatureInfo);
+ syncStatusChangeCallback(CodePush.SyncStatus.SIGNATURE_SUCCESS);
...
}
}
syncStatusChangeCallback 沿用 CodePush 的状态同步处理,SIGNATURE_START、SIGNATURE_SUCCESS 都是新增的状态,当然还有其他。除此之外,CodePush 模块还要新增验签的桥接方法,其内容基本与原有验签逻辑一致,这里便不再细述。
忽略下载失败
CodePush 原有逻辑中,当 package 在下载、更新、验签的过程中,只要其中一个环节出现错误,都将当成一个 failed package,在 shouldUpdateBeIgnored 判断中会被作为忽略的 package。
由于下载过程中场景的多样性,package 在下载安装过程中出现的错误,都不作为其无效的依据,取而代之的,是在验签过程中的成功与否。所以这里涉及两处改动:
- 移除 CodePushPackage 下载失败回调中
safeFailedUpdate的处理 - 在新增的
signatureVerification中,如果有环节失败,那么就标记为 failed package
在 CodePush.m 中:
// 下载更新
RCT_EXPORT_METHOD(downloadUpdate:(NSDictionary*)updatePackage
notifyProgress:(BOOL)notifyProgress
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
{
...
NSString * publicKey = [[CodePushConfig current] publicKey];
[CodePushPackage
downloadPackage:mutableUpdatePackage
expectedBundleFileName:[bundleResourceName stringByAppendingPathExtension:bundleResourceExtension]
publicKey:publicKey
operationQueue:_methodQueue
// The download is progressing forward
progressCallback:^(long long expectedContentLength, long long receivedContentLength) {
...
}
- doneCallback:^{
- NSError *err;
- NSDictionary *newPackage = [CodePushPackage getPackage:mutableUpdatePackage[PackageHashKey] error:&err];
- if (err) {
- return reject([NSString stringWithFormat: @"%lu", (long)err.code], err.localizedDescription, err);
- }
- resolve(newPackage);
- }
+ doneCallback:^(NSDictionary *signatureInfo) {
+ resolve(signatureInfo);
+ }
// The download failed
failCallback:^(NSError *err) {
- if ([CodePushErrorUtils isCodePushError:err]) {
- [self saveFailedUpdate:mutableUpdatePackage];
- }
...
- reject([NSString stringWithFormat: @"%lu", (long)err.code], err.localizedDescription, err);
+ NSError *failedError = [NSError errorWithDomain:err.domain
+ code:-1
+ userInfo:@{
+ @"receivedContentLength": @(_latestReceivedConentLength),
+ @"expectedContentLength":@(_latestExpectedContentLength)
+ }];
+ reject([NSString stringWithFormat:@"%lu", -1], failedError.localizedDescription, failedError);
}];
}
// 新增的验签方法
+ RCT_REMAP_METHOD(signatureVerification,
+ signatureInfo:(NSDictionary *)signatureInfo
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject) {
+ ...
+ if (![CodePushUpdateUtils verifyFolderHash:newUpdateFolderPath
+ expectedHash:newUpdateHash
+ error:&error]) {
+ CPLog(@"-signatureVerification: The update contents failed the data integrity check.");
+ if (!error) {
+ error = [CodePushErrorUtils errorWithMessage:@"The update contents failed the data integrity check."];
+ }
+ // 标记failedUpdate
+ [self saveFailedUpdate:mutableUpdatePackage];
+ return reject([NSString stringWithFormat: @"%d", -1], error.localizedDescription, error);
+ } else {
+ CPLog(@"-signatureVerification: The update contents succeeded the data integrity check.");
+ }
+ ...
+ }
以上只列出部分示例,更具体的可参考修改后的 CodePush.m 源码。
修改isFailedUpdate逻辑
在资源下发的过程中,面对众多的机型设备,不排除存在兼容问题,导致在更新包下载安装成功之后,但是验签过程当中失败了,或是验签完成但下次启动时,没有正常运行 JavaScript bundle,进入了回滚逻辑,这些情况下,都会把当前包作为 failed package 保存到本地。
在 OTA 后台可以查看所有资源下发、安装更新情况,以及支持针对特定设备或用户推送更新的前提下,加入了 build version 作为是否 failed package 的判断逻辑。当某个特殊用户或设备安装更新失败时,而当前更新包又确定是正常的时候,就可以下发一个 build version 更大的包,这样就可以继续沿用现有的 package(hash 值不变),再次下发。
相关改动:
// CodePush.m
+ #ifdef XPENG_BUILD_CODE_PUSH
+ + (BOOL)isFailedHash:(NSString*)packageHash versionCode:(NSInteger)versionCode
+ {
+ NSUserDefaults *preferences = [NSUserDefaults standardUserDefaults];
+ NSMutableArray *failedUpdates = [preferences objectForKey:FailedUpdatesKey];
+ if (failedUpdates == nil || packageHash == nil) {
+ return NO;
+ } else {
+ for (NSDictionary *failedPackage in failedUpdates)
+ {
+ if ([failedPackage isKindOfClass:[NSDictionary class]]) {
+ NSString *failedPackageHash = [failedPackage objectForKey:PackageHashKey];
+ NSInteger failedPackageVersionCode = [[failedPackage objectForKey:VersionCodeKey] integerValue];
+ if ([packageHash isEqualToString:failedPackageHash] && versionCode <= failedPackageVersionCode && versionCode != 0) {
+ return YES;
+ }
+ }
+ }
+ return NO;
+ }
+ }
+ #else
- + (BOOL)isFailedHash:(NSString*)packageHash
+ #endif
回滚逻辑调整
CodePush 的原始逻辑中,当下载并运行了一次回滚包之后,再次启动会进入回滚逻辑,主要有两个关键步骤:
// CodePush.m
- (void)rollbackPackage
{
...
// 1 清除本地回滚包信息
[CodePushPackage rollbackPackage];
[CodePush removePendingUpdate];
// 2 重新加载 bundle
[self loadBundle];
}
在删除掉当前回滚包的文件目录后,会重新 reload bundle。测试童鞋在验证回滚功能的时候,当运行一次回滚包之后,再次启动时 App 会闪退。按上面分析,实际就是再次启动时,进入了回滚的操作,一般文件删除不会有问题,猜测问题应该出现在 reload bundle 上。
经过本地调试,基本可以确定这个猜想。与托哥确认后,小鹏 App 在进行分包之后,不支持 bundle 的 reload(暂时未能理解其技术原理,需深入学习),现 Android 端在回滚时并没有进行 bundle 的重新加载,只是单纯的删除目录,而且回滚的操作发生在返回 bundle URL 路径之前。参照 Android 端的处理,直接去掉 [self loadBundle] ,iOS 端回滚功能正常跑通。
这番修改之后,虽然功能正常运行,但自己依然有疑问:在注册完 main.unbundle,进入 CodePush 流程删除了本地回滚包且没有触发 reload 的情况下,App 当前运行的是哪里的 business.unbunde ?什么时候注册的?
先梳理现阶段的流程:
// ReactNativeManager.m
// 1.1 初始化 bridge
self.bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:self.launchOptions];
// 1.2 设置 main.unbundle URL 路径
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {
return [self.bundleLoader commonBundleURL];
}
// RCTBridge.m
// 2.1 bridage初始化中,触发 [RCTBridage setup]
- (void)setUp
{
...
NSURL *previousDelegateURL = _delegateBundleURL;
// 2.2 设置代理中的 URL
_delegateBundleURL = [self.delegate sourceURLForBridge:self];
if (_delegateBundleURL && ![_delegateBundleURL isEqual:previousDelegateURL]) {
_bundleURL = _delegateBundleURL;
}
...
[self.batchedBridge start];
}
// 2.3 RCTCxxBridge.m 正式进入 RN 初始化
- (void)start
{
...
// 新增一个队列组
dispatch_group_t prepareBridge = dispatch_group_create();
// 2.4 进行 NativeModule 的初始化,CodePush 的初始化在此进行,见下面 2.5
(void)[self _initializeModules:RCTGetModuleClasses() withDispatchGroup:prepareBridge lazilyDiscovered:NO];
...
// 2.6 加载 JavaScript bundle,目前是从之前返回的 main.unbundle URL
dispatch_group_enter(prepareBridge);
[self loadSource:^(NSError *error, RCTSource *source) {
...
dispatch_group_leave(prepareBridge);
} onProgress:^(RCTLoadingProgress *progressData) {
...
}];
// 2.7 加载完毕,执行 JavaScript 代码,流程到 3.1
dispatch_group_notify(prepareBridge, dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0), ^{
RCTCxxBridge *strongSelf = weakSelf;
if (sourceCode && strongSelf.loading) {
[strongSelf executeSourceCode:sourceCode sync:NO];
}
});
}
// 2.5 CodePush.m -> init 中直接调用 initializeUpdateAfterRestart 进行回滚
- (void)initializeUpdateAfterRestart
{
...
if (pendingUpdate) {
if (updateIsLoading) {
// 进行回滚,只是删除回滚包目录,注释掉了 reload bundle 逻辑,回到上面 2.6
[self rollbackPackage];
}
}
...
}
// 3.1 RNBundleLoader.m -> main.unbundle 的 JavaScript 已执行完毕,触发 RCTJavaScriptDidLoadNotification 通知
- (void)addAllObserver {
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(onLoadMainBundleFinished:)
name:RCTJavaScriptDidLoadNotification
object:nil];
}
- (void)onLoadMainBundleFinished:(NSNotification *)notification {
// 进行 business.unbundle 注册
[self registerAllBussniss];
}
以上基本就是一个从 ReactNative 初始化,到分包加载的完整流程,其中也列出了需要特别关注的 CodePush 模块初始化。这里面有几个比较关键的步骤:
1.2:返回 main.unbundle 路径,可能为 binary version,或是更新包的路径。很明显,这是回滚包的文件目录2.4、2.5:CodePush 模块的初始化,里面做了包的回滚操作,在该步骤之后,本地已经没有回滚包的目录2.6、2.7、3.1:一个队列组的操作,在 NativeModule 初始化完毕、main.unbundle 运行完毕后,触发 RCTJavaScriptDidLoadNotification 通知,进行 business.unbundle 注册,需要特别注意的是,此时所有 business.unbundle 的目录前缀已经是 binary version 或是上一个热更包的路径
总结就是:内存中注册的 main.unbundle ,实际位于回滚包中,而后续注册的 business.unbundle,是属于上一个可运行版本的包中的!由于拆包之后, App 自身和回滚包中的 main.unbundle 并无二异,所以当前流程回滚后 App 运行是正常的。
实际上并不能保证两者中的 main.unbundle 是一致的,有可能某个更新包中修改了 main.unbundle,而 business.unbundle 又依赖于该改动,那么以上流程就会有问题。为了从根源上规避这种情况的发生,iOS 端调整了回滚逻辑,保持与 Android 端一致:在返回 main.unbundle URL 之前,进行回滚操作,如有回滚包则删除,这样可以确保返回的 URL 是上一个可用的版本。
涉及的改动:
// RNBundleLoader.m
- (NSURL *)getBinaryOrCodePushBundleURL {
if (SystemConfig.currentConfig.enableCodePush) {
// 如果有回滚包,直接删除,确保 main.unbundle 与 business.unbundle 注册路径一致
[CodePush removeRollbackPackageIfNeed];
return [CodePush bundleURL];
}
// binary version
NSString *filePath = [RNBundleFileAppFullPath stringByAppendingPathComponent:[NSString stringWithFormat:@"main.%@", kReactNativeBundleFileSuffix]];
return [NSURL fileURLWithPath:filePath];
}
// CodePush.m 移除原有处理
- (void)initializeUpdateAfterRestart
{
#ifdef DEBUG
[self clearDebugUpdates];
#endif
self.paused = YES;
#ifdef XPENG_BUILD_CODE_PUSH
// 回滚逻辑提前到了 removeRollbackPackageIfNeed 中,这里注释
#else
NSUserDefaults *preferences = [NSUserDefaults standardUserDefaults];
NSDictionary *pendingUpdate = [preferences objectForKey:PendingUpdateKey];
if (pendingUpdate) {
...
}
#endif
}
-initializeUpdateAfterRestart 中的处理是必须移除的,因为一个更新包下载安装之后,刚刚提前的回滚判断逻辑会将该 pending update 里面的 isLoading 置为 true,如果此时又进入了 initializeUpdateAfterRestart 中的回滚判断,就会被当成回滚包直接删除了,最终结果就是每次启动 App 都会去下载安装更新包。
总结
以上,基本就是这次大部分的改造了,当然这里没有列出事件上报的内容。事件上报需要关注的,是在什么时候上报,需要上报什么内容,比如回滚包信息、下载中断时的进度等等,在一个流程比较清晰的前提下,其他的源码逻辑其实都比较简单,这里就不再赘述了。
目前测试下来,基本跑通了测试童鞋的所有用例,效果是比较乐观的。功能上线之后,后续应该关注每次更新活动中,安装更新成功或是失败的占比,还有上报到 OTA 的错误信息,以便做针对性的调休和流程的优化升级。
另为了以后升级考虑,改造源码都加以 XPENG_BUILD_CODE_PUSH 宏标记 可以考虑使用 patch-package