ReactNative学习笔记(四)热更新和增量更新

概括

关于RN的热更新,网上有很多现成方案,但是一般都依赖第三方服务,我所希望的是能够自己管控所有一切,所以只能自己折腾。

热更新的思路
getJSBundleFile/data/data/

又由于图片也需要更新,所以可以将更新资源(图片+JSBundle文件)打包成一个zip,在每次启动apk之后检测是否有更新包,如果有,后台偷偷下载下来,那么什么时候解压呢?个人推荐在下次启动apk的时候解压,那样可以保证图片和JS同时更新(因为我没有尝试过在程序运行时覆盖bundle文件会有什么问题)。

思路的具体实现

生成bundle文件

前面提到,RN会将所有JS压缩混淆成一个bundle文件,所以要做热更新,我们首先需要掌握如何自己手动生成bundle文件。

执行如下命令即可(记得先在项目根目录新建一个bundle文件夹,否则报错):

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

注意,bundle文件在哪,那么图片也必须放在哪,如果bundle默认放在assets下面,会自动读取apk内部res文件夹下的资源文件,但是如果你将bundle文件放在了其它自定义目录下,那么图片也要跟着复制过去,否则图片全部空白。

自定义bundle文件路径

getJSBundleFile

0.28及以前版本:

public class MainActivity extends ReactActivity
{

@Override<br/>
protected @Nullable String getJSBundleFile()<br/>
{<br/>
	String jsBundleFile = getFilesDir().getAbsolutePath() + &#34;/index.android.bundle&#34;;<br/>
	File file = new File(jsBundleFile);<br/>
	return file != null &amp;&amp; file.exists() ? jsBundleFile : null;<br/>
}<br/>

}

0.29及以后版本:

public class MainApplication extends Application implements ReactApplication
{

private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this)<br/>
{<br/>
	@Override<br/>
	protected @Nullable String getJSBundleFile()<br/>
	{<br/>
		String jsBundleFile = getFilesDir().getAbsolutePath() + &#34;/index.android.bundle&#34;;<br/>
		File file = new File(jsBundleFile);<br/>
		return file != null &amp;&amp; file.exists() ? jsBundleFile : null;<br/>
	}<br/>
}<br/>

}

com.helloworld/data/data/com.helloworld/files/index.android.bundle

封装下载方法

前面忘记介绍如何开发一个原生模块让JS调用了,这里正好借封装下载方法的机会介绍一下。

这里只是简单的实现一个下载的方法,实际项目中建议用更成熟方案。

HotUpdateModule.java
public class HotUpdateModule extends ReactContextBaseJavaModule
{

public HotUpdateModule(ReactApplicationContext reactContext) {<br/>
	super(reactContext);<br/>
}<br/>
@Override<br/>
public String getName() {<br/>
	return &#34;hotupdate&#34;; // 返回的名字就是最终模块的名字,前端调用时:NativeModules.hotupdate.xxx<br/>
}
@ReactMethod<br/>
public void download(final String url, String newFileName, final Promise promise)<br/>
{<br/>
	final String savePath = getReactApplicationContext().getFilesDir() + &#34;/&#34; + newFileName;<br/>
	new Thread(new Runnable()<br/>
	{<br/>
		@Override<br/>
		public void run()<br/>
		{<br/>
			try<br/>
			{<br/>
				String result = SimpleDownloadUtil.download(url, savePath);<br/>
				WritableMap map = Arguments.createMap();<br/>
				map.putString(&#34;result&#34;, result);<br/>
				promise.resolve(map);<br/>
			}<br/>
			catch (Exception e)<br/>
			{<br/>
				promise.reject(&#34;unknown error&#34;, e);<br/>
			}<br/>
		}<br/>
	}).start();<br/>
}<br/>

}

SimpleDownloadUtil.java
public class SimpleDownloadUtil
{

/**<br/>
 * 简单的下载工具类<br/>
 * @param downloadUrl<br/>
 * @param savePath<br/>
 * @return 返回保存路径,如果下载失败,返回空<br/>
 */<br/>
public static String download(String downloadUrl, String savePath) throws Exception<br/>
{<br/>
	Log.i(&#34;info&#34;, &#34;开始下载:&#34;+downloadUrl);<br/>
	HttpURLConnection con = (HttpURLConnection) new URL(downloadUrl).openConnection();<br/>
	con.setRequestMethod(&#34;GET&#34;);<br/>
	con.setUseCaches(false);<br/>
	con.setInstanceFollowRedirects(true);<br/>
	con.setRequestProperty(&#34;user-agent&#34;, &#34;Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.31 (KHTML, like Gecko) Chrome/26.0.1410.64 Safari/537.31&#34;);<br/>
	con.setRequestProperty(&#34;accept&#34;, &#34;*/*&#34;);// 这个可以不设置<br/>
	con.connect();// 连接<br/>
	InputStream is = con.getInputStream();<br/>
	File file = new File(savePath);<br/>
	FileOutputStream fos = new FileOutputStream(file);<br/>
	byte[] buf = new byte[1024];<br/>
	int len = -1;<br/>
	while ((len = is.read(buf)) != -1) fos.write(buf, 0, len);<br/>
	is.close();<br/>
	fos.close();<br/>
	con.disconnect();// 断开连接<br/>
	Log.i(&#34;info&#34;, &#34;下载完毕:&#34; + savePath);<br/>
	return savePath;<br/>
}<br/>

}

TestReactPackage.java
public class TestReactPackage implements ReactPackage {

@Override<br/>
public List&lt;NativeModule&gt; createNativeModules(ReactApplicationContext reactContext) {<br/>
    List&lt;NativeModule&gt; modules = new ArrayList&lt;&gt;();<br/>
    // modules.add(new TestModule(reactContext));<br/>
    modules.add(new HotUpdateModule(reactContext)); // 多个模块依次添加<br/>
    return modules;<br/>
}<br/>
@Override<br/>
public List&lt;Class&lt;? extends JavaScriptModule&gt;&gt; createJSModules() {<br/>
    return Collections.emptyList();<br/>
}<br/>
@Override<br/>
public List&lt;ViewManager&gt; createViewManagers(ReactApplicationContext reactContext) {<br/>
    return Collections.emptyList();<br/>
}<br/>

}

MainApplicationTestReactPackage
@Override
protected List&lt;ReactPackage&gt; getPackages()
{

return Arrays.&lt;ReactPackage&gt;asList(<br/>
		new MainReactPackage(),<br/>
		new TestReactPackage() // 自定义的<br/>
);<br/>

}

NativeModules.hotupdate.download()NativeModules

模拟服务器

假设有一个检测是否需要更新的接口,返回如下字段:

{

&#34;needUpdate&#34;: true, // 表示是否需要更新<br/>
&#34;updateUrl&#34;: &#34;http://192.168.191.1/update/bundle.zip&#34; // 更新地址<br/>

}

为了简单起见,直接用JSON文件模拟,bundle.zip就是我们上面用命令生成的bundle文件夹压缩后的文件(如果希望用批处理方式生成zip的话可以参考我之前写的Windows下使用命令行解压和压缩zip)。

检测更新并下载

import React, { Component } from ‘react’;
import { NativeModules } from ‘react-native’; class TestComponent extends Component
{

// 省略其它代码<br/>
componentDidMount()<br/>
{<br/>
	fetch(&#39;http://192.168.191.1/update/check_update.json&#39;)<br/>
	.then((response) =&gt; response.json())<br/>
	.then((json) =&gt;<br/>
	{<br/>
		if(json.needUpdate &amp;&amp; json.updateUrl)<br/>
		{<br/>
			Epg.tip(&#39;检测到省流量更新文件,开始自动下载!&#39;);<br/>
			NativeModules.hotupdate.download(json.updateUrl, &#39;bundle.zip&#39;)<br/>
			.then((e) =&gt; alert(&#39;下载成功:&#39;+e.result+&#39;,下次重启时生效!&#39;))<br/>
			.catch((error) =&gt; alert(&#39;下载失败:&#39;+error));<br/>
		}<br/>
	})<br/>
	.catch((error) =&gt; alert(&#39;检测更新失败:&#39;+error));<br/>
}<br/>

}

解压zip

由于JS本身可能需要更新,所以解压zip用JS来完成的话可能不太适合,我把它直接写在Activity里面:

@Override
protected void onCreate(Bundle savedInstanceState)
{

String root = this.getFilesDir().getAbsolutePath();<br/>
File zip = new File(root, &#34;bundle.zip&#34;);<br/>
if(zip.exists()) // 如果检测到zip更新包,解压之<br/>
{<br/>
    ZipUtil.extract(root+&#34;/bundle.zip&#34;, root); // 这个ZipUtil是自己随便封装的<br/>
    zip.delete(); // 解压之后删除zip文件<br/>
}<br/>
super.onCreate(savedInstanceState);<br/>

}

测试

一整个过程走下来感觉是有点折腾人的,虽然都比较简单,测试的时候最麻烦,因为必须生成release包之后热更新才能看到效果。

测试过程可以这样:

needUpdateneedUpdate
增量更新

图片的增量更新

前面提到了,bundle文件在哪,图片也要在哪,否则图片会找不到,但是更新包里面把所有的图片都包括进去太大了,有一种思路是:每次启动APK立即检测私有目录下是否有bundle文件,没有就从assets下复制一个,这样可以保证无论何时bundle文件都是从sd卡读取,现在要做的就是把图片也复制过去,但是图片是放在res文件夹作为资源文件存在的,怎么把res下的图片文件完整复制到sd卡,这个我还真不会,暂时也没有找到合适的方法,如果哪位知道方法还烦请告知(主要是针对非root用户,已经root的用户就好办了)。

所以目前的一个比较笨的办法是,打包时人工将所有图片丢到assets下,因为assets下的文件是可以随意复制的,缺点就是apk体积变大了,一个apk里面放了2份图片。

上述问题解决了,图片的增量更新就好办了,每次只需要把需要替换或增加的图片放到更新的zip包里面去就可以了。

bundle文件的增量更新

这是个文本文件,一般有几百kb,不作增量做全量更新问题也不大,但是还是有必要研究一下的。网上一般思路是用bsdiff对比文件,或者分离bundle,这个我没去做具体尝试,所以就不详细赘述了,有兴趣的可以看文末的参考链接。

参考