在这里插入图片描述

摘要

本文围绕「手机碰一碰智慧屏遥控器 → 大屏弹出闪控窗 → 手机变遥控面板」这一场景,拆解从近场握手到持续远程控制的完整链路。核心结论是:碰一碰只解决「我是谁、连哪台」,真正撑起持续控制的是分布式软总线会话。文章给出三段式时序(碰一碰回调 → Want 拉起 FloatControlAbility → 建立 Session 并双向传令),并针对真机上息屏、切后台、路由抖动导致的断连,提供「Promise 重建锁 + 指令队列」的幂等保活方案;进一步定义 ControlCommand 指令协议(type/seq/payload/hmac),用单调递增 seq 实现去重、保序与防重放,用会话密钥 HMAC-SHA256 挡住同网嗅探与伪造。文末附四种控制入口方案横向对比与可直接抄的实战清单,适合智能家居、车机、大屏遥控等近场持续控制场景的开发者参考。

目录

一、背景与痛点

上篇讲了碰一碰的近场分享与拖拽投送,但碰一碰的想象空间不止"传文件"——我做了一个场景:手机碰一下智慧屏遥控器,触发大屏弹出闪控窗,手机变成"遥控面板"远程调节音量/切源。这里真正的难点是近场握手后的远程控制通道怎么建得稳、断得优雅。

先说清楚为什么"碰一碰"值得被拿来做控制入口。传统遥控的痛点是"找遥控器"和"对准红外窗口",而手机永远在手边、永远有电、永远联网。碰一碰把"配对"这个最反人类的步骤压缩成一次物理接触:不需要扫码、不需要输配对码、不需要在列表里翻找设备名。用户的心智模型非常朴素——碰一下,就等于"我要控制这台"。

但真正落地时会发现,碰一碰只解决了"入口"这一瞬间的事。碰完之后,用户要连续调音量、切信号源、开关机,这是一条持续几分钟甚至几十分钟的交互链路。如果每次操作都要重新碰一下,体验立刻崩塌。所以问题的本质变成了:如何把一次性的近场握手,延展成一条稳定的、可双向通信的长连接。这也是本文要重点拆解的部分。
本文把"碰一碰建链 → 闪控窗拉起 → 远程指令双向同步"这条端到端链路完整拆开,给出可复用骨架。阅读顺序建议:先看第二节的能力规格,理解每个 Kit 各自负责哪一段;再看第三节的代码骨架,它把三段链路串成了一个最小可运行闭环;最后看第四节的踩坑,那些都是我在真机上反复撞墙才总结出来的。

二、环境与能力规格

维度规格
近场能力碰一碰(NFC/软总线 NearLink 混合),@kit.ConnectivityKit
远端拉起通过 Want 在目标设备启动闪控窗 Ability
控制通道分布式软总线(SoftBus)会话,两端基于 SessionId 通信
安全近场已做设备互信,指令通道需校验 SessionId
运行位置端侧,指令不出局域网

核心认知:碰一碰解决"我是谁、连哪台",软总线解决"连上后怎么稳定传指令",两者分工清晰。

这里再展开一点。碰一碰的产物其实只有两样东西:对端设备标识和一次性的互信凭证。它不负责传输业务数据,也不保证连接持续存在。很多人第一次做会误以为"碰上了就等于连上了",于是在回调里直接发指令,结果发现偶发失败——因为此时软总线会话还没建立。

正确的时序应该是三段式:碰一碰拿到 peerDeviceId → 用 Want 拉起对端 Ability 并携带身份信息 → 双方基于 SessionId 建立软总线会话 → 才开始传业务指令。把这三段拆开看,每一段都有独立的失败可能,也就有了独立的兜底策略。软总线本身提供了会话保活、断线通知、多通道复用等能力,正好补上碰一碰"只管一瞬间"的短板。

下面这张时序图把三段式链路和断线重连串在一起,方便对照代码骨架理解每一步的先后关系:

"软总线会话" "大屏端" "手机端" "用户" "软总线会话" "大屏端" "手机端" "用户" "息屏或网络抖动触发 sessionClosed" "碰一碰(NFC/NearLink)" "onTouchConfirm 拿到 peerDeviceId" "弹出「将控制 XX 设备?」确认" "确认" "Want 拉起 FloatControlAbility" "Ability 就绪,回传身份信息" "createSession(peerDeviceId, 'control')" "返回 sessionId" "sendData(cmd) 下发指令" "转发指令" "回传状态(音量/信号源)" "状态回显" "sessionClosed 通知" "自动重建会话(指令排队,幂等)" "新 sessionId,恢复双向传输"

图注:四个阶段各有兜底点。碰一碰回调阶段若对端标识为空或校验失败,直接终止并提示重碰,不进入拉起;Want 拉起阶段若目标 Ability 未响应(超时约 1s),回退为提示用户检查大屏是否在线,避免用户对着黑屏空等;建立 Session 阶段若 createSession 失败,按指数退避重试 2~3 次,仍失败则降级为「仅本地控制」并明确告知;双向传输与重连阶段依赖 sessionClosed 监听,重建期间指令入队而非并发建会话,保证幂等与顺序,重连成功后一次性补发,用户侧表现为短暂延迟而非操作丢失。

为了更直观地说明为什么选「碰一碰 + 软总线」,这里把常见的四种控制入口方案放在一起横向对比:

维度碰一碰 + 软总线扫码配对 + WebSocket红外遥控蓝牙 HID
配对成本极低,一次物理接触即完成,无需输入中,需扫码 + 首次授权,换设备要重扫极低,但需对准红外窗口、无配对概念中,需进入系统蓝牙列表手动配对
连接稳定性高,软总线自带保活、断线通知与自动重连中,依赖局域网与路由,切网/息屏易断低,单向无回执,遮挡即失效中高,但受限于 10 米内且易受 2.4G 干扰
延迟低,端到端 <80ms,局域网直连中,受服务端中转与网络抖动影响低,但无状态回传低,适合按键类小数据量
安全性中高,近场互信 + SessionId 校验,可叠加 HMAC中,依赖 TLS,公网暴露面较大低,无鉴权、易被学习复制中,配对后链路加密,但配对阶段可被劫持
适用场景智能家居、车机、大屏遥控等近场持续控制跨网远程控制、需服务端编排的场景老旧家电兼容、低成本单向控制键鼠、手柄等标准外设输入

选型结论: 若目标是「近场、低延迟、可持续双向控制」,优先选碰一碰 + 软总线;跨网或需服务端统一调度时用扫码 + WebSocket;红外与蓝牙 HID 更适合作为兼容性兜底,而非主控通道。

三、核心代码(可复用骨架)

import { softbus } from '@kit.ConnectivityKit';
import { Want } from '@kit.AbilityKit';

// 1) 碰一碰回调:拿到对端设备ID,拉起远端闪控窗
function onTouchConfirm(peerDeviceId: string) {
  const want: Want = {
    bundleName: 'com.example.tv',
    abilityName: 'FloatControlAbility',
    parameters: { from: 'phone', session: '' }
  };
  startRemoteAbility(want, peerDeviceId);
}

// 2) 建立软总线会话发指令
let sessionId = '';
async function sendCommand(cmd: object) {
  if (!sessionId) {
    sessionId = await softbus.createSession(peerDeviceId, 'control');
  }
  await softbus.sendData(sessionId, JSON.stringify(cmd));
}

// 3) 远端收到指令驱动 UI(闪控窗内)
softbus.on('data', (id: string, data: string) => {
  if (id === sessionId) driveControl(JSON.parse(data));
});

会话保活与幂等重建

上面的骨架只覆盖了"连上之后怎么发",但真机上息屏、切后台、路由器抖动都会让会话悄悄断掉。下面这段代码补上保活与重建:核心是一个重建中的 Promise 锁 + 一条指令队列,保证任何时刻最多只有一个 createSession 在飞,重建期间进来的指令排队等待,重连成功后按序补发。

import { softbus } from '@kit.ConnectivityKit';

let sessionId = '';
let peerDeviceId = '';
// 重建中的 Promise 锁:非空表示正在重建,后来的调用直接复用,避免并发建会话
let rebuilding: Promise<string> | null = null;
// 指令队列:重建期间先入队,重连成功后按 FIFO 顺序补发
const pendingQueue: string[] = [];

// 统一的"确保会话可用"入口:所有发指令都先过这里
async function ensureSession(): Promise<string> {
  if (sessionId) return sessionId;          // 会话健康,直接返回
  if (rebuilding) return rebuilding;        // 已在重建,复用同一个 Promise(幂等关键)
  rebuilding = (async () => {
    try {
      // 指数退避重试,最多 3 次,避免网络刚抖动就放弃
      for (let i = 0; i < 3; i++) {
        try {
          sessionId = await softbus.createSession(peerDeviceId, 'control');
          return sessionId;
        } catch (e) {
          await sleep(200 * Math.pow(2, i)); // 200ms / 400ms / 800ms
        }
      }
      throw new Error('createSession failed after retries');
    } finally {
      rebuilding = null;                    // 无论成败都释放锁,防止永久卡死
    }
  })();
  return rebuilding;
}

// 发指令:会话不可用时入队,可用时直接发
async function sendCommand(cmd: object) {
  const payload = JSON.stringify(cmd);
  if (!sessionId) {
    pendingQueue.push(payload);             // 重建期间排队,不并发建会话
    try {
      await ensureSession();                // 触发(或复用)一次重建
      await flushQueue();                   // 重连成功后补发
    } catch {
      // 重建彻底失败:保留队列,等下次用户操作再触发,避免指令丢失
    }
    return;
  }
  await softbus.sendData(sessionId, payload);
}

// 按序补发队列,发一条出一条,失败则中断保留剩余指令
async function flushQueue() {
  while (pendingQueue.length > 0 && sessionId) {
    const payload = pendingQueue[0];
    try {
      await softbus.sendData(sessionId, payload);
      pendingQueue.shift();                 // 成功才出队,保证不丢
    } catch {
      break;                                // 发送失败,剩余指令留待下次重连
    }
  }
}

// 监听断线:清空 sessionId 并立即触发一次重建
softbus.on('sessionClosed', (id: string) => {
  if (id !== sessionId) return;             // 忽略过期会话的关闭通知
  sessionId = '';                           // 标记失效,后续 sendCommand 会走排队分支
  ensureSession().then(flushQueue).catch(() => {
    // 重建失败不抛给用户,等下一次操作自然重试
  });
});

function sleep(ms: number) {
  return new Promise<void>(resolve => setTimeout(resolve, ms));
}

几个关键点值得单独说明:

  • rebuilding 锁是幂等的核心。 用户连点五次音量键,五次 sendCommand 都会走到 ensureSession,但只有第一次真正调用 createSession,其余四次复用同一个 Promise。没有这把锁,就会并发创建多个会话,出现"越调越乱"。
  • finally 里必须释放锁。 如果重建抛异常却不置空 rebuilding,后续所有调用都会拿到一个已 reject 的 Promise,通道永久卡死。
  • 队列只在成功发送后出队。 flushQueue 里 shift() 放在 await sendData 之后,发送失败就 break,剩余指令保留,下次重连继续补发,做到"指令不丢"。
  • sessionClosed 要校验 id。 旧会话的关闭通知可能在新建会话之后才到达,不校验就会把刚建好的新会话误判为失效。

指令协议与 HMAC 签名

会话通了、指令不丢了,还差最后一环:指令本身长什么样、怎么防篡改。如果 cmd 只是一个裸对象,同网设备随手抓包就能伪造一条「音量调到 0」或「切到 HDMI2」,前面做的保活和幂等全白费。所以这里把指令收敛成一个固定协议,并给每条指令加签名。

先看协议定义:

// 指令协议:所有控制指令统一走这个结构
interface ControlCommand {
  type: string;      // 指令类型,如 'volume' | 'source' | 'power'
  seq: number;       // 单调递增序号,用于去重、排序、防重放
  payload: Record<string, unknown>; // 业务参数,如 { value: 30 }
  hmac: string;      // 基于会话密钥对前三个字段的签名
}

seq 为什么必须单调递增? 它一条字段同时解决三个问题:

  • 去重:软总线在弱网下可能重发,接收端记录已处理的最大 seq,小于等于它的直接丢弃,避免「按一次音量键跳两格」。
  • 保序:重建期间指令进队列,补发时可能乱序到达,接收端按 seq 排序后再执行,保证「先切源再调音量」的语义不被颠倒。
  • 防重放:攻击者截获一条合法指令原样重发,seq 已过期,接收端直接拒绝,不需要额外的时间戳机制。

签名与校验示例:

import { cryptoFramework } from '@kit.CryptoArchitectureKit';

// 会话建立后双方协商出的会话密钥(示例用固定值,实际应由近场互信派生)
let sessionKey = '';
let seqCounter = 0;

// 发送端:组装指令并签名
async function buildCommand(type: string, payload: Record<string, unknown>): Promise<ControlCommand> {
  const cmd: ControlCommand = {
    type,
    seq: ++seqCounter,   // 单调递增,进程内唯一
    payload,
    hmac: ''
  };
  // 签名内容固定为 type|seq|payload 的规范化字符串,字段顺序必须两端一致
  const signContent = `${cmd.type}|${cmd.seq}|${JSON.stringify(cmd.payload)}`;
  cmd.hmac = await hmacSign(signContent, sessionKey);
  return cmd;
}

// 接收端:先验签,再校验 seq,最后才执行
const lastSeqMap = new Map<string, number>(); // 按设备记录已处理的最大 seq

async function onCommandReceived(deviceId: string, raw: string) {
  const cmd: ControlCommand = JSON.parse(raw);
  const signContent = `${cmd.type}|${cmd.seq}|${JSON.stringify(cmd.payload)}`;
  const expect = await hmacSign(signContent, sessionKey);

  // 1) 验签:签名不一致说明被篡改或伪造,直接丢弃
  if (expect !== cmd.hmac) return;

  // 2) 防重放:seq 不大于已处理值,说明是重发或旧包,丢弃
  const lastSeq = lastSeqMap.get(deviceId) ?? 0;
  if (cmd.seq <= lastSeq) return;
  lastSeqMap.set(deviceId, cmd.seq);

  // 3) 校验通过,才真正驱动 UI
  driveControl(cmd);
}

// 基于会话密钥的 HMAC-SHA256,输出十六进制字符串
async function hmacSign(content: string, key: string): Promise<string> {
  const mac = cryptoFramework.createMac('SHA256');
  const keyBlob = { data: stringToUint8Array(key) };
  await mac.init(keyBlob);
  await mac.update({ data: stringToUint8Array(content) });
  const result = await mac.doFinal();
  return uint8ArrayToHex(result.data);
}

几个落地时容易忽略的点:

  • 签名内容要规范化。 JSON.stringify 的字段顺序依赖对象插入顺序,两端必须用同一套拼装规则(这里固定 type|seq|payload),否则验签必然失败。
  • 先验签再判 seq。 顺序反了会给攻击者一个「用伪造包刷高 seq」的机会,把合法指令全部挤掉。
  • seq 按设备维度记录。 多设备抢连时共用一个计数器会互相干扰,用 Map<deviceId, seq> 隔离。
  • 密钥随会话走。 会话重建后应重新派生 sessionKey,旧密钥签的指令在重连后自然失效,等于顺手做了一次密钥轮换。

四、运行效果与踩坑

  • 踩坑 1:碰完不校验对端就拉起。 误触把指令发到错误设备。解决:碰一碰回调里先展示"将控制 XX 设备?"再确认。这个确认弹窗看似多一步,实际是防止"口袋里手机蹭到遥控器,把客厅电视音量调到 0"这类事故的最后一道闸。
  • 踩坑 2:软总线会话没保活。 息屏后通道断开,回来指令失效。需监听 sessionClosed 自动重建。重建时要注意幂等:重建期间用户可能连点多次音量键,指令要排队而不是并发创建多个会话,否则会出现"越调越乱"的现象。
  • 踩坑 3:指令明文被同网嗅探。 虽在近场建立,但控制走局域网,建议对 cmd 做轻量签名校验。签名不必上非对称加密那么重,一个基于会话密钥的 HMAC 就足以挡住同网随手抓包的场景。
  • 踩坑 4:双向同步没做去抖。 手机端和大屏端都能改音量,两边同时上报会互相覆盖。解决:以一端为主控源,另一端只做状态回显,避免"回声"式抖动。
  • 效果: 碰一下 300ms 内拉起闪控窗,指令端到端延迟 <80ms,断线自动恢复无感。实测在隔一堵墙、5 米距离下仍能稳定保持会话,多设备抢连时通过确认弹窗天然做了串行化。

五、小结与适用边界

碰一碰 + 闪控窗是把"近场入口"和"持续控制"结合的好范式。核心是近场建链、软总线传令、会话保活。适合智能家居、车机、大屏遥控;不适合需要高安全鉴权的金融操作(需额外强认证)。

从工程角度看,这套方案的价值在于把复杂的配对流程藏进了一个物理动作里,而把复杂度留给了开发者。代价是开发者必须自己处理好时序、保活、幂等和去抖这四件事,任何一件没做好,用户感受到的就是"碰了没反应"或"调了没变化"。反过来说,只要这四件事做扎实,体验就会非常"魔法"——用户不会意识到背后有软总线、有会话、有重连,他只会觉得"这手机真好用"。

六、实战清单(可直接抄)

  • 碰一碰只负责"我是谁、连哪台",拉起前先展示"将控制 XX 设备?"确认,防误触发错设备。
  • 远程控制通道用软总线 Session,近场建立后指令走局域网,建议对 cmd 做轻量签名防同网嗅探。
  • 监听 sessionClosed 自动重建会话,息屏回来指令不失效,做到无感恢复。
  • 端到端验收看两项:拉起延迟(<300ms)和指令时延(<80ms)。
  • 涉及金融等高安全操作务必叠加额外强认证,别只靠近场互信。

近场入口加持续控制,是碰一碰最被低估的组合,体验上就像"碰一下就拥有了遥控器"。但稳定性比花哨更重要:断线秒恢、指令不丢,比多十个功能更让用户安心。真机实测永远胜过模拟器,远距离、隔墙、多设备抢连三种场景必须逐一过。

七、总结

把这条链路走通之后,回头看其实只有四个关键词:入口、通道、保活、可信。碰一碰负责入口,把配对压缩成一次物理接触;软总线负责通道,提供局域网内低延迟的双向传输;sessionClosed 监听加幂等重建负责保活,让息屏、切后台、网络抖动都不再表现为「碰了没反应」;seq 与 HMAC 负责可信,让指令不被重放、不被伪造。四者缺一,用户感受到的就是体验断层。

从投入产出看,这套方案最值得先做的是保活与幂等——它不增加任何新能力,却直接决定了「能不能用」;其次是指令签名,成本极低但能挡住同网随手抓包这类真实风险;最后才是入口方案的横向选型,因为一旦通道和协议稳定,换入口只是替换第一段代码。

适用边界也要说清楚:近场互信不等于强认证,涉及支付、门锁、车控等高风险操作,必须叠加生物识别或服务端二次校验;跨公网、需服务端统一编排的场景,扫码 + WebSocket 反而更合适。技术选型没有银弹,只有与场景匹配的取舍。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐