鸿蒙开发干货 —— 用 Wear Engine P2P 打通手机与手表,手把手实现两步验证令牌的安全同步。

适用版本:HarmonyOS 5.0.0+(本文工程在手机 HarmonyOS 7.0(API 26)+ HUAWEI WATCH 真机上联调通过)。

一、从一个尴尬场景说起

最近给我们的两步验证应用「2FA 令牌箱」开发新功能时,遇到一个高频真实场景:用户在电脑前登录账号,手机正在充电、或者在另一个房间,登录页却等着输 6 位动态验证码——掏手机、解锁、找到令牌、抄码,一套流程下来验证码可能已经过期刷新了。

而验证码这个需求,天然适合手表:抬腕、看码、输入,三秒结束。手表就在手腕上,为什么不把验证码直接放到表盘上?

说干就干。但要往手表上同步令牌,第一个问题就来了:2FA 令牌的密钥(secret)是高度敏感数据,同步通道必须可靠且安全;令牌数据(尤其带备注、多令牌的场景)还可能超过单条消息的传输上限。这篇文章就把我们手机↔手表同步的完整实现拆给大家:Wear Engine P2P 通道 + 自研轻量分片协议 + 端侧加密落盘,附全套踩坑记录。

二、认识 Wear Engine P2P

Wear Engine 是华为提供的手机与穿戴设备间通信 Kit,其中 P2P 消息通道(P2pClient)支持应用与对端设备上的同名应用双向收发自定义消息——这正是「手机下发令牌、手表回执确认」需要的能力。

它有几个必须提前知道的约束,每一条都直接决定架构

约束说明
权限前置需在 AGC 申请并通过「设备基础信息 / 拉起对端应用」权限,module.json5 配置 client_id
应用配对收发双方需指定对端包名 + 证书指纹,指纹不匹配消息不可达
双端在线手机登录华为账号、通过运动健康与手表连接;收发瞬间双端应用都处于启动状态
消息上限单条消息不超过 4096 字节(这是后文自研分片协议的直接原因)
不支持模拟器联调必须真机 + 真表

能力判断用系统能力查询即可,不支持的手表机型上功能入口直接隐藏:

static isSupported(): boolean {
  return canIUse('SystemCapability.Health.WearEngine');
}

三、整体架构:一条通道,两侧对称

整体架构如图:手机端是发送侧(全量下发 + 等待回执),手表端是接收侧(重组 + 落盘 + 回执),两端通过 Wear Engine P2P 通道通信。因为手机端与手表端 App 同包名、同签名证书,P2P 配对指纹一致,不需要额外绑定流程。

整体架构

两端各只有两个核心类,职责清晰:

  • 手机端WatchSyncManager(同步编排:开关管理、监听令牌变更防抖自动同步、按 seq 等待 ACK)+ WatchSyncProtocol(协议:分片与信封);
  • 手表端WearSyncReceiver(接收、重组、分发、回执)+ WatchTokenStore(加密落盘)。

手表端令牌是手机端 TokenConfig精简只读投影:只保留展示与计算验证码必需的字段(uuid、类型、密钥、发行方、算法、位数、周期、排序分值等),图标、备注、Steam maFile 这些与表盘展示无关的数据一概不同步——数据最小化,既是安全考虑,也直接压低了传输体积。

四、自研轻量分片协议

Wear Engine 单条消息上限 4096 字节,而令牌数量一多,一个全量 payload 很容易超。所以协议层要解决的第一件事就是分片:业务 JSON 按字节切片,每片套一个信封发送,接收端按序拼回。

分片协议

信封结构六个字段,一个都不能少:

{
  "v": 1,            // 协议版本,不符直接拒收并回执
  "type": "full",    // hello / full / upsert / delete / clear / ack
  "seq": 1024,       // 逻辑序号:一次逻辑消息唯一,分片共享,ACK 靠它配对
  "part": 0,         // 分片序号
  "total": 3,        // 分片总数,total ≤ 1 可免重组直收
  "payload": "……"   // 业务 JSON 的一个字节切片
}

踩坑点 ①:UTF-8 切片不能拆开代理对。 令牌的发行方、账户名完全可能有 emoji(很多服务的默认账户名就带 😀)。JavaScript 字符串是 UTF-16 码元序列,一个 emoji 占两个码元,如果按码元数硬切,切口落在代理对中间,接收端解码直接得到乱码。所以切片按逐码元估算 UTF-8 字节数累加,遇到代理对按 4 字节由高位码元一并计入、低位码元按 0 计,保证切口永远落在完整字符边界上:

/** 估算单个 UTF-16 码元在 UTF-8 下的字节数(代理对按 4 字节由高位一并计) */
function utf8Bytes(code: number): number {
  if (code < 0x80) return 1;
  if (code < 0x800) return 2;
  if (code >= 0xD800 && code <= 0xDBFF) return 4;  // 高位代理
  if (code >= 0xDC00 && code <= 0xDFFF) return 0;  // 低位代理:字节已由高位计入
  return 3;
}

踩坑点 ②:重组器必须有超时驱逐。 手表端按 seq 归堆分片、按 part 顺序拼接,但如果某一片永远没来(蓝牙断开、应用被杀),这堆半截消息就该整体丢弃。我们给 SyncAssembler 设了 30 秒未收齐自动驱逐,避免脏数据常驻内存。

设计取舍:全量而不是增量。 协议里预留了 upsert / delete 增量消息,但当前主力是 full 全量下发:令牌数据量小,全量语义天然幂等——失败重发、断线重连都不存在「同步到一半」的中间态。手机端监听令牌变更后防抖 1.5 秒再触发全量,批量导入几十个令牌也只发一次。

五、手机端:从查设备到等回执

一次全量同步的完整时序如图:

同步时序

1. 查设备、查安装

const client = wearEngine.getDeviceClient(context);
const devices = await client.getConnectedDevices();
// 按 category 过滤,只保留手表
for (const d of devices) {
  if (d.category !== wearEngine.DeviceCategory.WATCH) continue;
  // 逐台检查手表端应用是否安装
  const installed = await p2p.isRemoteAppInstalled(d.randomId, WATCH_BUNDLE_NAME);
}

2. 拉起对端应用,分片发送,等待回执

手表端应用可能没在前台运行,发送前先拉起。注意 startRemoteApp已在运行的应用会返回 REMOTE_APP_RUNNING——这不是失败,别把它当错误码处理(踩坑点 ③):

const startResult = await p2p.startRemoteApp(device.randomId, WATCH_BUNDLE_NAME);
if (startResult.code !== undefined &&
    startResult.code !== wearEngine.P2pResultCode.REMOTE_APP_RUNNING &&
    startResult.code !== wearEngine.P2pResultCode.COMMUNICATION_SUCCESS) {
  // 真正的拉起失败才告警
}

// 先登记 ACK 等待(10s 超时),再逐片发送
const ackPromise = this.waitAck(seq);
const envelopes = SyncChunker.build(SyncMsgType.FULL, seq, JSON.stringify(payload));
for (const env of envelopes) {
  const msg: wearEngine.P2pMessage = { content: encoder.encodeInto(JSON.stringify(env)) };
  const result = await p2p.sendMessage(device.randomId, appParam, msg);
  // 非 COMMUNICATION_SUCCESS 立即取消该 seq 的等待并抛错
}
return ackPromise;

ACK 用 seq 配对:发送前把 (seq → resolver) 登记进 pendingAcks,手表回执里带同一个 seq,匹配上就 resolve。10 秒超时未收到回执判定失败,用户可手动重试。

3. 错误码本地化

Wear Engine 的错误码对用户毫无意义,发端统一映射成短码,页面再转成可读文案:

错误码短码含义
1008500002no_device没有已连接的手表
1008500003device_disconnected设备已断开
1008500004service_not_appliedWear Engine 服务未申请/未审核通过
1008500006privacy_not_agreed用户未同意隐私协议
1008500008 / 9account_error华为账号异常
1008500007unsupported设备不支持

六、手表端:接收、重组、安全落盘

手表端在 Ability 启动时查询对端设备并逐台注册接收器;收到消息后先校验协议版本,再交给重组器,完整消息按 type 分发处理,最后必须回 ACK——不回,手机端只会等到超时:

private async dispatch(msg: SyncCompleteMessage): Promise<void> {
  const store = WatchTokenStore.getInstance();
  switch (msg.type) {
    case SyncMsgType.FULL: {
      const p = JSON.parse(msg.payload) as SyncFullPayload;
      await store.replaceAll(p.tokens, p.syncedAt);   // 整表替换
      break;
    }
    case SyncMsgType.CLEAR:
      await store.clear();
      break;
    // upsert / delete / hello ……
  }
  this.sendAck(msg.seq, msg.type, true);              // 回执带最新令牌数量
}

安全上我们做了三层(篇幅所限讲思路,不展开实现):

  1. 传输层走 Wear Engine 系统级加密链路,不自定义传输加密;
  2. 存储层:手表端快照 JSON 经 AES 加密后才写入 Preferences,加密密钥随机生成、存放在关键资产 Asset 中,不落盘明文;
  3. 数据层:如前所述只同步精简只读投影,手表仅做展示,不含任何与展示无关的字段。

七、防踩坑清单(联调实录)

最后把真机联调踩过的坑汇总成清单,按检查顺序排列:

  1. AGC 权限先行:「设备基础信息 / 拉起对端应用」要申请并审核通过,client_id 配进 module.json5——没通过时典型报错就是 1008500004(service_not_applied)。

  2. 指纹配置:P2P 配对要求对端证书指纹。正式指纹 = AGC「证书、APP ID 和 Profile」里的 APP ID;调试期可以用 hdc shell bm dump -n <包名> | grep appIdentifier 取。

  3. 双端都要 registerMessageReceiver:手机端要收 ACK,同样要注册接收器;注册时的对端指纹不匹配,消息静默不可达,没有报错——这是最难查的一类问题。

  4. 双端在线约束:手机登录华为账号 + 运动健康连接手表 + 收发瞬间双端应用处于启动状态,三者缺一不可;拉起对端应用(startRemoteApp)就是为了满足第二条。下图为手表端应用没在前台时,手机端的真实提示——所以我们在设置页把这些状态全部显式暴露,而不是让用户对着一个转圈按钮猜:

    双端在线约束:手表端未打开时的未响应提示

  5. 模拟器不可用:Wear Engine 不支持模拟器,联调必须真机 + 真表。

  6. 多表场景逐台下发:用户可能同时连多块表,同步循环逐台发送、统计成功台数,全部失败才报错。

  7. 状态可见:同步开关、设备列表、每台安装状态、上次同步时间与同步条数(来自 ACK 的 count)全部展示在设置页——同步类功能最大的用户心智负担是「到底同步没有」,回执数据就是最好的安抚。

八、效果与总结

最终效果:手机端打开同步开关后,令牌自动全量同步到手表;之后手机端任何增删改(扫码添加、批量导入、删除令牌)都在防抖后自动同步。手表端只有一个页面,却承载了全部信息:令牌卡片列表(每张卡片自带验证码和周期倒计时圆环),底部常驻「上次同步时间」——用户抬腕即见最新验证码,扫一眼底部就知道数据新不新鲜。

手机端同步成功后的状态——设备列表、上次同步时间、手表上的令牌数一目了然:

同步成功:设备列表与上次同步状态

手表端实际效果——整个应用就是这一屏:标题 + 令牌卡片列表,每张卡片自带验证码与周期倒计时圆环,收藏的令牌带星标:

手表端令牌列表

单张卡片特写:发行方、账户、6 位验证码、圆环倒计时,一屏只做一件事:

单条卡片特写

总结一下这套方案的三个关键决策:

  1. 通道选 Wear Engine P2P:系统级加密、免配对(同包名同证书)、官方维护链路;
  2. 协议自研但极简:一个信封结构 + 分片重组 + seq 配对回执,200 行解决 4096 字节上限与可靠传输;
  3. 安全纵深:系统加密通道 + 端侧加密落盘(密钥入 Asset)+ 数据最小化投影,敏感数据不裸奔。

如果你的应用也有「往手表上放一点数据」的需求,照着这个思路就能落地。


交流与支持

这篇实战如果帮你省下了踩坑时间:

  • 👍 点赞——让更多鸿蒙开发者刷到这篇;
  • 收藏——接入 Wear Engine 时回来照着第七节的清单逐项排查;
  • 👀 关注——我正在持续输出鸿蒙开发实战系列:多设备协同、性能优化、上架避坑……每篇都来自真实上架项目,下篇见。

实现中遇到问题欢迎评论区讨论,看到都会回。


关于 2FA 令牌箱:鸿蒙原生两步验证管家 + 账号密码库,支持 TOTP / HOTP / Steam 令牌,数据端侧加密、桌面卡片查看验证码,手机手表双端可用。欢迎在华为应用市场搜索体验。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐