热更新
ReactNative告别CodePush,自建热更新版本升级环境
微软的CodePush热更新非常难用大家都知道,速度更墙了没什么区别。
另一方面,加入不希望代码放到别人的服务器上,自己写接口更新总归安全一些。
那如何自己做一个ReactNative更新管理工具。
ReactNative启动原理
首先我们要弄清react-native启动的原理,是直接调用jslocation中的jsbundle文件和assets资源文件。
由此,我们可以自己通过请求服务器接口来判断版本,并下载最新的然后替换相应的文件,然后从这个文件调用启动APP。这就像之前的一些H5 APP一样的做版本的管理。
以iOS为例,我们需要分一下几个步骤搭建自己的RN升级工具:
一、设置默认jsbundle地址(比如document文件夹)
1.首先打包的时候把jsbundle和assets放入copy bundle resource,每次启动后,检测document文件夹是否存在,不存在则拷贝到document文件夹,然后给RN框架读取启动。
我们建立如下的bundle文件管理类:
MXBundleHelper.h
#import <Foundataion/Foundation.h>
@interface MXBundleHelper : NSObject
+(NSURL *)getBundlerPath;
@end
MXBundlerHelper.m
#import "MaxBundleHelper.h"
#import "RCTBundleURLProvider.h"
@implementation MABundleHelper
+(NSURL *)getBundlePath {
#ifdef DEBUG
NSURL * jsCodeLocation = [[RCTBundleURLProvider sharedSetting] jsBundleURLForBundleRoot:@"index.ios" fallbackResource: nil];
return jsCodeLocation;
#else
// 需要存放和读取的document路径
// jsbundle地址
NSString * jsCachePath = [NSString stringWithFormat:@"%@/\%@",NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,NSUserDomainMask, Yes)[0],@"main.jsbundle"];
// assets文件夹地址
NSString *assetsCachePath = [NSString stringWithFormat:@"%@/\%@",NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0],@"assets"];
// 判断JSBundle是否存在
BOOL jsExist = [[NSFileManager defaultManager] fileExistsAtPath: jsCachePath];
// 如果已经存在
if (jsExist) {
NSLog(@"js已存在:%@",jsCachePath);
} else {
// 如果不存在
NSString * jsBundle = [[NSBundle mainBundle] pathForResource:@"main" ofType:@"jsbundle"];
[[NSFileManager defaultManager] copyItemAtPath: jsBundlePath toPath:jsCache error:nil];
NSLog(@"js已拷贝到Document: %@", jsCachePath);
}
// 判断assets是否存在
BOOL assetsExist = [[NSFileManager defaultManager] fileExistsAtPath: assetsCachePath];
// 如果已存在
if (assetsExist) {
NSLog(@"assets已存在:%@",assetsCachePath);
} else {
NSString *assetsBundlePath = [[NSBundle mainBundle] pathForResource:@"assets" ofType: nil];
[[NSFileManager defaultManager] copyItemAtPath: assetsBundlePath toPath: assetsCachePath error: nil];
NSLog(@"assets已拷贝至Document:%@",assetsCachePath);
}
return [NSURL URLWithString: jsCachePath];
#endif
}
二、做升级检测,有更新则下载,然后对本地文件进行替换:
加入我们不立即做更新,可以更新后替换,然后不会影响本次APP的使用,下次使用就会默认是最新的了。如果立即更新的话,需要使用到RCTBridge类里的relaod函数进行重启。
这里通过NSURLSession进行下载,然后zip解压缩等方法来实现文本的替换。
MXUpdateHelper.h
#import <Foundation/Foundation.h>
typedef void(^FinishBlock) (NSInteger status, id data);
@interface MXUpdateHelper : NSObject
+(void)checkUpdate:(FinishBlock)finish;
@end
MXUpdateHelper.m
#import "MXUpdateHelper.h"
@implementation MXUpdateHelper
+(void)checkupdate:(FinishBlock)finish {
NSString *url = @"http://www.xxx.com/xxxx";
NSMutableURLRequest * newRequest = [[NSMutableURLRequest alloc] initWithURL:[NSURL URLWithString: url]];
[newRequest setHTTPMethod:@"GET"];
[NSURLConnection sendAsynchronousRequest: newRequest queue:[NSOperationQueue mainQueue] completionHandler: ^(NSURLResponse * response, NSData * data, NSError * connectionError) {
if (connectionError == nil) {
// 请求自己服务器的API,判断当前的JS版本是否最新
/*{
"version": "1.0.5",
"fileUrl":"http://www.xxxx.com/xxx.zip",
"message": "有新版本,请更新到我们最新的版本",
"forecUpdate": "NO"
}*/
// 加入需要更新
NSString * curVersion = @"1.0.0";
NSString * newVersion = @"2.0.0"
// 一般情况下不一样,就是旧版本了
if (![curVersion isEqualToString: newVersion]) {
finish(1,data);
} else {
finish(0,nil);;
}
}
}];
}
@end
三、APPdelegate中的定制、弹框,直接强制更新等
如果需要强制刷新reload,我们新建RCTView的方式也需要稍微修改下,通过新建一个RCTBridge的对象。因为RCTBridge中有reload的接口可以使用。
#import "AppDelegate.h"
#import "RCTBundleURLProvider.h"
#import "RCTRootView.h"
#import "MXBundleHelper.h"
#import "MXUpdateHelper.h"
#import "MXFileHelper.h"
#import "SSZipArchive.h"
@interface AppDelegate()<UIAlertViewDelegate>
@property (nonatomic, strong) RCTBridge *bridge;
@property (nonatomic, strong) NSDictionary *versionDic;
@end
@implementation Appdelegate
- (BOOL) application:(UIApplication *)application didFinishLaunchingWithOptions: (NSDictionary *)launchOptions {
NSURL *jsCodeLocation;
jsCodeLocation = [MXBundleHelper getBundlePath];
_bridge = [[RCTBridge alloc] initWithBundleURL: jsCodeLocation mouduleName:@"MXVersionManger" 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 = [UIVeiwController new];
rootViewController.view = rootView;
self.window.rootViewController = rootViewController;
[self.window makeKeyAndVisible];
__weak AppDelegate *weakself = self;
// 更新检测
[MXUpdateHelper checkUpdate:^(NSInteger status, id data) {
if (status == 1) {
wekself.versionDic = data;
/*
这里具体关乎用户体验的方式就多种多样了,比如自动立即更新,弹框立即更新,自动下载打开再更新等。
*/
UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"提示" message:data[@"message"] delegatee:self cancelButtonTitle:@"取消" otherButtonTitle:@"现在更新", nil];
[alert show];
// 进行下载,并更新
// 下载完,覆盖JS和assets,并reload界面
}
}];
return YES;
}
-(void)alertView:(UIAlertView *)alertView clickedButtonAtIndex:(NSInteger)buttonIndex {
if (buttonIndex == 1) {
// 更新
[[MXFileHelper shared] downloadFileWithURLString: _versionDic[@"fileurl"] finish:^(NSInteger status, id data) {
if (status == 1) {
NSLog(@"下载完成");
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) {
NSLog(@"解压成功");
[_bridge reload];
} else {
NSLog(@"解压失败");
}
}
}];
}
}
流程简单,通过接口请求版本,然后下载到document去访问。其中需要做版本缓存,Zip的解压缩,以及文件拷贝。
// demo: https://github.com/rayshen/MXHotdog
差异化更新
以上我们完成了代码的热更新工作。但是如果bundle太大的情况下,会增加用户的流浪消耗,我们可以用生成补丁包的方式来进一步减少更新包zip的体积。
以安卓为例:
促使化项目发布时,生成并保留一份index.android.bundle文件。
有版本更新时,生成新的index.android.bundle文件,使用google-diff-match-patch对比两个文件,并生成差异不定文件。app下载补丁文件,在使用google-diff-match-patch和assets目录下的初始版本合并,生成新的index.android.bundle文件
1.添加google-diff-match-patch库
google-diff-match-patch库包含了多种编程语言的库文件,我们使用其中的java版本,所以我们将其的提取出来,方便大家下载使用:
http://download.csdn.net/detail/u013718120/9833398
下载之后添加到项目目录即可
2.生成补丁包
String oldPackeg = RefreshUpdateUtils.getStringFromPat(oldPath);
String newPackeg = RefreshUpdateUtils.getStringFromPat(newPath);
// 对比
diff_match_patch dmp = new diff_match_patch();
LinkedList<Diff> diffs = dmp.diff_main(oldPackeg, newPackeg);
// 生成差异补丁包
LinkedList<Patch> patches = dmp.patch_make(diffs);
// 解析补丁包
String patchesStr = dmp.patch_toText(patches);
try {
// 将补丁写入到某个位置
Files.write(Paths.get("targetPath"), pathcesStr.getBytes());
} catch (IOException e) {
e.printStacckTrace();
}
public static String getStringFromPat(String patPath) {
FileReader reader = null;
String result = "";
try {
reader = new FileReader(patPath);
int ch = reader.read();
StringBuilder sb = new StringBuilder();
while (ch != -1) {
sb.append((char) ch);
ch = reader.read();
}
reader.close();
result = sb.toString();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return result;
}
3.下载完成,解压后执行mergePatAndAsset方法将Assets目录下的index.android.bundle和pat文件合并
/**
* 下载完成后收到广播
*/
publci class CompleteReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
long completeId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
if (completeID == mDownLoadId) {
// 1. 解压
RefreshUpdateUtils.decompression();
zipfile.delete();
// 2. 将下载好的patches文件与assets目录下的原index.android.bundle合并,得到新的bundle文件
mergePatAndAsset();
startActivity(new Intent(MainActivity.this, MyReactActivity.class));
}
}
}
4、合并
/**
* 合并patches文件
*/
private void mergePatAndAsset() {
// 1. 获取Assets目录下的bundle
String assetsBundle = RefreshUpdateUtils.getJsBundleFromAssets(getApplicationContext());
// 2. 获取.pat淄川
String patchStr = RefreshUpdateUtils.getStringFromPat(FileConstant.JS_PATCH_LOCAL_FILE);
// 3. 初始化 dmp
diff_match_patch dmp = new diff_match_patch();
// 4. 转换pat
LinkedList<diff_match_patch.Patch> pathes = (LinkedList<diff_match_patch.Patch>) dmp.patch_fromText(patcheStr);
// 5. 与assets目录下的bundle合并,生成新的bundle
Object[] bundleArray = dmp.patch_apply(pathes, assetsBundle);
// 6. 保存新的bundle
try {
Writer writer = new FileWriter(FileConstant.JS_BUNDLE_LOCAL_PATH);
String newBundle = (String) bundleArray[0];
writer.write(newBundle);
writer.close();
// 7. 删除.pat文件
File patFile = new File(FileConstant.JS_PATCH_LOCAL_FILE);
patFile.delete();
} catch(IOException e) {
e.printStackTrace();
}
}
总结下来,合并分为如下过程:
(1)获取Assets目录下的bundle文件,转换为字符串。
(2)解析.pat文件将其转换为字符串。
(3)调用patch_fromText获取patches补丁包。
(4)调用patch_apply方法将第四步中生成patches补丁包与第一步中获取的bundle合并生成新的bundle。
(5)保存bundle。
6.读取Assets目录下的bundle文件
/**
* 获取Assets目录下的bundle文件
* @return
*/
public static String getJsBundleFromAssets(Context context) {
String result = "";
try {
InputStream is = context.getAssets().open(FileConstant.JS_BUNDLE_LOCAL_FILE);
int size = is.available();
byte[] buffer = new byte[size];
is.read(buffer);
is.close();
result = new String(buffer, "UTF-8");
} catch (IOException e) {
e.printStackTrace();
}
return result;
}
以上步骤执行完成后,我们就获取了新的bundle文件,继而加载新的bundle文件,实现React Native热更新。上述差异包更新方式只能更新不含图片引用的bundle代码文件,如果需要增量更新文件,需要修改React Native源码。
四、修改React Native 图片加载源码
渲染图片的方法在:node_modules/react-native/Libraries/Image/AssetSourceResolver.js下:
defaultAsset(): ResolveAssetSource {
if (this.isLoadedFromServer()) {
return this.assetServerURL();
}
if (Platform.OS === 'android') {
return this.isLoadedFromFileSystem() ?
this.drawableFolderInBundle() : this.resourceIdentifierWithoutScale();
} else {
return this.scaledAssetPathInBundle();
}
}
defaultAsset方法中根据平台的不同分别执行不同的图片加载逻辑。这里主要看android platform:drawableFolderInBundle方法为在存在离线Bundle文件时,从Bundle文件所在目录加载图片。resourceIdentifierWithoutScale方法从Asset资源目录下加载。由此,我们需要修改isLoadedFromFileSystem方法中的逻辑。
(1)在AssetSourceResolver.js中增加增量图片全局名称变量
'use strict';
export type ResolvedAssetSource = {
__packager_asset: boolean,
width: number,
height: number,
uri: string,
scale: number,
};
import type { PackagerAsset } from 'AssetRegistry';
// 全局缓存
var patchImgNames = ''; // 新加的代码
const PixelRatio = require('PixelRatio');
...
(2)修改isLoadedFromFileSystem方法
/* 原代码
* isLoadedFromFileSystem(): boolean {
* return !!this.bundlePath;
* }
*/
isLoadedFromFileSystem(): boolean {
var imgFolder = getAssetPathInDrawableFolder(this.asset);
var imgName = imgFolder.substr(imgFolder.indexOf("/")+1);
var isPatchImg = patchImgNames.indexOf("|" + imgName + "|") > -1;
return !!this.bundlePath && isPathcImg;
}
patchImgNames是增量更新的图片名称字符串全局缓存,其中包括所有更新和修改的图片名称,并且以“|”隔开。当系统加载图片时,如果在缓存中存在该图片名称,证明是我们增量更新或修改的图片,所以需要系统从Bundle文件所在目录下加载。否则直接从原有asset资源加载。
(3)每当有图片增量更新,修改patchImgName,例如images_ic_1.png和images_ic_2.png为增量更新或修改的图片。
var patchImgNames = '|images_ic_1.png|images_ic_2.png|';
注:生成bundle目录时,图片资源都会放在同一目录下(drawable-mdpi),如果引用图片包含其他路径,例如require("./img/test1.png"),图片在img目录下,则图片加载时会自动将img目录转换为图片名称:“img_test1.png”,即图片所在文件夹名称会作为图片名的前缀。此时图片名配置文件中的名称也需要声明为"img_test1.png",例如:"|img_test1.png|img_test2.png|"
(4)重新打包
react-native bundle --entry-file index.android.js --bundle-output ./bundle/index.android.bundle--platform android --assets-dest ./bundle --dev false
(5)生成.pat差异补丁包,并压缩为zip更新包
更新包没有太大区别,依然是增量更新的图片和pat。
小提示:
因为RN会从drawable-mdpi下加载图片,所以我们只需要将drawable-mdpi打包即可,其余的,drawalbe-xx文件夹可以不放进zip。
(6)既然是增量更新,就会分为第一次更新前雨后的情况。所以需要声明一个标识来表示当前是否为第一次下发更新包
第一次更新前:
1.缓存中不存在更新包,pat补丁包需要与Asset下的index.android.bundle进行合并,生成新的bundle文件。
2.增量图片直接下发到缓存中。
第一次更新后,即第一次更新后的更新操作:
1.缓存下存在更新包,需要将新的pat补丁包与缓存下上次生成的index.android.bundle进行合并,生成新的bundle文件。
2.增量图片需要添加到缓存bundle所在文件下的drawable-mdpi目录。
本次下发的更新包与之前的bundle进行合并以及将图片添加到之前drawable-mdpi后,需要删除。
核心代码如下:
// 下载前检查本地是否存在更新包。FIRST_UPDATE来标识是否为第一次下发更新包
bundleFile = new File(FileConstant.LOCAL_FOLDER);
if (bundleFile != null && bundleFile.exists()) {
ACache.get(getApplicationContext()).put(AppConstant.FIRST_UPDATE, false);
} else {
// 第一次更新
ACache.get(getApplicationContext()).put(AppcONSTANT.FIRST_UPDATE, true);
}
/**
* 下载完成后,处理ZIP压缩包
*/
private void handleZIP() {
// 开启单独线程,解压,合并
new Thread(new Runnable() {
@Override
public void run() {
boolean result = (Boolean) ACache.get(getApplicationContext()).getAsObject(AppConstant.FIRST_UPDATE);
if (result) {
// 解压到根目录
FileUtils.decompression(FileConstant.JS_PATCH_LOCAL_FOLDER);
// 合并
mergePatAndAsset();
} else {
// 解压到future目录
FileUtils.decompression(FileConstant.FUTURE_JS_PATCH_LOCAL_FOLDER);
// 合并
mergePatAndBundle();
}
// 删除ZIP压缩包
FileUtils.deleteFile(FileConstant.JS_PATCH_LOCAL_PATH);
}
}).start();
}
/**
* 与Asset资源目录下的bundle进行合并
*/
private void mergePatAndAsset() {
// 解析Asset目录下的bundle文件
String assetsBundle = FileUtils.getJsBundleFromAssets(getApplicationContext());
// 解析bundle当前目录下.pat文件字符串
String patcheStr = FileUtils.getStringFromPat(FileConstant.JS_PATCH_LOCAL_FILE);
// 合并
merge(patcheStr, assetsBundle);
// 删除pat
FileUtils.deleteFile(FileConstant.JS_PATCH_LOACL_FILE);
}
/**
* 与本地下的bundle进行合并
*/
private void mergePatAndBundle() {
// 解析本地目录下的bundle
String assetsBundle = FileUtils.getJsBundleFromSDCard(FileConstant.JS_BUNDLE_LOACL_PATH);
// 解析最新下发的.pat文件字符串
String patcheStr = FileUtils.getStringFromPat(FileConstant.FUTURE_PAT_PATH);
// 合并
merge(patchesStr, assetsBundle);
// 添加图片
FileUtils.copyPatchImgs(FileConstant.FUTURE_DRAWABLE_PATH, FileConstant.DRAWABLE_PATH);
// 删除本次下发的更新文件
FileUtils.traversalFile(FileConstant.FUTURE_JS_PATCH_LOACAL_FOLDER);
}
/**
* 合并,生成新的bundle文件
*/
private void merge(String patcheStr, String bundle) {
// 初始化dmp
diff_match_patch dmp = new diff_match_patch();
// 转化pat
LinkedList<diff)match_patch.Patch> pathes = (LinkedList<diff_match_patch.Patch>)dmp.patch_fromText(patcheStr);
// pat与bundle合并,并生成新的bundle
Object[] bundleArray = dmp.patch_apply(patches, bundle);
// 保存新的bundle文件
try {
Writer writer = new FleWriter(FileConstant.JS_BUNDLE_LOCAL_PATH);
String newBundle = (String)bundleArray[0];
writer.write(newBundle);
writer.close();
} catch (IOExcepiton e) {
e.printStackTrace();
}
}
FileUtils 工具类函数
/**
* 将图片复制到bundle所在文件夹下的drawable-mdpi
* @param srcFilePath
* @param destFilePath
*/
public static void copyPatchImgs(String srcFilePath, String destFilePath) {
File root = new File(srcFilePath);
File[] files;
if (root.exists() && root.listFiles() != null) {
files = root.listFiles();
for (File file: files) {
File oldFile = new File(srcFilePath+file.getName());
File newFile = new File(destFilePath+file.getName());
DataInputStream dis = null;
DataOutputStream dos = null;
try {
dos = new DataOutputStream(new FileOutputStream(newFile));
dis = new DataInputStream(new FileInputStream(oldFile));
} catch (FileNotFoundException e) {
e.printStackTrace();
}
int temp;
try {
while ((temp = dis.read()) != -1) {
dos.write(temp);
}
dis.close();
dos.close();
}catch (IOException e) {
e.printStackTrace();
}
}
}
}
/**
* 遍历删除文件夹下所有文件
* @param filePath
*/
public static void traversalFile(String filePath) {
File file = new File(filePath);
if (file.exists()) {
File[] files = file.listFiles();
for (File f: files) {
if (f.isDirectory()) {
traversalFile(f.getAbsolutePath());
} else {
f.delete();
}
}
file.delete();
}
}
/**
* 删除指定的File
* @param filePath
*/
public static void deleteFile(String filePath) {
File patFile = new File(filePath);
if (patFile.exists()) {
patFile.delete();
}
}
当客户端下载解析后,图片的增量更新就搞定了,这样我们的更新包就小了很多。缺点也很明显,每次更新RN版本的时候,都要修改RN的源码