把一张小卡片从A设备分享给B设备,用户眼里是一瞬间的动作;业务代码看到的却可能是若干次分片抵达。尤其数据一旦经历重试,网络或业务传输层不保证“第一个发送的一定最先收到”,重复回包也不等于新内容。我想验证的不是近场硬件有多快,而是接收侧如何避免把乱序分片拼成乱码、把重复分片算两次。因此做了一个纯ArkTS的可检验示例 ChunkRelay,作为日后精准碰一碰接入时的业务缓冲层。

01

一、先明确这不是系统碰一碰协议

我刻意没有在正文里写一个名字像官方接口、实际上找不到的“NearbyShareManager”。这篇的包格式完全是项目自定义:一次会话有唯一transferId,总分片数为total,每片有从0开始的index和Uint8Array载荷。该格式属于应用自定义消息格式,并非HarmonyOS精准碰一碰底层报文,也并不等于系统会按本文的方式拆包。实际设备接入阶段,必须由支持的官方Kit负责会话发起、设备发现、授权和真实数据通道;这里仅讨论已经进入应用业务层的字节片段怎样重组。

示例卡片的内容是ASCII字符串HARMONYOS,恰好九个字节,拆成三个小片段:HAR、MON、YOS。预期到达次序偏偏是SEG-02、SEG-01、SEG-02、SEG-03。也就是说第二片先到了,第一片随后抵达,第二片又重发一次,第三片最后才到。正确实现应该给出READY 3/3、DUPLICATE 1,最终只还原一次HARMONYOS;如果简单按到达顺序追加,得到的就可能是MONHARMONYOS,数据虽然长度看着不少,语义已经错了。

二、会话标识与分片序号必须同时校验

最容易误写的逻辑是“收到什么就放进数组”。但数组push从来不理解分片序号。即使用数组下标赋值,遇到另一场分享迟到的数据时,也可能误把旧会话的一片填进新会话。我的接收策略是:先检查会话ID是否等于当前事务、总片数是否一致、序号是否落在0到total-1,再检查载荷是不是空片。任何一项不满足,都不能占用缓冲槽位。若会话因用户取消而失效,更不能把旧片恢复为新的任务。

下面用接口描述单片数据。Uint8Array只表达字节,没有隐藏的字符编码承诺。样例为了便于读者检查才使用ASCII;实际中文卡片应指定UTF-8、内容类型、序列化格式以及最大消息大小,并在合并完成后再做字符串或JSON解析,不应该逐片调用字符串解码。

interface ShareChunk {
transferId: string;
index: number;     // 从0开始
total: number;     // 本会话总分片数
bytes: Uint8Array;
}

function checkHeader(frame: ShareChunk, id: string, expected: number): string {
if (frame.transferId !== id) return 'WRONG_SESSION';
if (frame.total !== expected) return 'TOTAL_CHANGED';
if (!Number.isInteger(frame.index) || frame.index < 0 || frame.index >= expected) {
return 'INDEX_OUT_OF_RANGE';
}
if (frame.bytes.length === 0) return 'EMPTY_CHUNK';
return 'PASS';
}

这个门禁故意把total固定为会话创建时的值。否则有人先发“共有3片”,随后又发“共有999片”,接收端可能不断扩容。生产协议还应限制分片大小、整体消息大小以及允许的总片数,并把会话标识绑定到真实安全通道,而不是信任任何人自报的transferId。数据校验层不等于身份认证层,这两者分工清楚,才不会因为示例能拼出文字就误以为分享链路安全。

三、Map按序号存一次,重复包单独计数

缓冲区选Map<number, Uint8Array>,关键在于以分片序号为键。同一序号第一次到达时保存数据;第二次到达,如果字节完全相同就记为DUPLICATE,如果同一序号对应不同内容则返回REPLAY_CONFLICT。后者不应该默默采用“最后一次覆盖”,因为协议已经出现歧义。对攻击面更大的应用,可以直接终止会话并提示重传整个消息,而不是继续尝试猜测哪份才是真的。

示例给总载荷设上限4096字节、总分片最多32片。这是业务测试配额而非系统限制。字节累计只针对首次接纳的片段进行;重复片不会让占用重复增加。合并前检查Map是否拥有全部序号,合并时再按照0、1、2顺序拷贝到新的Uint8Array里。这样即使接收顺序是2、1、2、3,也不会改变最终内容。

class ChunkCollector {
private slots: Map<number, Uint8Array> = new Map();
private bytesUsed: number = 0;

constructor(private id: string, private total: number, private limit: number = 4096) {}

receive(frame: ShareChunk): string {
const verdict = checkHeader(frame, this.id, this.total);
if (verdict !== 'PASS') return verdict;
const old = this.slots.get(frame.index);
if (old !== undefined) {
if (old.length !== frame.bytes.length) return 'REPLAY_CONFLICT';
for (let i = 0; i < old.length; i++) {
if (old[i] !== frame.bytes[i]) return 'REPLAY_CONFLICT';
}
return 'DUPLICATE';
}
if (this.bytesUsed + frame.bytes.length > this.limit) return 'OVER_LIMIT';
this.slots.set(frame.index, new Uint8Array(frame.bytes));
this.bytesUsed += frame.bytes.length;
return 'STORED';
}

getCount(): number { return this.slots.size; }

assemble(): Uint8Array | undefined {
if (this.slots.size !== this.total) return undefined;
const result = new Uint8Array(this.bytesUsed);
let pos = 0;
for (let i = 0; i < this.total; i++) {
const part = this.slots.get(i);
if (part === undefined) return undefined;
result.set(part, pos);
pos += part.length;
}
return result;
}
}

这里对数据做了一份防御性拷贝,避免上游继续复用frame.bytes并修改缓冲区内容。缺点也明显:大载荷会引入额外内存,消息规模上涨后应使用按块落盘和严格回收策略,而不是无限制把Uint8Array都保留在页面内存。对这个九字节的教学样例,少量拷贝便于验证行为;不要把4096字节写成所有分享业务都应使用的通用上限。

02

四、让UI只消费汇总状态,不直接操作Map

最初我想把Map放进@State,每来一片就在里面set。后来还是将它封装成普通业务对象,页面只保存READY 3/3等可展示字段。这样更容易写单元测试,也避免ArkUI状态更新与Map内部就地修改之间的观察差异。每接收完一个片段,业务层把计数、最后一次判定和是否完整计算好,页面再替换自己的字符串和日志数组,渲染只关心新的状态快照。

下面的代码是示例代码触发四次构造的接收事件的关键调用。SEG-02会被构造两次,其字节都为MON,因此第二次被判定为重复。最后拼接后逐字节恢复ASCII文本。为了突出协议处理,列表布局和按钮样式省略了;真实页面应当在新会话开始时重新创建收集器,并在取消或离开页面时释放其引用。

function ascii(bytes: Uint8Array): string {
let out = '';
for (let i = 0; i < bytes.length; i++) out += String.fromCharCode(bytes[i]);
return out;
}

const gate = new ChunkCollector('ChunkRelay', 3);
const frames: ShareChunk[] = [
{ transferId: 'ChunkRelay', index: 1, total: 3, bytes: new Uint8Array([77, 79, 78]) },
{ transferId: 'ChunkRelay', index: 0, total: 3, bytes: new Uint8Array([72, 65, 82]) },
{ transferId: 'ChunkRelay', index: 1, total: 3, bytes: new Uint8Array([77, 79, 78]) },
{ transferId: 'ChunkRelay', index: 2, total: 3, bytes: new Uint8Array([89, 79, 83]) }
];
const log = frames.map(f => `SEG-0${f.index + 1} ${gate.receive(f)}`);
const merged = gate.assemble();
// 预期log: SEG-02 STORED、SEG-01 STORED、SEG-02 DUPLICATE、SEG-03 STORED
// 预期merged: HARMONYOS; READY 3/3;DUPLICATE 1

这里的new Uint8Array([..])写得有些啰嗦,是为了让读者能直接对照字节和文字,而不依赖浏览器的TextEncoder或某个未知版本SDK接口。在业务正式上线时,编码解码应使用当前目标API版本已确认可用的转换能力;处理UTF-8还必须考虑一个汉字跨分片的情况。分片内各自解码再拼字符串会破坏编码边界,整个Uint8Array先按序组装、再一次性解码才是稳妥的顺序。

五、异常路径不要靠“重新点一下”掩盖

重复包是正常重试行为还是恶意回放,要看会话上下文。示例将同内容重复视为可忽略事件,并单独累计DUPLICATE 1;同编号不同内容被标为REPLAY_CONFLICT,不能悄悄覆盖。再加一条SEG-04测试应得到INDEX_OUT_OF_RANGE,换一个transferId应得到WRONG_SESSION。如果先把第三片丢掉,页面应该停在RECEIVING 2/3而不是误显示完成。这样的负向样例比不断展示“发送成功”更能说明接收逻辑是否可靠。

超时也必须有归宿。本轮核心类没有把定时器混进来,生产版应让会话管理器拥有截止时间,例如30秒后取消尚未完成的事务,同时清空Map与统计状态;页面消失时由拥有者释放收集器引用。若用Date.now()计算超时,还要认识到系统时间校正可能影响绝对时间差;涉及安全保证的重放窗口,应当配合底层会话安全机制和适当的单调时钟来源,不能仅凭本地墙上时钟判定消息真实性。

性能方面,决定内存占用的不是Map里有多少个键,而是实际缓存多少字节。应用应同时限制片数、单片大小、累计大小与并发会话数。即使消息已经凑齐,也要在上层业务确认消费后把字节释放,不要让上一轮的Map长驻页面。尤其近场交互伴随多设备切换,任何以“全局单例收集器”偷懒的做法都要警惕新旧会话污染。

六、分片状态与结果校验

最终的手机图把四次构造的接收事件按顺序列出来:SEG-02 STORED、SEG-01 STORED、SEG-02 DUPLICATE、SEG-03 STORED;上方显示READY 3/3,重复计数1,合并文本为HARMONYOS。

03

从这个小例子得到的结论是:分享数据是否完整,不取决于收到几个回调,而取决于同一会话内不同序号是否全部有效且只被计入一次。按序号Map保存、区分重复与冲突、一次性拼接、完成后回收,是进入真实传输通道前就能测试好的业务层行为。它与此前分享标题字符门禁、PNG头部预检的边界不同:前两者判断内容能否使用,本篇判断完整内容到底怎样到达。

七、API事实与工程边界

本文所示Map、Uint8Array和String.fromCharCode属于ArkTS可用的通用语言构造,未借用未经核实的碰一碰SDK方法名。有关HarmonyOS应用框架、ArkTS与ArkUI的官方文档入口,可从华为文档中心核对实际API版本、可用设备和权限要求:https://developer.huawei.com/consumer/cn/doc/%E3%80%82%E6%B6%89%E5%8F%8A%E7%B2%BE%E5%87%86%E7%A2%B0%E4%B8%80%E7%A2%B0%E7%B3%BB%E7%BB%9F%E4%BC%A0%E8%BE%93%E7%9A%84%E5%85%AC%E5%BC%80%E8%83%BD%E5%8A%9B%EF%BC%8C%E5%BF%85%E9%A1%BB%E5%8F%A6%E6%8C%89%E7%9B%AE%E6%A0%87Kit%E6%96%87%E6%A1%A3%E4%B8%8E%E5%87%86%E5%85%A5%E6%9D%A1%E4%BB%B6%E9%AA%8C%E8%AF%81%EF%BC%9B%E6%9C%AC%E6%96%87%E4%BB%A3%E7%A0%81%E6%B2%A1%E6%9C%89%E5%AE%9E%E7%8E%B0%E4%BC%9A%E8%AF%9D%E5%8F%91%E7%8E%B0%E3%80%81%E8%AE%A4%E8%AF%81%E3%80%81%E5%8A%A0%E5%AF%86%E3%80%81%E9%87%8D%E8%AF%95%E4%BC%A0%E8%BE%93%E6%88%96%E7%B3%BB%E7%BB%9F%E5%8D%A1%E7%89%87%E5%8F%91%E5%B8%83%E3%80%82

如果把它继续做成可上线模块,我会优先补UTF-8完整消息解码、哈希或签名校验、限流与超时、取消会话以及真实运行日志对齐,而不是急着堆发送动画。示例的三个片段、九字节、4096字节限额和三十秒建议都只属于示例策略。把边界说清楚,才有条件在后续真实Kit落地时判断哪个问题属于协议,哪个问题属于UI,哪个问题属于业务缓冲层。

Logo

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

更多推荐