在这里插入图片描述

每日一句正能量

“朝着自己的方向走,哪怕慢一点,也比在别人的眼光里兜兜转转更有意义。”
别人的眼光是个迷宫,你永远不知道出口在哪;而自己的方向哪怕只是一条小路,每一步都是在向前。速度不重要,方向才重要。

摘要

在移动应用开发中,文件下载是最常见也最考验工程能力的功能之一。本文系统讲解 HarmonyOS 提供的后台下载能力,从 request.agent 核心原理出发,逐步构建支持断点续传、多任务并发、优先级调度和异常恢复的完整下载管理器,帮助开发者打造稳定可靠的后台文件传输系统。


一、引言:后台下载的工程挑战

文件下载是移动应用的基础能力,但"接口能跑,体验不稳"是开发者最常遇到的困境。真实项目中,用户下载离线地图包、同步云端相册、更新应用资源包时,网络可能随时断开,应用可能切到后台被系统回收,用户可能手动暂停或取消,服务端也可能返回分片过期。

如果只写一个 download(url) 然后等待回调,失败后很难恢复,也说不清楚当前文件到底传到了哪里。后台下载任务的工程挑战可以归纳为四个核心问题:

  1. 生命周期管理:应用退后台后,下载任务能否继续执行?进程被回收后如何恢复?
  2. 断点续传:网络中断后,能否从断点位置继续下载,而不是全量重传?
  3. 多任务调度:多个文件同时下载时,如何控制并发数、管理优先级、防止网络和内存被打爆?
  4. 异常恢复:如何区分网络失败、业务失败和用户取消,并采取不同的恢复策略?

本文将围绕 HarmonyOS 的 @ohos.request 模块,从底层原理到上层架构,给出完整的工程解决方案。


二、能力选型:前台任务与后台任务的本质区别

HarmonyOS 的上传下载体系明确区分了前台任务后台任务两种模式,理解它们的差异是做出正确技术决策的第一步。

在这里插入图片描述

维度 前台任务 后台任务
执行时机 立即执行,模态界面 可等待,任意界面
生命周期 跟随应用生命周期 与应用生命周期无关
数据量 通常较小,耗时短 通常较大,耗时长
优先级 高,倾斜带宽资源 较低,系统统一调度
典型场景 发布朋友圈、微博配图 缓存电影、同步数百MB数据
断点续传 不支持 支持(需服务端配合)

核心原则:小文件、用户立即需要的结果用前台任务;大文件、允许后台慢慢完成的用后台任务。后台任务通过 request.agent 创建,由系统托管,即使应用被回收,任务仍会在系统层面继续执行。


三、request.agent 后台下载核心原理

3.1 任务托管模型

request.agent 是 HarmonyOS 提供的后台传输代理能力,它将下载任务从应用进程提升到系统服务层管理。这意味着:

  • 进程无关性:应用进程被回收后,系统服务继续执行下载,完成后通过回调通知应用。
  • 自动恢复:网络条件不满足时任务自动暂停,网络恢复后自动继续(需要 HTTP 服务器支持断点续传)。
  • 安全隔离:普通接口仅操作自己创建的任务,任务信息加密存储,防止遍历攻击和恶意静默下载。

3.2 任务状态机

后台下载任务具有完整的状态生命周期:

状态 说明
WAITING 等待中,等待网络或系统资源
RUNNING 运行中,正在传输数据
PAUSED 已暂停,用户手动暂停或系统资源紧张
RETRYING 重试中,网络失败后自动重试
COMPLETED 已完成,下载成功
FAILED 已失败,超过重试次数或业务错误

3.3 关键配置项

import { request } from '@kit.BasicServicesKit';

const config: request.agent.Config = {
  action: request.agent.Action.DOWNLOAD,    // 下载任务
  url: 'https://example.com/file.zip',      // 下载地址
  method: 'GET',                            // HTTP方法
  saveas: './file.zip',                     // 保存路径(应用沙箱内)
  overwrite: true,                          // 是否覆盖已存在文件
  mode: request.agent.Mode.BACKGROUND,      // 后台模式(关键!)
  gauge: true,                              // 启用进度通知
  retry: true,                              // 网络恢复后自动重试
  network: request.agent.Network.ANY        // 允许任意网络
};

关键提示mode: request.agent.Mode.BACKGROUND 是启用断点续传的前提。前台模式下任务跟随应用生命周期,退后台即停止;后台模式下任务由系统托管,支持暂停后继续。


四、单文件后台下载实战

4.1 基础下载实现

以下代码展示了创建后台下载任务、监听进度和状态变化的完整流程:

import { request } from '@kit.BasicServicesKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { common } from '@kit.AbilityKit';

@Entry
@Component
struct SingleDownloadPage {
  @State downloadProgress: number = 0;
  @State downloadState: string = '未开始';
  @State currentSize: string = '0 MB';
  @State totalSize: string = '0 MB';

  private downloadTask: request.agent.Task | undefined = undefined;
  private readonly DOWNLOAD_URL = 'https://example.com/large-file.zip';
  private readonly SAVE_PATH = './downloads/large-file.zip';

  build() {
    Column({ space: 20 }) {
      Text('单文件后台下载演示')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)

      Text(`状态: ${this.downloadState}`)
        .fontSize(14)
        .textColor(this.getStateColor())

      Progress({ value: this.downloadProgress, total: 100, type: ProgressType.Ring })
        .width(120)
        .height(120)

      Text(`${this.currentSize} / ${this.totalSize}`)
        .fontSize(12)
        .textColor('#666666')

      Row({ space: 15 }) {
        Button('开始下载')
          .onClick(() => this.startDownload())
          .enabled(this.downloadState !== '下载中')

        Button('暂停')
          .onClick(() => this.pauseDownload())
          .enabled(this.downloadState === '下载中')

        Button('恢复')
          .onClick(() => this.resumeDownload())
          .enabled(this.downloadState === '已暂停')

        Button('取消')
          .onClick(() => this.cancelDownload())
          .enabled(this.downloadState === '下载中' || this.downloadState === '已暂停')
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .padding(20)
  }

  private getStateColor(): ResourceColor {
    switch (this.downloadState) {
      case '已完成': return '#43A047';
      case '下载中': return '#1976D2';
      case '已失败': return '#C62828';
      case '已暂停': return '#F9A825';
      default: return '#666666';
    }
  }

  /**
   * 创建并启动后台下载任务
   */
  private async startDownload() {
    if (this.downloadTask) {
      console.info('[Download] 任务已存在,直接启动');
      await this.downloadTask.start();
      return;
    }

    const context = getContext(this) as common.UIAbilityContext;

    const config: request.agent.Config = {
      action: request.agent.Action.DOWNLOAD,
      url: this.DOWNLOAD_URL,
      method: 'GET',
      saveas: this.SAVE_PATH,
      overwrite: true,
      mode: request.agent.Mode.BACKGROUND,
      gauge: true,
      retry: true,
      network: request.agent.Network.ANY,
      title: '文件下载中',
      description: '正在下载 large-file.zip'
    };

    try {
      this.downloadTask = await request.agent.create(context, config);
      this.registerCallbacks();
      await this.downloadTask.start();
      this.downloadState = '下载中';
      console.info(`[Download] 任务创建成功,tid=${this.downloadTask.tid}`);
    } catch (error) {
      const err = error as BusinessError;
      this.downloadState = '已失败';
      console.error(`[Download] 创建任务失败: ${err.code}, ${err.message}`);
    }
  }

  /**
   * 注册任务状态监听回调
   */
  private registerCallbacks() {
    if (!this.downloadTask) return;

    // 进度更新
    this.downloadTask.on('progress', (progress: request.agent.Progress) => {
      const processed = progress.processed || 0;
      const sizes = progress.sizes || [0];
      const total = sizes[0] || 1;

      this.downloadProgress = Math.round((processed / total) * 100);
      this.currentSize = this.formatBytes(processed);
      this.totalSize = this.formatBytes(total);

      console.info(`[Download] 进度: ${this.downloadProgress}%, ${processed}/${total} bytes`);
    });

    // 下载完成
    this.downloadTask.on('completed', (progress: request.agent.Progress) => {
      this.downloadState = '已完成';
      this.downloadProgress = 100;
      console.info('[Download] 下载完成');
    });

    // 下载失败
    this.downloadTask.on('failed', (progress: request.agent.Progress) => {
      this.downloadState = '已失败';
      console.error('[Download] 下载失败');
    });

    // 任务暂停
    this.downloadTask.on('pause', (progress: request.agent.Progress) => {
      this.downloadState = '已暂停';
      console.info('[Download] 任务已暂停');
    });

    // 任务恢复
    this.downloadTask.on('resume', (progress: request.agent.Progress) => {
      this.downloadState = '下载中';
      console.info('[Download] 任务已恢复');
    });

    // 响应头数据
    this.downloadTask.on('response', (response: request.agent.HttpResponse) => {
      console.info(`[Download] 响应头: ${JSON.stringify(response.headers)}`);
    });
  }

  /**
   * 暂停下载
   */
  private async pauseDownload() {
    if (!this.downloadTask) return;
    try {
      await this.downloadTask.pause();
    } catch (error) {
      console.error('[Download] 暂停失败:', error);
    }
  }

  /**
   * 恢复下载(断点续传)
   */
  private async resumeDownload() {
    if (!this.downloadTask) return;
    try {
      await this.downloadTask.resume();
    } catch (error) {
      console.error('[Download] 恢复失败:', error);
    }
  }

  /**
   * 取消下载并清理
   */
  private async cancelDownload() {
    if (!this.downloadTask) return;
    try {
      await request.agent.remove(this.downloadTask.tid);
      this.downloadTask = undefined;
      this.downloadState = '未开始';
      this.downloadProgress = 0;
      console.info('[Download] 任务已取消');
    } catch (error) {
      console.error('[Download] 取消失败:', error);
    }
  }

  /**
   * 格式化字节数为可读字符串
   */
  private formatBytes(bytes: number): string {
    if (bytes === 0) return '0 MB';
    const mb = bytes / (1024 * 1024);
    return mb > 1024 ? `${(mb / 1024).toFixed(2)} GB` : `${mb.toFixed(2)} MB`;
  }
}

4.2 断点续传原理

后台下载的断点续传基于 HTTP 协议的 Range 请求头实现。当任务暂停或网络中断后恢复时,系统会自动在请求头中附加 Range: bytes=已下载字节- 字段,服务端返回 206 Partial Content 响应,只传输剩余部分。

在这里插入图片描述

断点续传的关键条件

  1. 服务端支持:HTTP 服务器必须正确处理 Range 请求头,返回 206 状态码。
  2. 后台模式mode 必须设置为 BACKGROUND,前台任务不支持断点续传。
  3. 文件一致性:服务端文件在下载期间不能发生变化,否则断点位置可能失效。
  4. 自动重试retry: true 配置下,网络恢复后系统会自动尝试续传。

五、多任务并发下载管理器

单文件下载只能解决基础需求,真实项目中往往需要同时管理多个下载任务。以下是一个生产级的并发下载管理器实现,支持优先级队列、并发控制、状态追踪和异常恢复。

5.1 任务模型定义

// src/model/DownloadTypes.ets

export enum TaskStatus {
  PENDING = 'pending',         // 等待中
  DOWNLOADING = 'downloading', // 下载中
  PAUSED = 'paused',           // 已暂停
  COMPLETED = 'completed',     // 已完成
  FAILED = 'failed',           // 失败
  CANCELLED = 'cancelled'      // 已取消
}

export interface DownloadTask {
  taskId: string;             // 唯一标识(不用URL,同一URL可能下载多次)
  url: string;                // 下载地址
  fileName: string;           // 文件名
  filePath: string;           // 保存路径
  priority: number;           // 优先级,数值越小优先级越高(1-10)
  status: TaskStatus;         // 当前状态
  progress: number;           // 进度 0-100
  downloadedBytes: number;    // 已下载字节数
  totalBytes: number;         // 总字节数
  failedReason?: string;      // 失败原因
  retryCount: number;         // 当前重试次数
  maxRetry: number;           // 最大重试次数
  createdAt: number;          // 创建时间戳
  updatedAt: number;          // 更新时间戳
}

5.2 下载管理器核心实现

// src/manager/DownloadManager.ets

import { request } from '@kit.BasicServicesKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { common } from '@kit.AbilityKit';
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
import { preferences } from '@kit.ArkData';
import { TaskStatus, DownloadTask } from '../model/DownloadTypes';

/**
 * 并发下载管理器(单例模式)
 * 支持:优先级队列、并发控制、断点续传、异常恢复、状态持久化
 */
export class DownloadManager {
  private static instance: DownloadManager | null = null;

  // 最大并发下载数
  private readonly MAX_CONCURRENT = 3;
  // 默认最大重试次数
  private readonly DEFAULT_MAX_RETRY = 3;
  // 重试退避基数(毫秒)
  private readonly RETRY_BASE_DELAY = 2000;

  // 当前活跃任务数
  private activeCount: number = 0;
  // 等待队列(按优先级排序)
  private waitingQueue: DownloadTask[] = [];
  // 运行中的任务映射:taskId -> request.agent.Task
  private runningTasks: Map<string, request.agent.Task> = new Map();
  // 所有任务的引用(用于UI绑定)
  private allTasks: Map<string, DownloadTask> = new Map();

  // 后台长时任务状态
  private hasBackgroundTask: boolean = false;
  // 持久化存储
  private pref: preferences.Preferences | null = null;

  // 状态变更监听器(供UI层订阅)
  private listeners: Array<(tasks: DownloadTask[]) => void> = [];

  public static getInstance(): DownloadManager {
    if (!DownloadManager.instance) {
      DownloadManager.instance = new DownloadManager();
    }
    return DownloadManager.instance;
  }

  private constructor() {
    this.initPreferences();
    this.restoreTasksFromStorage();
  }

  /**
   * 初始化持久化存储
   */
  private async initPreferences() {
    try {
      const context = getContext(this) as common.UIAbilityContext;
      this.pref = await preferences.getPreferences(context, 'download_tasks');
    } catch (error) {
      console.error('[DownloadManager] 初始化存储失败:', error);
    }
  }

  /**
   * 添加下载任务
   */
  public async addTask(url: string, fileName: string, priority: number = 5): Promise<string> {
    const taskId = `dl_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
    const context = getContext(this) as common.UIAbilityContext;

    const task: DownloadTask = {
      taskId,
      url,
      fileName,
      filePath: `${context.filesDir}/downloads/${fileName}`,
      priority,
      status: TaskStatus.PENDING,
      progress: 0,
      downloadedBytes: 0,
      totalBytes: 0,
      retryCount: 0,
      maxRetry: this.DEFAULT_MAX_RETRY,
      createdAt: Date.now(),
      updatedAt: Date.now()
    };

    // 检查是否已存在相同文件名的任务
    const existing = Array.from(this.allTasks.values()).find(t => t.fileName === fileName && t.status !== TaskStatus.COMPLETED);
    if (existing) {
      console.warn(`[DownloadManager] 文件 ${fileName} 已有未完成任务,跳过创建`);
      return existing.taskId;
    }

    this.allTasks.set(taskId, task);
    this.insertIntoQueue(task);
    await this.persistTask(task);
    this.notifyListeners();

    // 尝试执行
    this.processNext();

    return taskId;
  }

  /**
   * 按优先级插入等待队列
   */
  private insertIntoQueue(task: DownloadTask): void {
    let inserted = false;
    for (let i = 0; i < this.waitingQueue.length; i++) {
      if (this.waitingQueue[i].priority > task.priority) {
        this.waitingQueue.splice(i, 0, task);
        inserted = true;
        break;
      }
    }
    if (!inserted) {
      this.waitingQueue.push(task);
    }
  }

  /**
   * 处理下一个任务(核心调度逻辑)
   */
  private async processNext(): Promise<void> {
    if (this.activeCount >= this.MAX_CONCURRENT || this.waitingQueue.length === 0) {
      return;
    }

    const nextTask = this.waitingQueue.shift();
    if (!nextTask) return;

    nextTask.status = TaskStatus.DOWNLOADING;
    nextTask.updatedAt = Date.now();
    this.activeCount++;

    // 申请后台长时任务(数据传输类型)
    await this.ensureBackgroundTask();

    try {
      const context = getContext(this) as common.UIAbilityContext;

      const config: request.agent.Config = {
        action: request.agent.Action.DOWNLOAD,
        url: nextTask.url,
        method: 'GET',
        saveas: nextTask.filePath,
        overwrite: true,
        mode: request.agent.Mode.BACKGROUND,
        gauge: true,
        retry: true,
        network: request.agent.Network.ANY,
        title: `下载: ${nextTask.fileName}`,
        description: '后台下载中...'
      };

      const agentTask = await request.agent.create(context, config);
      this.runningTasks.set(nextTask.taskId, agentTask);

      // 注册回调
      agentTask.on('progress', (progress: request.agent.Progress) => {
        const processed = progress.processed || 0;
        const sizes = progress.sizes || [0];
        const total = sizes[0] || 1;

        nextTask.downloadedBytes = processed;
        nextTask.totalBytes = total;
        nextTask.progress = Math.round((processed / total) * 100);
        nextTask.updatedAt = Date.now();

        this.persistTask(nextTask);
        this.notifyListeners();
      });

      agentTask.on('completed', async () => {
        nextTask.status = TaskStatus.COMPLETED;
        nextTask.progress = 100;
        nextTask.updatedAt = Date.now();
        await this.finishTask(nextTask);
      });

      agentTask.on('failed', async () => {
        await this.handleTaskFailure(nextTask);
      });

      agentTask.on('pause', () => {
        nextTask.status = TaskStatus.PAUSED;
        nextTask.updatedAt = Date.now();
        this.persistTask(nextTask);
        this.notifyListeners();
      });

      await agentTask.start();
      this.persistTask(nextTask);
      this.notifyListeners();

    } catch (error) {
      nextTask.failedReason = (error as BusinessError).message;
      await this.handleTaskFailure(nextTask);
    }
  }

  /**
   * 处理任务失败
   */
  private async handleTaskFailure(task: DownloadTask): Promise<void> {
    task.retryCount++;

    if (task.retryCount <= task.maxRetry) {
      // 指数退避重试
      const delay = this.RETRY_BASE_DELAY * Math.pow(2, task.retryCount - 1);
      console.info(`[DownloadManager] 任务 ${task.taskId} 将在 ${delay}ms 后第 ${task.retryCount} 次重试`);

      task.status = TaskStatus.PENDING;
      task.updatedAt = Date.now();
      this.activeCount--;
      this.runningTasks.delete(task.taskId);

      setTimeout(() => {
        this.insertIntoQueue(task);
        this.processNext();
      }, delay);
    } else {
      // 超过最大重试次数,标记为失败
      task.status = TaskStatus.FAILED;
      task.failedReason = task.failedReason || '超过最大重试次数';
      await this.finishTask(task);
    }

    this.persistTask(task);
    this.notifyListeners();
  }

  /**
   * 完成任务(成功或失败)
   */
  private async finishTask(task: DownloadTask): Promise<void> {
    this.activeCount--;
    this.runningTasks.delete(task.taskId);
    task.updatedAt = Date.now();

    await this.persistTask(task);
    this.notifyListeners();

    // 检查是否所有任务完成,释放后台长时任务
    const hasRunning = Array.from(this.allTasks.values()).some(t => 
      t.status === TaskStatus.DOWNLOADING || t.status === TaskStatus.PAUSED
    );
    if (!hasRunning && this.waitingQueue.length === 0) {
      await this.releaseBackgroundTask();
    }

    // 继续处理队列
    this.processNext();
  }

  /**
   * 暂停指定任务
   */
  public async pauseTask(taskId: string): Promise<void> {
    const task = this.allTasks.get(taskId);
    const agentTask = this.runningTasks.get(taskId);

    if (task && agentTask && task.status === TaskStatus.DOWNLOADING) {
      try {
        await agentTask.pause();
        // 状态在回调中更新
      } catch (error) {
        console.error(`[DownloadManager] 暂停任务失败: ${taskId}`, error);
      }
    }
  }

  /**
   * 恢复指定任务
   */
  public async resumeTask(taskId: string): Promise<void> {
    const task = this.allTasks.get(taskId);
    const agentTask = this.runningTasks.get(taskId);

    if (task && agentTask && task.status === TaskStatus.PAUSED) {
      try {
        await agentTask.resume();
        // 状态在回调中更新
      } catch (error) {
        console.error(`[DownloadManager] 恢复任务失败: ${taskId}`, error);
      }
    } else if (task && task.status === TaskStatus.FAILED) {
      // 失败任务重新入队
      task.retryCount = 0;
      task.status = TaskStatus.PENDING;
      task.failedReason = undefined;
      this.insertIntoQueue(task);
      this.processNext();
    }
  }

  /**
   * 取消指定任务
   */
  public async cancelTask(taskId: string): Promise<void> {
    const task = this.allTasks.get(taskId);
    const agentTask = this.runningTasks.get(taskId);

    if (agentTask) {
      try {
        await request.agent.remove(agentTask.tid);
      } catch (error) {
        console.error(`[DownloadManager] 移除任务失败: ${taskId}`, error);
      }
      this.runningTasks.delete(taskId);
      this.activeCount--;
    }

    // 从等待队列移除
    const queueIdx = this.waitingQueue.findIndex(t => t.taskId === taskId);
    if (queueIdx > -1) {
      this.waitingQueue.splice(queueIdx, 1);
    }

    if (task) {
      task.status = TaskStatus.CANCELLED;
      task.updatedAt = Date.now();
      await this.persistTask(task);
    }

    this.notifyListeners();
    this.processNext();
  }

  /**
   * 申请后台长时任务
   */
  private async ensureBackgroundTask(): Promise<void> {
    if (this.hasBackgroundTask) return;

    try {
      const context = getContext(this) as common.UIAbilityContext;
      await backgroundTaskManager.startBackgroundRunning(
        context,
        backgroundTaskManager.BackgroundMode.DATA_TRANSFER
      );
      this.hasBackgroundTask = true;
      console.info('[DownloadManager] 后台长时任务已申请');
    } catch (error) {
      console.error('[DownloadManager] 申请后台长时任务失败:', error);
    }
  }

  /**
   * 释放后台长时任务
   */
  private async releaseBackgroundTask(): Promise<void> {
    if (!this.hasBackgroundTask) return;

    try {
      const context = getContext(this) as common.UIAbilityContext;
      await backgroundTaskManager.stopBackgroundRunning(context);
      this.hasBackgroundTask = false;
      console.info('[DownloadManager] 后台长时任务已释放');
    } catch (error) {
      console.error('[DownloadManager] 释放后台长时任务失败:', error);
    }
  }

  /**
   * 持久化任务状态
   */
  private async persistTask(task: DownloadTask): Promise<void> {
    if (!this.pref) return;
    try {
      await this.pref.put(task.taskId, JSON.stringify(task));
      await this.pref.flush();
    } catch (error) {
      console.error('[DownloadManager] 持久化任务失败:', error);
    }
  }

  /**
   * 从存储恢复任务
   */
  private async restoreTasksFromStorage(): Promise<void> {
    if (!this.pref) return;
    try {
      const allKeys = await this.pref.getAllKeys();
      for (const key of allKeys) {
        const value = await this.pref.get(key, '');
        if (value) {
          const task: DownloadTask = JSON.parse(value as string);
          // 只恢复未完成的任务
          if (task.status !== TaskStatus.COMPLETED && task.status !== TaskStatus.CANCELLED) {
            task.status = TaskStatus.PENDING;
            task.retryCount = 0;
            this.allTasks.set(task.taskId, task);
            this.insertIntoQueue(task);
          }
        }
      }
      console.info(`[DownloadManager] 从存储恢复 ${this.waitingQueue.length} 个任务`);
      this.processNext();
    } catch (error) {
      console.error('[DownloadManager] 恢复任务失败:', error);
    }
  }

  /**
   * 获取所有任务列表(供UI绑定)
   */
  public getAllTasks(): DownloadTask[] {
    return Array.from(this.allTasks.values()).sort((a, b) => b.createdAt - a.createdAt);
  }

  /**
   * 订阅状态变更
   */
  public subscribe(listener: (tasks: DownloadTask[]) => void): void {
    this.listeners.push(listener);
  }

  /**
   * 取消订阅
   */
  public unsubscribe(listener: (tasks: DownloadTask[]) => void): void {
    const idx = this.listeners.indexOf(listener);
    if (idx > -1) this.listeners.splice(idx, 1);
  }

  private notifyListeners(): void {
    const tasks = this.getAllTasks();
    this.listeners.forEach(l => l(tasks));
  }
}

5.3 UI 绑定与使用示例

// src/pages/DownloadListPage.ets

import { DownloadManager } from '../manager/DownloadManager';
import { DownloadTask, TaskStatus } from '../model/DownloadTypes';

@Entry
@Component
struct DownloadListPage {
  @State taskList: DownloadTask[] = [];
  private manager: DownloadManager = DownloadManager.getInstance();
  private listener = (tasks: DownloadTask[]) => {
    this.taskList = tasks;
  };

  aboutToAppear() {
    this.manager.subscribe(this.listener);
    this.taskList = this.manager.getAllTasks();
  }

  aboutToDisappear() {
    this.manager.unsubscribe(this.listener);
  }

  build() {
    Column() {
      Text('下载管理器')
        .fontSize(22)
        .fontWeight(FontWeight.Bold)
        .margin(20)

      Row({ space: 10 }) {
        Button('添加测试任务')
          .onClick(() => this.addTestTasks())
        Button('全部开始')
          .onClick(() => this.resumeAll())
        Button('全部暂停')
          .onClick(() => this.pauseAll())
      }
      .margin(10)

      List() {
        ForEach(this.taskList, (task: DownloadTask) => {
          ListItem() {
            this.TaskItemBuilder(task)
          }
        }, (task: DownloadTask) => task.taskId)
      }
      .layoutWeight(1)
      .divider({ strokeWidth: 1, color: '#EEEEEE' })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  @Builder
  TaskItemBuilder(task: DownloadTask) {
    Column({ space: 8 }) {
      Row() {
        Text(task.fileName)
          .fontSize(14)
          .fontWeight(FontWeight.Medium)
          .layoutWeight(1)
        Text(this.getStatusText(task.status))
          .fontSize(12)
          .textColor(this.getStatusColor(task.status))
      }
      .width('100%')

      Progress({ value: task.progress, total: 100 })
        .width('100%')
        .height(6)
        .color('#1976D2')

      Row() {
        Text(`${task.progress}%`)
          .fontSize(11)
          .textColor('#666666')
        Text(`${this.formatBytes(task.downloadedBytes)} / ${this.formatBytes(task.totalBytes)}`)
          .fontSize(11)
          .textColor('#666666')

        if (task.status === TaskStatus.DOWNLOADING) {
          Button('暂停', { buttonStyle: ButtonStyleMode.TEXTUAL })
            .fontSize(12)
            .onClick(() => this.manager.pauseTask(task.taskId))
        } else if (task.status === TaskStatus.PAUSED || task.status === TaskStatus.FAILED) {
          Button('恢复', { buttonStyle: ButtonStyleMode.TEXTUAL })
            .fontSize(12)
            .onClick(() => this.manager.resumeTask(task.taskId))
        }

        Button('取消', { buttonStyle: ButtonStyleMode.TEXTUAL })
          .fontSize(12)
          .fontColor('#C62828')
          .onClick(() => this.manager.cancelTask(task.taskId))
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
    }
    .width('100%')
    .padding(15)
    .backgroundColor(Color.White)
    .borderRadius(8)
    .margin({ top: 8, bottom: 8, left: 12, right: 12 })
  }

  private addTestTasks() {
    this.manager.addTask('https://example.com/file1.zip', '离线地图包.zip', 1);
    this.manager.addTask('https://example.com/file2.mp4', '教学视频.mp4', 3);
    this.manager.addTask('https://example.com/file3.pdf', '技术文档.pdf', 5);
  }

  private pauseAll() {
    this.taskList
      .filter(t => t.status === TaskStatus.DOWNLOADING)
      .forEach(t => this.manager.pauseTask(t.taskId));
  }

  private resumeAll() {
    this.taskList
      .filter(t => t.status === TaskStatus.PAUSED || t.status === TaskStatus.FAILED)
      .forEach(t => this.manager.resumeTask(t.taskId));
  }

  private getStatusText(status: TaskStatus): string {
    const map: Record<string, string> = {
      [TaskStatus.PENDING]: '等待中',
      [TaskStatus.DOWNLOADING]: '下载中',
      [TaskStatus.PAUSED]: '已暂停',
      [TaskStatus.COMPLETED]: '已完成',
      [TaskStatus.FAILED]: '失败',
      [TaskStatus.CANCELLED]: '已取消'
    };
    return map[status] || status;
  }

  private getStatusColor(status: TaskStatus): ResourceColor {
    switch (status) {
      case TaskStatus.COMPLETED: return '#43A047';
      case TaskStatus.DOWNLOADING: return '#1976D2';
      case TaskStatus.FAILED: return '#C62828';
      case TaskStatus.PAUSED: return '#F9A825';
      case TaskStatus.PENDING: return '#9E9E9E';
      default: return '#666666';
    }
  }

  private formatBytes(bytes: number): string {
    if (bytes === 0) return '0 B';
    const k = 1024;
    const sizes = ['B', 'KB', 'MB', 'GB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
  }
}

六、异常处理与状态恢复策略

6.1 区分三种失败类型

文件传输失败不能只显示"失败",三种失败的处理完全不同:

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

6.2 检查点持久化机制

应用进程被系统回收后,内存中的任务状态全部丢失。必须通过持久化存储保存检查点:

// 关键检查点数据
interface DownloadCheckpoint {
  taskId: string;
  url: string;
  filePath: string;
  downloadedBytes: number;    // 已下载字节数(断点位置)
  totalBytes: number;         // 文件总大小
  status: TaskStatus;         // 最后已知状态
  etag?: string;              // 服务端ETag(校验文件一致性)
  lastModified?: string;      // 最后修改时间
  updatedAt: number;          // 检查点保存时间
}

持久化触发时机

  • 每收到进度回调时(节流,如每 5% 或每 10 秒保存一次)
  • 任务状态变更时(暂停、失败、完成)
  • 应用收到 onWorkStopaboutToDisappear

恢复时机

  • 应用启动时从 Preference 读取未完成任务
  • PENDING 状态任务重新入队
  • 系统会自动通过 Range 请求头从断点续传

6.3 并发控制与资源保护

// 防止资源打爆的保护机制
private readonly MAX_CONCURRENT = 3;        // 最大并发数
private readonly MAX_RETRY = 3;             // 最大重试次数
private readonly RETRY_BASE_DELAY = 2000;   // 重试退避基数

// 指数退避:第1次等2秒,第2次等4秒,第3次等8秒
const delay = RETRY_BASE_DELAY * Math.pow(2, retryCount - 1);

在这里插入图片描述


七、权限配置与模块声明

7.1 必要权限

module.json5 中声明网络权限和后台任务权限:

{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET"
      },
      {
        "name": "ohos.permission.KEEP_BACKGROUND_RUNNING"
      }
    ]
  }
}

7.2 后台模式声明

如果使用了 backgroundTaskManager.startBackgroundRunning,需要在 module.json5 中声明对应的后台模式:

{
  "module": {
    "abilities": [
      {
        "name": "EntryAbility",
        "backgroundModes": [
          "dataTransfer"
        ]
      }
    ]
  }
}

八、测试验收清单

后台下载的稳定性需要在多种边界场景下验证:

8.1 生命周期测试

  • 前台下载时切到桌面,任务是否继续执行
  • 息屏待机 30 分钟,下载进度是否正常推进
  • 应用被系统回收后重新启动,未完成任务是否自动恢复
  • 设备重启后,检查点数据是否完整保留
  • 下载过程中接听电话,任务是否被正确暂停/恢复

8.2 网络与异常测试

  • 下载中切换 Wi-Fi 到移动数据,任务是否自动恢复
  • 断网 1 分钟后恢复,是否从断点续传
  • 服务端返回 404/403,是否正确标记为业务失败
  • 服务端不支持 Range 请求,是否降级为全量下载
  • 存储空间不足时,是否正确暂停并提示用户

8.3 并发与队列测试

  • 同时添加 10 个任务,是否只并发执行 3 个
  • 高优先级任务是否优先于低优先级任务执行
  • 取消正在下载的任务,等待队列中的任务是否立即补充
  • 重复添加相同文件,是否正确去重

8.4 性能指标

  • 单文件下载速度是否达到网络带宽上限
  • 多任务并发时,总带宽是否合理分配
  • 进度回调频率是否适中(不过于频繁导致 UI 卡顿)
  • 持久化操作是否使用节流策略,避免频繁写磁盘

九、常见问题与最佳实践

Q1:为什么后台任务有时不触发进度回调?

Agauge: true 只在后台模式下生效,且回调频率由系统控制。如果进度变化很小,系统可能合并回调。建议在回调中更新 UI,但不要依赖回调的精确频率做业务逻辑。

Q2:下载完成后文件在哪里?

Asaveas 路径是相对于应用沙箱的。如果配置为 ./file.zip,实际保存路径是应用 filesDir 下的 file.zip。可以通过 context.filesDir 获取绝对路径。

Q3:如何支持服务端鉴权下载?

A:在 config 中配置 header 字段添加鉴权信息:

const config: request.agent.Config = {
  // ...其他配置
  header: {
    'Authorization': 'Bearer your-token',
    'X-Request-ID': generateRequestId()
  }
};

Q4:大文件下载时内存占用过高怎么办?

Arequest.agent 采用流式写入,不会将整个文件加载到内存。如果自定义实现分片下载,确保每片下载后及时写入磁盘并释放缓冲区,不要将所有分片数据同时保存在内存中。

Q5:如何清理已完成的任务记录?

A:建议定期清理持久化存储中的已完成任务,避免存储膨胀:

// 清理 7 天前已完成的任务
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
for (const [taskId, task] of this.allTasks) {
  if (task.status === TaskStatus.COMPLETED && task.updatedAt < sevenDaysAgo) {
    await this.pref?.delete(taskId);
    this.allTasks.delete(taskId);
  }
}

十、总结

HarmonyOS 的 request.agent 为后台下载提供了系统级的可靠托管能力。本文从单文件下载入手,逐步构建了一个支持并发控制、优先级队列、断点续传、异常恢复和状态持久化的生产级下载管理器。核心设计要点总结如下:

  1. 前台任务与后台任务严格区分:小文件用前台立即传输,大文件用后台可靠下载。
  2. 后台模式是断点续传的前提mode: BACKGROUND 让任务脱离应用生命周期,由系统统一调度。
  3. 优先级队列 + 并发控制:防止多个大任务同时下载打爆网络和内存。
  4. 检查点持久化是恢复的基础:进程被回收或设备重启后,从持久化存储恢复任务状态。
  5. 区分网络失败、业务失败和用户取消:三种失败采取完全不同的恢复策略。
  6. 可观测性决定排障效率:完整的进度追踪、状态日志和错误码记录是生产环境必备。

希望本文能帮助开发者在 HarmonyOS 应用中构建稳定、高效、用户友好的后台文件传输系统。


转载自:https://blog.csdn.net/u014727709/article/details/163729821
欢迎 👍点赞✍评论⭐收藏,欢迎指正

Logo

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

更多推荐