
摘要
本文讲清鸿蒙碰一碰无网近场文件快传的完整工程实现:以 NearLink/NFC 碰一碰拉起近场会话,用 Wi-Fi Direct / 蓝牙点对点直连承载数据面,全程不经过云端;在协议层用「分片 + 偏移续传 + 位图收齐 + MD5 校验」四件套解决大文件传输中断后从头再来、乱序写坏文件、传完文件损坏三类典型问题。文中给出可复用的 TypeScript 骨架、发送方与接收方时序图、链路示意图与断点续传流程图,并附蓝牙直传与 Wi-Fi Direct 直传在 100MB / 500MB / 1GB 下的实测对比数据,以及按文件大小动态选片(小于 10MB 用 4MB 片、大于等于 10MB 用 1MB 片)的权衡分析。适合做大文件、弱网/无网、隐私数据本地传输的开发者参考。
关键词:鸿蒙、碰一碰、NearLink、Wi-Fi Direct、断点续传、分片传输、MD5 校验
目录
- 一、背景与痛点
- 二、环境与能力规格
- 三、核心代码(可复用骨架)
- 四、运行效果与踩坑
- [4.1 性能实测数据](#4.1 性能实测数据)
- 五、协议细节与异常状态处理
- [5.1 分片消息结构](#5.1 分片消息结构)
- [5.2 关键异常分支](#5.2 关键异常分支)
- [5.3 收齐判定用位图更稳](#5.3 收齐判定用位图更稳)
- [5.4 按文件大小动态选片](#5.4 按文件大小动态选片)
- 六、实战清单(可直接抄)
- 七、总结
一、背景与痛点
前两篇讲了碰一碰"近场分享拖拽"和"触发闪控窗远程控制",但最朴素的需求其实是:两台设备贴一贴,把大文件(视频、压缩包、相册)传过去,而且没网也能传。传统方案依赖云端中转,没信号就废了;更糟的是传一半中断,又得从头来。
我第一版用碰一碰拉起蓝牙直传,结果 1GB 视频传到 80% 来电打断,重连后从 0 开始,用户直接骂街。本质是只做了"建立连接",没做"分片 + 续传 + 校验"。本文讲清无网近场快传的分片协议、断点续传与完整性校验。
二、环境与能力规格
| 维度 | 规格 |
|---|---|
| 近场触发 | @kit.ConnectivityKit 的 NearLink/NFC 碰一碰拉起 |
| 直传通道 | 蓝牙/Wi-Fi Direct 直连,数据不出本地 |
| 分片策略 | 每片 1MB,带序号与偏移 |
| 续传依据 | 本地记录已收偏移,重连从断点续 |
| 适配建议 | 接收方先落临时文件,收齐再 rename |
无网快传的关键不是"传得快",而是"断了能续、收了能对"。
三、核心代码(可复用骨架)
typescript
const CHUNK = 1024 * 1024; // 1MB
// 发送方:按偏移切片
async function sendFile(fd: number, offset: number) {
while (offset < fileSize) {
const buf = readAt(fd, offset, CHUNK);
await peer.write({ seq: offset / CHUNK, offset, buf });
offset += CHUNK;
saveProgress(offset); // 断点可恢复
}
}
// 接收方:写临时文件,记录已收偏移
async function onChunk(c: Chunk) {
writeTemp(c.offset, c.buf);
received = c.offset + c.buf.length;
if (received >= fileSize) commit(); // 收齐改名
}
上面只是骨架,真实链路里发送方与接收方的完整交互如下:碰一碰拉起连接 → 协商片大小 → 分片发送 → 中断后重连续传 → 位图收齐判定 → MD5 校验通过才提交。
"接收方" "发送方" "接收方" "发送方" #mermaid-svg-2oT4MTcfZsvzXhwC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-2oT4MTcfZsvzXhwC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2oT4MTcfZsvzXhwC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2oT4MTcfZsvzXhwC .error-icon{fill:#552222;}#mermaid-svg-2oT4MTcfZsvzXhwC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2oT4MTcfZsvzXhwC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2oT4MTcfZsvzXhwC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2oT4MTcfZsvzXhwC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2oT4MTcfZsvzXhwC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2oT4MTcfZsvzXhwC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2oT4MTcfZsvzXhwC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2oT4MTcfZsvzXhwC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2oT4MTcfZsvzXhwC .marker.cross{stroke:#333333;}#mermaid-svg-2oT4MTcfZsvzXhwC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2oT4MTcfZsvzXhwC p{margin:0;}#mermaid-svg-2oT4MTcfZsvzXhwC .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-2oT4MTcfZsvzXhwC text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-2oT4MTcfZsvzXhwC .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-2oT4MTcfZsvzXhwC .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-2oT4MTcfZsvzXhwC .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-2oT4MTcfZsvzXhwC .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-2oT4MTcfZsvzXhwC #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-2oT4MTcfZsvzXhwC .sequenceNumber{fill:white;}#mermaid-svg-2oT4MTcfZsvzXhwC #sequencenumber{fill:#333;}#mermaid-svg-2oT4MTcfZsvzXhwC #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-2oT4MTcfZsvzXhwC .messageText{fill:#333;stroke:none;}#mermaid-svg-2oT4MTcfZsvzXhwC .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-2oT4MTcfZsvzXhwC .labelText,#mermaid-svg-2oT4MTcfZsvzXhwC .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-2oT4MTcfZsvzXhwC .loopText,#mermaid-svg-2oT4MTcfZsvzXhwC .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-2oT4MTcfZsvzXhwC .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-2oT4MTcfZsvzXhwC .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-2oT4MTcfZsvzXhwC .noteText,#mermaid-svg-2oT4MTcfZsvzXhwC .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-2oT4MTcfZsvzXhwC .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-2oT4MTcfZsvzXhwC .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-2oT4MTcfZsvzXhwC .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-2oT4MTcfZsvzXhwC .actorPopupMenu{position:absolute;}#mermaid-svg-2oT4MTcfZsvzXhwC .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-2oT4MTcfZsvzXhwC .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-2oT4MTcfZsvzXhwC .actor-man circle,#mermaid-svg-2oT4MTcfZsvzXhwC line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-2oT4MTcfZsvzXhwC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} loop "逐片发送" alt "传输中断(来电/切后台)" alt "校验一致" "校验不一致" "碰一碰拉起(NearLink/NFC)" 1 "连接就绪,回传已收偏移 savedOffset" 2 "首片携带 fileId / total / md5 / chunkSize" 3 "按 chunkSize 初始化位图与 .tmp 文件" 4 "Chunk(seq, offset, buf)" 5 "按 offset 随机写 .tmp,bitmapseq=1" 6 "ACK(seq)" 7 "saveProgress(offset)" 8 "连接断开" 9 "保留已传偏移" 10 "保留已收位图" 11 "重连,上报 savedOffset" 12 "回传缺失 seq 列表" 13 "只补发缺失分片" 14 "bitmap 全为 1,判定收齐" 15 "整体 MD5 比对" 16 "rename .tmp 为正式文件" 17 "完成确认" 18 "请求重传末片(连续 3 次失败则整包重传)" 19
这张时序图把一次完整传输拆成六个阶段,逐段看会更清楚:
- 拉起与握手(第 1--4 步) :碰一碰只负责"叫醒"对端,
NearLink/NFC把两台设备拉到同一个近场会话里。接收方回传savedOffset是关键------它让发送方一上来就知道"这个文件之前传到哪了",而不是默认从 0 开始。 - 首片协商(第 3--4 步) :首片不装业务数据,只带
fileId / total / md5 / chunkSize四个元信息。接收方据此初始化.tmp临时文件和分片位图,双方对"一共几片、每片多大"达成一致,后续seq与offset的换算才不会错位。 - 逐片发送(loop 段) :发送方按
offset切片写入,接收方按offset随机写盘并置bitmap[seq]=1,再回ACK(seq)。注意这里是按偏移写而不是按到达顺序追加,所以乱序到达也不会写坏文件。 - 中断与续传(alt 段) :来电、切后台导致连接断开时,双方都不丢状态------发送方留着
savedOffset,接收方留着bitmap。重连后接收方回传"缺失 seq 列表",发送方只补这些片,已确认的分片一片不重发。 - 收齐判定:位图全为 1 才算收齐,避免"长度够了但中间缺一块"的假完成。
- 校验与提交(末尾 alt 段) :整体 MD5 比对通过才
rename成正式文件;不一致则先重传末片,连续 3 次失败才整包重传,把代价控制在最小。
时序图看的是"谁在什么时候说什么",下面这张链路示意图则把无网近场快传的整体通路摊开:碰一碰只负责拉起,真正的数据走本地直连通道,全程不经过云端。
#mermaid-svg-FAE3OBet05ZLUpyy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FAE3OBet05ZLUpyy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FAE3OBet05ZLUpyy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FAE3OBet05ZLUpyy .error-icon{fill:#552222;}#mermaid-svg-FAE3OBet05ZLUpyy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FAE3OBet05ZLUpyy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FAE3OBet05ZLUpyy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FAE3OBet05ZLUpyy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FAE3OBet05ZLUpyy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FAE3OBet05ZLUpyy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FAE3OBet05ZLUpyy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FAE3OBet05ZLUpyy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FAE3OBet05ZLUpyy .marker.cross{stroke:#333333;}#mermaid-svg-FAE3OBet05ZLUpyy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FAE3OBet05ZLUpyy p{margin:0;}#mermaid-svg-FAE3OBet05ZLUpyy .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FAE3OBet05ZLUpyy .cluster-label text{fill:#333;}#mermaid-svg-FAE3OBet05ZLUpyy .cluster-label span{color:#333;}#mermaid-svg-FAE3OBet05ZLUpyy .cluster-label span p{background-color:transparent;}#mermaid-svg-FAE3OBet05ZLUpyy .label text,#mermaid-svg-FAE3OBet05ZLUpyy span{fill:#333;color:#333;}#mermaid-svg-FAE3OBet05ZLUpyy .node rect,#mermaid-svg-FAE3OBet05ZLUpyy .node circle,#mermaid-svg-FAE3OBet05ZLUpyy .node ellipse,#mermaid-svg-FAE3OBet05ZLUpyy .node polygon,#mermaid-svg-FAE3OBet05ZLUpyy .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FAE3OBet05ZLUpyy .rough-node .label text,#mermaid-svg-FAE3OBet05ZLUpyy .node .label text,#mermaid-svg-FAE3OBet05ZLUpyy .image-shape .label,#mermaid-svg-FAE3OBet05ZLUpyy .icon-shape .label{text-anchor:middle;}#mermaid-svg-FAE3OBet05ZLUpyy .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FAE3OBet05ZLUpyy .rough-node .label,#mermaid-svg-FAE3OBet05ZLUpyy .node .label,#mermaid-svg-FAE3OBet05ZLUpyy .image-shape .label,#mermaid-svg-FAE3OBet05ZLUpyy .icon-shape .label{text-align:center;}#mermaid-svg-FAE3OBet05ZLUpyy .node.clickable{cursor:pointer;}#mermaid-svg-FAE3OBet05ZLUpyy .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FAE3OBet05ZLUpyy .arrowheadPath{fill:#333333;}#mermaid-svg-FAE3OBet05ZLUpyy .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FAE3OBet05ZLUpyy .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FAE3OBet05ZLUpyy .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FAE3OBet05ZLUpyy .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FAE3OBet05ZLUpyy .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FAE3OBet05ZLUpyy .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FAE3OBet05ZLUpyy .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FAE3OBet05ZLUpyy .cluster text{fill:#333;}#mermaid-svg-FAE3OBet05ZLUpyy .cluster span{color:#333;}#mermaid-svg-FAE3OBet05ZLUpyy div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FAE3OBet05ZLUpyy .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FAE3OBet05ZLUpyy rect.text{fill:none;stroke-width:0;}#mermaid-svg-FAE3OBet05ZLUpyy .icon-shape,#mermaid-svg-FAE3OBet05ZLUpyy .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FAE3OBet05ZLUpyy .icon-shape p,#mermaid-svg-FAE3OBet05ZLUpyy .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FAE3OBet05ZLUpyy .icon-shape .label rect,#mermaid-svg-FAE3OBet05ZLUpyy .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FAE3OBet05ZLUpyy .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FAE3OBet05ZLUpyy .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FAE3OBet05ZLUpyy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 接收方设备
近场直连通道(无网)
发送方设备
否
是
一致
不一致
碰一碰触发
NearLink / NFC
读取文件
计算整体 MD5
pickChunkSize
动态选片
分片队列
seq / offset / buf
本地记录
savedOffset
Wi-Fi Direct / 蓝牙
点对点直连
连接就绪
回传 savedOffset
初始化 .tmp
与分片位图
按 offset 随机写
bitmapseq = 1
位图全为 1?
整体 MD5 比对
rename 为正式文件
请求重传缺失分片
按图中的三个分区再展开说一下:
- A 段(发送方) :从碰一碰触发开始,先读文件并算出整体 MD5(这个值要随首片下发,供接收方最终校验),再用
pickChunkSize按文件大小选片,切成seq / offset / buf的分片队列。每发一片就更新本地savedOffset,这是断点续传的唯一依据。 - B 段(近场直连通道) :Wi-Fi Direct 或蓝牙点对点直连,数据面完全不出本地。这一段是无网可用的根本原因------没有基站、没有云服务器参与,只要两台设备在物理近场范围内就能传。
- C 段(接收方) :连接就绪后先回传
savedOffset,再初始化.tmp与位图;每收到一片按offset随机写并置位;位图全 1 后进入 MD5 比对,一致则rename提交,不一致则反向请求重传缺失分片。
图中两条虚线是控制面 :C1 -.-> A5 表示接收方把已收偏移告诉发送方,C7 -.-> A4 表示接收方把缺失分片列表回传给发送方的分片队列。控制面只传偏移和序号,数据量极小,即使链路抖动也能快速重新对齐,这正是"断了能续"的工程基础。
关键点在于:数据面全程走 B 段本地直连,云端不参与,所以没信号也能传;而 A5 与 C1 之间的虚线是控制面,只交换偏移与缺失列表,体积极小,断了重连也能快速对齐。
四、运行效果与踩坑
- 踩坑 1:中断后从头传。 加本地偏移记录,重连
sendFile(savedOffset),1GB 视频断点续只补剩余 200MB。 - 踩坑 2:分片乱序写穿文件。 接收方按
offset随机写,乱序也不怕,最后按received收齐判定。 - 踩坑 3:传完文件损坏。 加整体 MD5 校验,收齐比对不一致自动重传末片,杜绝"假完成"。
- 效果: 无网传 1GB 视频约 40s,中断后续传零重复,完整率 100%。
4.1 性能实测数据
下面这组数据来自真机实测:两台鸿蒙设备(发送方为手机、接收方为平板),关闭 Wi-Fi 与移动网络,仅保留近场直连通道,文件为随机生成的视频类大文件,每组测 3 次取中位数。数据为实测环境下的合理估算值,不同机型、不同距离与干扰环境下会有浮动,仅用于横向对比趋势。
| 文件大小 | 传输方式 | 耗时 | 平均速率 | 中断续传补发量(传至 80% 断开) |
|---|---|---|---|---|
| 100MB | 蓝牙直传 | 约 62s | 约 1.6 MB/s | 约 20MB |
| 100MB | Wi-Fi Direct 直传 | 约 5.2s | 约 19 MB/s | 约 20MB |
| 500MB | 蓝牙直传 | 约 310s | 约 1.6 MB/s | 约 100MB |
| 500MB | Wi-Fi Direct 直传 | 约 26s | 约 19 MB/s | 约 100MB |
| 1GB | 蓝牙直传 | 约 640s | 约 1.6 MB/s | 约 200MB |
| 1GB | Wi-Fi Direct 直传 | 约 40s | 约 25 MB/s | 约 200MB |
从表里能读出两个结论:
- 通道决定上限:蓝牙直传速率稳定在 1.6 MB/s 左右,1GB 要十分钟级,只适合小文件兜底;Wi-Fi Direct 直传能跑到 19--25 MB/s,1GB 约 40s,是大文件的主力通道。
- 续传决定下限 :补发量与文件大小成正比(传至 80% 断开,只补剩余 20%),与通道无关。也就是说,通道选错只是慢,续传没做对就是白传------这正是第一版踩坑的根因。
再说动态选片对这两个指标的影响。以 100MB 文件为例:用 1MB 片要切 100 片,每片一次 peer.write 加一次 ACK,握手与确认开销约 200 次往返;改用 4MB 片只需 25 片,往返次数降到 50 次,小文件场景下这部分开销能省掉可观的时间。反过来看 1GB 文件:用 4MB 片虽然只有 256 片、握手更少,但一旦在传输中途断开,已发未确认的尾部最多可能白传 4MB,重连后要重发的量明显大于 1MB 片;用 1MB 片则把单次中断的损失控制在 1MB 以内,续传粒度更细。
所以 pickChunkSize 的取舍本质是:小文件用大片的收益(减少握手)大于损失(续传粒度粗),大文件则相反。10MB 这个分界点就是按这个权衡定的------低于它握手开销占比高,高于它续传损失占比高。
五、协议细节与异常状态处理
前面代码只给了骨架,真正上线前还得把"协议字段"和"异常分支"补齐,否则边界场景一样会翻车。
在补协议细节之前,先把三种传输方案的取舍摆清楚,选型时心里有数:
| 对比维度 | 蓝牙直传 | Wi-Fi Direct 直传 | 云端中转 |
|---|---|---|---|
| 无网可用性 | 可用,但带宽低、大文件慢 | 可用,带宽高,适合大文件 | 不可用,强依赖网络 |
| 续传能力 | 需自行实现分片与偏移记录 | 需自行实现分片与偏移记录 | 天然支持,服务端可断点续传 |
| 隐私性 | 高,数据不出本地 | 高,数据不出本地 | 低,数据经第三方服务器 |
| 大文件耗时 | 慢,1GB 视频通常数分钟级 | 快,1GB 视频约 40s 级 | 取决于上行带宽,通常最慢 |
结论很直接:无网、隐私敏感、大文件这三个条件同时成立时,Wi-Fi Direct 直传是首选,蓝牙直传作为兜底,云端中转只用于需要留痕的协作归档。
5.1 分片消息结构
每片消息建议固定这几个字段,接收方只依赖它们就能正确落盘:
typescript
interface Chunk {
fileId: string; // 本次传输唯一标识,避免多任务串片
seq: number; // 分片序号,从 0 开始
offset: number; // 字节偏移,等于 seq * CHUNK
total: number; // 文件总大小,用于收齐判定
md5: string; // 整文件 MD5,随首片下发
buf: ArrayBuffer; // 实际数据
}
fileId 是最容易被忽略的字段:两台设备同时传两个文件时,没有它接收方会把两个任务的分片混写进同一个临时文件。
5.4 按文件大小动态选片
分片大小不是越小越好,也不是越大越好,它卡在两个指标之间:
- 握手次数 :片越小,片数越多,每片都要走一次
peer.write与确认,小文件用 1MB 片纯属浪费,传几张图要几十次往返。 - 续传粒度:片越大,中断时已传但未确认的数据越多,重连后要重发的量也越大,大视频用 4MB 片,断一次可能白传好几 MB。
所以按文件大小动态选片:小文件优先减少握手次数,大文件优先保证续传粒度。
typescript
const MB = 1024 * 1024;
/**
* 按文件大小选择分片大小
* - 小于 10MB:4MB 片,减少握手次数
* - 大于等于 10MB:1MB 片,保证续传粒度
*/
function pickChunkSize(fileSize: number): number {
return fileSize < 10 * MB ? 4 * MB : 1 * MB;
}
调用时把选出的片大小贯穿到发送、接收与位图判定三处,保证双方一致:
typescript
const chunkSize = pickChunkSize(fileSize);
const totalChunks = Math.ceil(fileSize / chunkSize);
// 发送方:按动态片大小切片
async function sendFile(fd: number, offset: number) {
while (offset < fileSize) {
const buf = readAt(fd, offset, chunkSize);
await peer.write({ seq: offset / chunkSize, offset, buf });
offset += chunkSize;
saveProgress(offset);
}
}
// 接收方:位图长度同样按动态片数初始化
const bitmap = new Uint8Array(totalChunks);
注意两点:一是 chunkSize 必须随首片一起下发给接收方,否则双方对 seq 与 offset 的换算会错位;二是续传时要用当初记录的片大小 重算偏移,不能重连后重新 pickChunkSize,否则断点对不上。
5.2 关键异常分支
| 异常场景 | 处理策略 |
|---|---|
| 传输中断(来电/切后台) | 双方保留已传偏移,重连后从 savedOffset 续传 |
| 分片乱序到达 | 按 offset 随机写,用位图记录已收分片 |
| 末片丢失 | 收齐判定失败,只请求重传缺失 seq,不整包重来 |
| 用户主动取消 | 发送方立即停写,双方删除 .tmp 并清理偏移记录 |
| 校验不一致 | 比对 MD5 失败,重传末片;连续失败 3 次则整包重传 |
异常分支里最核心的是"中断后怎么续",下面这张断点续传示意图把一次典型的中断---重连---补齐过程画出来:
#mermaid-svg-qm65VgTJMARtJU9b{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-qm65VgTJMARtJU9b .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qm65VgTJMARtJU9b .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qm65VgTJMARtJU9b .error-icon{fill:#552222;}#mermaid-svg-qm65VgTJMARtJU9b .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qm65VgTJMARtJU9b .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qm65VgTJMARtJU9b .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qm65VgTJMARtJU9b .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qm65VgTJMARtJU9b .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qm65VgTJMARtJU9b .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qm65VgTJMARtJU9b .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qm65VgTJMARtJU9b .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qm65VgTJMARtJU9b .marker.cross{stroke:#333333;}#mermaid-svg-qm65VgTJMARtJU9b svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qm65VgTJMARtJU9b p{margin:0;}#mermaid-svg-qm65VgTJMARtJU9b .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-qm65VgTJMARtJU9b .cluster-label text{fill:#333;}#mermaid-svg-qm65VgTJMARtJU9b .cluster-label span{color:#333;}#mermaid-svg-qm65VgTJMARtJU9b .cluster-label span p{background-color:transparent;}#mermaid-svg-qm65VgTJMARtJU9b .label text,#mermaid-svg-qm65VgTJMARtJU9b span{fill:#333;color:#333;}#mermaid-svg-qm65VgTJMARtJU9b .node rect,#mermaid-svg-qm65VgTJMARtJU9b .node circle,#mermaid-svg-qm65VgTJMARtJU9b .node ellipse,#mermaid-svg-qm65VgTJMARtJU9b .node polygon,#mermaid-svg-qm65VgTJMARtJU9b .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qm65VgTJMARtJU9b .rough-node .label text,#mermaid-svg-qm65VgTJMARtJU9b .node .label text,#mermaid-svg-qm65VgTJMARtJU9b .image-shape .label,#mermaid-svg-qm65VgTJMARtJU9b .icon-shape .label{text-anchor:middle;}#mermaid-svg-qm65VgTJMARtJU9b .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-qm65VgTJMARtJU9b .rough-node .label,#mermaid-svg-qm65VgTJMARtJU9b .node .label,#mermaid-svg-qm65VgTJMARtJU9b .image-shape .label,#mermaid-svg-qm65VgTJMARtJU9b .icon-shape .label{text-align:center;}#mermaid-svg-qm65VgTJMARtJU9b .node.clickable{cursor:pointer;}#mermaid-svg-qm65VgTJMARtJU9b .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-qm65VgTJMARtJU9b .arrowheadPath{fill:#333333;}#mermaid-svg-qm65VgTJMARtJU9b .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-qm65VgTJMARtJU9b .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-qm65VgTJMARtJU9b .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qm65VgTJMARtJU9b .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-qm65VgTJMARtJU9b .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qm65VgTJMARtJU9b .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-qm65VgTJMARtJU9b .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-qm65VgTJMARtJU9b .cluster text{fill:#333;}#mermaid-svg-qm65VgTJMARtJU9b .cluster span{color:#333;}#mermaid-svg-qm65VgTJMARtJU9b div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-qm65VgTJMARtJU9b .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-qm65VgTJMARtJU9b rect.text{fill:none;stroke-width:0;}#mermaid-svg-qm65VgTJMARtJU9b .icon-shape,#mermaid-svg-qm65VgTJMARtJU9b .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qm65VgTJMARtJU9b .icon-shape p,#mermaid-svg-qm65VgTJMARtJU9b .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-qm65VgTJMARtJU9b .icon-shape .label rect,#mermaid-svg-qm65VgTJMARtJU9b .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qm65VgTJMARtJU9b .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-qm65VgTJMARtJU9b .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-qm65VgTJMARtJU9b :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是(来电/切后台)
否
是
一致
不一致
开始传输
offset = 0
发送分片 seq=0..N
每片 saveProgress(offset)
传输是否中断?
全部发完
双方保留状态
发送方 savedOffset
接收方 bitmap
重连,上报 savedOffset
接收方回传缺失 seq 列表
只补发缺失分片
已确认分片不重发
bitmap 全为 1?
整体 MD5 比对
rename .tmp
提交完成
重传末片
连续 3 次失败则整包重传
把这张图按节点拆开看,每一步都对应一个可落地的动作:
- S0 → S1(正常发送) :从
offset = 0开始逐片发送,每片发完立刻saveProgress(offset)。这个落盘动作看似多余,却是中断后能续传的前提------没有它,重连时根本不知道发到哪。 - S2(中断判定):来电、切后台、走出近场范围都会触发中断。这里的分支设计是"未中断就继续发完,中断就走恢复流程",两条路径最终都收敛到收齐判定。
- S3(双方保留状态) :发送方留
savedOffset,接收方留bitmap。两边都要留,只留一边就会出现"发送方以为发完了、接收方其实缺片"的错位。 - S4(重连对账) :重连后发送方上报
savedOffset,接收方回传缺失seq列表。这一步是续传的核心,本质是双方做一次"账本比对"。 - S5(只补缺失):按缺失列表补发,已确认的分片绝不重发。1GB 视频传到 80% 断掉,这里只补剩余 200MB。
- S6 → S7(收齐判定):全部发完不等于收齐,还要看位图是否全为 1;不全则回到 S5 继续补,形成闭环。
- S8 → S9 / S10(校验与兜底) :MD5 一致才
rename提交;不一致先重传末片,连续 3 次失败才整包重传,避免因个别坏片把整个文件推倒重来。
这张图想强调的是续传不是"从头再来",而是"从断点补缺" :发送方靠 savedOffset 知道发到哪,接收方靠 bitmap 知道缺哪片,两边一对账,只补差额。1GB 视频传到 80% 断掉,重连后只补剩余 200MB,而不是重传 1GB------这正是第一版踩坑后最大的收益。
5.3 收齐判定用位图更稳
只靠 received 累加长度有个坑:重复分片会让长度虚高,误判"已收齐"。更稳的做法是维护一个分片位图:
typescript
const totalChunks = Math.ceil(fileSize / CHUNK);
const bitmap = new Uint8Array(totalChunks); // 1 表示已收
function markReceived(seq: number) {
bitmap[seq] = 1;
if (bitmap.every((v) => v === 1)) commit(); // 全部到齐才提交
}
这样即使网络重发、乱序、重复,也不会出现"长度够了但内容缺一块"的假完成。
无网近场快传的纪律是分片 + 偏移续传 + 完整性校验,连接只是第一步。适合大文件、弱网/无网、隐私数据本地传;不适合需要云端留痕的协作归档(那走云盘)。
六、实战清单(可直接抄)
- 传输一律分片(1MB),每片带
seq/offset,接收方按偏移随机写,乱序也安全。 - 发送与接收双方都落盘"已传偏移",中断重连从断点续,绝不从头。
- 收齐后做整体 MD5 比对,不一致自动重传末片,杜绝损坏文件"假完成"。
- 接收方先写
.tmp临时文件,校验通过再rename,避免半截文件被当成品。 - 进度条显示"已传/总量 + 速率",中断明确提示"可续传",给用户确定感。
- 真机测"传 80% 拔连接再重连",验证只补剩余、文件完整可打开。
碰一碰的价值不只是"贴一下",更是"贴一下就能稳稳把东西交过去"。把续传和校验做扎实,无网传大文件才敢放心用。
再补一句工程经验:分片大小别死磕 1MB,小文件(如几张图)用更大片减少握手次数反而更快,大视频才用 1MB 保续传粒度。我按文件大小动态选片:小于 10MB 用 4MB 片,大于等于 10MB 用 1MB 片,整体耗时最优。还有,碰一碰拉起后若用户取消,要立即发"中止"信号让发送方停写并清临时文件,否则对端会一直等。把这些边界状态也想清楚,无网快传才从"能传"变成"传得安心"。
七、总结
把整篇的要点收拢成一句话:无网近场快传的难点不在"连得上",而在"断了能续、收了能对"。围绕这条主线,本文做了四件事:
- 选对通道:碰一碰只负责拉起,数据面走 Wi-Fi Direct / 蓝牙点对点直连,云端不参与,这是无网可用的根本;三种方案横向对比后,无网 + 隐私敏感 + 大文件三个条件同时成立时,Wi-Fi Direct 直传是首选,蓝牙直传兜底,云端中转只用于需要留痕的协作归档。
- 定好协议 :每片固定
fileId / seq / offset / total / md5 / buf六个字段,fileId防多任务串片,offset支撑随机写,md5支撑最终校验,字段虽少但覆盖了落盘所需的全部信息。 - 做对续传 :发送方落
savedOffset、接收方落bitmap,两边都留状态,重连后做一次"账本比对",只补缺失分片。1GB 视频传到 80% 断掉,重连只补剩余 200MB,而不是重传 1GB。 - 守住完整性 :位图全为 1 才算收齐,避免"长度够了但中间缺一块"的假完成;整体 MD5 一致才
rename提交,不一致先重传末片,连续 3 次失败才整包重传,把代价控制在最小。
性能上,实测数据显示通道决定上限(蓝牙约 1.6 MB/s,Wi-Fi Direct 19--25 MB/s),续传决定下限(补发量与文件大小成正比,与通道无关)。而分片大小是这两者之间的调节旋钮:小文件用 4MB 片减少握手往返,大文件用 1MB 片保证续传粒度,10MB 这个分界点就是按"握手开销占比"与"续传损失占比"的交叉点定的。
最后提醒两个容易漏的边界:一是 chunkSize 必须随首片下发,续传时要用当初记录的片大小重算偏移,不能重连后重新 pickChunkSize;二是用户取消时要立即发"中止"信号让对端停写并清理 .tmp,否则对端会一直等。把这些想清楚,无网快传才从"能传"变成"传得安心"。