熱更新
ReactNative告別CodePush残家,自建熱更新版本升級(jí)環(huán)境
微軟的CodePush熱更新非常難用大家都知道泣港,速度更墻了沒什么區(qū)別。
另一方面历葛,加入不希望代碼放到別人的服務(wù)器上正塌,自己寫接口更新總歸安全一些。
那如何自己做一個(gè)ReactNative更新管理工具恤溶。
ReactNative啟動(dòng)原理
首先我們要弄清react-native啟動(dòng)的原理乓诽,是直接調(diào)用jslocation中的jsbundle文件和assets資源文件。
由此咒程,我們可以自己通過請(qǐng)求服務(wù)器接口來判斷版本鸠天,并下載最新的然后替換相應(yīng)的文件,然后從這個(gè)文件調(diào)用啟動(dòng)APP帐姻。這就像之前的一些H5 APP一樣的做版本的管理稠集。
以iOS為例,我們需要分一下幾個(gè)步驟搭建自己的RN升級(jí)工具:
一饥瓷、設(shè)置默認(rèn)jsbundle地址(比如document文件夾)
1.首先打包的時(shí)候把jsbundle和assets放入copy bundle resource剥纷,每次啟動(dòng)后,檢測(cè)document文件夾是否存在呢铆,不存在則拷貝到document文件夾晦鞋,然后給RN框架讀取啟動(dòng)。
我們建立如下的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];
// 如果已經(jīng)存在
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
}
二、做升級(jí)檢測(cè)娜谊,有更新則下載确买,然后對(duì)本地文件進(jìn)行替換:
加入我們不立即做更新,可以更新后替換因俐,然后不會(huì)影響本次APP的使用,下次使用就會(huì)默認(rèn)是最新的了周偎。如果立即更新的話抹剩,需要使用到RCTBridge類里的relaod函數(shù)進(jìn)行重啟。
這里通過NSURLSession進(jìn)行下載蓉坎,然后zip解壓縮等方法來實(shí)現(xiàn)文本的替換澳眷。
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) {
// 請(qǐng)求自己服務(wù)器的API,判斷當(dāng)前的JS版本是否最新
/*{
"version": "1.0.5",
"fileUrl":"http://www.xxxx.com/xxx.zip",
"message": "有新版本蛉艾,請(qǐng)更新到我們最新的版本"钳踊,
"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中的定制拓瞪、彈框缴罗,直接強(qiáng)制更新等
如果需要強(qiáng)制刷新reload,我們新建RCTView的方式也需要稍微修改下祭埂,通過新建一個(gè)RCTBridge的對(duì)象面氓。因?yàn)镽CTBridge中有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;
// 更新檢測(cè)
[MXUpdateHelper checkUpdate:^(NSInteger status, id data) {
if (status == 1) {
wekself.versionDic = data;
/*
這里具體關(guān)乎用戶體驗(yàn)的方式就多種多樣了蛆橡,比如自動(dòng)立即更新舌界,彈框立即更新,自動(dòng)下載打開再更新等泰演。
*/
UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"提示" message:data[@"message"] delegatee:self cancelButtonTitle:@"取消" otherButtonTitle:@"現(xiàn)在更新", nil];
[alert show];
// 進(jìn)行下載呻拌,并更新
// 下載完,覆蓋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(@"解壓失敗");
}
}
}];
}
}
流程簡(jiǎn)單睦焕,通過接口請(qǐng)求版本藐握,然后下載到document去訪問。其中需要做版本緩存复亏,Zip的解壓縮趾娃,以及文件拷貝。
// demo: https://github.com/rayshen/MXHotdog
差異化更新
以上我們完成了代碼的熱更新工作缔御。但是如果bundle太大的情況下抬闷,會(huì)增加用戶的流浪消耗,我們可以用生成補(bǔ)丁包的方式來進(jìn)一步減少更新包zip的體積耕突。
以安卓為例:
促使化項(xiàng)目發(fā)布時(shí)笤成,生成并保留一份index.android.bundle文件。
有版本更新時(shí)眷茁,生成新的index.android.bundle文件炕泳,使用google-diff-match-patch對(duì)比兩個(gè)文件,并生成差異不定文件上祈。app下載補(bǔ)丁文件培遵,在使用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
下載之后添加到項(xiàng)目目錄即可
2.生成補(bǔ)丁包
String oldPackeg = RefreshUpdateUtils.getStringFromPat(oldPath);
String newPackeg = RefreshUpdateUtils.getStringFromPat(newPath);
// 對(duì)比
diff_match_patch dmp = new diff_match_patch();
LinkedList<Diff> diffs = dmp.diff_main(oldPackeg, newPackeg);
// 生成差異補(bǔ)丁包
LinkedList<Patch> patches = dmp.patch_make(diffs);
// 解析補(bǔ)丁包
String patchesStr = dmp.patch_toText(patches);
try {
// 將補(bǔ)丁寫入到某個(gè)位置
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.下載完成皇耗,解壓后執(zhí)行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. 轉(zhuǎn)換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();
}
}
總結(jié)下來万伤,合并分為如下過程:
(1)獲取Assets目錄下的bundle文件,轉(zhuǎn)換為字符串呜袁。
(2)解析.pat文件將其轉(zhuǎn)換為字符串敌买。
(3)調(diào)用patch_fromText獲取patches補(bǔ)丁包。
(4)調(diào)用patch_apply方法將第四步中生成patches補(bǔ)丁包與第一步中獲取的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;
}
以上步驟執(zhí)行完成后,我們就獲取了新的bundle文件荐操,繼而加載新的bundle文件芜抒,實(shí)現(xiàn)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方法中根據(jù)平臺(tái)的不同分別執(zhí)行不同的圖片加載邏輯拐迁。這里主要看android platform:drawableFolderInBundle方法為在存在離線Bundle文件時(shí),從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是增量更新的圖片名稱字符串全局緩存缓淹,其中包括所有更新和修改的圖片名稱,并且以“|”隔開塔逃。當(dāng)系統(tǒng)加載圖片時(shí)讯壶,如果在緩存中存在該圖片名稱,證明是我們?cè)隽扛禄蛐薷牡膱D片湾盗,所以需要系統(tǒng)從Bundle文件所在目錄下加載伏蚊。否則直接從原有asset資源加載。
(3)每當(dāng)有圖片增量更新格粪,修改patchImgName躏吊,例如images_ic_1.png和images_ic_2.png為增量更新或修改的圖片。
var patchImgNames = '|images_ic_1.png|images_ic_2.png|';
注:生成bundle目錄時(shí)帐萎,圖片資源都會(huì)放在同一目錄下(drawable-mdpi)比伏,如果引用圖片包含其他路徑,例如require("./img/test1.png")吓肋,圖片在img目錄下凳怨,則圖片加載時(shí)會(huì)自動(dòng)將img目錄轉(zhuǎn)換為圖片名稱:“img_test1.png”瑰艘,即圖片所在文件夾名稱會(huì)作為圖片名的前綴是鬼。此時(shí)圖片名配置文件中的名稱也需要聲明為"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差異補(bǔ)丁包,并壓縮為zip更新包
更新包沒有太大區(qū)別均蜜,依然是增量更新的圖片和pat李剖。
小提示:
因?yàn)镽N會(huì)從drawable-mdpi下加載圖片,所以我們只需要將drawable-mdpi打包即可囤耳,其余的篙顺,drawalbe-xx文件夾可以不放進(jìn)zip。
(6)既然是增量更新拂蝎,就會(huì)分為第一次更新前雨后的情況掌桩。所以需要聲明一個(gè)標(biāo)識(shí)來表示當(dāng)前是否為第一次下發(fā)更新包
第一次更新前:
1.緩存中不存在更新包扳碍,pat補(bǔ)丁包需要與Asset下的index.android.bundle進(jìn)行合并,生成新的bundle文件宰僧。
2.增量圖片直接下發(fā)到緩存中。
第一次更新后观挎,即第一次更新后的更新操作:
1.緩存下存在更新包琴儿,需要將新的pat補(bǔ)丁包與緩存下上次生成的index.android.bundle進(jìn)行合并,生成新的bundle文件嘁捷。
2.增量圖片需要添加到緩存bundle所在文件下的drawable-mdpi目錄造成。
本次下發(fā)的更新包與之前的bundle進(jìn)行合并以及將圖片添加到之前drawable-mdpi后,需要?jiǎng)h除雄嚣。
核心代碼如下:
// 下載前檢查本地是否存在更新包晒屎。FIRST_UPDATE來標(biāo)識(shí)是否為第一次下發(fā)更新包
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() {
// 開啟單獨(dú)線程现诀,解壓夷磕,合并
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進(jìn)行合并
*/
private void mergePatAndAsset() {
// 解析Asset目錄下的bundle文件
String assetsBundle = FileUtils.getJsBundleFromAssets(getApplicationContext());
// 解析bundle當(dāng)前目錄下.pat文件字符串
String patcheStr = FileUtils.getStringFromPat(FileConstant.JS_PATCH_LOCAL_FILE);
// 合并
merge(patcheStr, assetsBundle);
// 刪除pat
FileUtils.deleteFile(FileConstant.JS_PATCH_LOACL_FILE);
}
/**
* 與本地下的bundle進(jìn)行合并
*/
private void mergePatAndBundle() {
// 解析本地目錄下的bundle
String assetsBundle = FileUtils.getJsBundleFromSDCard(FileConstant.JS_BUNDLE_LOACL_PATH);
// 解析最新下發(fā)的.pat文件字符串
String patcheStr = FileUtils.getStringFromPat(FileConstant.FUTURE_PAT_PATH);
// 合并
merge(patchesStr, assetsBundle);
// 添加圖片
FileUtils.copyPatchImgs(FileConstant.FUTURE_DRAWABLE_PATH, FileConstant.DRAWABLE_PATH);
// 刪除本次下發(fā)的更新文件
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();
// 轉(zhuǎn)化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 工具類函數(shù)
/**
* 將圖片復(fù)制到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();
}
}
當(dāng)客戶端下載解析后坐桩,圖片的增量更新就搞定了,這樣我們的更新包就小了很多封锉。缺點(diǎn)也很明顯绵跷,每次更新RN版本的時(shí)候,都要修改RN的源碼