前言
关于ReactNative热更新,我首先是从网上对比了几个常见方案,然后从中选择较合适的方案实现。RN热更新分为全量热更新和差量热更新。全量的好处是实现逻辑相比差量而言较轻松一些,弊端是全量前端代码量如果很大的话,网络下载耗时较长,就影响了APP的启动体验了。
而差量热更新也大致分为2种思路:
- 1.将jsbundle 分离成通用部分和业务部分,每次热更新主要是业务部分脚本下发,然后和通用部分脚本合并。
- 2.利用比对工具biff将老版本和新版本比对出一个差异部分(我们称之为patch"补丁"),客户端每次下载补丁包,然后和老的部分合并出新的完整脚本。
鉴于差量更新第一种思路,业务脚本后续版本其实也存在大量重复的脚本,每次下载全量的业务脚本其实不是彻底的差量更新方案,而且资源文件更新也未能体现。所以,我最终采用了第二种差分方案实现。
可行性探究
1.biff差分方案最核心的要借助第三方开源的biff。由于合并部分要在客户端上完成,所以我预先下载了bsdiff-4.3和bzip2的开源代码,由于是C语言实现,我们要在iOS平台编译,直接拷贝到项目中,由于多处方法名main同名而编译报错,所以,我选择了将该库c实现打包成.a静态库。(我把这个静态库放在本文末链接里,有需要的同学可自取)
然后将打包好的静态库导入到项目中(头文件bspatch.h别忘了),然后在项目的pch文件import bspatch.h即可
使用代码:
- (NSString *)bspatch:(NSString *)patchPath newVersion:(NSString *)newVersion {
const char *argv[4];
argv[0] = "bspatch";
// oldPath
NSString *oldPath = self.currHotZipPath;
if (!oldPath) {
return nil;
}
argv[1] = [oldPath UTF8String];
// newPath
argv[2] = [[self newZipPath:newVersion] UTF8String];
// patchPath
argv[3] = [patchPath UTF8String];
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wincompatible-pointer-types-discards-qualifiers"
int result = BsdiffUntils_bspatch(4, argv);
#pragma clang diagnostic pop
NSString *newZipPath = [NSString stringWithFormat:@"%s", argv[2]];
if (result == 0 && [NSFileManager.defaultManager fileExistsAtPath:newZipPath]) {
//success
return newZipPath;
}else {
return nil;
}
}
2.关于资源文件
我一开始担心资源文件(比如图片)会找不到路径,因为我们更新的新的zip包所有资源文件由于不是在assets目录下,会担心RNImage无法找到,实际上,我多虑了,因为我们指定了index.jsbundle的path给RN后,RNImage图片查找逻辑先是根据jsbundle的所在目录下遍历资源文件,如果找不到,最后才取bundle的Assets找资源文件。而我们的资源文件总是和jsbundle打包在一起的,所以,code和资源文件都可以热更新。
关于版本管理
约定:
- 1.所有的完整的RN版本包命名规则:hot_V[版本号].zip
例如:hot_V1.0.0.zip - 2.所有的补丁包命名规则:hot_V[老版本号]_V[新版本号].patched
例如:hot_V1.0.0_V1.0.1.patched
表示:1.0.1版本和1.0.0版本差异部分产生的补丁包。
3.不做跨原生版本的补丁
为啥不做跨原生版本的补丁呢?
因为考虑到原生版本更新的原生组件的API,很可能v2.0.1版本的脚本调用了只仅限于v2.0.0原生版本才有的原生组件,那么如果v1.0.3版本跨native版本更新到了v2.0.1的脚本,导致调用的native 组件api找不到方法而报错!4.每次原生版本更新的颗粒度精确到版本数组的第二位,第3位留给热更新
我们的版本号预定格式: x_y_z 3位数字
x:代表整个APP大的功能升级或者重构
y:App 原生版本升级(需要市场审核发布)
z:补丁包升级
x、y版本升级均需要native版本升级,z标识补丁包升级,不需要重新发布APP市场审核,即热更新。
逻辑流程图
核心逻辑代码
- (void)loadResourceURL {
//1.检查是否有新的补丁
NSString *url = [NSString stringWithFormat:@"%@/rn_hot/checkPatch.php?appVersion=%@", kBaseURL, self.hotVersion];
[[HttpEngine shareInstance] requestUrl:url complete:^(BOOL succ, HttpEngineResponse * _Nonnull resp) {
if (succ) {
NSDictionary *patchDic = resp.data;
DLog(@"%@", patchDic);
if (![patchDic isKindOfClass:[NSDictionary class]]) {
DLog(@"没有新的补丁,使用现有的");
self->_bundleURL = [self loadCurrResource];
return ;
}
NSString *patchUrl = [patchDic objectForKey:@"patchUrl"];
NSString *newVersion = [patchDic objectForKey:@"newVersion"];
if (patchUrl && [patchUrl isKindOfClass:[NSString class]] && patchUrl.length > 0) {
//2.如果没有补丁目录,则创建
NSError *err = nil;
[self createDirIfNotExist:kPatchesDir error:&err];
if (err) {
DLog(@"创建补丁目录失败,使用现有的");
self->_bundleURL = [self loadCurrResource];
return ;
}
//3.下载补丁文件
[[HttpEngine shareInstance] downloadUrl:patchUrl saveToPath:kPatchesDir complete:^(BOOL succ, NSString * _Nonnull filePath, NSString * _Nonnull error) {
if (succ) {
DLog(@"下载保存到地址:%@",filePath);
//4.补丁合并现有zip生成newzip
NSString *newZipPath = [self bspatch:filePath newVersion:newVersion];
if (newZipPath.length) {
//5.解压
[SSZipArchive unzipFileAtPath:newZipPath toDestination:kDocumentDir overwrite:YES password:@"" progressHandler:nil completionHandler:^(NSString * _Nonnull path, BOOL succeeded, NSError * _Nullable error) {
if (succeeded && !error) {
//6.升级成功,更新版本号
DLog(@"升级成功,更新版本号");
[NSUserDefaults.standardUserDefaults setObject:newVersion forKey:kAppHotVersion];
[NSUserDefaults.standardUserDefaults synchronize];
//7.继续检查有无新的版本更新
[self loadResourceURL];
}else {
DLog(@"解压失败,使用现有的");
self->_bundleURL = [self loadCurrResource];
}
}];
}else {
DLog(@"合成补丁失败,使用现有的");
self->_bundleURL = [self loadCurrResource];
}
}else {
DLog(@"下载补丁失败,使用现有的");
self->_bundleURL = [self loadCurrResource];
}
}];
}else {
DLog(@"没有新的补丁,使用现有的");
self->_bundleURL = [self loadCurrResource];
}
}else {
DLog(@"checkPatch接口失败,使用现有的");
self->_bundleURL = [self loadCurrResource];
}
}];
}
最后在你的宿主RN工程中调用即可:
值得说明的:
1.上面代码流程中最后第7步,继续递归调用了方法本身。
我的考虑是,虽然本次版本刚升级了,一般来说肯定就是最新的版本了,可以直接用本次zip解压的脚本启动APP了。但是当迭代了多个版本后,会出现这样的场景:
v1.0.0_v1.0.1.patch
v1.0.1_v1.0.2.patch
v1.0.2_v1.0.3.patch
...
假设服务器已经下发了3个补丁包了,但是,我们的用户可能很久没有打开过APP,很可能他的补丁版本还停留在初始的v1.0.0或者v1.0.1, 对于这两个版本的用户,升级一次版本后的版本相应的为1.0.1和v1.0.2, 而最新的版本是v1.0.3, 也就是说只有上次停留在v1.0.2的版本只需要一次升级到最新的版本,而其他版本需要多次升级到最新版本。所以这里采用递归升级策略。
2.每次APP 原生版本发布(有别于补丁包发布),客户端需要手动保存一份和app原生版本相同的zip格式在项目中(即bundle中),这是因为,第一个补丁x.x.0_x.x.1.patch包要合并的初始zip必须存在。
关于服务端部署
1.需要编写个服务端脚本,用于判断是否存在最新的可用补丁包。需要一个入参:当前版本号。
逻辑是遍历服务端存放补丁的列表文件目录,根据存放的补丁文件名称解析出老版本号-新版本号,然后和入参版本比对,返回补丁包下载链接和新版本号。
php代码逻辑如下:
$appVersion = $_GET['appVersion'];
$fileDir = "hot_patches";
function checkAvaliblePatchesByVersion($version, $fileDir){
$result = '';
//1、首先先读取文件夹
$temp=scandir($fileDir);
//遍历文件夹
foreach($temp as $v){
$a = $fileDir.'/'.$v;
if(is_dir($a)){//如果是文件夹则执行
if($v=='.' || $v=='..'){//判断是否为系统隐藏的文件.和.. 如果是则跳过否则就继续往下走,防止无限循环再这里。
continue;
}
return checkAvaliblePatchesByVersion($a);//因为是文件夹所以再次调用自己这个函数,把这个文件夹下的文件遍历出来
}else{
$ext = pathinfo($a, PATHINFO_EXTENSION);
$baseName = pathinfo($a, PATHINFO_BASENAME);
$dirName = pathinfo($a, PATHINFO_DIRNAME);
$filename = str_replace(strrchr($baseName, "."),"",$baseName);
if ($ext == 'patched') {
//将文件名转成数组,以_分割
$arr = explode('_', $filename);
if (count($arr) >= 2) {
//倒数第2个字符串:
$oldVersion = str_replace("V","",strtoupper($arr[count($arr)-2]));
//倒数第1个字符串:
$newVersion = str_replace("V","",strtoupper($arr[count($arr)-1]));
if ($oldVersion == $version) {
$currDir = 'http://'.$_SERVER['SERVER_NAME'].':'.$_SERVER['SERVER_PORT'].dirname($_SERVER['PHP_SELF']);
//返回完整的下载URL
$url = $currDir.'/'.$a;
$result = array(
'patchUrl' => $url,
'newVersion' => $newVersion,
);
break;
}
}
}
}
}
return $result;
}
$result = checkAvaliblePatchesByVersion($appVersion, $fileDir);
$response = array(
'code' => 200,
'message' => 'success for request',
'data' => $result,
);
//echo $result."
";
echo json_encode($response);
return json_encode($response);
日常补丁更新维护
1.终端命令行:
biff [oldzip_path] [newzip_path] [生成的补丁输出目录]
2.将 生成的补丁包版本放入上图中的hot_patches目录下即可
整个RN差量热更新方案大致就是这样,也经过实测通过。此间前后花了4天时间,连php脚本都是现学现用的。特此记录一下研究过程。
最后,附上我的研究的成果,我上传至我的github上了:https://github.com/GithubXkw1573/RN_BiffHot
如果我的方案成果对你有所帮助,请给个star吧,谢谢~