uni-app 跑上鸿蒙,发现蓝牙全瞎:我把 BLE 补齐的实战(一)基建篇

面向:要在 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

三条方法论:

  1. 签名一律查本机 .d.ts,不要凭印象写 。位置:<DevEco>/sdk/default/openharmony/ets/api/@ohos.bluetooth.*.d.ts,桶文件 ets/kits/@kit.ConnectivityKit.d.ts。
  2. any 报错往往是上游 API 名写错的连锁反应,先修最上层那个「成员不存在」。
  3. .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         # 需要时关闭流控

三个会浪费你半天的细节:

  1. tag 不是 chromium 也不是 EntryAbility ,是 JSAPP。按应用自己的日志前缀 grep 之前,先按 JSAPP 过滤------之前「一条前端日志都搜不到」就是没过滤 + 缓冲区轮转。
  2. PowerShell *> 重定向会二次编码 (UTF-8 被按 GBK 读走再写成 UTF-8)。检索中文要先 UTF8 读 → GetEncoding(936).GetBytes → UTF8.GetString 还原;文件被采集进程占用时用 FileStream(..., 'ReadWrite') 共享读。计数以 ASCII 关键字为准,全角标点还原可能丢字节。
  3. 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」------查表五分钟就有结论。难的是后面三步:

  1. 让补齐能力这件事不影响存量业务:同名同签名垫片 + 注入点替换,协议逻辑零移植。
  2. 让改动真的进包:四份副本的一致性,以及「压缩产物不能文本验证」这类元规则。
  3. 让链路稳到敢交给用户:单次终态语义、成对解注、写队列、地址刷新兜底------四个修复都不复杂,但它们的缺失表现为随机故障,而随机故障在真机排障里成本最高。

第二篇会写这台设备本身:厂商 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\<你> 路径、公司域名。

相关推荐
软件交付观察室6 小时前
旧设备要换平台,找谁开发?2026上海物联网软件服务方式比较
物联网·软件开发·系统集成
microrain7 小时前
SagooIoT 的数据接入层只有四张表,却能接住三种外部数据
物联网·golang·数据建模·sagooiot
sun153690275727 小时前
基坑边坡监测:振弦式土压力计选型、安装埋设与数据采集方案
大数据·物联网·自动化
计算机毕设定制辅导-无忧学长9 小时前
《基于微信小程序健康习惯激励系统设计与实现》
java·spring boot·mysql·微信小程序·uniapp·健康习惯激励系统
TDengine (老段)10 小时前
TDengine IDMP 十大实用功能(7)
数据库·物联网·时序数据库·tdengine·涛思数据·工业数据·idmp
yusur10 小时前
万物智联 中科驭数受邀参加2026世界物联网博览会
物联网
CServer_0112 小时前
开源!汽车零部件仓储的数字孪生协同方案
物联网·开源·汽车·制造
爱聊灵光的维C1 天前
从看见世界到重构认知:在视觉与硬件的交汇处,寻找我们这代人的北极星
c语言·物联网·计算机视觉·重构·硬件工程·射频工程
乐橙开放平台1 天前
跨地域监控难管理?通过 GB28181 把各地海康大华乐橙摄像头注册到云端平台
开发语言·笔记·物联网·自动化·php·音视频·智能家居