鸿蒙星闪实战:从零构建跨设备文件传输——拆解文件传输数据流
本文介绍了星闪文件传输系统的设计思想与实现方案。系统采用分层架构设计,通过单一职责、外观模式等原则实现高内聚低耦合。核心协议定义了六种数据包类型,采用小端序字节序处理文件元数据、分块传输等操作。文章详细解析了文件传输协议设计,包括元数据包结构、文件分块策略及字节处理机制,并提供了关键代码实现。该系统支持可靠的文件传输功能,包括完整性校验、取消通知等特性,为星闪设备间文件传输提供了完整解决方案。
完整源码:NearLinkFileTransfer
上一篇我们已经完整介绍了星闪的能力以及使用方法。本文重点阐述设计思想、架构分层、协议设计、文件二进制数据流转。为啥要先说设计思想与架构封层,真的是为了方便维护和调试。为了功能堆代码,最后出现Bug真难找这个坑我踩了。
让他们各司其职便于查阅代码和调试。主要是一开始小看它了,觉得没多少代码。当认真的想做一个有交互的文件传输,需要涉及的内容太多了。
一、星闪文件传输的设计思想
实现一个星闪文件传输工具,技术点繁多:权限、广播、扫描、连接、分块、重组、确认、取消、进度、MTU 优化……但真正的价值在于如何将这些点组织成一个高内聚、低耦合、可扩展、可维护的系统。
- 单一职责:每个模块只做一件事。
- 外观模式:对 UI 层隐藏底层复杂性。
- 协议自描述:应用层协议清晰、可扩展。
- 会话隔离:支持单任务串行,并为未来多任务预留空间。
- 可靠性优先:完整性校验、取消通知、状态清理。
运行效果
| 接收端 | 发送端 |
|---|---|
![]() |
![]() |
二、整体架构:从混沌到有序
2.1 初始问题:如何避免“上帝类”?
最早的原型将所有逻辑(权限、广播、扫描、数据收发、文件分块)堆在一个页面里,结果:
- 代码膨胀,难以调试。
- 无法复用(比如换个页面也要重写一遍)。
- 状态管理混乱,静态变量到处飞。
解决思路:按职责垂直切分,每个模块独立封装,最后用一个外观类统一对外。
2.2 架构分层
┌─────────────────────────────────────────────┐
│ UI 层(Pages) │
│ SenderPage / ReceiverPage / Index │
└─────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ 外观层(NearLink) │
│ 统一API:init/startAdvertising/sendFile...│
└─────────────────────────────────────────────┘
│
┌─────────────┼─────────────┬─────────────┐
▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌──────────┐ ┌──────────┐
│Permission│ │ State │ │Advertising│ │ Scan │
│ Manager │ │ Manager │ │ Manager │ │ Manager │
└─────────┘ └─────────┘ └──────────┘ └──────────┘
│ │ │ │
└─────────────┼─────────────┼─────────────┘
▼
┌─────────────────────────────┐
│ DataTransferManager (底层) │
│ 端口、连接、读写、回调 │
└─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ FileTransferHandler │
│ 分块、重组、确认、校验 │
└─────────────────────────────┘
设计要点:
- 横向切分:权限、状态、广播、扫描各自独立,互不依赖。
- 纵向分层:UI → 外观 → 底层协议 → 文件处理,依赖关系单向。
- 外观模式:
NearLink聚合所有子模块,UI 只需认识它一个。
说完设计思想和结构分层我们回到本篇最最重要的FileTransferHandler文件,一个文件是如何通过协议约定、切割、重组完成文件流操作。
三、数据在线上到底长什么样?—— 协议与字节
有部分人可能会对“协议”感到抽象,这里多写一些:协议不是一堆看不懂的数字,而是双方约定好的数据格式——每个字节代表什么含义。下面我们从最原始的角度,手把手拆解一个文件是如何变成字节流,再重新变回文件的。
3.1 为什么需要自定义协议?
星闪的 writeData 只负责发送一个 ArrayBuffer(一段二进制数据),但它不关心这段数据里装的是什么。就像邮局只负责送包裹,不关心包裹里是书还是衣服。我们需要在包裹上贴“标签”,告诉对方:这是文件名、这是文件大小、这是第几个数据块……这些标签就是协议。
3.2 整体协议概览
设计的协议定义了六种“包裹”类型,通过首字节区分:
| 首字节(十六进制) | 含义 | 后续内容 |
|---|---|---|
0xF1 |
元数据包(文件信息) | 文件名长度(2字节) + 文件大小(8字节) + 文件名(UTF-8) |
0xF2 |
数据块包 | 块序号(4字节) + 块数据(≤2KB) |
0xF3 |
完成标记 | 无 |
0xEE |
同意接收 | 无 |
0xEF |
拒绝接收 | 无 |
0xEC |
取消传输 | 无 |
十六进制前缀
0x表示后面的数字是十六进制,0xF1等于十进制的 241。一个字节可以表示 0~255。
3.3 字节基础:小端序
在协议中,多个字节的数字需要决定哪个字节在前。我们统一使用小端序(Little-Endian):低位字节在前,高位字节在后。
例如数字 0x12345678(4字节)在内存中的存储顺序:
- 小端序:
0x78, 0x56, 0x34, 0x12 - 大端序:
0x12, 0x34, 0x56, 0x78
大部分现代 CPU(包括 ARM 架构的鸿蒙设备)使用小端序。我们统一采用小端序,确保两端解析一致。
3.4 第一步:发送元数据包(文件名 + 文件大小)
假设我们要发送一个名为 cat.jpg 的文件,大小 1234567 字节(约 1.18MB)。发送端需要把这些信息打包成一个字节数组。
3.4.1 文件名长度(2 字节,小端序)
文件名 cat.jpg 用 UTF-8 编码后占几个字节?c a t . j p g 共 7 个字节(英文和点都是单字节)。所以文件名长度 = 7。
小端序存储 7(十六进制 0x0007):
- 第1字节:
0x07 - 第2字节:
0x00
3.4.2 文件大小(8 字节,小端序)
文件大小 1,234,567 字节,十六进制 0x0012D687(4字节足够,但协议固定用8字节,高位补0)。小端序存储:
- 第1字节:
0x87 - 第2字节:
0xD6 - 第3字节:
0x12 - 第4字节:
0x00 - 第5~8字节:
0x00
3.4.3 文件名(UTF-8 编码)
cat.jpg 的 UTF-8 编码就是每个字符的 ASCII 码:0x63, 0x61, 0x74, 0x2E, 0x6A, 0x70, 0x67。
3.4.4 组装完整元数据包
首先构造元数据体(不含首字节):
- 偏移 0~1:文件名长度
[0x07, 0x00] - 偏移 2~9:文件大小
[0x87, 0xD6, 0x12, 0x00, 0x00, 0x00, 0x00, 0x00] - 偏移 10~16:文件名
[0x63, 0x61, 0x74, 0x2E, 0x6A, 0x70, 0x67]
然后加上首字节 0xF1,得到完整的元数据包字节序列:
首字节: 0xF1
后续: 0x07, 0x00, 0x87, 0xD6, 0x12, 0x00, 0x00, 0x00, 0x00, 0x00, 0x63, 0x61, 0x74, 0x2E, 0x6A, 0x70, 0x67
接收端收到后,先读首字节 0xF1,知道是元数据包;再读第2~3字节得到文件名长度 7;然后读第4~11字节得到文件大小 1,234,567;最后读后续 7 个字节得到文件名 cat.jpg。
3.4.5 核心代码
const encoder = new util.TextEncoder();
const fileNameBytes = encoder.encodeInto(fileName);
const nameLen = fileNameBytes.length;
const metaData = new ArrayBuffer(2 + 8 + nameLen);
const view = new DataView(metaData);
view.setUint16(0, nameLen, true);
view.setBigUint64(2, BigInt(totalBytes), true);
new Uint8Array(metaData, 10).set(fileNameBytes);
const packet = new Uint8Array(1 + metaData.byteLength);
packet[0] = NearLinkConstants.FLAG_METADATA;
packet.set(new Uint8Array(metaData), 1);
await NearLinkDataTransfer.writeData(packet.buffer as ArrayBuffer);
3.5 第二步:切割文件为数据块
现在开始发送文件本体。假设 MTU 设置为 2048,协议头占 5 字节(1字节标志 + 4字节序号),所以每个数据块最多携带 2048 - 5 = 2043 字节的文件数据。
// 星闪连接 MTU 根据设备能力调整,默认 512,区间值 [0,65535]
static readonly DEFAULT_MTU: number = 1024 * 2;
// 分块大小设为 MTU 减去协议头开销(1字节类型 + 4字节序号 = 5字节)
static readonly DEFAULT_CHUNK_SIZE: number = NearLinkConstants.DEFAULT_MTU - 5;
1,234,567 字节的文件需要分成多少块?
- 块数 = ceil(1,234,567 / 2043) = 605 块(最后一块可能不足 2043 字节)。
3.5.1 每块的打包格式
以第 0 块(第一个块)为例,序号 0,假设前 2043 字节数据为 [D0, D1, D2, ..., D2042]。打包后的字节序列:
首字节: 0xF2
序号(4字节小端): 0x00, 0x00, 0x00, 0x00
数据: D0, D1, D2, ..., D2042
第 1 块:序号 1,打包为 0xF2, 0x01, 0x00, 0x00, 0x00, 数据...
以此类推,直到第 604 块(最后一块),序号 604,数据长度可能小于 2043。
3.5.2 核心代码
// 3. 分块发送
const chunkSize = NearLinkConstants.DEFAULT_CHUNK_SIZE;
const totalBytes = fileBuffer.byteLength;
const totalChunks = Math.ceil(totalBytes / chunkSize);
let sentBytes = 0;
const startTime = Date.now();
for (let i = 0; i < totalChunks; i++) {
if (session.isCancelled) {
await FileTransferHandler.sendCancelPacket();
throw new Error('发送已被取消');
}
const start = i * chunkSize;
const end = Math.min(start + chunkSize, totalBytes);
const chunk = fileBuffer.slice(start, end);
await FileTransferHandler.sendDataChunk(chunk, i);
sentBytes += chunk.byteLength;
}
private static async sendDataChunk(chunk: ArrayBuffer, seq: number): Promise<void> {
const packet = new Uint8Array(1 + 4 + chunk.byteLength);
packet[0] = NearLinkConstants.FLAG_DATA_CHUNK;
new DataView(packet.buffer).setUint32(1, seq, true);
packet.set(new Uint8Array(chunk), 5);
await NearLinkDataTransfer.writeData(packet.buffer as ArrayBuffer);
}
3.6 第三步:发送完成标记
所有块发送完毕后,发送一个单独的 0xF3 字节(无后续数据)。接收端收到这个单字节包后,就知道文件数据传输结束,可以开始重组。
private static async sendFinishMarker(): Promise<void> {
const packet = new Uint8Array(1);
packet[0] = NearLinkConstants.FLAG_FINISH;
await NearLinkDataTransfer.writeData(packet.buffer as ArrayBuffer);
Logger.i(TAG, '完成标记发送成功');
}
3.7 第四步:接收端重组文件
接收端在收到每个数据块时,会解析出序号和数据体,暂存到 Map<序号, ArrayBuffer> 中,同时累加 receivedBytes。
当收到 0xF3 完成标记后:
- 检查
receivedBytes是否等于元数据中声明的文件大小 1,234,567。若不相等,说明有丢包,传输失败。 - 若相等,按序号从小到大遍历 Map,将每个数据块按顺序拷贝到一个新的
ArrayBuffer中。 - 最终得到完整的文件数据,回调给 UI 层保存。
// 按块索引排序并组装文件
const sortedIndexes = Array.from(session.receivedChunks.keys()).sort((a, b) => a - b);
const result = new ArrayBuffer(session.expectedTotalBytes);
const resultView = new Uint8Array(result);
let offset = 0;
for (const idx of sortedIndexes) {
const chunk = session.receivedChunks.get(idx)!;
resultView.set(new Uint8Array(chunk), offset);
offset += chunk.byteLength;
}
四、可靠传输:确认、取消与完整性
4.1 为什么需要对方确认?
发送端发送元数据后,不能立即开始发文件,因为接收端可能正在忙、磁盘空间不足、或者用户拒绝接收。所以必须等待接收端明确回复 0xEE(同意)或 0xEF(拒绝)。如果收到拒绝,发送端直接失败。
4.2 取消机制
用户点击“取消发送”时,发送端发送 0xEC 取消包,然后清理本地发送会话。接收端收到 0xEC 后,立即关闭弹窗、清理接收缓存,并提示“对方已取消”。这样两端状态一致,不会残留。
4.3 完整性校验:为什么能检测丢包?
某些数据块可能丢失。接收端收到完成标记时,receivedBytes 必然小于 expectedTotalBytes,此时校验失败,不会输出损坏的文件。这是最后一道防线。
五、完整代码
import { util } from '@kit.ArkTS';
import { Logger } from '../utils/Logger';
import { NearLinkConstants } from '../constants/NearLinkConstants';
import { ProgressData } from '../bean/ProgressData';
import { NearLinkDataTransfer } from './DataTransferManager';
const TAG = 'FileTransferHandler';
// 接收会话状态
class ReceiveSession {
receivedChunks: Map<number, ArrayBufferLike> = new Map();
expectedTotalBytes: number = 0;
expectedFileName: string = '';
receivedBytes: number = 0;
lastProgressTime: number = 0;
progressTimer?: number;
isCompleted: boolean = false;
}
// 发送会话状态
class SendSession {
fileBuffer: ArrayBuffer;
fileName: string;
onProgress: (progress: ProgressData) => void;
confirmResolver?: (value: boolean) => void;
isCancelled: boolean = false;
constructor(fileBuffer: ArrayBuffer, fileName: string, onProgress: (progress: ProgressData) => void) {
this.fileBuffer = fileBuffer;
this.fileName = fileName;
this.onProgress = onProgress;
}
}
export class FileTransferHandler {
// 当前活动会话(一次只允许一个发送和一个接收,避免状态覆盖)
private static currentReceiveSession: ReceiveSession | null = null;
private static currentSendSession: SendSession | null = null;
// 外部回调
private static onConfirmCallback?: (agreed: boolean) => void;
private static onFileInfoCallback?: (fileName: string, fileSize: number) => void;
private static onFileReceiveCompleteCallback?: (fileBuffer: ArrayBuffer, fileName: string) => void;
private static onFileReceiveProgressCallback?: (progress: ProgressData) => void;
private static onErrorCallback?: (error: string) => void;
static init(): void {
NearLinkDataTransfer.onRawData((data: ArrayBuffer) => {
FileTransferHandler.processReceivedData(data);
});
Logger.i(TAG, '文件传输协议处理器已初始化');
}
static onError(callback: (error: string) => void): void {
FileTransferHandler.onErrorCallback = callback;
}
static onConfirm(callback: (agreed: boolean) => void): void {
FileTransferHandler.onConfirmCallback = callback;
}
static onFileInfo(callback: (fileName: string, fileSize: number) => void): void {
FileTransferHandler.onFileInfoCallback = callback;
}
static onFileReceiveComplete(callback: (fileBuffer: ArrayBuffer, fileName: string) => void): void {
FileTransferHandler.onFileReceiveCompleteCallback = callback;
}
static onFileReceiveProgress(callback: (progress: ProgressData) => void): void {
FileTransferHandler.onFileReceiveProgressCallback = callback;
}
// ========== 发送端 API ==========
static async sendFile(
fileBuffer: ArrayBuffer,
fileName: string,
onProgress: (progress: ProgressData) => void
): Promise<void> {
if (FileTransferHandler.currentSendSession) {
throw new Error('已有发送任务在进行中');
}
const session = new SendSession(fileBuffer, fileName, onProgress);
FileTransferHandler.currentSendSession = session;
try {
// 1. 发送元数据
await FileTransferHandler.sendMetadata(fileBuffer.byteLength, fileName);
// 2. 等待确认(无超时,一直等待直到对方响应或手动取消)
const agreed = await new Promise<boolean>((resolve) => {
session.confirmResolver = resolve;
});
if (!agreed) {
throw new Error('接收端拒绝接收');
}
if (session.isCancelled) {
await FileTransferHandler.sendCancelPacket();
throw new Error('发送已被取消');
}
// 3. 分块发送
const chunkSize = NearLinkConstants.DEFAULT_CHUNK_SIZE;
const totalBytes = fileBuffer.byteLength;
const totalChunks = Math.ceil(totalBytes / chunkSize);
let sentBytes = 0;
const startTime = Date.now();
for (let i = 0; i < totalChunks; i++) {
if (session.isCancelled) {
await FileTransferHandler.sendCancelPacket();
throw new Error('发送已被取消');
}
const start = i * chunkSize;
const end = Math.min(start + chunkSize, totalBytes);
const chunk = fileBuffer.slice(start, end);
await FileTransferHandler.sendDataChunk(chunk, i);
sentBytes += chunk.byteLength;
const elapsed = (Date.now() - startTime) / 1000;
const speedMbps = elapsed > 0 ? (sentBytes / elapsed) * 8 / 1e6 : 0;
const remainingBytes = totalBytes - sentBytes;
const remainingSeconds = (sentBytes > 0 && elapsed > 0) ? remainingBytes / (sentBytes / elapsed) : 0;
onProgress({
transferredBytes: sentBytes,
totalBytes: totalBytes,
speedMbps: speedMbps,
remainingSeconds: remainingSeconds,
percent: (sentBytes / totalBytes) * 100
});
// 官方要求最小间隔 10ms
if (i < totalChunks - 1) {
await FileTransferHandler.delay(NearLinkConstants.WRITE_DATA_INTERVAL_MS);
}
}
// 4. 发送完成标记
await FileTransferHandler.sendFinishMarker();
Logger.i(TAG, '文件发送完成');
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
Logger.e(TAG, `发送失败: ${errorMsg}`);
throw new Error(`发送失败: ${errorMsg}`);
} finally {
FileTransferHandler.currentSendSession = null;
}
}
static async cancelSend(): Promise<void> {
const session = FileTransferHandler.currentSendSession;
if (!session) return;
// 标记取消
session.isCancelled = true;
// 如果还在等待对方确认,立即结束 Promise(让发送流程退出)
if (session.confirmResolver) {
session.confirmResolver(false); // 让对方视为拒绝,从而退出等待
session.confirmResolver = undefined;
}
// 发送取消包(通知对方)
await FileTransferHandler.sendCancelPacket();
// 立即清理发送会话,避免下次发送时误判
FileTransferHandler.currentSendSession = null;
}
static acceptFile(): void {
FileTransferHandler.sendConfirmPacket(NearLinkConstants.CONFIRM_AGREE);
}
static rejectFile(): void {
FileTransferHandler.sendConfirmPacket(NearLinkConstants.CONFIRM_REJECT);
FileTransferHandler.clearReceiveSession();
}
// ========== 内部发送方法 ==========
private static async sendMetadata(totalBytes: number, fileName: string): Promise<void> {
// 校验文件大小和文件名长度
if (totalBytes > NearLinkConstants.MAX_FILE_SIZE_BYTES) {
throw new Error(`文件过大 (${totalBytes} > ${NearLinkConstants.MAX_FILE_SIZE_BYTES})`);
}
if (fileName.length > NearLinkConstants.MAX_FILENAME_LENGTH) {
throw new Error(`文件名过长 (${fileName.length} > ${NearLinkConstants.MAX_FILENAME_LENGTH})`);
}
const encoder = new util.TextEncoder();
const fileNameBytes = encoder.encodeInto(fileName);
const nameLen = fileNameBytes.length;
const metaData = new ArrayBuffer(2 + 8 + nameLen);
const view = new DataView(metaData);
view.setUint16(0, nameLen, true);
view.setBigUint64(2, BigInt(totalBytes), true);
new Uint8Array(metaData, 10).set(fileNameBytes);
const packet = new Uint8Array(1 + metaData.byteLength);
packet[0] = NearLinkConstants.FLAG_METADATA;
packet.set(new Uint8Array(metaData), 1);
await NearLinkDataTransfer.writeData(packet.buffer as ArrayBuffer);
Logger.i(TAG, `元数据发送成功: ${fileName}, ${totalBytes} bytes`);
}
private static async sendDataChunk(chunk: ArrayBuffer, seq: number): Promise<void> {
const packet = new Uint8Array(1 + 4 + chunk.byteLength);
packet[0] = NearLinkConstants.FLAG_DATA_CHUNK;
new DataView(packet.buffer).setUint32(1, seq, true);
packet.set(new Uint8Array(chunk), 5);
await NearLinkDataTransfer.writeData(packet.buffer as ArrayBuffer);
}
private static async sendFinishMarker(): Promise<void> {
const packet = new Uint8Array(1);
packet[0] = NearLinkConstants.FLAG_FINISH;
await NearLinkDataTransfer.writeData(packet.buffer as ArrayBuffer);
Logger.i(TAG, '完成标记发送成功');
}
private static async sendConfirmPacket(code: number): Promise<void> {
const packet = new Uint8Array(1);
packet[0] = code;
await NearLinkDataTransfer.writeData(packet.buffer as ArrayBuffer);
Logger.i(TAG, `确认包发送: 0x${code.toString(16)}`);
}
private static async sendCancelPacket(): Promise<void> {
const packet = new Uint8Array(1);
packet[0] = NearLinkConstants.FLAG_CANCEL;
await NearLinkDataTransfer.writeData(packet.buffer as ArrayBuffer);
Logger.i(TAG, '取消包发送');
}
// ========== 接收端处理 ==========
private static processReceivedData(data: ArrayBuffer): void {
const bytes = new Uint8Array(data);
if (bytes.length === 0) return;
const firstByte = bytes[0];
// 处理单字节控制包
if (data.byteLength === 1) {
switch (firstByte) {
case NearLinkConstants.FLAG_FINISH:
FileTransferHandler.handleFinishMarker();
break;
case NearLinkConstants.CONFIRM_AGREE:
Logger.i(TAG, '收到对方同意确认');
FileTransferHandler.currentSendSession?.confirmResolver?.(true);
FileTransferHandler.onConfirmCallback?.(true);
break;
case NearLinkConstants.CONFIRM_REJECT:
Logger.i(TAG, '收到对方拒绝确认');
FileTransferHandler.currentSendSession?.confirmResolver?.(false);
FileTransferHandler.onConfirmCallback?.(false);
FileTransferHandler.currentSendSession = null;
break;
case NearLinkConstants.FLAG_CANCEL:
Logger.i(TAG, '收到取消传输指令');
FileTransferHandler.clearReceiveSession();
FileTransferHandler.onErrorCallback?.('传输已被对方取消');
break;
default:
Logger.w(TAG, `未知单字节包: 0x${firstByte.toString(16)}`);
}
return;
}
// 处理多字节数据包
switch (firstByte) {
case NearLinkConstants.FLAG_METADATA:
FileTransferHandler.handleMetadataPacket(data);
break;
case NearLinkConstants.FLAG_DATA_CHUNK:
FileTransferHandler.handleDataChunkPacket(bytes);
break;
default:
Logger.w(TAG, `未知包类型: 0x${firstByte.toString(16)}`);
}
}
private static handleMetadataPacket(data: ArrayBuffer): void {
// 清除之前的接收会话
FileTransferHandler.clearReceiveSession();
const session = new ReceiveSession();
FileTransferHandler.currentReceiveSession = session;
if (data.byteLength < 12) {
FileTransferHandler.onErrorCallback?.('元数据包过小');
FileTransferHandler.rejectFile();
return;
}
const metaData = data.slice(1);
const view = new DataView(metaData);
const nameLen = view.getUint16(0, true);
if (nameLen === 0 || nameLen > NearLinkConstants.MAX_FILENAME_LENGTH || nameLen > metaData.byteLength - 10) {
FileTransferHandler.onErrorCallback?.('文件名长度异常');
FileTransferHandler.rejectFile();
return;
}
const totalBytes = Number(view.getBigUint64(2, true));
if (totalBytes > NearLinkConstants.MAX_FILE_SIZE_BYTES) {
FileTransferHandler.onErrorCallback?.(`文件过大 (${totalBytes} > ${NearLinkConstants.MAX_FILE_SIZE_BYTES})`);
FileTransferHandler.rejectFile();
return;
}
const fileNameBytes = new Uint8Array(metaData, 10, nameLen);
const decoder = new util.TextDecoder();
session.expectedFileName = decoder.decodeToString(fileNameBytes);
session.expectedTotalBytes = totalBytes;
session.receivedBytes = 0;
session.receivedChunks.clear();
Logger.i(TAG, `收到元数据: ${session.expectedFileName}, 大小: ${session.expectedTotalBytes} bytes`);
FileTransferHandler.onFileInfoCallback?.(session.expectedFileName, session.expectedTotalBytes);
}
private static handleDataChunkPacket(bytes: Uint8Array): void {
const session = FileTransferHandler.currentReceiveSession;
if (!session || session.isCompleted) {
Logger.w(TAG, '未就绪或已完成,忽略数据块');
return;
}
if (bytes.length < 5) {
Logger.w(TAG, `数据块过小: ${bytes.length} bytes,忽略`);
return;
}
const view = new DataView(bytes.buffer);
const chunkIndex = view.getUint32(1, true);
const chunkData = bytes.slice(5).buffer;
session.receivedChunks.set(chunkIndex, chunkData);
session.receivedBytes += chunkData.byteLength;
Logger.i(TAG, `收到数据块 ${chunkIndex}, 大小: ${chunkData.byteLength}`);
// 进度回调(节流:每 200ms 最多一次)
if (FileTransferHandler.onFileReceiveProgressCallback && session.expectedTotalBytes > 0) {
const percent = (session.receivedBytes / session.expectedTotalBytes) * 100;
const progress: ProgressData = {
transferredBytes: session.receivedBytes,
totalBytes: session.expectedTotalBytes,
speedMbps: 0,
remainingSeconds: 0,
percent: percent
};
const now = Date.now();
if (now - session.lastProgressTime >= 200) {
FileTransferHandler.onFileReceiveProgressCallback(progress);
session.lastProgressTime = now;
if (session.progressTimer) {
clearTimeout(session.progressTimer);
session.progressTimer = undefined;
}
} else {
if (session.progressTimer) {
clearTimeout(session.progressTimer);
}
session.progressTimer = setTimeout(() => {
if (FileTransferHandler.onFileReceiveProgressCallback && !session.isCompleted) {
FileTransferHandler.onFileReceiveProgressCallback(progress);
session.lastProgressTime = Date.now();
session.progressTimer = undefined;
}
}, 200 - (now - session.lastProgressTime));
}
}
}
private static handleFinishMarker(): void {
const session = FileTransferHandler.currentReceiveSession;
if (!session) {
Logger.w(TAG, '收到完成标记但没有接收会话');
return;
}
// 标记已完成,防止延迟进度回调再执行
session.isCompleted = true;
if (session.progressTimer) {
clearTimeout(session.progressTimer);
session.progressTimer = undefined;
}
// 强制回调 100% 进度
if (FileTransferHandler.onFileReceiveProgressCallback && session.expectedTotalBytes > 0) {
const finalProgress: ProgressData = {
transferredBytes: session.expectedTotalBytes,
totalBytes: session.expectedTotalBytes,
speedMbps: 0,
remainingSeconds: 0,
percent: 100
};
FileTransferHandler.onFileReceiveProgressCallback(finalProgress);
}
// 完整性校验:实际收到的总字节数是否等于期望值
if (session.receivedBytes !== session.expectedTotalBytes) {
const errorMsg = `文件完整性校验失败: 期望 ${session.expectedTotalBytes} 字节,实际收到 ${session.receivedBytes} 字节`;
Logger.e(TAG, errorMsg);
FileTransferHandler.onErrorCallback?.(errorMsg);
FileTransferHandler.clearReceiveSession();
return;
}
if (session.receivedChunks.size === 0) {
Logger.w(TAG, '收到完成标记但没有数据块');
FileTransferHandler.clearReceiveSession();
return;
}
// 按块索引排序并组装文件
const sortedIndexes = Array.from(session.receivedChunks.keys()).sort((a, b) => a - b);
const result = new ArrayBuffer(session.expectedTotalBytes);
const resultView = new Uint8Array(result);
let offset = 0;
for (const idx of sortedIndexes) {
const chunk = session.receivedChunks.get(idx)!;
resultView.set(new Uint8Array(chunk), offset);
offset += chunk.byteLength;
}
Logger.i(TAG, `文件组装完成: ${session.expectedFileName}, 大小=${session.expectedTotalBytes}`);
FileTransferHandler.onFileReceiveCompleteCallback?.(result, session.expectedFileName);
FileTransferHandler.clearReceiveSession();
}
private static clearReceiveSession(): void {
if (FileTransferHandler.currentReceiveSession) {
const session = FileTransferHandler.currentReceiveSession;
if (session.progressTimer) {
clearTimeout(session.progressTimer);
}
FileTransferHandler.currentReceiveSession = null;
}
}
private static delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
六、性能优化
6.1 MTU 与分块大小的关系
MTU(Maximum Transmission Unit)是底层一次能传输的最大数据包长度。默认 512 字节,取值范围 [0,65535]。如果数据包超过 MTU,底层可能分片或直接丢弃。通过 dataTransfer.connect 中的 mtu 参数,我们可以协商更大的值(如 2048、4096),从而允许更大的分块,提高传输速度。但需要注意传输通道拥堵,所以发送间隔 ≥10ms。
优化对比:
- MTU 未设置(默认 512),分块 1KB:速度 ~100KB/s
- MTU 显式设置为 2048,分块 2KB:速度 ~200KB/s(翻倍)
注意:分块大小必须 ≤ MTU - 协议头,否则会被底层截断或丢弃。
七、踩坑与设计教训
| 问题现象 | 根本原因(设计层面) | 改进措施(设计层面) |
|---|---|---|
| 超过 1KB 的分块无法传输 | 未考虑 MTU 参数,假设底层自动处理 | 显式设置 MTU,分块大小与 MTU 联动 |
| 取消后弹窗不关闭 | 协议缺少取消包,状态单向同步 | 增加 FLAG_CANCEL,双向通知 |
| “已有发送任务”误报 | 取消时未清理会话状态 | cancelSend 中立即清空 currentSendSession |
| 进度回调卡顿 | 每次收到块都遍历 Map 计算总大小 | 维护 receivedBytes 增量累加 |
| 协议解析错误 | 元数据与完成标记共用 0xFF |
分配独立标志,避免歧义 |
| 文件名乱码 | 未指定编码方式 | 统一使用 UTF-8 编码,两端一致 |
八、总结
本文从设计思想出发,详细拆解了星闪文件传输系统的架构、协议字节级实现、文件切割与重组、可靠传输机制以及性能优化。当然还有很多不足,例如加密没有设计。如果觉得本文对你有帮助,请点赞、收藏、转发支持!
更多推荐





所有评论(0)