野火🔥

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

2016/11/23 Share

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部分需要做的工作。

CATALOG
  1. 1. 一、Android中RN的View初始化及传参
    1. 1.1. 马上看代码
    2. 1.2. 先解释下方法名和对应的参数
    3. 1.3. 再重点说下支持的传参(android.os.Bundle)。因为同名,为消除歧义,Bundle特指Android中的android.os.Bundle,React Native中的Bundle用JSBundle命名。
  2. 2. 二、UI集成
    1. 2.1. Activity集成
    2. 2.2. Fragment集成
    3. 2.3. View的集成(不推荐)
  3. 3. 三、Native部分被调用的Module实现
    1. 3.1. 1、定义JavaModule
    2. 3.2. 2、定义ReactPackage
  4. 4. 四、0.30版本下的Android热修复实现
    1. 4.1. 1、java部分将资源从assert copy到data下的固定文件夹中
    2. 4.2. 2、JS部分操作需要在JSBundle动态替换资源路径