HarmonyOS 文件传输实战:上传下载队列、断点续传与失败恢复怎么设计

文件传输最怕“接口能跑,体验不稳”。真实项目里,用户下载离线地图、上传日志包、同步大文件时,网络可能断开,应用可能切后台,用户可能暂停或取消,服务端也可能返回分片过期。如果只写一个 download(url)upload(file),失败后很难恢复,也说不清楚当前文件到底传到哪里。

这篇文章只解决一个工程问题:HarmonyOS 应用里如何把上传、下载、队列、断点续传、失败恢复和日志验收设计成一条可维护链路。

请添加图片描述

本文会落到四个结果:

  1. 每个传输任务都有唯一 id、状态、进度和错误原因。
  2. 上传下载都进入队列,不让多个大任务把网络和内存打爆。
  3. 断点续传记录分片进度,失败后能从最近可用位置恢复。
  4. 用户取消、网络失败、服务端过期都有明确回退策略。

一、先区分三种失败:网络失败、业务失败、用户取消

文件传输失败不能只显示“失败”。三种失败的处理完全不同。

类型 例子 处理方式
网络失败 弱网、断网、超时 可重试,保留进度
业务失败 文件不存在、权限不足、分片过期 停止任务,提示原因
用户取消 用户手动暂停或取消 按用户意图保存或清理

如果这三类都混成一个 error,后面就会出现两个问题:用户不知道能不能重试,开发也不知道该从哪里恢复。

二、资料与版本边界:本文写应用层传输管理

本文示例面向 HarmonyOS NEXT / ArkTS 工程,应用层重点放在任务队列、分片记录、网络恢复、进度持久化和 UI 状态管理。具体网络请求可结合 @ohos.net.http、上传下载能力或团队已有网络库落地。

请添加图片描述

层级 本文关注 不展开
任务层 上传、下载、暂停、取消、恢复 底层 TCP 实现
队列层 并发数、等待、重试 复杂调度算法
进度层 分片、已完成字节、校验 服务端存储实现
验证层 弱网、断点、后台、取消 压测平台搭建

请添加图片描述

三、任务模型:别只保存 URL

先定义统一任务模型。上传和下载都可以复用同一套状态。

export type TransferType = 'upload' | 'download';
export type TransferStatus = 'waiting' | 'running' | 'paused' | 'success' | 'failed' | 'cancelled';

export interface TransferTask {
  taskId: string;
  type: TransferType;
  sourceUri: string;
  targetUri: string;
  totalBytes: number;
  finishedBytes: number;
  status: TransferStatus;
  retryCount: number;
  errorMessage?: string;
  updatedAt: number;
}

这个模型解决四个问题:

  1. taskId 用于日志、UI 和恢复。
  2. finishedBytes 让断点续传有依据。
  3. retryCount 避免无限重试。
  4. status 让页面不用猜任务阶段。

四、队列控制:大文件传输不能全部并发

大文件上传下载要控制并发,尤其是移动网络和后台场景。

export class TransferQueue {
  private waiting: TransferTask[] = [];
  private running = new Map<string, TransferTask>();

  constructor(private readonly maxRunning: number) {}

  enqueue(task: TransferTask): void {
    this.waiting.push(task);
  }

  next(): TransferTask[] {
    const picked: TransferTask[] = [];
    while (this.running.size < this.maxRunning && this.waiting.length > 0) {
      const task = this.waiting.shift();
      if (task === undefined) {
        break;
      }
      task.status = 'running';
      this.running.set(task.taskId, task);
      picked.push(task);
    }
    return picked;
  }

  finish(taskId: string): void {
    this.running.delete(taskId);
  }
}

这段队列不是为了炫技,而是保护体验。一次跑太多下载,进度可能都很慢,失败率更高,内存也更容易上涨。

五、断点记录:恢复靠数据,不靠记忆

断点续传要记录每个任务完成到哪里。下载通常记录已完成字节,上传通常记录已上传分片。

export interface TransferCheckpoint {
  taskId: string;
  offset: number;
  chunkSize: number;
  chunkIndex: number;
  checksum?: string;
  savedAt: number;
}

export class CheckpointStore {
  private data = new Map<string, TransferCheckpoint>();

  save(checkpoint: TransferCheckpoint): void {
    this.data.set(checkpoint.taskId, checkpoint);
  }

  get(taskId: string): TransferCheckpoint | undefined {
    return this.data.get(taskId);
  }

  remove(taskId: string): void {
    this.data.delete(taskId);
  }
}

真实项目里可以把 CheckpointStore 换成 Preferences、关系型数据库或文件。关键是不能只存在内存里,否则应用重启后就无法恢复。

六、下载恢复:从 offset 开始,而不是重新下载

下载恢复时要读取 checkpoint,再从对应位置请求。

export interface DownloadRequest {
  taskId: string;
  url: string;
  savePath: string;
  startOffset: number;
}

export function buildDownloadRequest(
  task: TransferTask,
  store: CheckpointStore
): DownloadRequest {
  const checkpoint = store.get(task.taskId);
  const startOffset = checkpoint !== undefined ? checkpoint.offset : task.finishedBytes;

  return {
    taskId: task.taskId,
    url: task.sourceUri,
    savePath: task.targetUri,
    startOffset
  };
}

注意两点:

  1. 服务端必须支持范围请求或业务层分片下载,否则客户端无法真正断点。
  2. 恢复前要校验本地临时文件是否存在,不能只相信进度数字。

七、上传分片:每片成功后再保存进度

上传大文件更适合分片。每片成功后保存 checkpoint。

export interface UploadChunk {
  taskId: string;
  chunkIndex: number;
  start: number;
  end: number;
  checksum: string;
}

export function createUploadChunks(task: TransferTask, chunkSize: number): UploadChunk[] {
  const chunks: UploadChunk[] = [];
  let start = 0;
  let index = 0;

  while (start < task.totalBytes) {
    const end = Math.min(start + chunkSize, task.totalBytes);
    chunks.push({
      taskId: task.taskId,
      chunkIndex: index,
      start,
      end,
      checksum: `${task.taskId}_${index}_${end - start}`
    });
    start = end;
    index += 1;
  }

  return chunks;
}

这段代码里的 checksum 只是示例占位。真实项目应使用可靠摘要算法,并和服务端约定校验规则。上传分片如果没有校验,弱网重试时很容易产生重复片或坏片。

八、重试策略:失败不是无限重试

重试要有边界,也要区分失败类型。

export type TransferFailType = 'network' | 'server' | 'permission' | 'expired' | 'unknown';

export interface RetryDecision {
  shouldRetry: boolean;
  delayMs: number;
  message: string;
}

export function resolveRetryDecision(type: TransferFailType, retryCount: number): RetryDecision {
  if (type === 'permission' || type === 'expired') {
    return { shouldRetry: false, delayMs: 0, message: '当前任务无法恢复,需要重新发起' };
  }

  if (retryCount >= 3) {
    return { shouldRetry: false, delayMs: 0, message: '重试次数已达上限,请稍后手动重试' };
  }

  return {
    shouldRetry: true,
    delayMs: 1000 * Math.pow(2, retryCount),
    message: '网络异常,稍后自动重试'
  };
}

重试次数、退避间隔要可配置。无限重试会增加耗电,也会让用户误以为任务卡住。

九、页面状态:用户要能暂停、继续、取消

文件传输页面至少要展示状态、进度和可执行动作。

export interface TransferUiState {
  title: string;
  progressText: string;
  primaryAction: 'pause' | 'resume' | 'retry' | 'open' | 'none';
  secondaryAction: 'cancel' | 'delete' | 'none';
}

export function buildTransferUiState(task: TransferTask): TransferUiState {
  const percent = task.totalBytes === 0
    ? 0
    : Math.floor((task.finishedBytes / task.totalBytes) * 100);

  if (task.status === 'running') {
    return { title: '传输中', progressText: `${percent}%`, primaryAction: 'pause', secondaryAction: 'cancel' };
  }
  if (task.status === 'paused') {
    return { title: '已暂停', progressText: `${percent}%`, primaryAction: 'resume', secondaryAction: 'cancel' };
  }
  if (task.status === 'failed') {
    const message = task.errorMessage !== undefined ? task.errorMessage : '请稍后重试';
    return { title: '传输失败', progressText: message, primaryAction: 'retry', secondaryAction: 'delete' };
  }
  if (task.status === 'success') {
    return { title: '传输完成', progressText: '100%', primaryAction: 'open', secondaryAction: 'delete' };
  }
  return { title: '等待中', progressText: `${percent}%`, primaryAction: 'none', secondaryAction: 'cancel' };
}

UI 状态不要散落在页面判断里。统一函数能保证上传和下载的操作含义一致。

十、日志记录:排查要能看到完整链路

文件传输问题经常跨越网络、存储、后台和服务端,必须有日志。

export interface TransferLog {
  taskId: string;
  action: 'enqueue' | 'start' | 'progress' | 'pause' | 'resume' | 'retry' | 'success' | 'fail' | 'cancel';
  status: TransferStatus;
  finishedBytes: number;
  message: string;
  timestamp: number;
}

export function createTransferLog(task: TransferTask, action: TransferLog['action'], message: string): TransferLog {
  return {
    taskId: task.taskId,
    action,
    status: task.status,
    finishedBytes: task.finishedBytes,
    message,
    timestamp: Date.now()
  };
}

排查时至少要能回答:任务什么时候创建、什么时候开始、失败前完成了多少、是否保存 checkpoint、是否自动重试、用户是否取消。

十一、文件传输问题排查表

现象 优先怀疑 检查方式 修复方向
断网后从头下载 checkpoint 没保存 CheckpointStore 每次进度变化后持久化
上传重复分片 服务端幂等不足 查 chunkIndex 和 checksum 上传前查询已完成分片
任务越跑越多 队列无并发限制 查 running 数量 maxRunning
用户取消后还在传 取消状态没传到底层 查 cancel 日志 取消后停止请求并清理临时文件
失败原因不清楚 error 太泛 查失败类型 区分 network/server/permission
切后台后状态丢失 进度只存在内存 重启应用验证 持久化任务和 checkpoint

不要把所有问题都当成“网络差”。弱网只是触发器,真正的问题通常是状态没有保存。

十二、上线前传输验收表

检查项 通过标准
队列并发受控 同时运行任务数量不超过阈值
断点可恢复 断网、重启后能从 checkpoint 继续
用户可暂停取消 UI 操作和任务状态一致
失败有分类 网络、权限、过期、服务端错误可区分
临时文件可清理 取消和失败不会留下垃圾文件
日志可串联 一个 taskId 能追踪全链路
弱网已验证 模拟断网、慢网、切后台都通过

验收一定要覆盖异常路径。文件传输不是“下载成功一次”就算完成。

十三、文件传输相关官方资料

  1. 华为开发者文档:Network Kit / 网络请求
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/network-kit-overview
  2. 华为开发者文档:http 请求能力
    https://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-http
  3. 华为开发者文档:应用文件访问与管理
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/app-file-access
  4. 华为开发者文档:后台任务
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/background-task-overview

十四、把传输做成可恢复任务

上传下载的核心不是“发起请求”,而是“任务可恢复”。只要任务模型、队列、checkpoint、重试、UI 和日志都完整,文件传输就能从一次脆弱请求变成可维护能力。

最后用这张表做复盘:

问题 稳定答案
任务是谁 taskId 唯一标识
传到哪里 finishedBytes 和 checkpoint 记录
失败怎么办 按失败类型决定重试或重建
用户取消怎么办 停止请求并清理临时文件
怎么证明稳定 弱网、断网、重启、切后台全走一遍
Logo

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

更多推荐