碰一碰近场触发 + 闪控窗远程控制端到端协同【鸿蒙心迹】

摘要
本文围绕「手机碰一碰智慧屏遥控器 → 大屏弹出闪控窗 → 手机变遥控面板」这一场景,拆解从近场握手到持续远程控制的完整链路。核心结论是:碰一碰只解决「我是谁、连哪台」,真正撑起持续控制的是分布式软总线会话。文章给出三段式时序(碰一碰回调 → 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 建立软总线会话 → 才开始传业务指令。把这三段拆开看,每一段都有独立的失败可能,也就有了独立的兜底策略。软总线本身提供了会话保活、断线通知、多通道复用等能力,正好补上碰一碰"只管一瞬间"的短板。
下面这张时序图把三段式链路和断线重连串在一起,方便对照代码骨架理解每一步的先后关系:
图注:四个阶段各有兜底点。碰一碰回调阶段若对端标识为空或校验失败,直接终止并提示重碰,不进入拉起;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 反而更合适。技术选型没有银弹,只有与场景匹配的取舍。
更多推荐
所有评论(0)