React Native热更新与拆包iOS版

Author: Necfol

说明: 本文档用于指导前端React Native的热更新与拆包(iOS),如需开发其他框架应用,不适用本文档
我们在预研ReactNative的时候经常会遇到一个不可回避的问题:bundle太大了,如何给打包后的bundle拆包。毕竟没有任何业务的情况下,React Native打包之后的大小约为525kb。第三方也有解决方案CodePush,但是调研发现微软云服务在国内有点慢且有些不稳定。网上还有一些方案是需要侵入RN的打包代码以及原生代码,打包代码那块要花相对多的时间研究流程,暂时没有精力深入研究。Diff-Patch方案至此上位,笔者这个方案只需要编写部分js脚本文件和一些简单的iOS代码即可实现,性价比比较高。废话不多说:

1 热更新模块的实现方案

React Native打包之后会生成bundle文件,拆分方案是基于本次打包与前一次打包之间的差异完成的。即前一次打出来的bundle(lastbundle)作为base,本次打包出来的bundle(newbundle)与lastbundle之间的差异作为diffbundle。这样我们可以把一个最新的完整的bundle拆成两部分(如图):

拆分

将diffbundle上传到服务器,客户端请求最新diffbundle,与之前保存在客户端的lastbunlde进行合并(除了上传appstore时,其余状态下客户端完整的bundle均是由保存在客户端的lastbundle与diffbundle合并的)(如图):

合并

2 分包

拆分

新建一个ReactNative项目(hotExample),在该文件夹下新建config文件用来记录版本号,新建一个脚本文件diff.js,
用来生成diff.bundle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
const exec = require('child_process').exec
const fs = require('fs')
const path = require('path')
const DiffMatchPatch = require('diff-match-patch')
function ensureFolder(dir) {
try {
fs.accessSync(dir, fs.F_OK)
return true
} catch (e) {
fs.mkdirSync(dir)
return false
}
}
function mkdirsSync(dirname) {
if (fs.existsSync(dirname)) {
return true
} else {
if (mkdirsSync(path.dirname(dirname))) {
fs.mkdirSync(dirname)
return true
}
}
}
function copyFile(src, dist) {
fs.writeFileSync(dist, fs.readFileSync(src))
}
function increaseVersion(str) {
let newstr = Number(str.split('.').join(''))
newstr++
newstr = (newstr + '').split('')
return newstr.join('.')
}

mkdirsSync('./build/last/')
mkdirsSync('./build/new/')
mkdirsSync('./build/diff/')
let lastVersionStr = fs.readFileSync('./.necfolconfig')
let lastVersion = JSON.parse(lastVersionStr)
let newVersion = increaseVersion(lastVersion.lastVersion)
const lastPath = path.resolve('./build/last/', `last_${lastVersion.lastVersion}.bundle`)
const newPath = path.resolve('./build/new/', `main_${newVersion}.bundle`)
const diffPath = path.resolve('./build/diff/', 'diff.bundle')
let build = exec(`react-native bundle --entry-file index.js --bundle-output ./build/new/main_${newVersion}.bundle --platform ios --dev false `)
build.stdout.on('data', data => console.log('======: ', data))
build.stdout.on('close', nextStep)
function nextStep(e) {
copyFile(`./build/new/main_${newVersion}.bundle`, `./build/last/last_${newVersion}.bundle`)
let lastCode
try {
lastCode = fs.readFileSync(lastPath, 'utf-8')
} catch(e) {
lastCode = fs.readFileSync(newPath, 'utf-8')
}
let newCode = fs.readFileSync(newPath, 'utf-8')
let dmp = new DiffMatchPatch()
let diffCode = dmp.patch_make(lastCode, newCode)
fs.writeFileSync(diffPath, diffCode, 'utf-8')
fs.writeFileSync('./.necfolconfig', JSON.stringify({
lastVersion: newVersion
}), 'utf-8')
exec(`zip -q -r -j ./build/diff/diff_${newVersion}.bundle.zip ./build/diff/diff.bundle`)
}

具体代码可参考Demo,至此,我们的拆包已经完成。

3 客户端流程及合包

3.1 整体流程

当用户打开应用时即冷启动时,判断是否有我们自定义的jsbundle文件,如果没有,则从iOS应用的默认bundle中获取,并且存入我们自定义的jsbundle中,返回自定义jsbundle。

整体流程

在xcode工程AppDelegate.m中修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
NSURL *jsCodeLocation;
jsBundleURLForBundleRoot:@"index" fallbackResource:nil];
jsCodeLocation = [MICGetBundle getBundle];
_bridge = [[RCTBridge alloc] initWithBundleURL:jsCodeLocation
moduleProvider:nil
launchOptions:launchOptions];

RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:_bridge
moduleName:@"hotExample"
initialProperties:nil];
rootView.backgroundColor = [[UIColor alloc] initWithRed:1.0f green:1.0f blue:1.0f alpha:1];

self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
UIViewController *rootViewController = [UIViewController new];
rootViewController.view = rootView;
self.window.rootViewController = rootViewController;
[self.window makeKeyAndVisible];
return YES;
}

新建获取自定义bundle的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//
// MICGetBundle.m
// hotExample
//
// Created by necfol on 4/2/18.
// Copyright © 2018 Facebook. All rights reserved.
//

#import "MICGetBundle.h"

@implementation MICGetBundle

+(NSURL *)getBundle {
/** 每次打包之后,每一个应用程序生成一个私有目录随即生成一个数字字母串作为目录名,在每一次应用程序启动时,这个字母数字串都是不同于上一次。Documents目录可以通过:NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,NSUserdomainMask,YES) 得到*/
NSString *jsCodeLocation = [NSString stringWithFormat:@"%@/\%@",NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0],@"main.bundle"];
BOOL jsExist = [[NSFileManager defaultManager] fileExistsAtPath:jsCodeLocation];
if(jsExist)
return [NSURL URLWithString:jsCodeLocation];
NSString *jsBundlePath = [[NSBundle mainBundle] pathForResource:@"main" ofType:@"bundle"];
[[NSFileManager defaultManager] copyItemAtPath:jsBundlePath toPath:jsCodeLocation error:nil];
return [NSURL URLWithString:jsCodeLocation];
}

@end

3.2 版本对比下载及合包

本节提到的版本对比及下载是针对diff生成的bundle,即diff.bundle。

3.2.1 注意点

本文中使用的SSZipArchive,DiffMatchPatch读者可以自己选择合适的工具替换,如果和鄙人一致,那导入SSZipArchive,DiffMatchPatch,可以参考Demo。这里指出注意点:
1.导入SSZipArchive时,注意在Build Phases->Link Binary With Libraries 中导入libz.tbd

libz

2.导入DiffMatchPatch时,需要注意iOS的ARC,自动释放引用之类的。在Build Phases->Compile Sources中所有涉及到DiffMatchPatch的.m文件全部加上

1
-fno-objc-arc

DiffMatchPatch

版本对比及下载

3.2.2 合包

当应用从后台返回前台的时候,进行shouldUpdate操作,请求服务端,给予最新的diff.bundle的版本信息,客户端版本号和服务端版本相比对,判断是否需要下载,如果需要下载,则下载并解压到自定义的文件中,并且将版本号更新,这样下次用户再进来,虽然代码没有更新生效,但是由于版本号升上去了,用户也不会再次下载代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
-(void)applicationDidBecomeActive:(UIApplication *)application {
[MICShouldUpdate shouldUpdate:^(NSInteger status, id datas) {
if(status == 1){
[[MICDownLoad download] downloadFileWithURLString:datas[@"zip"] callback:^(NSInteger status, id data) {
if(status == 1){
NSError *error;
NSString *filePath = (NSString *)data;
NSString *desPath = [NSString stringWithFormat:@"%@",NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0]];
[SSZipArchive unzipFileAtPath:filePath toDestination:desPath overwrite:YES password:nil error:&error];
if(!error){
NSString *diffPath = [NSString stringWithFormat:@"%@%@", desPath, @"/diff.bundle"];
NSString *jsCodeLocationPath = [NSString stringWithFormat:@"%@/\%@",NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0],@"main.bundle"];
NSString *diffCode = [NSString stringWithContentsOfFile:diffPath encoding:NSUTF8StringEncoding error:nil];
NSString *jsCodeLocation = [NSString stringWithContentsOfFile:jsCodeLocationPath encoding:NSUTF8StringEncoding error:nil];

DiffMatchPatch *diffMatchPatch = [[DiffMatchPatch alloc] init];
NSError *error;
NSArray *patches = [diffMatchPatch patch_fromText:diffCode error:&error];
if (error != nil) {
NSLog(@"diff match failed : %@", error);
}
NSArray *resultsArray = [diffMatchPatch patch_apply:patches toString:jsCodeLocation];
NSString *resultJSCode = resultsArray[0]; //patch合并后的js
NSLog(@"=====================%@",resultJSCode);
[resultJSCode writeToFile:jsCodeLocationPath atomically:YES encoding:NSUTF8StringEncoding error:nil];
NSString *filePath = [[NSBundle mainBundle] pathForResource:@"Version" ofType:@"plist"];
NSMutableDictionary *newsDict = [NSMutableDictionary dictionary];
[newsDict setObject:datas[@"version"] forKey:@"version"];
[newsDict writeToFile:filePath atomically:YES];
NSLog(@"解压成功");
// 如果是立马更新则打开下面代码
// [_bridge reload];
}else{
NSLog(@"解压失败");
}
}
}];
}
}];
}

4 总结

整个方案大概就是如此,有描述不清楚的细节,可以阅读代码,参考Demo,本方案简单,可以说是花费成本最小的改动,但是难免有不足或者疏漏之处,恳请斧正。

分享到