Open ljunb opened 3 years ago
最近在托哥的带领下,参与了小鹏 App 的技术专项开发,主要也是因为公司上市后,部门和业务都有相应的变化。为了提供更高的交付效率,提出了服务营销效能交付的技术专项。主要包括:
目前已经启动的是在搭建热更新体系,现已进入与 OTA 平台联调阶段。本篇主要针对搭建过程中,对 JavaScript 和 iOS 端改造的一些简单记录。
CodePush 在 JavaScript 端暴露的接入方式很简单,只需要在入口文件调用 CodePush.sync(syncOptions)(App) 即可,实际该函数是一个高阶函数,其内部最终调用的是 syncInternal 函数,因此可以从该入口函数开始源码阅读之旅。进入 syncInternal 函数可以发现,整个 CodePush 的流程状态就是在这里定义的,不过可以先忽略,重点关注主要流程。以下为流程简单摘要:
CodePush.sync(syncOptions)(App)
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
loadBundle
[CodePush bundleURL]
checkForUpdate
shouldUpdateBeIgnored
doDownloadAndInstall
当一个 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,支持 bundle 分包和按需加载。由于 CodePush 并不支持分包热更,经过一番技术预研后,需要对其进行针对性改造,涉及 JavaScript 和 Native 两端。改造之前,需要明确的一些关键点:
以上基本规则在改造过程中具备一定的指导意义,最终源码也只针对这些场景,所以会与官方流程有比较大的出入。总结下来,会有以下几个改造点:
小鹏 App 之前已经支持了分包,可以按需加载不同业务 bundle,其中比较关键的流程是:
- sourceURLForBridge:
上面已经提到,当前 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 端,原有方法的下载结束回调中没有回传信息,与改造后的流程不大相符,我们是需要拿到验签用的信息,然后回传给 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 模块还要新增验签的桥接方法,其内容基本与原有验签逻辑一致,这里便不再细述。
syncStatusChangeCallback
CodePush 原有逻辑中,当 package 在下载、更新、验签的过程中,只要其中一个环节出现错误,都将当成一个 failed package,在 shouldUpdateBeIgnored 判断中会被作为忽略的 package。
由于下载过程中场景的多样性,package 在下载安装过程中出现的错误,都不作为其无效的依据,取而代之的,是在验签过程中的成功与否。所以这里涉及两处改动:
safeFailedUpdate
signatureVerification
在 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 源码。
在资源下发的过程中,面对众多的机型设备,不排除存在兼容问题,导致在更新包下载安装成功之后,但是验签过程当中失败了,或是验签完成但下次启动时,没有正常运行 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 端回滚功能正常跑通。
[self loadBundle]
这番修改之后,虽然功能正常运行,但自己依然有疑问:在注册完 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
2.4、2.5
2.6、2.7、3.1
总结就是:内存中注册的 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 都会去下载安装更新包。
-initializeUpdateAfterRestart
initializeUpdateAfterRestart
以上,基本就是这次大部分的改造了,当然这里没有列出事件上报的内容。事件上报需要关注的,是在什么时候上报,需要上报什么内容,比如回滚包信息、下载中断时的进度等等,在一个流程比较清晰的前提下,其他的源码逻辑其实都比较简单,这里就不再赘述了。
目前测试下来,基本跑通了测试童鞋的所有用例,效果是比较乐观的。功能上线之后,后续应该关注每次更新活动中,安装更新成功或是失败的占比,还有上报到 OTA 的错误信息,以便做针对性的调休和流程的优化升级。
另为了以后升级考虑,改造源码都加以 XPENG_BUILD_CODE_PUSH 宏标记 可以考虑使用 patch-package
最近在托哥的带领下,参与了小鹏 App 的技术专项开发,主要也是因为公司上市后,部门和业务都有相应的变化。为了提供更高的交付效率,提出了服务营销效能交付的技术专项。主要包括:
目前已经启动的是在搭建热更新体系,现已进入与 OTA 平台联调阶段。本篇主要针对搭建过程中,对 JavaScript 和 iOS 端改造的一些简单记录。
CodePush热更流程简述
CodePush 在 JavaScript 端暴露的接入方式很简单,只需要在入口文件调用
CodePush.sync(syncOptions)(App)
即可,实际该函数是一个高阶函数,其内部最终调用的是 syncInternal 函数,因此可以从该入口函数开始源码阅读之旅。进入 syncInternal 函数可以发现,整个 CodePush 的流程状态就是在这里定义的,不过可以先忽略,重点关注主要流程。以下为流程简单摘要:简单梳理:
notifyApplicationReady
:用来清理 Native 端的 pending package,这里涉及到 CodePush 的回滚逻辑:loadBundle
方法,重新进入[CodePush bundleURL]
流程(有 update package 则加载,无则加载 binary version )checkForUpdate
:开始检查更新,会获取本地配置信息,比如版本号、deployment key 和 package hash,作为请求参数传到服务端。其返回的 remotePackage 是与本地 package (如有)合并的结果,其中包含了 isFailedPackage 的关键信息,会作为是否忽略当前 package 的依据shouldUpdateBeIgnored
:是否忽略当前 package,里面的判断涉及到 isFailedPackagedoDownloadAndInstall
:检查更新返回的 package 符合要求,进入下载和安装流程,主要工作在 Native 端:当一个 package 安装成功之后,沙盒中会存在 /Library/Application Support/CodePush/ 目录,其完整内容:
如上,每个 package 对应一个 hash 目录,codepush.json 中保存了当前以及上一个(如有) package 的信息。package 内的 app.json 文件,存有该 package 所有的信息:
下一步,就是 package 加载的环节。小鹏 App 的热更策略是下次启动生效,所以 package 安装成功之后是不做处理的。当 App 下次启动时,会进入
[CodePush bundleURL]
的逻辑,其主要是判断沙盒中是否有新 package 的信息,如有则返回该文件路径;否则返回 main bundle 的路径,即当前运行的是 binary version。整个热更流程基本如上所述,更详尽的内容可以直接参考源码。
App背景
小鹏 App 属于混编 App,支持 bundle 分包和按需加载。由于 CodePush 并不支持分包热更,经过一番技术预研后,需要对其进行针对性改造,涉及 JavaScript 和 Native 两端。改造之前,需要明确的一些关键点:
以上基本规则在改造过程中具备一定的指导意义,最终源码也只针对这些场景,所以会与官方流程有比较大的出入。总结下来,会有以下几个改造点:
技术改造
注册unbundle
小鹏 App 之前已经支持了分包,可以按需加载不同业务 bundle,其中比较关键的流程是:
- sourceURLForBridge:
中返回 main.unbundle 的路径上面已经提到,当前 package 信息会存在于沙盒中的某个目录,unbundle 注册的首要任务其实就是获取正确的路径,然后沿用现有的逻辑进行注册。CodePush 内部已经有获取当前 package 路径的方法,可以增加接口将其暴露出来:
上面 packageFile 返回的是 main.unbundle 的路径,而我们需要的文件所在的目录,用于拼接其他业务 unbundle,所以这里只要返回目录即可。最终现有 RNBundleLoader.m 修改:
独立验签过程
验签抽离的思路其实很简单,不过会涉及 JavaScript 和 Native 两端的改造:
首先是 Native 端,原有方法的下载结束回调中没有回传信息,与改造后的流程不大相符,我们是需要拿到验签用的信息,然后回传给 JavaScript 端的,故弃用原有的下载方法:
JavaScript 端也比较简单,在拿到更新之后的验签信息后,手动调用 Native 端的验签处理。这里略去了一些异常情况的处理,只列出最主要的相关改动:
syncStatusChangeCallback
沿用 CodePush 的状态同步处理,SIGNATURE_START、SIGNATURE_SUCCESS 都是新增的状态,当然还有其他。除此之外,CodePush 模块还要新增验签的桥接方法,其内容基本与原有验签逻辑一致,这里便不再细述。忽略下载失败
CodePush 原有逻辑中,当 package 在下载、更新、验签的过程中,只要其中一个环节出现错误,都将当成一个 failed package,在
shouldUpdateBeIgnored
判断中会被作为忽略的 package。由于下载过程中场景的多样性,package 在下载安装过程中出现的错误,都不作为其无效的依据,取而代之的,是在验签过程中的成功与否。所以这里涉及两处改动:
safeFailedUpdate
的处理signatureVerification
中,如果有环节失败,那么就标记为 failed package在 CodePush.m 中:
以上只列出部分示例,更具体的可参考修改后的 CodePush.m 源码。
修改isFailedUpdate逻辑
在资源下发的过程中,面对众多的机型设备,不排除存在兼容问题,导致在更新包下载安装成功之后,但是验签过程当中失败了,或是验签完成但下次启动时,没有正常运行 JavaScript bundle,进入了回滚逻辑,这些情况下,都会把当前包作为 failed package 保存到本地。
在 OTA 后台可以查看所有资源下发、安装更新情况,以及支持针对特定设备或用户推送更新的前提下,加入了 build version 作为是否 failed package 的判断逻辑。当某个特殊用户或设备安装更新失败时,而当前更新包又确定是正常的时候,就可以下发一个 build version 更大的包,这样就可以继续沿用现有的 package(hash 值不变),再次下发。
相关改动:
回滚逻辑调整
CodePush 的原始逻辑中,当下载并运行了一次回滚包之后,再次启动会进入回滚逻辑,主要有两个关键步骤:
在删除掉当前回滚包的文件目录后,会重新 reload bundle。测试童鞋在验证回滚功能的时候,当运行一次回滚包之后,再次启动时 App 会闪退。按上面分析,实际就是再次启动时,进入了回滚的操作,一般文件删除不会有问题,猜测问题应该出现在 reload bundle 上。
经过本地调试,基本可以确定这个猜想。与托哥确认后,小鹏 App 在进行分包之后,不支持 bundle 的 reload(暂时未能理解其技术原理,需深入学习),现 Android 端在回滚时并没有进行 bundle 的重新加载,只是单纯的删除目录,而且回滚的操作发生在返回 bundle URL 路径之前。参照 Android 端的处理,直接去掉
[self loadBundle]
,iOS 端回滚功能正常跑通。这番修改之后,虽然功能正常运行,但自己依然有疑问:在注册完 main.unbundle,进入 CodePush 流程删除了本地回滚包且没有触发 reload 的情况下,App 当前运行的是哪里的 business.unbunde ?什么时候注册的?
先梳理现阶段的流程:
以上基本就是一个从 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 是上一个可用的版本。
涉及的改动:
-initializeUpdateAfterRestart
中的处理是必须移除的,因为一个更新包下载安装之后,刚刚提前的回滚判断逻辑会将该 pending update 里面的 isLoading 置为 true,如果此时又进入了initializeUpdateAfterRestart
中的回滚判断,就会被当成回滚包直接删除了,最终结果就是每次启动 App 都会去下载安装更新包。总结
以上,基本就是这次大部分的改造了,当然这里没有列出事件上报的内容。事件上报需要关注的,是在什么时候上报,需要上报什么内容,比如回滚包信息、下载中断时的进度等等,在一个流程比较清晰的前提下,其他的源码逻辑其实都比较简单,这里就不再赘述了。
目前测试下来,基本跑通了测试童鞋的所有用例,效果是比较乐观的。功能上线之后,后续应该关注每次更新活动中,安装更新成功或是失败的占比,还有上报到 OTA 的错误信息,以便做针对性的调休和流程的优化升级。