rn-relates icon indicating copy to clipboard operation
rn-relates copied to clipboard

XPCodePush 热更新体系

Open ljunb opened this issue 4 years ago • 1 comments

最近在托哥的带领下,参与了小鹏 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 信息;否则本地依然保存该信息,会走回滚流程
  • checkForUpdate:开始检查更新,会获取本地配置信息,比如版本号、deployment key 和 package hash,作为请求参数传到服务端。其返回的 remotePackage 是与本地 package (如有)合并的结果,其中包含了 isFailedPackage 的关键信息,会作为是否忽略当前 package 的依据
  • shouldUpdateBeIgnored:是否忽略当前 package,里面的判断涉及到 isFailedPackage
  • doDownloadAndInstall:检查更新返回的 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 的错误信息,以便做针对性的调休和流程的优化升级。

ljunb avatar Nov 17 '20 15:11 ljunb

另为了以后升级考虑,改造源码都加以 XPENG_BUILD_CODE_PUSH 宏标记 可以考虑使用 patch-package

syanbo avatar Feb 16 '22 02:02 syanbo