面向:要在 HarmonyOS Next 上用 uni-app 调 BLE 的开发者。
本篇只讲平台层基建 (插件、垫片、编译链、生命周期缺陷),不涉及任何具体设备协议;设备侧对接(握手、数据解析、读数入库)见系列第二篇。
文中所有包名、AppID、插件名、路径均为占位符或代称。
TL;DR
- uni-app 的蓝牙 API 族在 HarmonyOS Next 官方兼容性表里是
x(不支持),调用永不回调,表现为「按钮点了没反应」,且不是权限问题。 - 我们用 UTS 插件包一层
@kit.ConnectivityKit,再用一个与uni蓝牙 API 同名同签名的垫片注入给厂商 SDK,从而一行协议代码都不移植。 - 真正的坑不在「API 不存在」,而在三处:产物有 4 份副本不同步 、原生监听注册/解注不成对 、持久化的 deviceId 会腐烂。
- 全流程在真机上跑通并回归,踩到的 ArkTS 编译错误全部有确定解法,本文给对照表。
1. 现象:三个「像权限问题」的假象
设备列表页点「开始搜索」,鸿蒙端毫无反应;安卓端正常。当时脑子里的三个假设全被证伪:
| 假设 | 取证方式 | 结论 |
|---|---|---|
| 改动没同步进包 | 比对设备上 www/app-service.js 的构建指纹 + build intermediates |
已进包 |
| 条件编译把代码剥了 | 检查产物里对应 #ifdef 分支的字符串计数 |
分支正确 |
| 没声明蓝牙权限 | 检查包内 module.json 是否含 ACCESS_BLUETOOTH |
已声明 |
而点击确实到达了 JS------页面弹出了我们自己加的「蓝牙初始化超时」兜底提示。
排障经验 :当 await 一个 API 永远不返回、而你的代码链路已被证明进包时,先怀疑平台能力缺失,而不是桥接 bug。
2. 根因:平台能力缺失,不是 bug
HarmonyOS Next 的 uni-app 兼容性表中,下列 API 全部标注为不支持:
openBluetoothAdapter / getBluetoothAdapterState
startBluetoothDevicesDiscovery / stopBluetoothDevicesDiscovery / onBluetoothDeviceFound
createBLEConnection / closeBLEConnection
getBLEDeviceServices / getBLEDeviceCharacteristics
writeBLECharacteristicValue / notifyBLECharacteristicValueChange / onBLECharacteristicValueChange
失败形态很安静:不报错、不回调 ,success/fail/complete 三家都不来。于是 await 挂起、页面卡在初始化、按钮看起来「无反应」。补齐能力必须走原生。
3. 选型:三条路,选了最省的一条
| 方案 | 做法 | 代价 |
|---|---|---|
| A. 协议移植到原生 | ArkTS 里重写设备协议与状态机 | 工作量最大,双端两份协议逻辑长期分叉 |
| B. 等官方支持 | 无 | 不可控 |
| C. 补一层同名 API | UTS 插件提供原生原语 + 前端垫片伪装成 uni 蓝牙 API |
一次性投入,业务代码几乎不动 |
选 C 的关键前提,是先去读厂商 SDK 的实现:
js
// node_modules/<vendor>-bluetooth-sdk/package.json
"dependencies": {} // 无原生依赖
// 内部调用形态
uni.createBLEConnection(...) // 蓝牙能力全部来自「注入进来的宿主对象」
SDK 是纯 JS ,且蓝牙 API 宿主是参数注入的:
ts
adapter.createBLEConnection(deviceId, callback, host, timeout, authParams)
// ^^^^ 原来传 uni,现在可以传任何东西
只要提供一个和 uni 蓝牙子集同名同签名的对象,协议逻辑(握手、指令、解包)就原样复用。这是整个方案的地基。
4. 架构:三层,各管一件事
业务层 useXxxScan.ts / useBluetoothDevice.ts
│ bleApi(条件编译赋值)
│ #ifdef APP-HARMONY → bleApi = harmonyBle
│ #ifndef → bleApi = uni
▼
垫片层 utils/bluetooth/harmonyBle.ts
与 uni 蓝牙 API 同名、同签名({ success, fail, complete } + errCode)
负责:桥接语义归一(广播数据、二进制)+ 插件缺失时显式 fail
│ uni.hbXxx(...) 扁平属性访问
▼
原生层 uni_modules/harmony-ble/utssdk/app-harmony/index.uts
@kit.ConnectivityKit:access.getState / ble.startBLEScan /
ble.createGattClientDevice / device.writeCharacteristicValue ...
4.1 平台间接层:两个 const 会编译报错
条件编译的原文 会被 tsc/IDE 一起看到,两个分支各自 const bleApi 会「无法重新声明块范围变量」。正确写法是声明一次、分支内赋值:
ts
let bleApi: any;
// #ifdef APP-HARMONY
import { harmonyBle } from '@/utils/bluetooth/harmonyBle';
bleApi = harmonyBle;
// #endif
// #ifndef APP-HARMONY
bleApi = uni;
// #endif
另两个小坑:
APP-HARMONY与APP-PLUS是并列 关系,不是包含。原来写在#ifdef APP-PLUS里的授权逻辑,鸿蒙下会被整段剥离。- 从
@/uni_modules/<id>命名导入 UTS 插件这条路,在我们这套离线构建工程里没有被证实可用(详见 §6),最终改走扁平属性访问。
4.2 垫片:宁可显式失败,也不要静默超时
垫片最重要的不是转发,而是兜底失败 :原生插件没进包时,属性访问会拿到 undefined,如果直接调用就又是「不回调卡死」。所以先探测再回调:
ts
const ERR_PLUGIN_MISSING = 10002;
function call(flatName: string, opts: any) {
const f = (uni as any)[flatName];
if (typeof f !== 'function') {
const errMsg = `harmonyBle.${flatName}:fail native plugin not installed`;
console.log('[BLE]', errMsg);
opts?.fail?.({ errMsg, errCode: ERR_PLUGIN_MISSING });
opts?.complete?.({ errMsg, errCode: ERR_PLUGIN_MISSING });
return;
}
f(opts);
}
这段代码换来的是:故障从「玄学超时」变成「日志里一行可读原因」。整个排障周期因此缩短了一半。
5. ArkTS 会教你做人:编译错误对照表
.uts 在鸿蒙侧要编成 ArkTS,而 ArkTS 是 TypeScript 的受限子集。首轮 12 个编译错的成因与解法:
| 报错 | 根因 | 解法 |
|---|---|---|
Indexed signatures are not supported(arkts-no-indexed-signatures) |
ArkTS 禁索引签名,[key: string]: any 不能用 |
接口里逐个显式声明 透传字段:deviceId?/serviceId?/characteristicId?/value?: ArrayBuffer |
not assignable to string index type 'Object' |
同上连带 | 同上 |
Property 'advData' does not exist on type 'ScanResult' |
字段名猜错了,原始广播叫 data |
advertisData: r.data |
Property 'createBLEDevice' does not exist on type 'typeof ble' |
工厂函数名记错 | ble.createGattClientDevice(deviceId) |
Use explicit types instead of "any"(arkts-no-any-unknown) |
上一条 API 不存在导致返回值被推成 any 的连带错 |
修真实 API 名后自动消失;入参判空用 undefined 守卫而非 as string |
Namespace 'ble' has no exported member 'ConnectionState' |
事件回调类型名不同 | (state: ble.BLEConnectionChangeState) => ... |
Property 'STATE_CONNECTED' does not exist on type 'typeof BluetoothState' |
把适配器开关枚举 当成了连接状态枚举 | state.state === constant.ProfileConnectionState.STATE_CONNECTED |
三条方法论:
- 签名一律查本机
.d.ts,不要凭印象写 。位置:<DevEco>/sdk/default/openharmony/ets/api/@ohos.bluetooth.*.d.ts,桶文件ets/kits/@kit.ConnectivityKit.d.ts。 any报错往往是上游 API 名写错的连锁反应,先修最上层那个「成员不存在」。.uts文件通常没有本地 lint 支持 ,语法合法性只能靠assembleHar暴露------所以第一步就要把编译链跑起来。
顺带一个 ArkTS 闭包限制:device.off('BLEConnectionStateChange', listener) 里引用 listener 自身会撞上自引用问题,而 off(type)(不带 callback)会移除该事件全部监听------只要该事件在进程内的唯一注册方就是插件自己,用无 callback 形式反而更干净。
6. 前端函数怎么变成原生函数:四处登记 + 两种形态
UTS 插件不是写完 .uts 就能被 uni. 调到的。离线(不经 HBuilderX 云打包)需要四处协同:
1) 把插件编成 har → <原生工程>/libs/uni_modules__harmony_ble.har
2) 声明依赖 → entry/oh-package.json5
"@uni_modules/harmony-ble": "../libs/uni_modules__harmony_ble.har"
3) 注册挂载 → entry/src/main/ets/uni_modules/index.generated.ets
import { openAdapter, ... } from '@uni_modules/harmony-ble'
Reflect.set(uni, 'hbOpenAdapter', openAdapter) // ×14
4) 构建期登记 → standalone-ext-apis.json
{ "type": "extapi", "plugin": "harmony-ble", "apis": [14 个 hbXxx], "version": "..." }
两个必须记住的点:
index.generated.ets在很多工程里是手工维护的超集 (已注册 verify/push/payment/share...)。publish生成的版本只含本次参与编译的插件,整份覆盖会静默丢掉其它插件,务必逐条追加。- 第 3 步有两种形态,取决于前端怎么消费:
- 扁平挂载 :
Reflect.set(uni, 'hbOpenAdapter', fn),前端uni.hbOpenAdapter(...)纯属性访问。ArkTS 友好(规避匿名对象字面量限制),且插件缺失时前端能探测到undefined。 - 插件注册 :
uni.registerUTSPlugin('uni_modules/harmony-ble', ns),配合前端import from '@/uni_modules/harmony-ble'。dev 工程走的就是这条,但它与我们离线主工程的运行模型不同------dev 跑通不等于你的调用能通。
- 扁平挂载 :
我们的 api 数量一致性 靠一个约束兜住:standalone-ext-apis.json 的条目数必须等于 Reflect.set 的行数(当前各 14)。新增 API 时四处同改,漏一处就是「编译过、运行 10002」。
7. ArrayBuffer 跨桥:byteLength 会丢
ArkTS 的 ArrayBuffer 直接抛给 JS,到对面常变成 {}(byteLength 丢失)。方案是跨桥只走 Array<number>:
ts
// 原生侧:ArrayBuffer → Array<number>
function ab2arrForJs(ab: ArrayBuffer): Array<number> {
const u8 = new Uint8Array(ab);
const out: Array<number> = [];
for (let k = 0; k < u8.length; k++) out.push(u8[k]);
return out;
}
// JS 侧垫片:Array<number> → ArrayBuffer,并做语义对齐
function normalizeIncomingValue(v: any): ArrayBuffer | null {
if (v instanceof ArrayBuffer) return v;
if (Array.isArray(v)) { /* 逐字节 & 0xff 重建 */ }
// TypedArray/DataView 兜底:v 只是底层 buffer 上的一个窗口视图,
// 直接返回 v.buffer 会把整段内存写出去(长度、内容全错),必须按视图范围切片
if (typeof v.byteLength === 'number' && v.buffer != null) {
return v.buffer.slice(v.byteOffset, v.byteOffset + v.byteLength);
}
return null;
}
最后一个分支是最容易漏的:视图不等于其底层 buffer。这个 bug 的症状是「写指令偶发多带几个尾字节」,极难归因。
8. 产物一致性红线:同一段逻辑的 4 份副本
这一节没有技术难度,但它让我们白花了两轮真机验证。同一段插件逻辑在机器上同时存在 4 份:
① src/uni_modules/harmony-ble/utssdk/app-harmony/index.uts ← 你在这里改代码
② dist/dev/app-harmony/.../app-harmony/index.ets ← har 的真实编译源
③ <原生工程>/libs/uni_modules__harmony_ble.har ← 打进包的东西
④ 设备上的 www / hap ← 实际运行的东西
② 是 UTS→ArkTS 转译 + 手工修正版 (对象字面量转 UTSJSONObject、Map 取值转 UTS.mapGet 等)。只改 ①,har 里永远是旧逻辑;反向地,IDE 的「运行到鸿蒙」会用 ① 重新转译覆盖 ②,覆盖后手工修正可能丢失。改动必须在 ①② 双侧按语义同步。
还有 www 双源陷阱 :GUI 出包和 CLI 出 appResource 分别落到两个不同目录,而带 /MIR 的同步脚本以其中一个为源------跑错方向会把新包倒回旧包。我们的做法是给每条产物链写死一个专用脚本,不允许手工组合命令。
校验压缩产物不能靠文本搜索 :.har 是压缩归档,在 har 文件上 grep/findstr 关键标识符必然零命中------零命中只代表「没解压」,不代表「没这个逻辑」。
powershell
tar -xzf uni_modules__harmony_ble.har -C tmp_verify
# 在解出的 modules.abc(字节码)里做字符串计数
前端 JS 产物同理:压缩会重命名变量,要按语义指纹判断(字符串常量、调用形态),而不是变量名。
通用教训:对
har/jar/aar/zip这类压缩产物做「内容是否包含我的改动」的验证,必须先解包再检字节码。
9. 三个真实缺陷:从「能跑」到「敢用」
链路通了以后,冒出来三个随机故障。它们的共性是:包装原生事件的语义层次搞错了。
9.1 幽灵失败:连接成功了,却弹「连接失败」
最初的实现把本次调用的 success 和 fail 一起闭包进连接状态监听:
ts
// ❌ 初版
device.on('BLEConnectionStateChange', (state) => {
if (state.state === CONNECTED) options.success(...);
else if (state.state === DISCONNECTED) options.fail(...); // 监听从不解除
});
连接建立后,设备测完自动关机 、或上层主动 disconnect(),都会再派生一次 DISCONNECTED ------ 于是触发了当初那次已经成功返回的调用的 fail。上层复位连接态、弹出「连接失败」。症状随机、难复现,且日志里「success 在前、fail 在后」。
修复模式是单次终态语义:
ts
// ✅ 只认首个终态,到终态立即解注
let settled = false;
const listener = (state: ble.BLEConnectionChangeState) => {
if (settled) return;
const connected = state.state === constant.ProfileConnectionState.STATE_CONNECTED;
const disconnected = state.state === constant.ProfileConnectionState.STATE_DISCONNECTED;
if (!connected && !disconnected) return; // CONNECTING 等中间态只记日志
settled = true;
clearConnTimer(deviceId);
device.off('BLEConnectionStateChange'); // 无 callback 形式,移除该事件全部监听
if (connected) {
options.success?.({ errMsg: 'createBLEConnection:ok' });
} else {
gattDevices.delete(deviceId); // 建连以断连收尾 → 回收实例,重连走全新流程
charListenerDevices.delete(deviceId);
clearWriteState(deviceId);
try { device.close(); } catch (e) { /* ignore */ }
options.fail?.({ errMsg: 'createBLEConnection:fail disconnected', errCode: 10003 });
}
};
同时:closeConnection 里先解注再 disconnect() ;同 deviceId 重复建连先解注并 close() 旧 GattClient(否则 GATT 连接泄漏)。
可迁移的规则 :不要把请求级的 success/fail 回调挂在会多次触发的长生命周期设备事件上------后续合法事件会反噬已完成的调用。凡是把「连接类原生事件 → Promise 风格回调」做封装的垫片/插件(蓝牙、串口、socket)都适用。反过来,本身就是持续订阅语义的状态回调 API,应当保留监听,但必须成对解注。
顺带一个日志判读坑:constant.ProfileConnectionState 是 DISCONNECTED=0 / CONNECTING=1 / CONNECTED=2 。只打原始枚举值时,state=1 极易被读成「已连接」,我们一度因此把方向找反。
9.2 监听注册不成对:每条广播/通知被派发 K 次
onDeviceFound / notifyCharacteristic 每次 JS 调用都用新闭包 去注册原生 on,而 JS 侧回调是单槽覆盖。ArkTS 里 on 传入不同函数引用不具幂等性 ,于是每条广播、每条通知被派发多次------除了日志洪水,还有重复上报测量值的业务风险。
修复用两类守卫:
ts
// 扫描监听:模块级布尔,原生 on 只注册一次;off 时复位
let scanListenerRegistered = false;
export function onDeviceFound(callback: (res: any) => void) {
deviceFoundCb = callback; // 单槽派发
if (scanListenerRegistered) return;
scanListenerRegistered = true;
ble.on('BLEDeviceFind', (results) => { deviceFoundCb?.({ devices: /* 归一 */ }); });
}
// 特征监听:deviceId → boolean,同设备多特征(notify + indicate)共用一个原生监听
const charListenerDevices = new Map<string, boolean>();
// off 系列必须做真正的原生 off,不能只把回调置 null
export function offDeviceFound() {
deviceFoundCb = null;
scanListenerRegistered = false;
try { ble.off('BLEDeviceFind'); } catch (e) { /* ignore */ }
}
offCharacteristicChange 要遍历已注册设备逐个 device.off 后 clear()------只清 JS 侧引用是最常见的假解除。
9.3 并发写被拒:The operation is busy
厂商 SDK 在订阅成功后会在几毫秒内并发发起两次 writeBLECharacteristicValue(握手命令 + 设置时间),还带 1200ms 自重发。鸿蒙 GATT 同一时刻只允许一个 pending 写,并发直接被原生拒为 The operation is busy。
解法是在插件层加按 deviceId 的串行写队列,并把「订阅用的 CCCD 写」也纳入同一条队列(同一条 GATT 写通道):
ts
type BleWriteTask = (next: () => void) => void;
const writeQueues = new Map<string, Array<BleWriteTask>>();
const writeBusy = new Map<string, boolean>();
const WRITE_TIMEOUT_MS = 5000;
function flushWrite(deviceId: string) {
if (writeBusy.get(deviceId) === true) return;
const q = writeQueues.get(deviceId);
if (q == null || q.length === 0) return;
const task = q[0]; q.shift();
writeBusy.set(deviceId, true);
task(() => { writeBusy.set(deviceId, false); flushWrite(deviceId); }); // 结算后放行下一个
}
function enqueueWrite(deviceId: string, task: BleWriteTask) {
let q = writeQueues.get(deviceId);
if (q == null) { q = []; writeQueues.set(deviceId, q); }
q.push(task);
flushWrite(deviceId);
}
两个细节:
- 单次写要有超时。原生 Promise 若不结算,会永久占住该设备的队列。
- 超时分支里
next()要放在finally。上层fail回调抛异常也必须放行队列,否则该设备后续写任务永久排队。
同时给 createConnection 加上对入参 timeout 的尊重(SDK 传 20000,缺省不限时):
ts
const timeoutMs: number = options.timeout == null ? 0 : options.timeout as number;
if (timeoutMs > 0) {
const timer = setTimeout(() => {
if (settled) return;
settled = true;
connTimers.delete(deviceId);
try { device.off('BLEConnectionStateChange'); } catch (e) { /* ignore */ }
gattDevices.delete(deviceId); charListenerDevices.delete(deviceId); clearWriteState(deviceId);
try { device.disconnect(); device.close(); } catch (e) { /* ignore */ }
options.fail?.({ errMsg: `createBLEConnection:fail timeout ${timeoutMs}ms`, errCode: 10003 });
}, timeoutMs);
connTimers.set(deviceId, timer); // 终态 / closeConnection / 重复建连 三处都要 clearConnTimer
}
connTimers 的三处清理不是防御性冗余------计时器回头触发 fail,就是 9.1 那个幽灵失败的第二个入口。
修复后的真机回归:The operation is busy 0 次、connect fail 0 次、迟到回调 0 条、通知无重复派发。
10. deviceId 是易腐数据
这是最隐蔽的一个坑,因为症状与「设备没开机」完全相同。
鸿蒙侧 ble.createGattClientDevice(deviceId) 用的 deviceId 是扫描期派生的广播地址,设备重启或地址轮换后,存的旧值立刻失效。而重连代码往往是这样的:
ts
// ❌ 只 openAdapter + 取本地存储的 deviceId 直连,从不扫描
const ok = await connectDevice(storedDevice);
失效后 GATT 一直停在 CONNECTING,直到系统 ~30s 超时才回 createBLEConnection:fail disconnected(实测 4 次都在 30.05~30.14s)。如果失败提示又被注释掉了,用户看到的就是「点了没反应」。Android 同一路径可用,因为系统持有 bond/地址缓存------这是平台差异,不是代码差异。
我们在一台设备上一天之内观察到 4 种地址形态:
| 形态 | 类型 |
|---|---|
FE:79:xx:xx:xx:29 |
随机静态地址 |
0C:35:xx:xx:xx:FC |
public 地址 |
5E:F1:xx:xx:xx:20 |
可解析私有地址(RPA) |
02:1E:xx:xx:xx:5B |
又一种轮换值 |
结论:绝不能假设持久化的 deviceId 长期可用。
修复三件套(前端侧,不动 har):
ts
const HARMONY_CONNECT_WATCHDOG_MS = 12000; // 不等系统 30s,主动收敛
const HARMONY_REFRESH_SCAN_MS = 10000;
// 1) 看门狗:超时即 closeBLEConnection 并 resolve(false),配 settled 忽略迟到回调
const watchdog = setTimeout(() => {
if (settled) return;
settled = true;
bleApi.closeBLEConnection({ deviceId: device.deviceId });
resolve(false);
}, HARMONY_CONNECT_WATCHDOG_MS);
// 2) 直连失败后扫一轮拿新鲜地址,再重连一次;成功后由 saveBoundDevice 写回存储
const fresh = await scanForFreshDevice((name) => prefixInName(deviceType.bleNamePrefix, name));
if (fresh && fresh.deviceId !== boundDevice.deviceId) {
return connectDevice({ ...device, deviceId: fresh.deviceId }, deviceType);
}
// 3) 页面 onShow 的自动重连走 silent 分支:不扫描、不打扰用户
另外两条经验:
- 失败提示必须可见 ,但只在页面层汇总弹一次 ------按
productCode循环连接时,composable 内弹 toast 会连着弹好几条。 - 判「失败原因是不是地址过期」要看扫描结果:如果扫到的
deviceId与绑定记录一致,说明失败另有原因,不要盲目重试。
实测时序:看门狗 11.8s 触发 → 扫描 384ms 命中新地址 → 建连 state=2 仅 0.4~1s → SDK connect success,新地址写回存储,后续 7 次建连都直接用新地址且成功。设备真的关机时,扫描 10s 超时后回「未扫描到设备」并只提示一次。
11. 权限:声明 + 主动申请,两件事
原生 module.json5 常驻三条:
| 权限 | 类型 | 备注 |
|---|---|---|
ohos.permission.USE_BLUETOOTH |
system_grant | 声明即可 |
ohos.permission.DISCOVER_BLUETOOTH |
system_grant | 声明即可 |
ohos.permission.ACCESS_BLUETOOTH |
user_grant | 需 reason + usedScene,且必须运行时申请 |
uni.openBluetoothAdapter 在鸿蒙不会帮你弹授权框,所以插件在 openAdapter 前主动申请:
ts
export function requestBluetoothPermission(success: (granted: boolean) => void) {
const atManager = abilityAccessCtrl.createAtManager();
const perms: Array<Permissions> = ['ohos.permission.ACCESS_BLUETOOTH'];
atManager.requestPermissionsFromUser(getContext(), perms)
.then((r) => success(r.authResults.length > 0 && r.authResults[0] === 0))
.catch(() => success(false));
}
getContext() 取当前窗口的宿主 context:
ts
function getContext(): common.UIAbilityContext {
return UTSHarmony.getCurrentWindow()!.getUIContext().getHostContext() as common.UIAbilityContext;
}
顺带一条构建基线坑:har 在哪个 API 版本的工程里编出来,就带着那个最低版本要求。如果主工程 compatibleSdkVersion 低于它,assembleHap 会在 MergeProfile 阶段报 00306004 Specification Limit Violation。要么统一升主工程基线(需同时核对 compatibleSdkVersionStage 与签名证书),要么把插件降到目标 API 重编------选一个,别混用。
12. 真机日志:tag、编码与丢日志
powershell
hdc tconn <设备IP:端口> # 无线连接,掉线需重连并授权
hdc shell hilog -T JSAPP # 前端 console.log 落在 JSAPP tag
hdc shell hilog -Q pidoff # 需要时关闭流控
三个会浪费你半天的细节:
- tag 不是
chromium也不是EntryAbility,是JSAPP。按应用自己的日志前缀 grep 之前,先按JSAPP过滤------之前「一条前端日志都搜不到」就是没过滤 + 缓冲区轮转。 - PowerShell
*>重定向会二次编码 (UTF-8 被按 GBK 读走再写成 UTF-8)。检索中文要先UTF8 读 → GetEncoding(936).GetBytes → UTF8.GetString还原;文件被采集进程占用时用FileStream(..., 'ReadWrite')共享读。计数以 ASCII 关键字为准,全角标点还原可能丢字节。 - hilog 有 rate limit ,高频应用日志会被整段丢弃 。别把「日志里没有」当成「没发生」;
Start-Process -RedirectStandardOutput起的采集子进程也可能被随父进程回收,留下一个 0 字节文件。
13. 影响面与回滚
对非鸿蒙端零影响 :bleApi = uni 分支使 Android / iOS / H5 / 小程序行为与改动前完全一致。
要临时停用鸿蒙接线,只需把 #ifdef APP-HARMONY 里的 bleApi = harmonyBle 改回 bleApi = uni(回到「API 不支持、卡初始化」的旧行为)。
| 回滚范围 | 操作 | 注意 |
|---|---|---|
| 前端 | bleApi 一行 |
热更新(只换 www)即可生效 |
| 插件 | 回退旧 har → 删 oh_modules 与两个 oh-package-lock.json5 → ohpm install --all → assembleHap |
har 与 standalone-ext-apis.json、index.generated.ets 必须同版本回退 |
| 整包 | 重装 hap | 覆盖安装保留登录态与设备绑定记录;hdc uninstall 会丢,排期时留意 |
还有一条:WGT/热更新只能承载 www,不能承载新增 UTS 插件。插件层改动必须出整包。
14. 上真机前的自查清单
- ①②③④ 四份产物都是最新(尤其别只改了
.uts就重编 har) - har 内容用「解包 + 字节码字符串计数」验证过,不是在 har 上直接 grep
-
standalone-ext-apis.json条目数 ==index.generated.ets里Reflect.set行数 - 所有连接类长事件都有
settled+ 终态即off -
on系列有注册守卫,off系列做真正的原生off - 写通道按设备串行,且单次写有超时、超时分支放行队列
-
deviceId直连失败后有「重扫刷新地址」兜底,且失败提示可见、只弹一次 - 首帧就确认插件挂载状态(未挂载时给可读
fail,不要靠超时) - 判读日志时按
DISCONNECTED=0 / CONNECTING=1 / CONNECTED=2核对状态值
15. 小结
这件事真正难的部分不是「找到鸿蒙不支持 uni 蓝牙 API」------查表五分钟就有结论。难的是后面三步:
- 让补齐能力这件事不影响存量业务:同名同签名垫片 + 注入点替换,协议逻辑零移植。
- 让改动真的进包:四份副本的一致性,以及「压缩产物不能文本验证」这类元规则。
- 让链路稳到敢交给用户:单次终态语义、成对解注、写队列、地址刷新兜底------四个修复都不复杂,但它们的缺失表现为随机故障,而随机故障在真机排障里成本最高。
第二篇会写这台设备本身:厂商 SDK 的鉴权为什么只回一句「鉴权失败」、鸿蒙的广播数据为什么要手动解析 AD 结构、从一包一包的过程数据到表单里的血压与脉搏三个数字。
附:本文脱敏说明(发布前删除本节)
| 真实值 | 文中处理 |
|---|---|
真实插件名与其 uni. 挂载前缀、har 文件名 |
统一换为中性代称 harmony-ble / hbXxx / uni_modules__harmony_ble.har(正文已无真实标识) |
前端工程名、原生工程名、包名 com.*、DCloud AppID、bundleName |
删除或改 <原生工程> / <DevEco> / <设备IP:端口> 等占位符 |
厂商 SDK 包名、设备型号、appKey / appIdentifier 与其后台错误码 |
本篇只写「厂商 SDK(纯 JS)」;厂商相关信息留到第二篇,且不出现真实值 |
| 真机完整 MAC / deviceId 四形态 | 中间字节以 xx 掩码,只保留首尾用于区分地址类型 |
证书绝对路径、keyPassword / storePassword |
未引用 ,也请勿从 src/manifest.json 复制到任何对外文稿 |
仓库内 scripts/ 下的具体构建脚本名 |
未点名,改为「给每条产物链写死专用脚本」 |
具体 @dcloudio 版本号、UniAppRuntime 版本、实测读数 |
未出现(读数属第二篇,且需注明为自测样本) |
发布前建议再全文检索一遍:AppID、包名、hdc tconn 后的 IP、C:\Users\<你> 路径、公司域名。