最近帮团队把一个 IoT 调试工具从 Android 迁到鸿蒙上,遇到一个特别典型的需求:扫描周围的蓝牙设备。这个功能在 Android 上用原生 API 半小时就能跑通,但切到 React Native 鸿蒙版(RNOH)环境里,要处理的细节比想象中多得多。网上关于鸿蒙蓝牙的资料本来就少,RN 桥接层的资料更少,我几乎是一路踩坑过来的。把整个项目从设计到落地的过程整理成这篇博文,给正在做 RNOH 适配、或者接智能硬件类需求的开发者一点参考。
先交代一下背景:我们团队的技术栈是 React Native,产品主要面向低功耗蓝牙设备调试场景,需要实时扫描附近设备、展示设备名和信号强度。鸿蒙生态发展到现在,纯 ArkTS 开发当然是主线,但对存量 RN 团队来说,全量重写成本太高,所以选了 React Native for OpenHarmony 这条路。项目本身的输出物很简单,就是一个隐藏在 RN 页面里的蓝牙扫描器,但这个看似简单的功能背后,牵扯到权限模型、原生桥接、事件回调、线程切换、扫描策略一堆问题。
这篇文章会从方案选型开始,把桥接层的设计思路、原生代码怎么写、JS 侧怎么封装、以及我在实际调试中遇到的白屏、扫描超时、回调丢失等问题全部摊开来讲。代码部分是基于我项目里的精简示意,不同 SDK 版本下导入路径和参数名可能略有出入,跑之前一定以官方文档为准。
1. 项目拆解:RN鸿蒙版蓝牙扫描到底要做什么
1.1 需求本质:不是写一个页面,而是打通三层技术栈
这个项目表面上看起来是“一个 RN 页面 + 一个扫描按钮”,但剥开来看,它其实横跨了三层技术栈:RN 的 JS 层、RNOH 的模块通信层、鸿蒙原生蓝牙 API 层。任何一层掉链子,功能都跑不起来。
先说为什么不能绕开原生层。React Native 本身没有内置蓝牙模块,Web Bluetooth API 在鸿蒙 WebView 里也不支持,所以唯一的路径就是通过 NativeModule 把 JS 调用透传到鸿蒙原生代码。也就是说,扫描蓝牙设备的核心逻辑其实是在 ArkTS 代码里完成的,RN 只负责 UI 交互和状态展示。
我在接到这个需求时先列了一张依赖清单:
- 鸿蒙系统蓝牙状态检查(是否支持 BLE、是否开启)
- 蓝牙权限动态申请(涉及个人隐私和位置权限)
- BLE 扫描启动与停止
- 扫描结果实时回调(名称、设备 ID、RSSI、广播数据)
- 扫描状态变化监听(蓝牙开关、系统级中断)
这张清单基本决定了项目的边界。如果只是“能扫到设备”就算完成,那可以实现得很粗糙;但要做成一个可上线的功能,上面每一项都需要在不同系统版本上验证过。
1.2 为什么最终选择“RN + 原生桥接”而不是三种方案中的其他两个
当时团队内部讨论过三条路线,我把它们的优劣拉了个表:
| 方案 | 开发成本 | 双端复用率 | 蓝牙功能覆盖度 | 维护成本 | 适用场景 |
|---|---|---|---|---|---|
| 纯 ArkTS 重写 | 高 | 低,Android 要重做 | 高,完整原生能力 | 高,两套代码 | 鸿蒙是主要平台 |
| RN + 纯 JS 蓝牙库 | 低 | 高 | 极低,鸿蒙 WebView 不支持 | 低 | 仅在 Android/iOS 运行 |
| RN + 鸿蒙原生桥接 | 中 | 高 | 高,取决于桥能力 | 中 | 存量 RN 团队迁鸿蒙 |
我直接说结论:如果你的项目还同时维护 Android/iOS 版本,那纯 ArkTS 重写意味着三端逻辑彻底分家,后面每改一个需求都要同步改三遍。纯 JS 库在鸿蒙上又完全走不通,剩下就只有桥接方案。
桥接方案的核心代价是“中间层”复杂,但这个代价可以通过封装来摊销。我把蓝牙相关的原生逻辑收敛成一个BTScanner模块,JS 侧只面对几个简单的 Promise 方法和事件订阅,团队其他成员不需要懂 ArkTS 也能正常开发 UI。这个封装思路后面详细讲。
2. 核心细节:蓝牙扫描桥接层与权限处理要点
2.1 鸿蒙BLE扫描的底层逻辑:从 ScanFilter 到回调
鸿蒙的蓝牙 API 和 Android 在思路上是很接近的,都分经典蓝牙和低功耗蓝牙(BLE)两套体系。这个项目只需要扫描 BLE 设备,所以核心 API 都集中在@kit.ConnectivityKit的ble模块下,旧版本可能叫@ohos.bluetooth.ble,用的时候先确认你 SDK 对应的是哪一套。
扫描的基本流程可以归纳成四步:
- 获取蓝牙状态,确认系统支持 BLE 且蓝牙已打开
- 构造
ScanFilter过滤条件(可以按设备名、服务 UUID 过滤) - 调用
ble.startBLEScan(filters, options)启动扫描 - 注册
BLEDeviceFind监听,在回调里接收扫描结果
听起来很简单,但有几个关键参数必须理解透。ScanOptions里的interval和dutyMode直接影响扫描的功耗和结果返回速度。官方提供低功耗、均衡、高性能等几种模式,我实际测试下来:
SCAN_MODE_LOW_POWER:扫描间隔长,省电,但低功耗设备广播周期通常在 100ms 到 500ms 之间,间隔太长会漏设备SCAN_MODE_BALANCED:比较均衡,大部分场景够用SCAN_MODE_HIGH_PERFORMANCE:返回最快,但发热和耗电明显,不适合长时间运行
我在正式上线的版本里,给用户提供了“普通扫描”和“快速扫描”两个按钮,分别对应均衡和高性能模式。很多设备首次进入配对模式后只广播 30 秒,这时候用高性能模式提高抓到设备的概率,体验会好很多。
还有一个很多人容易忽略的点:BLEDeviceFind回调返回的是一组ScanResult数组,不是单台设备。回调触发频率由系统扫描策略决定,同一台设备可能被上报多次。所以 JS 侧拿到数据后必须做去重和按 RSSI 排序,否则同一个设备会在列表里出现好几行。
2.2 桥接层设计:用模块封装替代散落的原生调用
桥接层是这个项目里最值得花心思的地方。如果只是简单地把startBLEScan暴露给 JS,那 JS 代码里会到处散落着对系统状态的判断、权限检查、错误处理,整个项目会变得很难维护。
我设计模块时遵循两个原则:
第一个原则是“原生层做重活,JS 层做轻展示”。权限申请、蓝牙状态检查、扫描启停全部收敛到原生侧。JS 侧调用方只想知道两件事:扫描有没有成功、扫到了哪些设备。至于中间这些前置条件怎么满足,应该由原生模块去处理。
第二个原则是“事件驱动代替轮询”。扫描蓝牙设备是高频回调场景,如果用 Promise 包一层,那用户感知到的就是“点了开始,等几秒,一次性拿到一堆设备”。但如果想体验“列表实时往出蹦设备”,必须用事件订阅的方式把每一条发现记录实时推到 JS。
具体到 RNOH 的模块注册上,我用的是 TurboModule 模式。模块类继承TurboModule,然后在构造函数里初始化上下文。JS 侧通过NativeModules.BTScanner拿到这个模块的引用。这里有个实测下来的经验:原生模块导出方法时,建议统一用 Promise 包装所有同步操作,不要在原生侧抛出异常让 JS 去捕捉,因为 RN 的异常栈有时候会丢失原生错误信息,排查起来非常痛苦。
事件回调我用的是NativeEventEmitter方案。原生侧调sendEvent推送事件,JS 侧在useEffect里订阅。这样扫描到新设备时,原生层每推送一次,JS 层 UI 就刷新一次,整个过程是实时的。
2.3 权限处理:鸿蒙比 Android 更严格的地方
蓝牙扫描涉及用户隐私,鸿蒙在权限这块管得比一般开发者想象中严。除了蓝牙开关权限,扫描 BLE 设备还涉及到位置信息的获取,因为你有可能通过广播数据推测出设备大致距离。所以实际上需要动态申请以下权限:
ohos.permission.ACCESS_BLUETOOTH:蓝牙连接权限,访问蓝牙相关能力的基石ohos.permission.DISCOVER_BLUETOOTH:扫描广播蓝牙设备权限ohos.permission.APPROXIMATELY_LOCATION:模糊定位权限,部分版本扫描时需要
这里最容易踩的坑是权限申请的时机。不要把权限申请放在原生模块的内部方法里,因为requestPermissionsFromUser需要当前 UIAbility 的上下文,模块内部拿不到,会导致权限弹窗不提示或者直接崩溃。正确做法是在页面装载后、用户点击“开始扫描”前,由 JS 侧显式调用一次原生暴露的requestPermissions方法,然后再开始扫描流程。
我还在页面上加了一个“权限状态检测”的入口。每次进入扫描页时先调用checkPermissions(),如果发现权限被用户拒绝过,就弹一个自定义提示框,引导用户去系统设置里手动打开。这个体验比反复弹系统权限框好很多,也符合主流 App 的交互习惯。
3. 实操实现:从原生模块到JS页面的完整代码
3.1 环境准备:DevEco Studio 和 RNOH 工程初始化
这个项目开始前,先确认你的开发环境满足这几个条件:
- DevEco Studio 版本建议 5.0 以上,鸿蒙 SDK API 12 以上
- Node.js 16 以上,RNOH 对 Node 版本有一定要求
- 安装了 React Native for OpenHarmony 的脚手架工具
如果你的团队还没有 RNOH 工程,可以用@react-native-ohos-community/cli初始化,或者参照官方文档在现有 RN 工程上添加鸿蒙目标目录。我建议直接新建一个带鸿蒙 target 的工程,因为双端的原生配置差异不小,硬塞进同一个目录容易互相污染。
工程初始化好之后,需要在entry/src/main/module.json5里声明蓝牙和定位权限。这个文件相当于 Android 里的AndroidManifest.xml,权限不在这里声明,运行时申请权限会直接失败。
{ "module": { "name": "entry", "requestPermissions": [ { "name": "ohos.permission.ACCESS_BLUETOOTH", "reason": "$string:bluetooth_reason", "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } }, { "name": "ohos.permission.DISCOVER_BLUETOOTH", "reason": "$string:bluetooth_reason", "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } }, { "name": "ohos.permission.APPROXIMATELY_LOCATION", "reason": "$string:bluetooth_reason", "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } } ] } }注意reason字段对应的是本地化字符串,必须在string.json里配好,否则编译会报错。这也是鸿蒙和 Android 不太一样的地方,权限申请理由强制要求本地化处理。
3.2 原生侧实现:BTScanner 模块代码逐段拆解
原生侧核心模块我用 ArkTS 实现,主要分三块:权限申请、扫描控制、事件回调。代码是基于我项目的精简示意,运行前请核对当前 SDK 版本的 API 差异。
import { ble } from '@kit.ConnectivityKit'; import { abilityAccessCtrl, Permissions } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { TurboModule, TurboModuleContext } from '@rnoh/react-native-openharmony';权限申请这块,我封装了一个公共方法,接收页面传过来的上下文,这个上下文在 JS 侧发起调用时比较难拿,所以我选择在原生 EntryAbility 初始化阶段把它保存到一个全局单例里,权限方法直接复用。
export class BTScannerModule extends TurboModule { private scanResults: Map<string, ble.ScanResult> = new Map(); constructor(ctx: TurboModuleContext) { super(ctx); } checkPermissions(): Promise<boolean> { const atManager = abilityAccessCtrl.createAtManager(); const permissions: Permissions[] = [ 'ohos.permission.ACCESS_BLUETOOTH', 'ohos.permission.DISCOVER_BLUETOOTH', 'ohos.permission.APPROXIMATELY_LOCATION' ]; try { return atManager.requestPermissionsFromUser(this.ctx.uiAbilityContext, permissions) .then((result) => { return result.authResults.every((res) => res === 0); }) .catch((err: BusinessError) => { return false; }); } catch (e) { return Promise.resolve(false); } }扫描方法我设计成接受一个filterDeviceName参数,这样 JS 侧可以根据用户输入框的内容动态调整过滤条件。ScanFilter里可以传设备名,也可以传服务 UUID,但项目里设备名过滤更常见。
startScan(filterDeviceName: string): Promise<boolean> { if (!ble.isBleEnabled()) { return Promise.resolve(false); } this.scanResults.clear(); const filters: ble.ScanFilter[] = []; if (filterDeviceName && filterDeviceName.length > 0) { filters.push({ name: filterDeviceName, }); } const options: ble.ScanOptions = { interval: 1000, dutyMode: ble.ScanDuty.SCAN_MODE_BALANCED, }; try { ble.startBLEScan(filters, options); // 注册扫描结果监听 ble.on('BLEDeviceFind', this.onDeviceFind); return Promise.resolve(true); } catch (err) { return Promise.resolve(false); } }扫描结果回调里,我先把所有设备暂存在一个Map里,以设备 ID 为 key,实现天然去重。然后按 RSSI 排序后,把前 50 个设备一次性推送到 JS 侧。这里没有做“每发现一台就推送一次”的粒度的原因,是为了减少 JS 侧渲染频率。有些蓝牙模块会同时上报十几台设备,逐个推送会造成列表频繁重排,体验反而不好。
private onDeviceFind = (data: Array<ble.ScanResult>) => { data.forEach((device) => { this.scanResults.set(device.deviceId, device); }); const sorted = Array.from(this.scanResults.values()) .sort((a, b) => (b.rssi ?? -100) - (a.rssi ?? -100)) .slice(0, 50); this.emit('onScanResults', sorted.map((device) => ({ deviceId: device.deviceId, deviceName: device.deviceName ?? '未知设备', rssi: device.rssi ?? -100, }))); } stopScan(): Promise<boolean> { try { ble.stopBLEScan(); ble.off('BLEDeviceFind', this.onDeviceFind); return Promise.resolve(true); } catch (err) { return Promise.resolve(false); } } }这里我用了this.emit往 JS 侧发事件,RNOH 的 TurboModule 内置了事件发送能力。在实际项目里,你会发现这个方法的名字和参数格式可能在各个版本间有变化,我强烈建议封装一层,把原生事件先映射成项目内部统一的数据格式,这样后续升级 SDK 时只改映射层,业务代码不用动。
3.3 JS 侧封装:自定义 Hook 和扫描页面
JS 侧如果直接调NativeModules.BTScanner,代码会非常啰嗦。所以我写了一个自定义 HookuseBluetoothScanner,把权限检查、开始扫描、停止扫描、结果状态都封装进去,页面组件只需要关心 UI 状态。
import { useEffect, useRef, useState, useCallback } from 'react'; import { NativeModules, NativeEventEmitter, Platform, } from 'react-native'; const { BTScanner } = NativeModules; const scannerEmitter = new NativeEventEmitter(BTScanner); export interface BleDevice { deviceId: string; deviceName: string; rssi: number; } type ScannerState = 'idle' | 'scanning' | 'error'; export function useBluetoothScanner() { const [devices, setDevices] = useState<BleDevice[]>([]); const [scannerState, setScannerState] = useState<ScannerState>('idle'); const [hasPermission, setHasPermission] = useState<boolean>(false); const isMountedRef = useRef(true); useEffect(() => { isMountedRef.current = true; const subscription = scannerEmitter.addListener('onScanResults', (list: BleDevice[]) => { if (isMountedRef.current) { setDevices(list); } }); return () => { isMountedRef.current = false; subscription.remove(); }; }, []); const requestPermission = useCallback(async () => { const granted = await BTScanner.checkPermissions(); setHasPermission(granted); return granted; }, []); const startScan = useCallback(async (keyword?: string) => { const granted = hasPermission ? true : await requestPermission(); if (!granted) { setScannerState('error'); return; } const started = await BTScanner.startScan(keyword || ''); if (started) { setScannerState('scanning'); setDevices([]); } else { setScannerState('error'); } }, [hasPermission, requestPermission]); const stopScan = useCallback(async () => { await BTScanner.stopScan(); setScannerState('idle'); }, []); return { devices, scannerState, hasPermission, startScan, stopScan, setScannerState, }; }代码里有几个细节值得说明。第一,isMountedRef是为了避免卸载后回调刷新 state 导致的警告。第二,hasPermission用 state 缓存,避免每次点扫描都弹一次权限框。第三,事件订阅在useEffect里注册,组件卸载时一定要调用remove(),否则会形成内存泄漏。
页面组件用FlatList做列表渲染,因为扫描结果可能刷新很频繁,FlatList自带的渲染优化很重要。字段展示上我按“设备名称 + 信号强度”两个维度展示,信号强度用 RSSI 数值加上一个简单的图标示意。设备去重是在原生层做的,所以 JS 侧不需要再处理重复数据。
3.4 完整扫描流程串联:从点到面的状态驱动
整个页面跑的流程我梳理成下面这条链路,每一步都是前一步成功后才能进入:
- 页面 onMounted:检查权限状态,更新按钮可用性
- 用户点击“开始扫描”:调用
startScan(keyword) - 内部先检查
hasPermission,没有就请求权限 - 权限通过后,原生层检查蓝牙是否开启,没开启则返回失败
- 原生层启动 BLE 扫描,注册回调
- 扫描结果通过事件通道实时推送到 JS,更新列表
- 用户点击“停止扫描”或页面卸载,调用
stopScan()
这里我特意把“蓝牙是否开启”的处理放到原生层返回 false 之后,由 JS 侧弹提示。因为判断蓝牙开关状态在原生侧只有一行代码,但提示文案和引导跳转逻辑在 JS 侧更好维护。
4. 问题排查:白屏、扫不到设备、回调丢失的避坑实录
4.1 RN鸿蒙版启动白屏:最常见的第一个坑
我在项目初期遇到最头疼的问题,不是蓝牙扫描本身,而是 App 启动后直接白屏。而且这个现象不是必现的,有时候清掉后台重新进就好,有时候怎么重启都是白屏,非常影响验证扫描功能。
排查下来,白屏的根源基本集中在三处。第一处是 Bundle 加载失败。RNOH 开发模式下需要从 Metro Server 拉取 JS Bundle,如果电脑防火墙拦截了 Metro 端口,或者真机和电脑不在同一网段,Bpple 就会一直加载不出来。第二处是原生模块注册异常。当你新增了一个 TurboModule 但没有正确注册到模块列表里,整个应用会在 Native 层崩溃,表现也是白屏。第三处是 Dev Server 崩溃,这个比较容易发现,命令行里会直接报错。
排查顺序我建议是:
- 先看 Metro 终端输出,有没有明显报错
- 再确认真机网络能访问电脑的 Metro 端口,用浏览器直接访问
http://电脑IP:8081/status试试 - 检查原生模块注册文件,确认
BTScanner模块被正确加载 - 最后看 DevEco Studio 的日志输出,定位底层崩溃原因
白屏问题往往和用户环境强相关,社区里讨论“react native 启动白屏”的频率一直很高。我的建议是在工程里加一个全局的错误边界组件,任何 JS 异常都弹一个错误页,至少让测试人员能判断是 JS 问题还是原生问题,而不是面对一片白屏无从下手。
4.2 扫描不到任何设备:先别怀疑代码,按顺序排查
蓝牙扫描跑起来之后,第二类高频问题是“点了开始扫描,一个设备都扫不到”。我把排查过程整理成一张表,你在遇到类似问题时可以按顺序过一遍:
| 排查项 | 检查方法 | 常见原因 |
|---|---|---|
| 系统权限 | 去系统设置里看 App 的蓝牙和定位权限 | 权限被拒绝,尤其是在首次弹窗时误点 |
| 蓝牙开关 | 正常进设置看蓝牙是否打开 | 部分设备需要重新开关蓝牙才恢复广播 |
| 过滤条件 | 去掉ScanFilter里的 name 限制,扫全量 | 设备广播名和预期不一致,被过滤掉了 |
| 扫描模式 | 切到SCAN_MODE_HIGH_PERFORMANCE | 低功耗模式漏扫信号弱的设备 |
| 系统兼容性 | 用另一台已知正常的手机测试 | 个别定制系统对后台扫描限制严格 |
我在项目里还遇到过一个很奇怪的场景,用鸿蒙模拟器调试时,扫描一直返回空列表。后来查了文档才发现,鸿蒙模拟器默认没有模拟蓝牙硬件,BLE 扫描功能在模拟器上压根不可用。所以做蓝牙功能一定要用真机调试,模拟器只能验证 UI 层。
另外有开发者在 Windows 上做调试时,打开设备管理器看到“Generic Bluetooth Adapter”带感叹号,结果真机上的扫描功能一直异常。这个其实是电脑自带蓝牙适配器驱动异常,影响的是电脑和手机之间的通信链路,不是手机端扫描本身的问题。遇到这种状况,重启蓝牙驱动或者换 USB 连接方式就能排查出来。
4.3 扫描结果回调丢失:事件订阅时机和线程切换
第三种比较隐蔽的问题是“扫描启动了,但 ListView 一直不刷新”。这种问题代码逻辑上看起来都对,权限也好好的,但就是没有设备回流。
我定位到的第一个原因是事件订阅时机。我最初把NativeEventEmitter.addListener写在startScan回来之后,但原生层可能已经先于 JS 订阅回调触发了emit,导致最初的几条扫描结果没人接收。解决方法是把订阅提前到useEffect里,页面一加载就注册监听,然后再处理扫描逻辑。
第二个原因是线程切换。鸿蒙蓝牙扫描结果回调在某些版本上是跑在 IO 线程上的,如果你直接在这个回调里调用emit推送事件,RNOH 的事件机制可能不会主动切回 JS 线程,结果就是丢事件或者延迟。稳妥的做法是在原生回调里用this.ctx.taskExecutor.runTask或者等价的机制切回主线程,再执行事件发送。
这两个问题都解决之后,扫描列表的刷新就稳定多了。我的经验是:蓝牙相关的事件回调默认都不信任当前线程,所有涉及跨线程传数据的操作,显式处理线程切换,不要赌框架会帮你做。
4.4 功耗和重复扫描的优化:不只是“扫完就停”
最后一个值得拿出来说的点是功耗优化。蓝牙扫描如果一直开着,手机发烫特别明显。而且很多低功耗设备广播是有频次限制的,扫描太频繁对设备本身也是负担。
我做的优化有三个:
- 扫描兜底超时机制,默认 15 秒自动停止,防止用户忘记关
- 提供“仅扫描一次”模式,扫到指定设备后立即停止
- 扫描中降低列表刷新频率,原生侧累积一批结果再推送
这三点做下来,项目的体感和续航表现都好了不少。特别是在调试现场,一堆设备同时广播时,没有超时机制的话,手机很快就变成暖手宝。
5. 影响范围与后续扩展:一个扫描功能背后的鸿蒙适配思考
5.1 扫描只是开始,功能扩展的想象空间很大
蓝牙扫描功能跑通以后,这个桥接层就成了整个 App 连接智能硬件的地基。后面要做设备连接、服务发现、特征值读写、蓝牙串口透传,都可以在同一个BTScanner模块里扩展。
比如我后面紧接着做的一个需求,是连接设备后读取广播数据里的自定义服务数据,用于判断设备固件版本。这个功能在原生侧可以直接通过广播数据解析出来,桥接层只需要增加一个readDeviceServices(deviceId)方法。如果当时没有做好模块封装,这段逻辑就会散落在页面生命周期里,扩展起来会非常痛苦。
应用场景上,这类 RN 鸿蒙版蓝牙方案最适合的领域包括:
- 智能家居设备配对与调试工具
- 运动健康类 App 连接手环、体脂秤
- 工业现场的 BLE 传感器数据采集
- 蓝牙外设产线测试工具
这些场景有几个共同特点:需要跨平台,需要快速迭代 UI,但又必须访问完整的蓝牙底层能力。RNOH 桥接方案刚好卡在中间,既能复用业务代码,又不牺牲原生能力。
5.2 组件化思路:把蓝牙能力沉淀成团队公共库
在项目推进过程中,我发现蓝牙能力其实是一个很通用的基础设施。与其每个项目都重新写一遍桥接层,不如把它拆成一个独立的 npm 包,内部封装好权限申请、扫描、连接、通讯这些能力,对外只暴露一套 Promise 风格 API。
组件化的好处在团队协作中非常明显。UI 组员可以完全不用关心底层是鸿蒙还是 Android,他们只需要调scanner.startScan()、监听结果事件。原生开发同学也能在自己擅长的领域深耕,把蓝牙适配、线程处理这些脏活累活都收敛在包里。
遇到一个特别典型的协作场景:产品需求突然要加“扫描时实时显示设备RSSI波形”,这时如果桥接层封装得好,原生侧只需要多透传一个字段,JS 侧画个图表就行,整个改动量非常小。但如果封装得差,可能要动好几个原生方法,还要处理各种线程问题。
从更宏观的角度看,做鸿蒙适配不仅是把代码跑起来,更是审视整个技术栈分层是否合理的机会。React Native 鸿蒙版还在快速演进中,API 变动频繁,只有把业务代码和系统细节隔离开,才能在这个生态里少踩坑、走得稳。
5.3 给后来者的三条建议
最后分享三个我在这个项目里沉淀下来的经验,供大家参考。
第一,一定要保留一份“最小可运行示例”。我建了一个独立的分支,里面只有一个扫描页面和一个原生模块,不掺杂任何业务逻辑。每次升级 RNOH 版本或者鸿蒙 SDK,先在这个例子里验证蓝牙功能是否正常,再合入业务分支。这会帮你把“SDK升级引起的问题”和“业务代码的问题”快速区分开。
第二,原生日志和 JS 日志给足上下文。蓝牙问题经常需要跨端定位,如果日志里没有设备 ID 和操作时间线,排查起来会很费劲。我在原生层每次扫描开始、停止、发现设备都打日志,并在传给 JS 的数据里带上时间戳,这样无论从哪端查问题都能拼出完整的调用链。
第三,准备好蓝牙调试工具。手机端我一般用串口蓝牙终端类的工具模拟外设端,配合测试。电脑端要注意蓝牙适配器状态,这在 Windows 上尤其容易出问题,驱动异常会直接影响真机调试链路。工具不行的时候换个 USB 口、重启蓝牙服务都是常规操作,别一上来就改代码。
这次把 React Native 鸿蒙版的蓝牙扫描功能完整做完,我最大的感受是:鸿蒙生态虽然年轻,但技术栈的成熟速度比想象中快。只要找到合适的桥接方式,存量 RN 团队的迁移成本并没有传说中那么高。当然,前提是有人愿意把原生层这些细碎的坑提前踩平,希望这篇文章能帮你省下这部分时间。