React Native Turbo Module 实战:从 0 封装一个 PDA 扫码模块

React Native Turbo Module 实战:从 0 封装一个 PDA 扫码模块

React Native 项目做久之后,不少问题会从 JS 层逐渐走到原生侧。

路由、尺寸适配、本地缓存、状态管理这些能力,大多可以在 JS 生态里解决。但 PDA 硬件扫码、设备长连接、标签打印这类需求,通常需要直接接入 Android 或 iOS 能力。

这篇文章以"PDA 扫码"为例,记录一次从 0 初始化、设计、实现,再到 JS 层封装 Turbo Module 的过程。

重点不是扫码业务本身,而是通过这个场景说明几个更通用的问题:Turbo Module 如何初始化,JS 和 Native 的接口契约怎么设计,原生事件怎么回到 JS,以及为什么不能把 Native Module 直接暴露给页面。

本文示例基于 React Native 0.82.x,新架构和 Codegen 已经作为默认前提。

为什么要自己封装 Turbo Module

React Native 本身已经提供了不少跨平台能力,但在企业级移动端开发里,总会遇到一些"强依赖设备"的需求。

比如企业现场作业场景里常见的 PDA 扫码:

  • 扫码结果不是从相机页面返回,而是由硬件扫码头通过 Android 广播发出;
  • 不同厂商的广播 action 和 extra key 可能不一样;
  • 业务希望在 JS 页面里像监听普通事件一样拿到扫码结果;
  • 页面退出、应用销毁时必须释放原生资源,否则容易出现重复监听或内存泄漏;
  • 后续还可能需要设置蜂鸣、震动、连续扫描等硬件参数。

这些能力更适合抽象成一个本地原生模块,而不是把 Android 代码散落在业务页面里。

最终的使用方式应该尽量接近普通 React 代码:

ts 复制代码
useScanListener(
  event => {
    console.log('扫码结果:', event.data);
  },
  {
    enabled: isFocused,
    onError: error => {
      console.warn(error.message);
    },
  },
);

页面不需要知道 Android 广播、不需要知道厂商 action,也不应该直接关心原生模块的生命周期细节。

初始化模块骨架

创建 Turbo Module,最直接的方式是使用 create-react-native-library 生成本地模块骨架。

在项目根目录下执行:

bash 复制代码
npx create-react-native-library@latest DemoScan \
  --local \
  --directory modules/DemoScan \
  --slug react-native-demo-scan \
  --description "PDA hardware scan module" \
  --type turbo-module \
  --languages kotlin-objc \
  --react-native-version 0.82.1 \
  --no-interactive

生成后的模块大致是这个结构:

text 复制代码
modules/DemoScan/
  package.json
  src/
    NativeDemoScan.ts
    index.tsx
  android/
    build.gradle
    src/main/
      AndroidManifest.xml
      java/com/demoscan/
        DemoScanModule.kt
        DemoScanPackage.kt
  ios/

如果是本地模块,可以在根工程 package.json 中通过 link: 引入:

json 复制代码
{
  "dependencies": {
    "react-native-demo-scan": "link:./modules/DemoScan"
  }
}

然后执行:

bash 复制代码
yarn install

Android 侧会通过 autolinking 读取模块的 package.jsoncodegenConfig,正常情况下不需要手动改宿主 App 的 settings.gradle

模块自己的 package.json 里最关键的是 codegenConfig

json 复制代码
{
  "name": "react-native-demo-scan",
  "main": "src/index",
  "codegenConfig": {
    "name": "DemoScanSpec",
    "type": "modules",
    "jsSrcsDir": "src",
    "android": {
      "javaPackageName": "com.demoscan"
    }
  }
}

这里可以简单理解为:Codegen 会扫描 src 目录下的 Native Spec,根据声明生成 Android 和 iOS 侧需要继承的类型。

先设计 Spec,再写原生实现

做 Turbo Module 时,更稳妥的顺序是先设计 JS 和 Native 之间的契约,再写平台实现。

也就是先写 src/NativeDemoScan.ts

一个扫码模块至少需要这几类能力:

  • 初始化扫描能力;
  • 开始/停止监听;
  • 设置扫描选项;
  • 获取当前配置和设备信息;
  • 清理资源;
  • 向 JS 发送扫码结果和扫码错误事件。

对应的 Spec 可以这样设计:

ts 复制代码
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export type ScanEvent = 'scanResult' | 'scanError';

export interface ScanResultEvent {
  data: string;
  timestamp: number;
  source: 'hardware' | 'simulation';
}

export interface ScanErrorEvent {
  code: string;
  message: string;
  timestamp: number;
}

export interface ScanOptions {
  beep?: boolean;
  vibrate?: boolean;
  continuous?: boolean;
}

export interface ScannerInfo {
  model: string;
  version: string;
  serialNumber?: string;
  isAvailable: boolean;
}

export interface Spec extends TurboModule {
  initScanner(): Promise<boolean>;
  startListening(): Promise<void>;
  stopListening(): Promise<void>;
  setScanOptions(options?: ScanOptions): Promise<void>;
  getScanOptions(): Promise<ScanOptions>;
  cleanup(): Promise<void>;
  getScannerInfo(): Promise<ScannerInfo>;

  addListener(eventName: ScanEvent): void;
  removeListeners(count: number): void;
}

export default TurboModuleRegistry.get<Spec>('DemoScan');

这里有几个设计点。

第一,NativeDemoScan.ts 不是普通类型文件,它是 Codegen 的输入。里面声明的方法会影响原生侧生成的抽象类,所以不要随意改方法名和参数类型。

第二,模块暴露的是原子能力,不是业务能力。比如这里没有 scanProductCodescanContainerCode 这类业务方法,因为这些应该属于业务页面或业务 hook。

第三,事件也要放进接口设计里。这里有两种常见写法:一种是本文示例当前使用的 NativeEventEmitter + addListener/removeListeners,另一种是 React Native 新架构文档更推荐的 CodegenTypes.EventEmitter。两者都能完成 Native 到 JS 的事件回传,但边界和类型约束不太一样,后面会单独展开对比。

Android 侧实现模块

Codegen 之后,Android 侧会生成一个 NativeDemoScanSpec。Kotlin 模块继承它,然后实现 Spec 里声明的方法:

kotlin 复制代码
@ReactModule(name = DemoScanModule.NAME)
class DemoScanModule(
  reactContext: ReactApplicationContext
) : NativeDemoScanSpec(reactContext) {

  private var isInitialized = false
  private var isListening = false
  private var scanOptions = ScanOptionsState()

  override fun getName() = NAME

  override fun initScanner(promise: Promise) {
    if (isInitialized) {
      promise.resolve(true)
      return
    }

    try {
      registerReceiverIfNeeded()
      startListeningInternal()
      isInitialized = true
      promise.resolve(true)
    } catch (error: Exception) {
      promise.reject(
        ERROR_INIT_FAILED,
        "Failed to initialise scanner: ${error.message}",
        error
      )
    }
  }
}

这里最核心的不是 initScanner 这个方法本身,而是它背后做了两件事:

  • 注册 Android 广播接收器;
  • 打开硬件扫码能力。

扫码枪通常会通过系统广播返回结果,所以原生侧需要注册 BroadcastReceiver

kotlin 复制代码
private fun registerReceiverIfNeeded() {
  val filter = IntentFilter().apply {
    SCAN_RESULT_ACTIONS.forEach { addAction(it) }
  }

  val receiver = object : BroadcastReceiver() {
    override fun onReceive(context: Context?, intent: Intent?) {
      handleScanBroadcast(intent)
    }
  }

  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
    reactApplicationContext.registerReceiver(
      receiver,
      filter,
      Context.RECEIVER_EXPORTED
    )
  } else {
    reactApplicationContext.registerReceiver(receiver, filter)
  }
}

这里需要处理 Android 版本兼容。Android 13 之后注册 receiver 时需要更明确地声明 exported 行为,否则在部分设备上会遇到运行时异常。

厂商之间的广播协议不完全一致,所以 action 和 extra key 应该抽出来集中维护:

kotlin 复制代码
private val SCAN_RESULT_ACTIONS =
  setOf(
    "android.intent.action.SCANRESULT",
    "com.android.server.scannerservice.broadcast",
    "urovo.rcv.message",
    "com.idata.scan.action.SCAN_RESULT",
    "com.symbol.datawedge.api.RESULT_ACTION",
    "com.honeywell.decode.intent.action.SCAN_RESULT",
  )

private val RESULT_EXTRA_KEYS =
  listOf(
    "value",
    "data",
    "barcode",
    "scan_result",
    "result",
    "decode_data",
    "com.symbol.datawedge.data_string",
    "com.honeywell.decode.data",
  )

收到广播后,再从 intent 里提取扫码值:

kotlin 复制代码
private fun handleScanBroadcast(intent: Intent?) {
  if (intent == null || !isListening) return

  val action = intent.action ?: return
  if (!SCAN_RESULT_ACTIONS.contains(action)) return

  val data = extractScanData(intent)
  if (data.isNullOrBlank()) {
    sendScanError(ERROR_EMPTY_RESULT, "Empty scan result for action: $action")
    return
  }

  sendScanResult(data.trim(), SOURCE_HARDWARE)
}

private fun extractScanData(intent: Intent): String? {
  RESULT_EXTRA_KEYS.forEach { key ->
    val value = intent.getStringExtra(key)
    if (!value.isNullOrBlank()) {
      return value
    }
  }

  return intent.dataString
}

这样做的好处是后续接入新设备时,不需要动 JS 层,也不需要改业务页面,只需要补充原生侧兼容规则。

从 Native 发事件到 JS

扫码结果不是 JS 主动调用一个方法后立即返回的,而是由硬件异步触发,所以更适合用事件模型。

方案一:NativeEventEmitter + addListener/removeListeners

这是不少模块里比较常见的写法。

Spec 里声明事件名,以及 NativeEventEmitter 需要的两个方法:

ts 复制代码
export type ScanEvent = 'scanResult' | 'scanError';

export interface Spec extends TurboModule {
  initScanner(): Promise<boolean>;
  startListening(): Promise<void>;
  stopListening(): Promise<void>;

  addListener(eventName: ScanEvent): void;
  removeListeners(count: number): void;
}

Android 侧通过 DeviceEventManagerModule.RCTDeviceEventEmitter 把事件发回 JS:

kotlin 复制代码
private fun sendScanResult(data: String, source: String) {
  val payload = Arguments.createMap().apply {
    putString("data", data)
    putDouble("timestamp", SystemClock.uptimeMillis().toDouble())
    putString("source", source)
  }

  sendEvent(EVENT_SCAN_RESULT, payload)
}

private fun sendScanError(code: String, message: String) {
  val payload = Arguments.createMap().apply {
    putString("code", code)
    putString("message", message)
    putDouble("timestamp", SystemClock.uptimeMillis().toDouble())
  }

  sendEvent(EVENT_SCAN_ERROR, payload)
}

private fun sendEvent(eventName: String, params: WritableMap?) {
  if (!reactApplicationContext.hasActiveReactInstance()) {
    return
  }

  reactApplicationContext
    .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
    .emit(eventName, params)
}

这里建议在发送前判断 hasActiveReactInstance()。应用退出、调试重载、React 实例还没准备好时,直接发送事件可能导致异常或无效调用。

同时,因为 JS 侧会使用 NativeEventEmitter,原生模块需要实现这两个方法:

kotlin 复制代码
override fun addListener(eventName: String) {
  listenersCount += 1
}

override fun removeListeners(count: Double) {
  listenersCount = (listenersCount - count.toInt()).coerceAtLeast(0)
}

哪怕当前暂时不基于 listenersCount 做懒加载,也建议保留这两个方法。否则在 JS 侧创建 NativeEventEmitter(nativeModule) 时,React Native 会提示原生模块缺少事件监听方法。

JS 包装层再创建 NativeEventEmitter

ts 复制代码
const emitter = new NativeEventEmitter(requireNativeModule());

const subscription = emitter.addListener('scanResult', event => {
  console.log(event.data);
});

这种方式的优点是直观,不少旧项目和社区库都这样写;缺点是事件名本质上还是字符串,Native 侧 emit 的事件名和 JS 侧监听的事件名没有被 Codegen 强约束。事件 payload 的类型也主要靠约定维护,写错字段名时通常要到运行时才暴露。

方案二:CodegenTypes.EventEmitter

React Native 新架构文档里更推荐把事件也声明成 Spec 的一部分。

Spec 可以改成这样:

ts 复制代码
import type { TurboModule, CodegenTypes } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export interface ScanResultEvent {
  data: string;
  timestamp: number;
  source: 'hardware' | 'simulation';
}

export interface ScanErrorEvent {
  code: string;
  message: string;
  timestamp: number;
}

export interface Spec extends TurboModule {
  initScanner(): Promise<boolean>;
  startListening(): Promise<void>;
  stopListening(): Promise<void>;

  readonly onScanResult: CodegenTypes.EventEmitter<ScanResultEvent>;
  readonly onScanError: CodegenTypes.EventEmitter<ScanErrorEvent>;
}

export default TurboModuleRegistry.get<Spec>('DemoScan');

这时事件不再通过 addListener('scanResult', callback) 这种字符串方式注册,而是直接调用 Codegen 生成出来的事件订阅函数:

ts 复制代码
const subscription = NativeDemoScan?.onScanResult(event => {
  console.log(event.data);
});

subscription?.remove();

Android 侧也不再手动调用 RCTDeviceEventEmitter.emit,而是调用 Codegen 生成的方法。假设 Spec 里声明的是 onScanResult,生成的方法通常就是 emitOnScanResult

kotlin 复制代码
private fun sendScanResult(data: String, source: String) {
  val payload = Arguments.createMap().apply {
    putString("data", data)
    putDouble("timestamp", SystemClock.uptimeMillis().toDouble())
    putString("source", source)
  }

  emitOnScanResult(payload)
}

private fun sendScanError(code: String, message: String) {
  val payload = Arguments.createMap().apply {
    putString("code", code)
    putString("message", message)
    putDouble("timestamp", SystemClock.uptimeMillis().toDouble())
  }

  emitOnScanError(payload)
}

这套写法的关键变化是:事件名和事件 payload 都进入了 Codegen 契约。JS 侧订阅的是 onScanResult 这个明确的方法,Native 侧发送的是 emitOnScanResult 这个生成方法,中间不再靠手写字符串对齐。

两种方案怎么选

两种方案的边界如下:

对比项 NativeEventEmitter + addListener/removeListeners CodegenTypes.EventEmitter
事件声明位置 事件名通常是字符串联合类型,监听方法单独封装 事件直接声明在 Spec 中
JS 订阅方式 emitter.addListener('scanResult', cb) NativeModule.onScanResult(cb)
Native 发送方式 RCTDeviceEventEmitter.emit(eventName, payload) emitOnScanResult(payload)
类型约束 JS 侧可约束,Native 侧主要靠约定 事件本身进入 Codegen 契约
字符串事件名 需要手写和维护 基本不需要暴露给业务侧
迁移成本 低,适合已有项目和旧模块 中等,需要改 Spec、Native、JS 包装层并重新编译
适合场景 已有模块、兼容旧写法、快速接入 新模块、希望事件契约更稳定、项目已经全面使用新架构

如果是新写一个 Turbo Module,更推荐直接使用 CodegenTypes.EventEmitter。它更符合新架构的设计,也能减少字符串事件名带来的维护成本。

如果是已有模块,尤其已经被多个业务页面依赖,就不一定要立刻重构。可以先保持外层 JS SDK 不变,只替换 SDK 内部的事件实现,这样业务页面基本无感。

如果要从 NativeEventEmitter 重构到 CodegenTypes.EventEmitter

以当前扫码模块为例,迁移可以分四步。

第一步,修改 NativeDemoScan.ts

删掉这几个声明:

ts 复制代码
export type ScanEvent = 'scanResult' | 'scanError';

addListener(eventName: ScanEvent): void;
removeListeners(count: number): void;

新增声明式事件:

ts 复制代码
import type { TurboModule, CodegenTypes } from 'react-native';

export interface Spec extends TurboModule {
  initScanner(): Promise<boolean>;
  startListening(): Promise<void>;
  stopListening(): Promise<void>;

  readonly onScanResult: CodegenTypes.EventEmitter<ScanResultEvent>;
  readonly onScanError: CodegenTypes.EventEmitter<ScanErrorEvent>;
}

第二步,修改 Android 模块。

删除手动事件通道相关代码:

kotlin 复制代码
import com.facebook.react.modules.core.DeviceEventManagerModule

private var listenersCount = 0

override fun addListener(eventName: String) {
  listenersCount += 1
}

override fun removeListeners(count: Double) {
  listenersCount = (listenersCount - count.toInt()).coerceAtLeast(0)
}

然后把原来的:

kotlin 复制代码
sendEvent(EVENT_SCAN_RESULT, payload)
sendEvent(EVENT_SCAN_ERROR, payload)

改成:

kotlin 复制代码
emitOnScanResult(payload)
emitOnScanError(payload)

第三步,修改 JS SDK 封装层。

原来可能是这样:

ts 复制代码
const emitter = new NativeEventEmitter(requireNativeModule());

onScanResult(callback: (event: ScanResultEvent) => void) {
  return emitter.addListener('scanResult', callback);
}

可以改成:

ts 复制代码
onScanResult(callback: (event: ScanResultEvent) => void) {
  const subscription = requireNativeModule().onScanResult(callback);
  this.activeSubscriptions.add(subscription);

  return {
    remove: () => {
      subscription.remove();
      this.activeSubscriptions.delete(subscription);
    },
  };
}

onScanError(callback: (event: ScanErrorEvent) => void) {
  const subscription = requireNativeModule().onScanError(callback);
  this.activeSubscriptions.add(subscription);

  return {
    remove: () => {
      subscription.remove();
      this.activeSubscriptions.delete(subscription);
    },
  };
}

第四步,重新生成并编译。

因为 Spec 变了,所以必须重新编译原生项目:

bash 复制代码
yarn install
cd android
./gradlew :app:compileDevDebugKotlin

如果编译报找不到 emitOnScanResult,通常说明 Codegen 没跑到最新 Spec,或者事件属性命名和你调用的生成方法不一致。先清一下 Android build,再重新编译:

bash 复制代码
yarn clean
yarn install
cd android
./gradlew :app:compileDevDebugKotlin

实际迁移时,建议保留业务层的 DemoScan.onScanResultuseScanListener API 不变,只改 SDK 内部和 Native 层。这样业务页面不需要知道底层事件模型从 NativeEventEmitter 换成了 CodegenTypes.EventEmitter

别忘了 Package

模块类写完后,还需要把它注册成 React Native 可以识别的 Package。

kotlin 复制代码
class DemoScanPackage : BaseReactPackage() {
  override fun getModule(
    name: String,
    reactContext: ReactApplicationContext
  ): NativeModule? {
    return if (name == DemoScanModule.NAME) {
      DemoScanModule(reactContext)
    } else {
      null
    }
  }

  override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
    return ReactModuleInfoProvider {
      val moduleInfos: MutableMap<String, ReactModuleInfo> = HashMap()
      moduleInfos[DemoScanModule.NAME] = ReactModuleInfo(
        DemoScanModule.NAME,
        DemoScanModule.NAME,
        false,
        false,
        false,
        true,
      )
      moduleInfos
    }
  }
}

其中 true 表示这是 Turbo Module。

如果你是通过 create-react-native-library 创建的模块,这部分通常已经生成好,但仍然需要理解它的作用:Spec 定义的是接口,Module 写的是实现,Package 负责把模块交给 React Native。

JS 层不要直接暴露 Native Module

到这里,原生模块已经可以被 JS 调用了。但不建议业务页面直接使用 NativeDemoScan

原因有几个:

  • 原生模块可能没有正确 link,需要给出更明确的错误信息;
  • 事件订阅和取消订阅最好集中管理;
  • 原生 API 命名不一定是最适合业务侧使用的命名;
  • 后续如果要替换事件实现方式,不应该影响所有页面。

因此可以再封装一层 DemoScanModule

ts 复制代码
import { EventSubscription, NativeEventEmitter } from 'react-native';
import NativeDemoScan, {
  type ScanEvent,
  type ScanOptions,
  type ScanResultEvent,
  type ScanErrorEvent,
  type ScannerInfo,
} from './NativeDemoScan';

const requireNativeModule = () => {
  if (!NativeDemoScan) {
    throw new Error(
      '[DemoScan] Native module is not linked. Did you rebuild the app?',
    );
  }

  return NativeDemoScan;
};

const emitter = new NativeEventEmitter(requireNativeModule());

class DemoScanModule {
  private isInitialized = false;
  private activeSubscriptions = new Set<EventSubscription>();

  async init(): Promise<boolean> {
    if (this.isInitialized) {
      return true;
    }

    const result = await requireNativeModule().initScanner();
    this.isInitialized = result;
    return result;
  }

  async setOptions(options?: ScanOptions): Promise<void> {
    await requireNativeModule().setScanOptions(options);
  }

  async getScannerInfo(): Promise<ScannerInfo> {
    return requireNativeModule().getScannerInfo();
  }

  on<Event extends ScanEvent>(
    eventName: Event,
    callback: Event extends 'scanResult'
      ? (event: ScanResultEvent) => void
      : (event: ScanErrorEvent) => void,
  ) {
    const subscription = emitter.addListener(
      eventName,
      callback as (event: unknown) => void,
    );

    this.activeSubscriptions.add(subscription);

    return {
      remove: () => {
        subscription.remove();
        this.activeSubscriptions.delete(subscription);
      },
    };
  }

  onScanResult(callback: (event: ScanResultEvent) => void) {
    return this.on('scanResult', callback);
  }

  onScanError(callback: (event: ScanErrorEvent) => void) {
    return this.on('scanError', callback);
  }
}

export const DemoScan = new DemoScanModule();

这一层的价值是把"原生桥接模块"变成"业务侧稳定 SDK"。

以后即使底层从 NativeEventEmitter 换成 CodegenTypes.EventEmitter,业务页面也不需要大面积跟着改。

再封装成 Hook

React 页面里最容易出问题的是订阅生命周期。

如果每个页面都手写:

ts 复制代码
useEffect(() => {
  const sub = DemoScan.onScanResult(handleScan);
  return () => sub.remove();
}, []);

一开始还能接受,但页面多了之后,容易漏清理、重复订阅,或者闭包拿到旧状态。

所以这里再封装一个 hook:

ts 复制代码
export const useScanListener = (
  onScan: (event: DemoScanResultEvent) => void,
  options: {
    enabled: boolean;
    onError?: (error: DemoScanErrorEvent) => void;
  },
) => {
  const { enabled, onError } = options;
  const onScanRef = useRef(onScan);
  const onErrorRef = useRef(onError);
  const subscriptionRef = useRef<ScanSubscription | null>(null);
  const errorSubscriptionRef = useRef<ScanSubscription | null>(null);

  useEffect(() => {
    onScanRef.current = onScan;
  }, [onScan]);

  useEffect(() => {
    onErrorRef.current = onError;
  }, [onError]);

  useEffect(() => {
    if (!enabled) {
      return;
    }

    subscriptionRef.current = DemoScan.onScanResult(event => {
      onScanRef.current(event);
    });

    errorSubscriptionRef.current = DemoScan.onScanError(error => {
      onErrorRef.current?.(error);
    });

    return () => {
      subscriptionRef.current?.remove();
      errorSubscriptionRef.current?.remove();
      subscriptionRef.current = null;
      errorSubscriptionRef.current = null;
    };
  }, [enabled]);
};

这个 hook 解决的是 React 层的问题:

  • enabled 控制当前页面是否接收扫码;
  • useRef 避免事件回调拿到旧闭包;
  • effect cleanup 负责移除原生事件订阅;
  • 页面不需要感知 NativeEventEmitter

如果业务进一步复杂,比如页面、弹窗、底部抽屉都可能监听扫码,还可以在 hook 之上继续加一层"扫码监听栈"。但那已经是业务调度问题,不是 Turbo Module 本身必须处理的问题。

生命周期和资源清理

硬件能力模块一定要重视清理。

扫码模块至少需要处理这些情况:

  • 页面不再监听时移除 JS 事件订阅;
  • 应用销毁时 unregister receiver;
  • 停止监听时关闭硬件扫码;
  • React 实例不可用时不再 emit 事件;
  • 模块重新初始化时不要重复注册 receiver。

Android 侧可以监听宿主生命周期:

kotlin 复制代码
reactContext.addLifecycleEventListener(
  object : LifecycleEventListener {
    override fun onHostResume() = Unit

    override fun onHostPause() = Unit

    override fun onHostDestroy() {
      cleanupInternal()
    }
  }
)

清理时做三件事:

kotlin 复制代码
private fun cleanupInternal() {
  stopListeningInternal()
  unregisterReceiverIfNeeded()
  isInitialized = false
  scanOptions = ScanOptionsState()
}

JS 侧也应该提供 cleanup,统一移除事件订阅:

ts 复制代码
async cleanup(): Promise<void> {
  this.activeSubscriptions.forEach(sub => sub.remove());
  this.activeSubscriptions.clear();

  await requireNativeModule().cleanup();
  this.isInitialized = false;
}

这类模块容易出现"功能能跑,但资源没收干净"的问题。它不像普通页面 bug 那样容易马上暴露,经常是在多次进入页面、热重载、切账号、应用退后台之后才出现。

调试和验证

新增或修改 Turbo Module 后,建议至少跑这几类验证。

第一,TypeScript 检查:

bash 复制代码
yarn tsc --noEmit

Spec 类型变更后,JS 层错误通常能在这里先暴露。

第二,Android 编译:

bash 复制代码
cd android
./gradlew :app:compileDevDebugKotlin

如果 Kotlin 侧没有正确继承 Codegen 生成的 Spec,或者方法签名对不上,编译阶段就会失败。

第三,重新安装 App。

Turbo Module 不是纯 JS 代码,新增方法、修改原生实现、改 codegenConfig 之后,只重启 Metro 通常不够,需要重新编译安装。

第四,真机验证。

扫码这种能力必须上真机或真实 PDA 验证。模拟器最多验证 JS 调用链,不能验证硬件广播、厂商 extra key、蜂鸣震动等真实行为。

容易出问题的地方

1. Spec 名和原生模块名对不上

TurboModuleRegistry.get<Spec>('DemoScan') 里的名字,需要和 Android 侧 NAME 保持一致。否则 JS 侧拿不到模块。

2. 改了 Spec 但没有重新编译

Spec 是 Codegen 输入,不是普通 TS 类型。改完方法后如果只重启 Metro,容易出现 JS 以为有新方法、原生侧实际没生成的情况。

3. NativeEventEmitter 缺少 addListener/removeListeners

使用 new NativeEventEmitter(nativeModule) 时,模块需要提供 addListenerremoveListeners。即使暂时不使用 listener count,也要实现。

4. BroadcastReceiver 重复注册

多次初始化、热重载或 React 实例重建时,如果没有处理好 unregister,就可能出现一次扫码触发多次回调。

5. 厂商广播字段不统一

不要把某一个设备的 action 和 extra key 写死在业务代码里。集中维护兼容表,后续加设备时成本会更低。

6. 页面直接使用原生模块

一开始看起来省事,但后面事件实现、错误处理、初始化策略一变,所有页面都会受影响。更稳妥的方式是把 Turbo Module 当底层能力,再包装成业务侧 SDK。

总结

用 Turbo Module 封装原生能力时,可以按这条链路拆:

text 复制代码
Native Spec
  -> Codegen
  -> Android/iOS 原生实现
  -> JS SDK 封装
  -> React Hook
  -> 业务页面

扫码只是一个例子。同样的思路也可以用在打印机、蓝牙设备、前台服务、系统通知、长连接保活等能力上。

这类模块的关键不是"把原生方法暴露给 JS"这么简单,而是设计一条稳定边界:

  • Spec 负责定义跨端契约;
  • Native 负责处理平台细节;
  • JS SDK 负责提供稳定 API;
  • Hook 负责适配 React 生命周期;
  • 业务页面只关心业务动作。

边界清楚之后,Turbo Module 才不会变成一堆难维护的桥接代码,而会成为业务侧真正可复用的原生能力层。

相关推荐
私人珍藏库4 小时前
【Android】图片工具箱-免费开源图片处理软件
android·人工智能·app·工具·软件·多功能
JiaWen技术圈4 小时前
React 19 Fiber 架构 深度解析
前端·react.js·架构
以身入局4 小时前
android Binder 讲解
android
大阳光男孩4 小时前
【UniApp小程序开发】解决无法使用Vue自定义指令的完美替代方案:权限组件封装
前端·vue.js·uni-app
2501_915918414 小时前
Linux 上生成 AppStoreInfo.plist,App Store 上架 iOS
android·ios·小程序·https·uni-app·iphone·webview
只要微微辣4 小时前
Uniapp 微信小程序 Canvas画框标注:拖拽缩放全攻略
前端·微信小程序·uni-app·canvas·canva可画
希冀1234 小时前
【CSS学习第十三篇】
前端·css·学习
踏歌~5 小时前
个人简历网站搭建:2 解析原有结构并构建首页
前端
Moment5 小时前
面试官:上下文过长导致语义偏移,工程上怎么优化
前端·后端·面试