您的位置:首页 > Web前端 > React

[置顶] React Native 实现热部署、差异化增量热更新

2017-02-14 11:37 666 查看
刚创建的React
Native 微信公众号,欢迎微信扫描关注订阅号,每天定期会分享react
native 技术文章,移动技术干货,精彩文章技术推送。同时可以扫描我的微信加入react-native技术交流微信群。欢迎各位大牛,React
Native技术爱好者加入交流!





项目已开源到github,链接为:ReactNativeApp,欢迎大家fork,star

上一篇和大家分享了如何在Android 现有App中集成React Native。本篇博客同样是React Native中比较经典的内容:热更新部署。

Android原生App中我们实现热修复有很多种选择:Tinker、hotFix、Qzone的热更新等等。基本的思路都是大同小异的。React Native中的热更新有点像App的版本更新,也就是根据查询server端的版本和手机端目前App的版本进行对比,然后来执行是否更新的操作。根本原因在于React Native的加载启动机制:React Native会将一系列资源打包成js bundle文件,系统加载js bundle文件,解析并渲染。所以,React
Native热更新的根本原理就是更换js bundle文件,并重新加载,新的内容就完美的展示出来了。微软为我们提供了CodePush来简化热更新的操作,但是由于速度等原因在国内并没有备受青睐。本篇内容就以自己服务器来更新的方式实现。

一、原理分析

前面简单的说了些基本原理,接下来先上一张具体的更新流程图:



上面流程图中展示了如何实现更新的步骤,可以总结为进入App根据版本检查是否需要更新:

(1)更新

          下载最新JsBundle文件以及所需要的图片资源等,下载完成后解析最新JsBundle文件。

(2)不更新

           判断本地是否还有缓存的JsBundle文件:

          1>存在

               本地存在JsBundle,即有过热更新操作。那么App直接加载在缓存目录下的JsBundle文件。

          2>不存在

               本地不存在JsBundle,即之前从未有过热更新操作。那么App只能使用初始化时打包在assets目录下的index.android.bundle文件。

Ok,根据上面的流程,我们来看下代码实现过程。

二、功能实现

(1)检查是否需要更新

/**
* 检查版本号
*/
private void checkVersion() {

if(true) {
// 有最新版本
Toast.makeText(this, "开始下载", Toast.LENGTH_SHORT).show();
downLoadBundle();
}
}

       实现步骤即请求服务器中的版本号,然后与本地版本号进行对比,此处我为了代码清晰易懂,直接执行下载更新的流程。

(2)Android为我们提供了下载工具类:DownLoadManager,我们使用它来执行下载

/**
* 下载最新Bundle
*/
private void downLoadBundle() {

// 1.检查是否存在pat压缩包,存在则删除
zipfile = new File(FileConstant.JS_PATCH_LOCAL_PATH);
if(zipfile != null && zipfile.exists()) {
zipfile.delete();
}
// 2.下载
DownloadManager downloadManager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);
DownloadManager.Request request = new DownloadManager
.Request(Uri.parse(FileConstant.JS_BUNDLE_REMOTE_URL));
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN);
request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_MOBILE| DownloadManager.Request.NETWORK_WIFI);
request.setDestinationUri(Uri.parse("file://"+ FileConstant.JS_PATCH_LOCAL_PATH));
mDownLoadId = downloadManager.enqueue(request);
}

      首先去判断是否存在有下载的更新压缩包,如果有,则先删除旧的,然后下载最新压缩包。

(3)下载完成后,DownLoadManager会发出一个DownloadManager.ACTION_DOWNLOAD_COMPLETE的广播,在收到广播后,对比下载任务ID   

/**
* 下载完成后收到广播
*/
public 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));
}
}
}

     因为我们下载的是Zip压缩文件(Zip压缩文件体积下,有效控制了由于更新文件大以及图片资源占用给用户带来消耗流量的问题),所以我们需要先解压

(4)解压Zip

/**
* 解压
*/
public static void decompression() {

try {

ZipInputStream inZip = new ZipInputStream(new FileInputStream(FileConstant.JS_PATCH_LOCAL_PATH));
ZipEntry zipEntry;
String szName;
try {
while((zipEntry = inZip.getNextEntry()) != null) {

szName = zipEntry.getName();
if(zipEntry.isDirectory()) {

szName = szName.substring(0,szName.length()-1);
File folder = new File(FileConstant.JS_PATCH_LOCAL_FOLDER + File.separator + szName);
folder.mkdirs();

}else{

File file1 = new File(FileConstant.JS_PATCH_LOCAL_FOLDER + File.separator + szName);
boolean s = file1.createNewFile();
FileOutputStream fos = new FileOutputStream(file1);
int len;
byte[] buffer = new byte[1024];

while((len = inZip.read(buffer)) != -1) {
fos.write(buffer, 0 , len);
fos.flush();
}

fos.close();
}
}
} catch (IOException e) {
e.printStackTrace();
}
inZip.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}


(5)解压完成后,加载最新Bundle和图片资源

        如何控制RN加载Bundle的方式呢?没错,0.26版本之后的RN系统在ReactApplication下的ReactNativeHost为我们提供了getJsBundleFile方法,在该方法中默认返回null,即加载assets下的bundle文件。我们可以根据条件来加载不同目录下的bundle文件即可

/**
* Created by Song on 2017/2/13.
*/
public class MainApplication extends Application implements ReactApplication {

private static MainApplication instance;
private static final CommPackage mCommPackage = new CommPackage();

@Override
public void onCreate() {
super.onCreate();
instance = this;
SoLoader.init(this,false);
}

private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {

@Nullable
@Override
protected String getJSBundleFile() {
File file = new File (FileConstant.JS_BUNDLE_LOCAL_PATH);
if(file != null && file.exists()) {
return FileConstant.JS_BUNDLE_LOCAL_PATH;
} else {
return super.getJSBundleFile();
}
}

@Override
public boolean getUseDeveloperSupport() {
return BuildConfig.DEBUG;
}

@Override
protected List<ReactPackage> getPackages() {
return Arrays.<ReactPackage>asList(
new MainReactPackage(),
mCommPackage
);
}
};

@Override
public ReactNativeHost getReactNativeHost() {
return mReactNativeHost;
}
}

    在当我们下载好最新更新文件后,跳转到RN界面,即会执行getJSBundleFile方法来执行加载Bundle文件的方式。在实际应用当中,我们可以在Splash页面去执行检查更新下载,然后在跳转到RN界面时,最新文件就会呈现出来。

如何获取最新的bundle文件和图片资源呢?我们在RN项目根目执行以下命令来得到bundle文件和图片资源:

react-native bundle --entry-file index.android.js --bundle-output ./bundle/index.android.bundle --platform android --assets-dest ./bundle --dev false

【扩展】iOS打包方式如下(要保证在项目目录下有release_ios文件夹):

react-native bundle --entry-file index.ios.js --platform ios --dev false --bundle-output release_ios/main.jsbundle --assets-dest release_ios/
(1)--entry   入口js文件,android系统就是index.android.js,ios系统就是index.ios.js

(2)--bundle-output   生成的bundle文件路径

(3)--platform   平台

(4)--assets-dest  图片资源的输出目录

(5)--dev   是否为开发版本,打正式版的安装包时我们将其赋值为false



执行命令之前,首先要在根目录下创建好bundle文件夹,bundle文件和图片资源将会输出到已创建好的bundle文件夹下。

解压后的最新更新文件:



三、差异化更新

到此,我们便完成了代码的热更新工作。大家可能会说,如果bundle太大的情况下怎么办呢?没错,这个问题同样在博客开始也提到了。打包成zip也是为了减小更新文件体积,减少用户流量消耗,同样,我们也可以生成用生成补丁包的方式来进一步减小更新包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版本,所以我将其提取出来,方便大家下载使用:

google-diff-match-patch-java

下载后将其添加到项目目录即可。



2.生成补丁包

// 获取新旧Bundle文件
String o = RefreshUpdateUtils.getStringFromPat("C:/Users/lenovo/Desktop/old.bundle");
String n = RefreshUpdateUtils.getStringFromPat("C:/Users/lenovo/Desktop/new.bundle");

// 对比
diff_match_patch dmp = new diff_match_patch();
LinkedList<Diff> diffs = dmp.diff_main(o, n);

// 生成差异补丁包
LinkedList<Patch> patches = dmp.patch_make(diffs);

// 解析补丁包
String patchesStr = dmp.patch_toText(patches);

try {
// 将补丁文件写入到某个位置
Files.write(Paths.get("C:/Users/lenovo/Desktop/patches.pat"), patchesStr.getBytes());
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
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文件合并

/**
* 下载完成后收到广播
*/
public 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目录下的bunlde
String assetsBundle = RefreshUpdateUtils.getJsBundleFromAssets(getApplicationContext());
// 2.获取.pat文件字符串
String patcheStr = 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



5.读取pat文件的方法:

/**
* 将.pat文件转换为String
* @param patPath 下载的.pat文件所在目录
* @return
*/
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;
}

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(): ResolvedAssetSource {
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中增加增量图片全局名称变量

/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
* @providesModule AssetSourceResolver
* @flow
*/
'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');const Platform = require('Platform');const assetPathUtils = require('../../local-cli/bundle/assetPathUtils');const invariant = require('fbjs/lib/invariant');/** * Returns a path like 'assets/AwesomeModule/icon@2x.png' */function getScaledAssetPath(asset): string { var scale = AssetSourceResolver.pickScale(asset.scales, PixelRatio.get()); var scaleSuffix = scale === 1 ? '' : '@' + scale + 'x'; var assetDir = assetPathUtils.getBasePath(asset); return assetDir + '/' + asset.name + scaleSuffix + '.' + asset.type;}
剩余代码略....
(2)修改isLoadedFromFileSystem方法
isLoadedFromFileSystem(): boolean {
var imgFolder = getAssetPathInDrawableFolder(this.asset);
var imgName = imgFolder.substr(imgFolder.indexOf("/") + 1);
var isPatchImg = patchImgNames.indexOf("|"+imgName+"|") > -1;
return !!this.bundlePath && isPatchImg;
}

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打包即可,其余的   drawable-xx文件夹可以不放进到zip。

(6)既然是增量更新,就会分为第一次更新前与后的情况。所以需要声明一个标识来表示当前是否为第一次下发更新包

【第一次更新前】:

  1> SD卡下不存在更新包,pat补丁包需要与Asset下的index.android.bundle进行合并,生成新的bundle文件。

  2> 增量图片直接下发到SD卡

【第一次更新后,即第一次更新后的更新操作】:

  1> SD卡下存在更新包,需要将新的pat补丁包与SD卡下的上次生成的index.android.bundle进行合并,生成新的bundle文件。

  2> 增量图片需要添加到SD卡bundle所在文件夹下的drawable-mdpi目录。

  3> 本次下发的更新包在与之前的bundle进行合并以及将图片添加到之前drawable-mdpi后,需要删除。

核心代码如下:

// 1.下载前检查SD卡是否存在更新包文件夹,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() {

// 1.解析Asset目录下的bundle文件
String assetsBundle = FileUtils.getJsBundleFromAssets(getApplicationContext());
// 2.解析bundle当前目录下.pat文件字符串
String patcheStr = FileUtils.getStringFromPat(FileConstant.JS_PATCH_LOCAL_FILE);
// 3.合并
merge(patcheStr,assetsBundle);
// 4.删除pat
FileUtils.deleteFile(FileConstant.JS_PATCH_LOCAL_FILE);
}
/**
* 与SD卡下的bundle进行合并
*/
private void mergePatAndBundle() {

// 1.解析sd卡目录下的bunlde
String assetsBundle = FileUtils.getJsBundleFromSDCard(FileConstant.JS_BUNDLE_LOCAL_PATH);
// 2.解析最新下发的.pat文件字符串
String patcheStr = FileUtils.getStringFromPat(FileConstant.FUTURE_PAT_PATH);
// 3.合并
merge(patcheStr,assetsBundle);
// 4.添加图片
FileUtils.copyPatchImgs(FileConstant.FUTURE_DRAWABLE_PATH,FileConstant.DRAWABLE_PATH);
// 5.删除本次下发的更新文件
FileUtils.traversalFile(FileConstant.FUTURE_JS_PATCH_LOCAL_FOLDER);
}
/**
* 合并,生成新的bundle文件
*/
private void merge(String patcheStr, String bundle) {

// 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.pat与bundle合并,生成新的bundle
Object[] bundleArray = dmp.patch_apply(pathes,bundle);
// 6.保存新的bundle文件
try {
Writer writer = new FileWriter(FileConstant.JS_BUNDLE_LOCAL_PATH);
String newBundle = (String) bundleArray[0];
writer.write(newBundle);
writer.close();
} catch (IOException 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的源码,不过这点小麻烦还是可以避免的。

其实还有另一种办法解决增量热更新。思路很简单,即不加载asset目录下的bundle文件,最开始就把bundle放到SD卡下。让RN加载Bundle的路径固定为SD卡路径。这样每次都可以直接更新SD卡的更新包即可。不过缺点也是很明显的,如果RN作为App的首显示界面,这就很尴尬了。这里只是提及,具体流程不再赘述。

六、iOS热更新

@清风飏 私信说实现了在iOS下的热更新,并且也是以压缩包形式下发。唯一区别是没有实现增量更新,大家有需要的,可以去了解一下:React-Native开发iOS篇-热更新的代码实现

七、效果图

 


以上就是使用React Native关于热更新的内容,其实还有很多不足地方,例如对更新文件进行加密,防止被恶意修改等等一些内容还需要不断完善。下一篇文章继续和大家分享关于React Native的内容,如何与原生进行交互,敬请期待~
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: