本节摘要:本节以一个「设备信息模块」为例,走通原生模块的完整生命周期:先写 TypeScript 接口声明(合同),再在 Kotlin 与 Swift 两侧各实现一遍(译文),最后在 JavaScript 里像调用本地模块一样使用。线程模型与事件回传是两处必须吃透的关节——前者决定模块快不快,后者决定原生能不能主动叫醒 JavaScript。
按 2.2 节立下的范式,动笔写任何原生代码之前,先把接口合同钉死。要做的模块提供三项能力:读取设备名称(异步)、判断是否平板(同步)、在电量变化时通知 JavaScript(事件)。声明如下:
// DeviceToolkit.ts:接口声明,代码生成的依据 import type { TurboModule } from 'react-native'; import { TurboModuleRegistry } from 'react-native'; export interface Spec extends TurboModule { getDeviceName(): Promise<string>; // 异步:耗时或需系统查询 isTablet(): boolean; // 同步:轻量即时 readonly BATTERY_EVENT: string; // 事件名常量:原生到 JS 的通知通道 } export default TurboModuleRegistry.getEnforcing<Spec>('DeviceToolkit');
Android 侧用 Kotlin 实现。要点有三:模块类继承框架基类并登记名称;方法按声明签名实现,异步方法用 Promise 参数回传结果;事件通过事件发射器发出,发射器在模块构造时挂到上下文:
// Android 侧实现 package com.rnbloom.device import com.facebook.react.bridge.* import com.facebook.react.modules.core.DeviceEventManagerModule class DeviceToolkitModule(reactContext: ReactApplicationContext) : NativeDeviceToolkitSpec(reactContext) { // 代码生成器产出的基类 override fun getName() = "DeviceToolkit" // 异步方法:结果经 Promise 回传,完成后必须 resolve 或 reject 二选一 override fun getDeviceName(promise: Promise) { try { val name = android.os.Build.MODEL ?: "unknown" promise.resolve(name) } catch (e: Exception) { promise.reject("DEVICE_NAME_ERROR", "读取设备名称失败", e) } } // 同步方法:直接返回,经桥接层直调 override fun isTablet(): Boolean { val metrics = reactContext.resources.configuration return metrics.screenLayout and android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK >= android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE } // 事件:电量变化时由系统广播触发(注册逻辑略),转发给 JS fun sendBatteryEvent(level: Int) { val params = Arguments.createMap().apply { putInt("level", level) } reactContext .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) .emit("BATTERY_EVENT", params) } }
iOS 侧用 Swift 配合少量桥接宏实现,节奏与 Kotlin 侧一一对应——这就是「合同先行」的红利:两侧实现都对着同一份声明写,签名不一致在生成阶段就会暴露:
// iOS 侧实现 import Foundation @objc(DeviceToolkit) class DeviceToolkit: NSObject { // 异步方法:解析器回传,成功失败二选一 @objc(getDeviceName:rejecter:) func getDeviceName(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { let name = UIDevice.current.name resolve(name) } // 同步方法:直接返回 @objc(isTablet) func isTablet() -> Bool { return UIDevice.current.userInterfaceIdiom == .pad } // 事件:电量变化时转发 @objc func sendBatteryEvent(level: Int) { emitter?.sendEvent(withName: "BATTERY_EVENT", body: ["level": level]) } }
线程模型。原生模块的方法默认不在原生主线程执行——框架为模块准备了独立的工作线程,这是刻意的保护:模块里的耗时操作(文件、网络、数据库)不会拖累界面绘制。两条推论:其一,模块方法里可以放心做耗时活,不必自己另起线程;其二,反过来,如果模块需要更新原生界面(7.2 的场景),必须主动切回主线程操作,这是新手翻车的高发点。双端线程调度细节略有差异,但「模块方法默认离主线程、UI 操作必须回主线程」这条总则两端通用。
事件回传。方法调用是「JavaScript 问、原生答」的单程票,而推送到达、传感器变化、下载进度是「原生主动叫醒」。事件机制的用法在两侧代码里已经出现:原生侧经事件发射器按事件名发出数据,JavaScript 侧按事件名订阅:
import DeviceToolkit from './DeviceToolkit'; import { NativeEventEmitter, NativeModules } from 'react-native'; // 新架构下事件发射器包装原生模块;订阅与清理必须配对(6.3 的纪律) const emitter = new NativeEventEmitter(NativeModules.DeviceToolkit); export function useBatteryLevel() { const [level, setLevel] = React.useState(null); React.useEffect(() => { const sub = emitter.addListener(DeviceToolkit.BATTERY_EVENT, e => { setLevel(e.level); }); return () => sub.remove(); // 不注销就是 6.3 节的惯犯二号 }, []); return level; }
背景:产品要在应用内展示「设备与网络状态卡片」(设备名、是否平板、Wi-Fi 名称),其中 Wi-Fi 名称的获取在两端机制完全不同,属于典型的必须写桥接的场景。操作:先写接口声明(三个方法、无事件);Android 侧经系统服务查询连接信息,iOS 侧发现「读取 SSID 需要位置权限授权」——这是两端权限模型的经典差异,于是把 Wi-Fi 名称改为可空字段,未授权时返回空并由界面层显示「不可用」;双端实现对着声明写,代码生成器一次性产出胶水;JS 侧卡片组件订阅调用,加载态、错误态按 4.3 的请求状态机处理。结果:卡片双端行为一致,iOS 未授权位置的降级路径在评审时被特别表扬。解读:案例里最有价值的部分是那次「声明阶段的发现」——合同先行让两端机制差异在设计期就浮出水面,而不是实现期才返工。变式:若产品进一步要求实时刷新 Wi-Fi 强度,就升级为事件通道(原生注册系统监听、节流后转发),模块的三类构件在同一个例子里凑齐。
无界面的能力会写了,下一节封装有界面的:把地图、播放器这类原生视图变成 React 组件。