编写 React Native 原生模块的若干提示 -- Android

假设你已经阅读了官方文档 Android Native Modulesopen in new window,并且已经学会如何编写一个简单的 Android 原生模块。

使用脚手架创建组件库

如果需要把原生模块封装成库,方便独立升级、维护、发布和共享, 可以使用 react-native-create-libopen in new window 来创建组件库。

例如:

npx react-native-create-lib  \
  --module-name @sdcx/sample-module \
  --repo-name react-native-sample-module \
  SampleModule

原生模块必须具有类型定义

没有类型的代码是难以维护的。我们要求为原生模块声明完整的类型。

这些类型定义同时作为接口和协议存在,方便多端同事协作,不管是编写原生模块,还是使用原生模块。

import { NativeModules, NativeModule } from 'react-native'

interface SampleInterface extends NativeModule {
  sampleMethod(): void
}

const SampleModule: SampleInterface = NativeModules.SampleModule

以模块名的方式导出原生模块

我们不能把原生模块直接暴露给客户代码,而是要先包裹一下,然后以模块名的方式导出。

可以有不同的导出方式,但不管采用哪种导出方式,务必确保客户代码以模块名的方式导入

import Sample from '@sdcx/sample-module'
// Sample 是模块名
Sample.sampleMethod()

方式一:直接导出

如果模块比较简单,JavaScript 不需要做任何处理,那么可以直接导出。

interface SampleInterface extends NativeModule {
  sampleMethod(): void
}

const SampleModule: SampleInterface = NativeModules.SampleModule

export default SampleModule

如果模块比较复杂,JavaScript 需要做一些额外的处理,则使用余下几种方式导出。

方式二:默认模块

type Callback = (error: Error | null, result: string) => void

interface SampleInterface extends NativeModule {
  sampleMethod(arg: number, callback: Callback): void
}

const SampleModule: SampleInterface = NativeModules.SampleModule

function sampleMethod(arg: number): Promise<string> {
  return new Promise(function (resolve, reject) {
    SampleModule.sampleMethod(arg, (error, result) => {
      if (error) {
        reject(error)
      } else {
        resolve(result)
      }
    })
  })
}

export default {
  sampleMethod,
}

方式三:对象

type Callback = (error: Error | null, result: string) => void

interface SampleInterface extends NativeModule {
  sampleMethod(arg: number, callback: Callback): void
}

const SampleModule: SampleInterface = NativeModules.SampleModule

const Sample = {
  sampleMethod(arg: number): Promise<string> {
    return new Promise(function (resolve, reject) {
      SampleModule.sampleMethod(arg, (error, result) => {
        if (error) {
          reject(error)
        } else {
          resolve(result)
        }
      })
    })
  },
}

export default Sample

也可以创建一个类,然后返回该类的唯一实例。

class Sample {}

export default new Sample()

JavaModule 是个单例 (Singleton)

这里的 JavaModule 是指继承了 ReactContextBaseJavaModule 的类,譬如:

public class SampleModule extends ReactContextBaseJavaModule {

private SampleHelper sampleHelper;

}

同一时刻,整个进程只有一个 SampleModule 实例,这是由 React Native 保证的。

既然 SampleModule 是个单例,那么它的实例成员 SampleHelper 也是个单例。

认识到这一点很重要,我们不需要把 SampleHelper 本身变成单例。

JavaModule 是个门面(Facade)

JavaModule 是个门面,有两层意思。

其一,JavaModule 是连接 Java 和 JavaScript 两个世界的桥梁,只起连接作用,真正干活的另有其人。

譬如,SampleModule 只负责线程切换,并将真正要做的事情委派给了 SampleHelper

import com.facebook.react.bridge.Callback;

@ReactMethod
public void sampleMethod(double param, Callback callback) {
    UiThreadUtil.runOnUiThread(() -> sampleHelper.sampleMethod(param, callback));
}

如果模块本身比较简单,也可以直接由 JavaModule 本身来实现所有功能。

其二,在 Java,如果需要在 JavaModule 之外进行额外的配置,那么应将该职责分配给 JavaModule,然后由 JavaModule 进行委托。

譬如,假设为了让 SampleModule 可以正常工作,我们需要挂载一个钩子到 MainActivityonNewIntent 方法上。

作为一个封装良好的原生模块,我们首先在 SampleModule 暴露一个名为 handleIntent 的静态公开方法,并在该方法的内部,获取 SampleModule 的唯一实例,将实际要做的事情委托给 SampleHelper

public static void handleIntent(@Nullable ReactContext reactContext, Intent intent) {
    if (reactContext == null || !reactContext.hasActiveCatalystInstance()) {
        return;
    }

    // 获得唯一实例
    SampleModule sampleModule = reactContext.getNativeModule(SampleModule.class);
    sampleModule.handleIntent(intent);
}

private void handleIntent(Intent intent) {
    // 将工作委托给 `SampleHelper`
    sampleHelper.handleIntent(intent);
}

然后在 MainActivity 中通过如下方式注册 SampleModulehandleIntent 方法:

@Override
protected void onNewIntent(Intent intent) {
    super.onNewIntent(intent);
    setIntent(intent);
    ReactContext reactContext = MainApplication.get().getCurrentReactContext();
    SampleModule.handleIntent(reactContext, getIntent());
}

当 JavaModule 被销毁时,释放相关资源

JavaModule 何时会被销毁? ReactContext 重新创建的时候。

譬如使用 CodePush 实现热更新时,会调用 ReactInstanceManagerrecreateReactContextInBackground 方法。

譬如在开发模式下,按下 r 键时,ReactContext 也会被销毁后重建。

ReactContext 销毁时,会调用所有 JavaModuleinvalidate 方法。

如果模块持有资源,那么务必在 invalidate 中释放它们。

@Override
public void invalidate() {
    // 在这里释放资源
}

转换 ReadableMap 参数为模型

一个 plain object 参数从 JavaScript 传递到 Java,就是一个 ReadableMap。把这种类型的参数转换成模型,是个较好的模式。

@ReactMethod
public void sampleMethod(ReadableMap params) {
    Options options = Options.fromMap(params);
}
public class Options {
    public String key;

    public static Options fromMap(ReadableMap params) {
        Options options = new Options();
        if (params.hasKey("key")) {
            options.key = params.getString("key");
        }
        return options;
    }
}

数字类型参数只有 Double

记住这条规则吧,从 JavaScript 传递过来的数字类型,只有 doubleDouble,没有 int 也没有 float

当使用 Arguments.toBundle()ReadableMap 转换成 Bundle 时,也只有 double

@ReactMethod
public void sampleMethod(ReadableMap params) {
    Bundle bundle = Arguments.toBundle(params);
    int count = (int)bundle.getDouble("count");
}

在 Java 使用 Callback 而不是 Promise

com.facebook.react.bridge.Callbackcom.facebook.react.bridge.Promise 都可以用于将数据异步地从 Java 传递到 JavaScript。

在 Java,可以只使用一个 Callback,同时用于处理成功和失败的情况。也可以使用两个 Callback,分别用于处理成功和失败的情况。

Callback 最多只能调用一次。如果你使用两个 Callback,那么你只能调用其中一个。

也可以使用 Promise 来代替 Callback。和 Callback 类似,Promise 可以 resolvereject(但不可以同时 resolvereject),并且最多只能做一次。这意味着你可以调用一个成功回调或失败回调,但不能同时调用,而且每个回调最多只能调用一次。然而,原生模块可以存储回调并在以后调用它。

值得注意的是,回调(Callback 或 Promise)不可以不调用,否则将产生内存泄漏

为了简化操作,避免困惑,我们的建议是,使用且仅使用一个 Callback 作为从 Java 到 JavaScript 的回调方式,并遵循 Node 的约定,将传递给 callback 的数组的第一个参数视为错误对象。

@ReactMethod
public void sampleMethod(Callback callback) {
    callback.invoke(null, "result");
}

在 JavaScript 使用 Promise 而不是 Callback

Java 为了自己方便,使用了 Callback 作为回调。但对于 JavaScript 来说,Promise 是一个更好的选择。

因此,我们需要在 JavaScript 这边做一些转换,以便能够使用 Promise

import { NativeModules, NativeModule } from 'react-native'

type Callback = (error: Error | null, result: string) => void

interface SampleInterface extends NativeModule {
  sampleMethod(callback: Callback)
}

const SampleModule: SampleInterface = NativeModules.SampleModule

function sampleMethod() {
  return new Promise((resolve, reject) => {
    SampleModule.sampleMethod((error, result) => {
      if (error) {
        reject(error)
      } else {
        resolve(result)
      }
    })
  })
}

export default {
  sampleMethod,
}

返回错误时,总是包含 code 和 message 字段

我们建议在回调返回的错误对象中,总是包含 codemessage 字段。

记住,code 是个字符串

我们遵循这么一个原则:code 用来进一步分析和定位问题,message 则可以直接提示给用户。

if (resp.errCode == ErrCode.Success) {
    callback.invoke(null, resp.returnKey)
} else {
    WritableMap error = Arguments.createMap();
    error.putString("code", errorCode(resp.errCode));
    error.putString("message", resp.errStr)
    callback.invoke(error, "")
}
private String errorCode(int errCode) {
    Map<Integer, String> map = new HashMap<>();
    map.put(ErrCode.ERR_USER_CANCEL, "ErrCodeUserCancel");
    map.put(ErrCode.ERR_SENT_FAILED, "ErrCodeSentFail");
    map.put(ErrCode.ERR_AUTH_DENIED, "ErrCodeAuthDeny");
    map.put(ErrCode.ERR_UNSUPPORT, "ErrCodeUnsupport");
    return map.get(errCode);
}

在 JavaScript,可以通过以下方式获取 codemessage

import Sample from '@sdcx/sample-module'

try {
  await Sample.sampleMethod()
} catch (error) {
  console.log(error.code, error.message)
}

使用 FLog 输出日志

import com.facebook.common.logging.FLog;

@ReactMethod
public void sampleMethod() {
     FLog.i(TAG, "example log info.");
}

为什么我们要使用 com.facebook.common.logging.FLog 而不是 android.util.Log 来输出日志?

一个最为重要的原因是,我们可以通过 FLog.setLoggingDelegate 方法来设置日志代理,这使得我们可以将 React Native 的日志输出到我们的远程日志系统中,以便我们远程查看日志,分析问题。

此外 FLog 还有以下特点:

  • 可以通过 FLog.setMinimumLoggingLevel 来控制输出的日志级别。

正确处理原生事件

CallbackPromise 只能调用一次不同,事件可以源源不断地从 Java 传递给 JavaScript。

在使用事件时,需要注意以下一些细节:

在 Java,通过 getConstants 导出事件名为常量

// 注意事件常量值不要和其它组件库发生冲突
public static final String EVENT_NAME = "sample_event_name";

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

发送事件的 JavaModule 需要实现以下两个方法。

@ReactMethod
public void addListener(String eventType) {
    // Set up any upstream listeners or background tasks as necessary
}

@ReactMethod
public void removeListeners(int count) {
    // Remove upstream listeners, stop unnecessary background tasks
}

发送事件前,需要检测 ReactContext 是否存活

public void sendEvent(@NonNull String eventName, @NonNull WritableMap params) {
    if (reactContext.hasActiveCatalystInstance()) {
        reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
                .emit(eventName, params);
    }
}

在 JavaScript,使用 NativeEventEmitter 包裹原生模块,得到一个 NativeEventEmitter 实例

import { NativeModules, NativeModule, NativeEventEmitter } from 'react-native'

interface Constants {
  EVENT_NAME: string
}

interface SampleInterface extends NativeModule {
  getConstants(): Constants
}

const SampleModule: SampleInterface = NativeModules.SampleModule
const SampleEventEmitter: NativeEventEmitter = new NativeEventEmitter(
  SampleModule
)

export { SampleEventEmitter }
export default SampleModule

像如下那样监听事件,留意事件名,我们使用了从 Java 导出的常量。

import Sample, { SampleEventEmitter } from '@sdcx/sample-module'

const { EVENT_NAME } = Sample.getConstants()

useEffect(() => {
  const subscription = SampleEventEmitter.addListener(EVENT_NAME, () => {
    // do something
  })
  return () => {
    subscription.remove()
  }
}, [])

避免 onActivityResult 陷阱

有时,我们需要启动其它 Activity 并通过 onActivityResult 来接收其返回来的结果。

此时要注意 requestCode 不要和其它组件库有所冲突,并且如果 requestCode 不属于本组件库,要尽早返回。

如下所示

@Override
public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) {
    if (!isValidRequestCode(requestCode)) {
        return;
    }
}

正确处理线程

所有原生模块的方法,都会在一个 Native 线程执行,因此不要阻塞这个线程。

如果有耗时任务,应该将该任务放到一个独立的线程中执行。

和 UI 相关的任务,应该放在主线程中执行。可以使用 UiThreadUtil 派发到主线程。

@ReactMethod
public void isWXAppInstalled(Promise promise) {
    UiThreadUtil.runOnUiThread(() -> wechatHelper.isWXAppInstalled(promise));
}

如果模块是对第三方 SDK 的封装,则需要留意第三方 SDK 对线程安全的要求。这些 SDK 或者会有自己的线程池。

将权限请求委托给第三方库

某些涉及隐私的操作,需要用户授予权限才能进行。譬如获取用户位置,访问相册等等。

我们遵分离关注点的原则,Java 不负责请求权限,仅在执行操作前,检查是否拥有执行操作所需权限,如果没有,则通过 Error 机制把异常信息返回给 JavaScript,由 JavaScript 进行处理。

@ReactMethod
public void sampleMethod(ReadableMap params, Callback callback) {
    if (!isPermissionFulfilled(reactContext, currentActivity)) {
        callback.invoke(permissionError())
        return;
    }
}

在 JavaScript,我们使用 react-native-permissionsopen in new window 处理权限的检查和请求。

通常会使用高阶函数,把需要权限的请求包裹一下。

export function withXxxPermission<T>(fn: () => Promise<T>) {
  return async () => {
    const granted = await Permissions.check(Permissions.XXX)
    if (!granted) {
      await Permissions.request(Permissions.XXX)
    }
    return fn()
  }
}

将服务鉴权委托给主工程

有时,我们封装的原生组件库,可能需要访问我们自己的服务,而这些服务需要鉴权。

我们的组件库是集成到我们的主工程中使用的,而我们的主工程已经拥有了一套鉴权机制来获取和刷新 token。

我们的组件库应该复用主工程这套鉴权机制。

可我们的组件库又不能依赖我们的主工程,该怎么办呢?

答案是控制反转。我们在组件库声明一套鉴权接口,由主工程注入实现。

// 组件库
import { NativeModules, NativeModule } from 'react-native'

interface SampleInterface extends NativeModule {
  sampleMethod(params: any, token: string): void
}

const SampleModule: SampleInterface = NativeModules.SampleModule

interface TokenProvider {
  refreshToken: () => Promise<string>
  accessToken: () => Promise<string>
}

class Sample {
  private tokenProvider: TokenProvider

  setTokenProvider(tokenProvider: TokenProvider) {
    this.tokenProvider = tokenProvider
  }

  async sampleMethod(params: any) {
    const token = await this.tokenProvider.accessToken()
    SampleModule.sampleMethod(params, token)
  }
}

export default new Sample()
// 主工程
import Sample from '@sdcx/sample-module'

Sample.setTokenProvider({
  refreshToken: AuthService.refreshToken,
  accessToken: AuthService.accessToken,
})

不仅仅是服务鉴权,任何组件库不知道怎么办,或不是它的职责的事情,都可以通过控制反转委托给主工程来处理。

合理使用请求-响应模式

如果 JavaModule 的不同方法需要执行不同的耗时任务,那么我们可以使用请求-响应模式。

我们可以将每一个功能都委托给一个具体的请求对象来实现。

@ReactMethod
public void sampleMethod(String param, Callback callback) {
    SampleRequest request = new SampleRequest(reactContext.getApplicationContext());
    request.doSomethingExpensive(param, callback);
}
public class SampleRequest {
    private static final String ErrorCode = "SampleModuleSampleRequestError";

    private final SDK sdk;

    public AdCodeRequest(Context context) {
        sdk = new SDK(context);
    }

    public void doSomethingExpensive(String param, Callback callback) {
        sdk.execute(param, new SDKListener(){
            public void onSuccess(String result) {
              callback.invoke(null, result);
            }

            public void onFailure(int errCode, String message) {
              callback.invoke(makeError(errCode, message), "");
            }
        } );
    }
}

别忘了混淆规则

如果引用了第三方库,别忘了在组件库的 proguard-rules.pro 文件中设置混淆规则。

通常可以在第三方库的文档中找到相应的混淆规则。

使用 Java 而不是 Kotlin

出于以下考虑,我们建议使用 Java 而不是 Kotlin 来开发组件库。

  • 和 React Native 保持一致,这是最重要的理由。

  • 对一名 Android 工程师来说,不管会不会 Kotlin,Java 是一定要会的。

  • 一个组件库的代码通常都比较简单,没必要让不熟悉 kotlin 的同事增加学习成本。

  • 在一个 React Native 中,我们使用 ts/js 来实现业务和 UI,而不是 Kotlin。

上次更新: