React Native 与 Kotlin 原生模块桥接:人脸识别与 TTS 语音播报实现
在先知体检通项目中,单纯依赖 React Native 的 JS 层已经无法满足需求:人脸识别身份核验依赖厂商提供的 Android SDK(纯 Java/Kotlin API),语音播报引导需要调用系统级 TTS 服务并支持队列管理。这两个功能都需要通过编写 Kotlin 原生模块(Native Module)来实现。
这篇文章完整记录桥接过程中遇到的问题与解决方案。
1. 原生模块的基础结构
React Native 的 Native Module 机制要求:
- 在 Android 侧创建继承
ReactContextBaseJavaModule的 Kotlin 类 - 用
@ReactMethod注解暴露给 JS 的方法 - 创建
ReactPackage注册该模块 - JS 侧通过
NativeModules访问
以 TTS 模块为例,先搭骨架:
// android/app/src/main/java/com/app/tts/TTSModule.kt
package com.app.tts
import android.speech.tts.TextToSpeech
import com.facebook.react.bridge.*
import com.facebook.react.modules.core.DeviceEventManagerModule
import java.util.Locale
import java.util.LinkedList
class TTSModule(private val reactContext: ReactApplicationContext)
: ReactContextBaseJavaModule(reactContext) {
override fun getName() = "TTSModule" // JS 侧通过此名称访问
private var tts: TextToSpeech? = null
private val speakQueue = LinkedList<String>()
private var isInitialized = false
}2. TTS 语音播报模块:解决队列中断问题
2.1 初始化与队列管理
体检流程中,TTS 播报有严格的顺序要求:“请站上体重秤” → 等待测量完成 → “测量完成,请移步下一项”。如果 JS 侧快速连续调用 speak,默认的 QUEUE_FLUSH 模式会导致后一条打断前一条。
我设计了一个受控队列:
@ReactMethod
fun speak(text: String, priority: Int, promise: Promise) {
if (!isInitialized) {
promise.reject("TTS_NOT_READY", "TTS engine not initialized")
return
}
if (priority == PRIORITY_HIGH) {
// 高优先级:清空队列,立即播报(如紧急提示)
speakQueue.clear()
tts?.stop()
executeSpeech(text, promise)
} else {
// 普通优先级:加入队列,等待上一条播完
speakQueue.offer(text)
if (speakQueue.size == 1) {
// 队列空时立即开始
processQueue(promise)
}
}
}
private fun processQueue(promise: Promise) {
val text = speakQueue.peek() ?: return
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, generateUtteranceId())
}2.2 监听播报完成事件
TTS 的关键是知道”何时播完”,从而触发下一步流程(如开始硬件测量)。通过 UtteranceProgressListener 回调 JS:
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
override fun onDone(utteranceId: String?) {
speakQueue.poll() // 移除已播报的文本
// 通知 JS 侧当前语音完成
sendEvent("tts:done", Arguments.createMap().apply {
putString("utteranceId", utteranceId)
})
// 继续处理队列
if (speakQueue.isNotEmpty()) {
processQueue(null)
}
}
override fun onError(utteranceId: String?) {
sendEvent("tts:error", Arguments.createMap().apply {
putString("utteranceId", utteranceId)
})
}
// Android API 21+ 才有 onStart
override fun onStart(utteranceId: String?) {}
})
private fun sendEvent(eventName: String, params: WritableMap) {
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit(eventName, params)
}2.3 JS 侧封装为 Hook
// hooks/useTTS.ts
import { NativeModules, NativeEventEmitter } from 'react-native'
const { TTSModule } = NativeModules
const emitter = new NativeEventEmitter(TTSModule)
export function useTTS() {
const speak = useCallback((text: string, priority: 'normal' | 'high' = 'normal') => {
return new Promise<void>((resolve, reject) => {
TTSModule.speak(text, priority === 'high' ? 1 : 0)
.then(resolve)
.catch(reject)
})
}, [])
const onDone = useCallback((callback: () => void) => {
const sub = emitter.addListener('tts:done', callback)
return () => sub.remove()
}, [])
return { speak, onDone }
}3. 人脸识别模块:跨进程 Activity 结果回传
人脸识别是更复杂的场景,因为厂商 SDK 的调用方式是启动一个新的 Activity,识别完成后通过 onActivityResult 返回结果。这在 RN 的模块体系中需要特别处理。
3.1 注册 Activity 事件监听器
RN 的 BaseActivityEventListener 专门处理这类场景:
class FaceRecognitionModule(reactContext: ReactApplicationContext)
: ReactContextBaseJavaModule(reactContext), ActivityEventListener {
private var currentPromise: Promise? = null
private val REQUEST_CODE_FACE = 1001
init {
reactContext.addActivityEventListener(this)
}
override fun getName() = "FaceRecognitionModule"
@ReactMethod
fun startVerification(config: ReadableMap, promise: Promise) {
currentPromise = promise
val activity = currentActivity ?: run {
promise.reject("NO_ACTIVITY", "Current activity is null")
return
}
// 启动厂商人脸识别 Activity
val intent = FaceSDK.buildIntent(activity).apply {
putExtra("maxRetryCount", config.getInt("maxRetryCount"))
putExtra("timeout", config.getInt("timeout"))
}
activity.startActivityForResult(intent, REQUEST_CODE_FACE)
}
override fun onActivityResult(
activity: Activity?,
requestCode: Int,
resultCode: Int,
data: Intent?
) {
if (requestCode != REQUEST_CODE_FACE) return
val promise = currentPromise ?: return
currentPromise = null
if (resultCode == Activity.RESULT_OK) {
val userId = data?.getStringExtra("userId") ?: ""
val score = data?.getFloatExtra("score", 0f) ?: 0f
promise.resolve(Arguments.createMap().apply {
putString("userId", userId)
putDouble("score", score.toDouble())
putBoolean("success", score >= 0.85f)
})
} else {
promise.reject("FACE_CANCELLED", "User cancelled or verification failed")
}
}
override fun onNewIntent(intent: Intent?) {}
}3.2 JS 侧的错误处理
// 业务层调用示例
async function verifyIdentity(userId: string) {
try {
const result = await NativeModules.FaceRecognitionModule.startVerification({
maxRetryCount: 3,
timeout: 30000,
})
if (!result.success) {
// 相似度不足 0.85,提示用户
await tts.speak(`身份核验失败,相似度 ${Math.round(result.score * 100)}%,请重试`)
return false
}
await tts.speak('身份核验成功,欢迎您')
return true
} catch (error) {
if (error.code === 'FACE_CANCELLED') {
await tts.speak('已取消核验')
} else {
await tts.speak('系统错误,请联系工作人员')
console.error('[FaceRecognition]', error)
}
return false
}
}4. 踩坑记录
坑 1:currentActivity 为 null 的时机
在 RN 应用刚启动或 Activity 重建时,currentActivity 可能为 null。原先没有做空检查导致 Promise 永远 pending,后来加了 null 检查并立即 reject,JS 侧会走 catch 流程给用户反馈。
坑 2:Promise 内存泄漏
如果用户直接按 Home 键退出了识别 Activity(不走 onActivityResult),currentPromise 会永远持有引用。解决方案是在 onHostDestroy(Activity 销毁回调)中主动 reject 并清空:
override fun onHostDestroy() {
currentPromise?.reject("MODULE_DESTROYED", "Activity destroyed")
currentPromise = null
}坑 3:TTS 在低端机上初始化延迟
部分体检终端是 Android 7 的老机器,TTS 引擎初始化需要 2-3 秒。如果初始化未完成就调用 speak 会直接失败。最终在应用启动时预热 TTS 引擎,并用一个 initPromise 让所有 speak 调用都等待初始化完成:
private var initPromise: Promise? = null
private var isInitialized = false
@ReactMethod
fun init(promise: Promise) {
initPromise = promise
tts = TextToSpeech(reactContext) { status ->
if (status == TextToSpeech.SUCCESS) {
isInitialized = true
tts?.language = Locale.CHINESE
initPromise?.resolve(null)
} else {
initPromise?.reject("TTS_INIT_FAILED", "Status: $status")
}
initPromise = null
}
}总结
编写 RN Native Module 的核心心得:
- 承诺(Promise)即契约:所有
@ReactMethod带 Promise 的方法都必须有确定的 resolve/reject 路径,不能让它”悬空”。 - 生命周期对齐:Native Module 需要感知 Activity 生命周期,
ActivityEventListener和LifecycleEventListener是必备工具。 - 事件驱动优于轮询:像 TTS 完成、串口数据到达这类异步通知,用
DeviceEventEmitter主动推送比 JS 侧轮询要稳定得多。
最后更新:2026年4月
标签:React Native、Kotlin、原生模块、人脸识别、TTS、Android开发