剑客
关注科技互联网

【Android】RN学习4——QDaily基于RN 0.30的混合app的通信和热修复实践

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

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

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

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

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

马上看代码

//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的集成(未测试)

Activity集成

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

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

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

@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的集成(不推荐)

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

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

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

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

1、定义JavaModule

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

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……)。方法调用堆栈如下:

【Android】RN学习4——QDaily基于RN 0.30的混合app的通信和热修复实践

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

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

2、定义ReactPackage

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

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下的固定文件夹中

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目录下的同名资源:

<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
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

    在文件结尾处增加:

module.exports.getDevServerURL = getDevServerURL;

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

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

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址