完整源码:NearLinkFileTransfer

上一篇我们已经完整介绍了星闪的能力以及使用方法。本文重点阐述设计思想、架构分层、协议设计、文件二进制数据流转。为啥要先说设计思想与架构封层,真的是为了方便维护和调试。为了功能堆代码,最后出现Bug真难找这个坑我踩了。

让他们各司其职便于查阅代码和调试。主要是一开始小看它了,觉得没多少代码。当认真的想做一个有交互的文件传输,需要涉及的内容太多了。

一、星闪文件传输的设计思想

实现一个星闪文件传输工具,技术点繁多:权限、广播、扫描、连接、分块、重组、确认、取消、进度、MTU 优化……但真正的价值在于如何将这些点组织成一个高内聚、低耦合、可扩展、可维护的系统。

  • 单一职责:每个模块只做一件事。
  • 外观模式:对 UI 层隐藏底层复杂性。
  • 协议自描述:应用层协议清晰、可扩展。
  • 会话隔离:支持单任务串行,并为未来多任务预留空间。
  • 可靠性优先:完整性校验、取消通知、状态清理。

运行效果

接收端 发送端
星闪平板接收端.gif 星闪手机端发送文件.gif

二、整体架构:从混沌到有序

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 完成标记后:

  1. 检查 receivedBytes 是否等于元数据中声明的文件大小 1,234,567。若不相等,说明有丢包,传输失败。
  2. 若相等,按序号从小到大遍历 Map,将每个数据块按顺序拷贝到一个新的 ArrayBuffer 中。
  3. 最终得到完整的文件数据,回调给 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 编码,两端一致

八、总结

本文从设计思想出发,详细拆解了星闪文件传输系统的架构、协议字节级实现、文件切割与重组、可靠传输机制以及性能优化。当然还有很多不足,例如加密没有设计。如果觉得本文对你有帮助,请点赞、收藏、转发支持!

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐