ljunb / rn-relates

📝一些关于 React Native 项目实践的记录。
41 stars 5 forks source link

XPCodePush 热更新体系 #8

Open ljunb opened 3 years ago

ljunb commented 3 years ago

最近在托哥的带领下,参与了小鹏 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) {
    ...
  }
};

简单梳理:

当一个 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 两端。改造之前,需要明确的一些关键点:

以上基本规则在改造过程中具备一定的指导意义,最终源码也只针对这些场景,所以会与官方流程有比较大的出入。总结下来,会有以下几个改造点:

技术改造

注册unbundle

小鹏 App 之前已经支持了分包,可以按需加载不同业务 bundle,其中比较关键的流程是:

上面已经提到,当前 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 模块还要新增验签的桥接方法,其内容基本与原有验签逻辑一致,这里便不再细述。

忽略下载失败

CodePush 原有逻辑中,当 package 在下载、更新、验签的过程中,只要其中一个环节出现错误,都将当成一个 failed package,在 shouldUpdateBeIgnored 判断中会被作为忽略的 package。

由于下载过程中场景的多样性,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 模块初始化。这里面有几个比较关键的步骤:

总结就是:内存中注册的 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 commented 2 years ago

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