Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

XPCodePush 热更新体系 #8

Open
ljunb opened this issue Nov 17, 2020 · 1 comment
Open

XPCodePush 热更新体系 #8

ljunb opened this issue Nov 17, 2020 · 1 comment

Comments

@ljunb
Copy link
Owner

ljunb commented Nov 17, 2020

最近在托哥的带领下,参与了小鹏 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 的错误信息,以便做针对性的调休和流程的优化升级。

@syanbo
Copy link

syanbo commented Feb 16, 2022

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants