RN学习4——QDaily Android app中通信和热修复实践

React Native现在已经到了0.37版本了,在集成RN初期使用的0.30版本还不支持将resources打入bundle实施热更新,0.37版本已经解决了这些问题,如果再不写篇文章,炒炒这份冷饭,那就过气了。

QDaily现在在Android和iOS的版本中都集成了React Native,用其做广告效果页的展示。

本文介绍基于Android平台,在RN进行混合app研发过程中,native部分做过的一些工作和踩过一些坑。

本该双平台一起介绍的,但Android的坑多,先说Android,iOS的对应工作会在下个文章描述。

该文章为系列文章,之前的文章为RN学习1——前奏,app插件化和热更新的探索RN学习2——客户端开发者的一些准备工作RN学习3——集成进现有原生项目

一、Android中RN的View初始化及传参

马上看代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//React相关组件初始化
ReactRootView mReactRootView = new ReactRootView(this);
mReactInstanceManager = ReactInstanceManager.builder()
.setApplication(getApplication())
.setJSBundleFile(getBundleFilePath())
.setJSMainModuleName("index.android")
.addPackage(new MainReactPackage())
.setUseDeveloperSupport(BuildConfig.DEBUG)
.setInitialLifecycleState(LifecycleState.BEFORE_RESUME)
.setUseOldBridge(true)
.build();
//准备参数
Bundle bundle = new Bundle();
bundle.putString("url", "http://www.qdaily.com");
bundle.putFloat("totalSeconds", 12);
bundle.putInt("style", 1);
bundle.putString("extras", "{\"key\" : \"value\"}");
bundle.putStringArray("imagePaths", new String[]{"url1", "url1"});
//组件启动
mReactRootView.startReactApplication(mReactInstanceManager, "adImageLaunch", bundle);

先解释下方法名和对应的参数

  • 上面的getBundleFilePath()可以指向本地路径,assert://开头或者file://开头,如果是测试情况,也可以以http://开头,最终对应一个JSBundle文件。
  • “index.android”为MainModule的名字,相当于当前JSBundle的程序入口。
  • addPackage()用来增加支持的package,基本意思就是用于js端调用的native部分,包含函数和原生自定义组件。
  • setUseOldBridge(true) 在当前版本需要设定,否则会报错,应该是用于兼容旧版本使用。最新版本(0.37)还没测试
  • “adImageLaunch”意思是该view对应的bundle中的对应component,该component会生命在JSBundle的MainModule(当前为”index.android”)中声名。

再重点说下支持的传参(android.os.Bundle)。因为同名,为消除歧义,Bundle特指Android中的android.os.Bundle,React Native中的Bundle用JSBundle命名。

RN的传参仅支持这一种方式(文件、网络自行读取不算),但也不是Bundle支持的全部支持,具体参见Arguments.java源码,它将Bundle转换成为WritableMap组件(类json数据结构)。查看源码可知:

仅支持通过Bundle传递Number(int、float、double等基本数据类型)、StringBoolean、或者包含以上类型的数组数据、或者包含以上类型的Bundle数据。

其它无论是否支持Parcelable、Serializable,或List都会在传输过程中收到IllegalArgumentException异常。

此处仅仅Android如此,iOS可以直接传输NSDictionary,系统级转换成JSON数据格式。

如果传输(透传)复杂数据类型,请自行转成JSON用字符串用String传输,ReactNative端用JSON.parse()进行转换

二、UI集成

此处集成可以分为Activity继承和fragment集成,以及View的集成(未测试)
参考这里http://www.voidcn.com/blog/chichengjunma/article/p-6293726.html,貌似会再集成过程中有问题。

Activity集成

onCreate方法中,调用setContentView(mReactRootView);,直接将上面生成的ReactRootView赋给Activity的ContentView。

Activity需要 implements DefaultHardwareBackBtnHandler 接口,用于处理返回按键。

同时在生命周期的template方法中透传生命周期给ReactInstanceManager:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Override
protected void onPause() {
super.onPause();
if (mReactInstanceManager != null) {
mReactInstanceManager.onHostPause();
}
}
@Override
protected void onResume() {
super.onResume();
if (mReactInstanceManager != null) {
mReactInstanceManager.onHostResume(this, this);
}
}
@Override
protected void onDestroy() {
super.onDestroy();
mReactInstanceManager.destroy();
}
@Override
public void onBackPressed() {
if (mReactInstanceManager != null) {
mReactInstanceManager.onBackPressed();
} else {
super.onBackPressed();
}
}

Fragment集成

和Activity集成相似,不过需要在onCreateView中返回上文的mReactRootView。生命周期自行处理。

View的集成(不推荐)

此处没做测试,感觉在处理生命周期会有问题,仅仅提供方法,欢迎交流

1
2
3
mNativeView = (FrameLayout) findViewById(R.id.nativeView);//原生布局中的view
//上文 mReactRootView 的初始化
mNativeView.addView(mReactRootView);//添加react布局

三、Native部分被调用的Module实现

说的太拗口了,其实就是React端调用原生方法时候,java部分需要做的一些工作,参见原生模块

1、定义JavaModule

官方文档其实很清楚了,我这里简单说下我们这边的使用情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class LaunchBridgeModule extends ReactContextBaseJavaModule {
public LaunchBridgeModule(ReactApplicationContext reactContext) {
super(reactContext);
}
@ReactMethod
public void dismissSplash(Callback errorCallback) {
Activity activity = MManagerCenter.getManager(ActivityController.class).getTopActivity();
if (activity != null) {
activity.finish();
}
errorCallback.invoke("");
}
@ReactMethod
public void open(String url, Callback errorCallback) {
Activity activity = MManagerCenter.getManager(ActivityController.class).getTopActivity();
if (activity != null) {
activity.setResult(Activity.RESULT_OK, new Intent().setData(Uri.parse(url)));
activity.finish();
}
errorCallback.invoke("");
}
@Override
public String getName() {
return "LaunchBridgeModule";
}
@Override
public Map<String, Object> getConstants() {
final Map<String, Object> constants = new HashMap<>();
constants.put("STATUS_BAR_HEIGHT", LocalDisplay.STATUS_BAR_HEIGHT_DP);
constants.put("SCREEN_HEIGHT", LocalDisplay.SCREEN_HEIGHT_DP);
constants.put("SCREEN_WIDTH", LocalDisplay.SCREEN_WIDTH_DP);
constants.put("NAVIGATION_BAR_HEIGHT", LocalDisplay.NAVIGATION_BAR_HEIGHT_DP);
return constants;
}
}

此处我们暴露了3个方法给JS端,分别为关闭页面、跳转页面和获取常量3个,其中前两个方法为异步方法,最后一个是同步方法。

异步方法用@ReactMethod注解标记,可以随便自定义方法名,所有被标记的方法会再addPackage后通过反射放在一个映射表中,一个类型的Module在同一ReactInstanceManager中只有一个对象。

同步方法在父类接口里面定义,默认返回null。之所以是同步方法,是因为在ReactInstanceManager时就进行了调用,将返回值封成了固定格式的String给了ReactBridge中,该bridge为配置表,JS端直接有这个表,因此能同步执行,但也存在问题,就是不能运行时动态使用,只能进行常量初始化(名字干脆就叫getConstants……)。方法调用堆栈如下:

getName()方法用于JS端调用时候使用的名字(不是类名),请不要重复!

一个注意点:请相同作用的Module在Android和iOS部分getName,getConstants,以及所有异步方法名称完全一致,异步方法的入餐类型完全一致。否则JS端调取原生方法时候还需要判断平台。

2、定义ReactPackage

所有Native部分的定义的方法以及View组件,最终都要用自定义的ReactPackage进行包装,来告诉ReactBridge,bridge用映射表供JS使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class LaunchReactPackage implements ReactPackage {
@Override
public List<Class<? extends JavaScriptModule>> createJSModules() {
return Collections.emptyList();
}
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}
@Override
public List<NativeModule> createNativeModules(
ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>();
modules.add(new LaunchBridgeModule(reactContext));
return modules;
}
}

我们这里没有自定义UI组件(比较复杂,如果自定义还要有JS部分和iOS部分的开发)。ReactPackage中定义的三个方法:

  • createViewManagers 为JS端可以使用的自定义Native View。
  • createNativeModules 为可使用的方法,我们只用了这里。
  • createJSModules 没使用过,也没在0.30的代码中看到实现,先忽略。

之后只要在需要使用自定义View或者方法的ReactRootView的ReactInstanceManager的初始化时,将响应的package add进去即可。

四、0.30版本下的Android热修复实现

JSBundle的更新为在app启动之后会向后台sync RN的最新JSBundle,如果server有新的且本地不存在,则下载,否则什么也不做。逻辑很简单,不做介绍了。

这里主要介绍资源的热更新。JS部分默认支持的协议包括assert://file://以及http://协议,一些资源icon采用http协议肯定是不合适的(那也就没有热更新的必要了),assert是打在app包里的,Android的热更新肯定是file协议了,具体实现分为两个部分:

1、java部分将资源从assert copy到data下的固定文件夹中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
private void copyFileOrDir(String path) {
AssetManager assetManager = mContext.getAssets();
String assets[] = null;
try {
assets = assetManager.list(path);
if (assets.length == 0) {
copyFile(path);
} else {
File dir = new File(mContext.getFilesDir(), path);
if (!dir.exists())
dir.mkdir();
for (int i = 0; i < assets.length; ++i) {
copyFileOrDir(path + "/" + assets[i]);
}
}
} catch (IOException ex) {
QLog.e("tag", "I/O Exception", ex);
}
}
private void copyFile(String filename) {
AssetManager assetManager = mContext.getAssets();
InputStream in = null;
OutputStream out = null;
try {
in = assetManager.open(filename);
out = new FileOutputStream(new File(mContext.getFilesDir(), filename));
byte[] buffer = new byte[1024];
int read;
while ((read = in.read(buffer)) != -1) {
out.write(buffer, 0, read);
}
in.close();
in = null;
out.flush();
out.close();
out = null;
} catch (Exception e) {
QLog.e(TAG, e.getMessage());
}
}

这样热更新只需要将资源覆盖到对应的文件夹中进行处理即可。此处Native部分很简单,关键是JS部分操作,因为按照写法,资源路径应该是file:///data/data/packagename/file/*,显然iOS部分不能正常读取,下面介绍JS部分。

2、JS部分操作需要在JSBundle动态替换资源路径

此处的规范比较严格,资源需要放在固定路径中,资源请求需要用严格的cage协议

#####(1)固定资源存放位置
首先请将所有资源文件(主要是image)放在根目录的resources下,不允许有嵌套文件夹;

#####(2)固定js中资源访问方式
将所有js代码中请求的本地资源代码由 require('./xxx.png') 改为{uri:'http://qdaily.cage/xxx.png'}。其中xxx.png为上一条中提到的resources目录下的同名资源:

1
2
3
<Image source={require('./cancel.png')>
改为
<Image source={{uri:'http://qdaily.cage/cancel.png'}}>

#####(3)修改0.30中react-native的js源码

  • 1、node_modules/react-native/Libraries/Image/image.android.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
render: function() {
...
...
//else后的代码块为新增
if (source && source.uri === '') {
console.warn('source.uri should not be an empty string');
} else {
var prefix = 'http://qdaily.cage/';
var match = source.uri.indexOf(prefix);
if (match == 0) { //qdaily.cage://开头
console.log(__DEV__);
var realUrl = source.uri.substring(prefix.length, source.uri.length);
if (__DEV__) { //debug 情况 RN 服务器
source.uri = resolveAssetSource.getDevServerURL() + 'react/resources/' + realUrl;
} else { //release 情况
source.uri = 'file://' + '/data/data/com.qdaily.ui/files/rnimgs/' + realUrl;
}
}
}
...
...
}
  • 2、node_modules/react-native/Libraries/Image/resolveAssetSource.js
    在文件结尾处增加:
1
module.exports.getDevServerURL = getDevServerURL;

在这一部分,我自己写了一个0.30情况下的shell脚本,在npm install之后运行脚本替换即可。可以点击这个链接进行下载。

下一篇文章会介绍iOS部分针对热修复在Native部分需要做的工作。