前段时间,由于业务的需求,开始接触到了 RN 原生模块和组件的开发,最近刚好有一位同事也是有这方面的需求要开发就过来问我,我一时间竟然有点说不清楚,回想一下,挺多一些点已经有些模糊了,趁着现在刚过完年回来,需求还不多的时候,按照官方的文档又重新理了一遍。

原生模块

我们做 RN 需求的过程,有时候需要拿一些硬件方面的数据,比如蓝牙、NFC,又或者人脸、指纹等数据,这些可能还会有些第三方库做这些东西,但是像我们自己业务的一些用户体系的一些数据,我们需要一套规范的 API ,这个时候就是原生模块上场的时机,我们可以定义一套获取用户体系的 API 接口,并且加上一些控制的逻辑来方便和规范 RN 的调用。

下面就以官方的例子来说明如何接入 原生模块 吧。

ReactContextBaseJavaModule

首先我们需要一个 Module ,我们后面在 RN 调用的这个模块的方法就来自这个类。我们先创建一个类,继承 ReactContextBaseJavaModule

public class CalendarModule extends ReactContextBaseJavaModule {
   CalendarModule(ReactApplicationContext context) {
       super(context);
   }
}

getName()

ReactContextBaseJavaModule 这个类是一个抽象类,但是只需要实现一个方法。

String getName();

getName() 这个方法会返回一个 String 这个就是我们模块的名称了。

@Override
public String getName() {
   return "CalendarModule";
}

实现之后,我们后面就可以像下面这样在 RN 中调用这个模块了。

const { CalendarModule } = ReactNative.NativeModules;

或者,下面这样也可以拿到这个模块对象。

const CalendarModule = NativeModules.CalendarModule

其实这两种方法本质是一样的,只是第一种方式是用了解构赋值的语法。

@ReactMethod

对于想要暴露出去的方法,只需要在方法的上面添加 @ReactMethod 这个注解就可以了,但是要记得方法的访问权限是 public 的。

@ReactMethod
public void createCalendarEvent(String name, String location) {
}

还有一点要注意,RN 调用原生模块的时候是异步的,也就是说,调用之后就会走 RN 的后续逻辑,并不会等待原生代码执行完成。

@ReactMethod(isBlockingSynchronousMethod = true)

刚才说到一般的 RN 调用是异步的,如果有同步调用的需求,可以在 @ReactMethod 这个注解里面添加 isBlockingSynchronousMethod = true 这个参数,这样的话,调用过程就是同步的了。

但是官方不是很建议我们使用这个,主要还是考虑到有些开发者会误用,导致性能问题,或者造成线程相关的bug。

另外,如果使用同步调用这种方式,就无法使用 Chrome 进行 debug 了。

注册模块

Module 写完了,但是现在 RN 还不能调用,因为还需要一个注册的过程。

ReactPackage

注册需要使用到 ReactPackage ,因为有时候我们可能会有好几个 Module ,需要我们把 Module 打包进 ReactPackage 里面,然后再进行注册。在 RN 初始化的阶段,会扫描所有注册进来的 ReactPackage ,并进行初始化。

同样的,我们需要一个类,实现 ReactPackage 。然后在 createNativeModules() 里面返回含有 ReactContextBaseJavaModule 的集合。

public class MyAppPackage implements ReactPackage {

   @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 CalendarModule(reactContext));

       return modules;
   }

}

如果你细心的话,还会发现有个 createViewManagers() 的方法,这个是用来返回 原生组件 的,后面再讲。

ReactNativeHost

现在我们 ReactNativePage 包也有了,就只剩最后一个问题了。如何注册?

其实也很简单,我们首先要找到 ReactNativeHost ,你可以简单理解为 RN 的容器,就像 Android 的 Activity 和 iOS 的 View Controller。如果你是用 RN 默认的构建脚本创建的项目,ReactNativeHost 一般是 MainApplication.java 这个文件。

找到之后就简单了。只需要把我们的 package 塞进 getPackages() 返回的 list 里面就可以了。

@Override
  protected List<ReactPackage> getPackages() {
    @SuppressWarnings("UnnecessaryLocalVariable")
    List<ReactPackage> packages = new PackageList(this).getPackages();
    // below MyAppPackage is added to the list of packages returned
    packages.add(new MyAppPackage());
    return packages;
  }

到这里,就已经可以写个 demo ,试一下能不能调到这个模块的方法了。记得要重新启动 RN 。

import { NativeModules } from 'react-native';
const { CalendarModule } = NativeModules;
...
const onPress = () => {
  CalendarModule.createCalendarEvent('testName', 'testLocation');
};

更好用的原生模块

按照上面的例子确实可以跑起来,但是你会发现有点问题,就是 CalendarModule 是没有类型检查的,而且如果每次到需要通过 NativeModules 来获取原生模块,这种方式,按照官方来说就是有点 clunky

所以为了使用起来更加舒服,我们一般都会用 js 或者 ts 再包上一层,这样其他地方使用的时候就会更加方便,而且还可以有类型检查。

/**
* This exposes the native CalendarModule module as a JS module. This has a
* function 'createCalendarEvent' which takes the following parameters:
*
* 1. String name: A string representing the name of the event
* 2. String location: A string representing the location of the event
*/
import { NativeModules } from 'react-native';
const { CalendarModule } = NativeModules
interface CalendarInterface {
   createCalendarEvent(name: string, location: string): void;
}
export default CalendarModule as CalendarInterface;

然后在其他的 RN 中使用的话就更加简单和舒服了。

import CalendarModule from './CalendarModule';
CalendarModule.createCalendarEvent('foo', 'bar');

类型映射

我们都知道在跨平台的时候,很多数据类型是不通用的,很多时候靠谱的就只有 字符串 了。RN 为了更方便我们 RN 和 原生 的互调,做了数据类型的映射。

JAVAJAVASCRIPT
Boolean?boolean
booleanboolean
Double?number
doublenumber
Stringstring
CallbackFunction
ReadableMapObject
ReadableArrayArray

还有几种类型也是支持的,但是未来新的 原生模块集成方式TurboModules 并不支持,所以,如果使用的是 TurboModules ,要注意避免使用。

  • Integer -> ?number
  • int -> number
  • Float -> ?number
  • float -> number

导出常量

原生的模块也是支持导出常量的,使用也非常简单。

在 Module 中,重写 getConstants() 这个方法,然后再把这个在返回的 map 里面插入需要的导出的常量就可以了。

@Override
public Map<String, Object> getConstants() {
   final Map<String, Object> constants = new HashMap<>();
   constants.put("DEFAULT_EVENT_NAME", "New Event");
   return constants;
}

使用的时候可以直接调用 getConstants() 来获取。

const { DEFAULT_EVENT_NAME } = CalendarModule.getConstants();
console.log(DEFAULT_EVENT_NAME);

回调

由于模块函数的调用一般都是异步的,也就无法返回参数,如果我们要返回数据,这个时候就要使用回调了。

RN 也是支持回调的,对应的类是 com.facebook.react.bridge.Callback 。RN 支持最多两个回调函数,对应 成功回调失败回调。并且最后一个参数如果是函数,会被当做成功回调,倒数第二个参数如果是函数,就会被当成失败回调。

@ReactMethod
public void createCalendarEvent(String name, String location, Callback myFailureCallback, Callback mySuccessCallback) {
}

RN 中这样调用:

const onPress = () => {
  CalendarModule.createCalendarEventCallback(
    'testName',
    'testLocation',
    (error) => {
      console.error(`Error found! ${error}`);
    },
    (eventId) => {
      console.log(`event id ${eventId} returned`);
    }
  );
};

但其实在项目开发中,我们使用两个回调的场景也不是很多,因为很多时候跟业务之间可能会约定其他的一些状态,一般会自己定数据类型,比如会包含一个 code 或者 另一个表示状态的枚举值。然后业务再根据这个状态做对应的业务。

官方也说到了这种类似的处理方式:

@ReactMethod
public void createCalendarEvent(String name, String location, Callback callBack) {
  Integer eventId = ...
    callBack.invoke(null, eventId);
}

然后再回调里面判断状态再处理。

const onPress = () => {
  CalendarModule.createCalendarEventCallback(
    'testName',
    'testLocation',
    (error, eventId) => {
      if (error) {
        console.error(`Error found! ${error}`);
      }
      console.log(`event id ${eventId} returned`);
    }
  );
};

还有一点要注意,就是回调传过去的数据类型是要支持序列化的。另外,不能同时调用 成功回调 和 失败回调,而且成功回调 和 失败回调 只能调用一次,我感觉底层就是用了类似 Promises 一样的机制吧,一旦状态确定,就无法改变了。但是可以把 回调 储存起来,后面有结果之后再调用。

Promises

还记得我们之前讲过的 Promises 吗?一种可以让我们以一种看似同步的代码方式来写异步的逻辑。现在 RN 也支持了。

import com.facebook.react.bridge.Promise;

@ReactMethod
public void createCalendarEvent(String name, String location, Promise promise) {
    try {
        Integer eventId = ...
        promise.resolve(eventId);
    } catch(Exception e) {
        promise.reject("Create Event Error", e);
    }
}

RN 中接收到的就是一个 Promises 的类型了。

const onSubmit = async () => {
  try {
    const eventId = await CalendarModule.createCalendarEvent(
      'Party',
      'My House'
    );
    console.log(`Created a new event with id ${eventId}`);
  } catch (e) {
    console.error(e);
  }
};

向 RN 发事件

有些情况下,我们需要向 RN 发一些事件,比如我们的日历例子中,现在需要提醒用户现在是某个日历事项,要做什么事情。这种情况就需要原生模块向 RN 发事件,而不是等着 RN 来轮询。

RCTDeviceEventEmitter

想要向 RN 发事件,需要一个发射器,原生代码 中使用的是 RCTDeviceEventEmitter 这个。

RCTDeviceEventEmitter这个又是从哪里来呢?

还记得我们一开始写 Module 吗?构造方法里面有 ReactApplicationContext ,用这个就可以了,我们可以在构造方法里面把这个保存起来,后面需要的时候再拿出来用就好了。

需要发事件的时候,就只需要向下面这样用就好了。

WritableMap params = Arguments.createMap();
params.putString("eventProperty", "someValue"); 
reactContext
     .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
     .emit(eventName, params);

RN 端要接收事件的话,需要注册一个监听。

componentDidMount() {
   ...
   const eventEmitter = new NativeEventEmitter(NativeModules.ToastExample);
   this.eventListener = eventEmitter.addListener('EventReminder', (event) => {
      console.log(event.eventProperty) // "someValue"
   });
   ...
 }

 componentWillUnmount() {
   this.eventListener.remove(); //Removes the listener
 }

其他

RN 还支持监听 ActivitystartActivityForResult 事件,但是我觉得这种场景应用不是很多,一般都是由原生去做对应的操作,然后再发事件给 RN,这里就不讲了。

原生组件

大概讲完了 原生模块,接下来讲一下 原生组件。我们知道 RN 提供了很多的组件给开发者使用,而且还可以导入第三方的一些组件,大部分的场景下已经能够满足我们开发的需求了。但是在某些特殊的场景或者特殊的需求,或者是一些第三方的组件不够灵活,不满足需求的,可能也需要我们开发一些原生的组件提供给 RN 使用。

ViewManager

和 原生模块 类似的,要创建一个 原生组件 需要使用 ViewManager 来承载。ViewManager 是一个抽象类,我们一般继承其子类 SimpleViewManager 来做开发。

官方使用了 ImageView 来做例子,我们也用这个例子来看一下吧。

public class ReactImageManager extends SimpleViewManager<ReactImageView> {

  public static final String REACT_CLASS = "RCTImageView";
  ReactApplicationContext mCallerContext;

  public ReactImageManager(ReactApplicationContext reactContext) {
    mCallerContext = reactContext;
  }

  @Override
  public String getName() {
    return REACT_CLASS;
  }
  
  @Override
  public ReactImageView createViewInstance(ThemedReactContext context) {
    return new ReactImageView(context, Fresco.newDraweeControllerBuilder(), null, mCallerContext);
  }
}

可以看到,也是有一个 getName() 的方法,用来返回组件的名称。

createViewInstance

除了 getName() ,还有一个 createViewInstance() 的方法,这个就是实际创建组件的地方,这个方法最终会返回一个 View,最终展示的就是这个 View。

public abstract class ViewManager<T extends View, C extends ReactShadowNode> extends BaseJavaModule{
  ...
  @Nonnull
  protected abstract T createViewInstance(@Nonnull ThemedReactContext var1);
  ...
}

@ReactProp

仅仅创建出 View 还不行呀,我们还需要可以动态配置的属性。

RN 中使用了 @ReactProp 这个注解来解析对应的属性,类似下面这样:

  @ReactProp(name = "src")
  public void setSrc(ReactImageView view, @Nullable ReadableArray sources) {
    view.setSource(sources);
  }

  @ReactProp(name = "borderRadius", defaultFloat = 0f)
  public void setBorderRadius(ReactImageView view, float borderRadius) {
    view.setBorderRadius(borderRadius);
  }

  @ReactProp(name = ViewProps.RESIZE_MODE)
  public void setResizeMode(ReactImageView view, @Nullable String resizeMode) {
    view.setScaleType(ImageResizeMode.toScaleType(resizeMode));
  }

当 RN 中更新 属性 的时候,就会触发对应的方法。

@ReactProp 这个注解至少需要一个 name 属性,类型是 String ,用来说明,这个属性在 RN 中对应的那个属性。

除此之外,还可以添加一个默认值,支持 defaultBoolean, defaultInt, defaultFloat 这几种类型。如果 RN 中把某个属性删掉之后,也会触发对应的方法,并传入默认值。如果是对象类型,则会传 null 。

@ReactProp 这个注解对被注解的方法也有要求,必须的 public void 的类型,并且第一个参数需要是 createViewInstance() 这个方法返回的类型,也就是当前 View 对象,第二个参数也只支持几种类型,boolean, int, float, double, String, Boolean, Integer, ReadableArray, ReadableMap

注册 ViewManager

之前我们说过,注册 Module 需要用到 ReactPackage ,也提了一嘴,注册原生组件也会用到,这里就来了。

public class MyAppPackage implements ReactPackage {

   @Override
   public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
       return Arrays.<ViewManager>asList(new ReactImageManager(reactContext));
   }

   @Override
   public List<NativeModule> createNativeModules(
           ReactApplicationContext reactContext) {
       return Collections.emptyList();
   }

}

其实如果需求里面同时需要 原生模块 和 原生组件 ,可以写在同一个 Package 里面。

注册完之后,记得也要把 Package 注册一下,然后重启 RN ,应该就可以用了。RN 可以这样使用:

const RCTImageView = requireNativeComponent('RCTImageView');
...
<RCTImageView
  src="https://xxxx"
  borderRadius=10 />

跟 原生模块 一样,导进来直接使用是没有类型检查的,我们同样可以再包一层,加上类型限制,后面使用的时候就可以有类型检查了。

原生向 RN 发事件

其实对于组件,不仅仅是根据参数来系那是不同的内容,有时候还需要一些交互效果,比如用户点击了这个组件,或者一个视频组件,需要通知使用方当前的播放进度。

第一个问题是,RN 要怎么接收?

RN 中是这么处理的,在 Manager 中重写 getExportedCustomBubblingEventTypeConstants() 返回 原生组件 发的事件名称,还有 RN 接收该事件的对属性名称。

public class ReactImageManager extends SimpleViewManager<MyCustomView> {
    ...
    public Map getExportedCustomBubblingEventTypeConstants() {
        return MapBuilder.builder().put(
            "topChange",
            MapBuilder.of(
                "phasedRegistrationNames",
                MapBuilder.of("bubbled", "onChange")
            )
        ).build();
    }
}

根据例子中来讲,就是原生的组件会发一个 topChange 的事件,RN 中要接收的属性名称就是 onChange 这个。这个结构是套了几层的 map ,写的时候记得别写错了。至于里面的 phasedRegistrationNamesbubbled,就是固定的写法,照着例子中这样写就好了。

还有关键的一步,就是原生的组件要怎么把事件发给 RN?

还记得 RCTDeviceEventEmitter 吗?这个是 Module 用来发事件 的,也跟 Module 类似,Manager 用的是 RCTEventEmitter 这个。原生组件 和 RN 通过 getId() 关联起来,也就是这个事件是发给某个特定的 View 。

WritableMap event = Arguments.createMap();
event.putString("message", "MyMessage");
ReactContext reactContext = (ReactContext)getContext();
reactContext
  .getJSModule(RCTEventEmitter.class)
  .receiveEvent(getId(), "topChange", event);

但是看到 receiveEvent 的时候,一开始也感到有点奇怪,Module 中使用的是 emit。发事件嘛,用 emit 正常一点。但是官方这么写,我只能理解为 RN 接收了一条事件。

RN 调用可以这样:

class MyCustomView extends React.Component {
  constructor(props) {
    super(props);
    this._onChange = this._onChange.bind(this);
  }
  _onChange(event) {
    if (!this.props.onChangeMessage) {
      return;
    }
    this.props.onChangeMessage(event.nativeEvent.message);
  }
  render() {
    return <RCTMyCustomView {...this.props} onChange={this._onChange} />;
  }
}
MyCustomView.propTypes = {
  /**
   * Callback that is called continuously when the user is dragging the map.
   */
  onChangeMessage: PropTypes.func,
  ...
};

const RCTMyCustomView = requireNativeComponent(`RCTMyCustomView`);

RN 向 原生 发事件

除了 原生 向 RN 发事件,还有一种情况是 RN 向 原生 发事件,这样两端就能互相通信了。比如你做了一个视频组件,RN 端想要跳转到某个时间点或者暂停、继续等情况。

返回支持的事件

在 Manager 中重写 getCommandsMap() 方法,在里面返回支持的事件。这是一个 Map ,可以配置多个事件。

@Override
public Map<String, Integer> getCommandsMap() {
    return MapBuilder.of("play", COMMAND_PLAY);
  }

处理事件

在 Manager 中重写 receiveCommand() 方法,在这里面处理事件。

@Override
  public void receiveCommand(
    @NonNull FrameLayout root,
    String commandId,
    @Nullable ReadableArray args
  ) {
    super.receiveCommand(root, commandId, args);
    switch (commandIdInt) {
      case COMMAND_PLAY:
        // 处理事件
        doSomething(root);
        break;
      default: {}
    }
  }

RN 端调用

原生端这样就搞完了,RN 端要怎么调用呢?

第一步,要获取对应的 ViewId,所以就需要使用 ref 来获取对应的组件对象的引用,然后再通过 findNodeHandle(ref) 来获取到 ViewId。

第二步,通过 UIManager.dispatchViewManagerCommand() 来发送对应的事件。

import React, { useEffect, useRef } from 'react';
import { UIManager, findNodeHandle } from 'react-native';

import { MyViewManager } from './my-view-manager';

// 调用这个方法就可以向 原生组件 发事件
const doSomething = (viewId) =>
  UIManager.dispatchViewManagerCommand(
    viewId,
    UIManager.MyViewManager.Commands.play.toString(),
    // 这里可以传参数
    []
  );

export const MyView = () => {
  const ref = useRef(null);

  useEffect(() => {
    // 获取 viewId
    const viewId = findNodeHandle(ref.current);
  }, []);
  
  return (
    <MyViewManager
      ref={ref}
    />
  );
};

在官方文档的最后,写了一个例子,介绍如何在 RN 中嵌入 Fragment ,但是我感觉这种场景很少用到,这里就不细说了,感兴趣的同学可以去看看官方文档。