RN热更新原理

热更新

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 
@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 
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()
@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 diffs = dmp.diff_main(oldPackeg, newPackeg);

// 生成差异补丁包
LinkedList 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 pathes = (LinkedList) 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 pathes = (LinkedList)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的源码

你可能感兴趣的:(RN热更新原理)