原文地址:http://blog.csdn.net/
u013718120/article/
details/55096393
项目已开源到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)检查是否需要更新
- <span style="color:#333333;">
-
-
- private void checkVersion() {
-
- if(true) {
-
- Toast.makeText(this, "开始下载", Toast.LENGTH_SHORT).show();
- downLoadBundle();
- }
- }</span>
实现步骤即请求服务器中的版本号,然后与本地版本号进行对比,此处我为了代码清晰易懂,直接执行下载更新的流程。
(2)Android为我们提供了下载工具类:DownLoadManager,我们使用它来执行下载
- <span style="color:#333333;">
-
-
- private void downLoadBundle() {
-
-
- zipfile = new File(FileConstant.JS_PATCH_LOCAL_PATH);
- if(zipfile != null && zipfile.exists()) {
- zipfile.delete();
- }
-
- 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);
- }</span>
首先去判断是否存在有下载的更新压缩包,如果有,则先删除旧的,然后下载最新压缩包。
(3)下载完成后,DownLoadManager会发出一个DownloadManager.ACTION_DOWNLOAD_COMPLETE的广播,在收到广播后,对比下载任务ID
- <span style="color:#333333;">
-
-
- 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) {
-
- RefreshUpdateUtils.decompression();
- zipfile.delete();
-
-
-
- startActivity(new Intent(MainActivity.this,MyReactActivity.class));
- }
- }
- }</span>
因为我们下载的是Zip压缩文件(Zip压缩文件体积下,有效控制了由于更新文件大以及图片资源占用给用户带来消耗流量的问题),所以我们需要先解压
(4)解压Zip
- <span style="color:#333333;">
-
-
- 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();
- }
- }</span>
(5)解压完成后,加载最新Bundle和图片资源
如何控制RN加载Bundle的方式呢?没错,0.26版本之后的RN系统在ReactApplication下的ReactNativeHost为我们提供了getJsBundleFile方法,在该方法中默认返回null,即加载assets下的bundle文件。我们可以根据条件来加载不同目录下的bundle文件即可
- <span style="color:#333333;">
-
-
- 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;
- }
- }</span>
在当我们下载好最新更新文件后,跳转到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.生成补丁包
- <span style="color:#333333;">
- 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) {
-
- e.printStackTrace();
- }</span>
- <span style="color:#333333;"> 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;
- }</span>
3.下载完成,解压后执行mergePatAndAsset方法将Assets目录下的index.android.bundle和pat文件合并
- <span style="color:#333333;">
-
-
- 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) {
-
- RefreshUpdateUtils.decompression();
- zipfile.delete();
-
-
- mergePatAndAsset();
- startActivity(new Intent(MainActivity.this,MyReactActivity.class));
- }
- }
- }</span>
4.合并
- <span style="color:#333333;">
-
-
- private void mergePatAndAsset() {
-
-
- String assetsBundle = RefreshUpdateUtils.getJsBundleFromAssets(getApplicationContext());
-
- String patcheStr = RefreshUpdateUtils.getStringFromPat(FileConstant.JS_PATCH_LOCAL_FILE);
-
- diff_match_patch dmp = new diff_match_patch();
-
- LinkedList<diff_match_patch.Patch> pathes = (LinkedList<diff_match_patch.Patch>) dmp.patch_fromText(patcheStr);
-
- Object[] bundleArray = dmp.patch_apply(pathes,assetsBundle);
-
- try {
- Writer writer = new FileWriter(FileConstant.JS_BUNDLE_LOCAL_PATH);
- String newBundle = (String) bundleArray[0];
- writer.write(newBundle);
- writer.close();
-
- File patFile = new File(FileConstant.JS_PATCH_LOCAL_FILE);
- patFile.delete();
- } catch (IOException e) {
- e.printStackTrace();
- }
- }</span>
从上述代码中我们看到,合并分为如下过程:
(1)获取Assets目录下的bundle文件,转换为字符串
(2)解析.pat文件将其转换为字符串
(3)调用patch_fromText获取patches补丁包
(4)调用patch_apply方法将第四步中生成patches补丁包与第一步中获取的bundle合并生成新的bundle
(5)保存bundle
5.读取pat文件的方法:
- <span style="color:#333333;">
-
-
-
-
- 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;
- }</span>
6.读取Assets目录下的bundle文件:
- <span style="color:#333333;">
-
-
-
- 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;
- }</span>
以上步骤执行完成后,我们就获取到了新的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中增加增量图片全局名称变量
-
-
-
-
-
-
-
-
-
-
-
- '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');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后,需要删除。
核心代码如下:
-
- 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);
- }
-
-
-
- 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 {
-
- FileUtils.decompression(FileConstant.FUTURE_JS_PATCH_LOCAL_FOLDER);
-
- mergePatAndBundle();
- }
-
- FileUtils.deleteFile(FileConstant.JS_PATCH_LOCAL_PATH);
- }
- }).start();
- }
-
-
-
- private void mergePatAndAsset() {
-
-
- String assetsBundle = FileUtils.getJsBundleFromAssets(getApplicationContext());
-
- String patcheStr = FileUtils.getStringFromPat(FileConstant.JS_PATCH_LOCAL_FILE);
-
- merge(patcheStr,assetsBundle);
-
- FileUtils.deleteFile(FileConstant.JS_PATCH_LOCAL_FILE);
- }
-
-
-
- private void mergePatAndBundle() {
-
-
- String assetsBundle = FileUtils.getJsBundleFromSDCard(FileConstant.JS_BUNDLE_LOCAL_PATH);
-
- String patcheStr = FileUtils.getStringFromPat(FileConstant.FUTURE_PAT_PATH);
-
- merge(patcheStr,assetsBundle);
-
- FileUtils.copyPatchImgs(FileConstant.FUTURE_DRAWABLE_PATH,FileConstant.DRAWABLE_PATH);
-
- FileUtils.traversalFile(FileConstant.FUTURE_JS_PATCH_LOCAL_FOLDER);
- }
-
-
-
- private void merge(String patcheStr, String bundle) {
-
-
- diff_match_patch dmp = new diff_match_patch();
-
- LinkedList<diff_match_patch.Patch> pathes = (LinkedList<diff_match_patch.Patch>) dmp.patch_fromText(patcheStr);
-
- Object[] bundleArray = dmp.patch_apply(pathes,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工具类函数:
-
-
-
-
-
- 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();
- }
- }
- }
- }
-
-
-
-
- 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();
- }
- }
-
-
-
-
- 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的内容,如何与原生进行交互,敬请期待~