HarmonyOS技术精讲-Network Kit(网络服务)| 02 文件传输大师:上传下载与断点续传

在这里插入图片描述

开篇:上传下载不只是调一下API

HarmonyOS开发里,上传下载算是最基础的需求了。但很多人第一次接触@ohos.net.netmanager时,会发现一个现象:官方示例能跑起来,真机上一测试,要么下载到一半进度不动了,要么上传大文件时直接崩溃。

这个问题在HarmonyOS NEXT开发中比较常见。Network Kit提供了UploadTaskDownloadTask两个核心类,但它们的真正使用边界和生命周期管理,官方文档描述得比较简略。这篇文章会从两个实际场景出发——头像上传(multipart表单)和视频文件下载(带断点续传),把完整的实现逻辑拆开讲清楚。

这个能力解决了什么问题

Network Kit的文件传输能力,本质上是对底层网络IO的封装。它把HTTP请求、进度监听、任务暂停/恢复等操作抽象成了统一的API,开发者不需要自己拼装request body,也不需要手动管理socket连接。

适用场景:

  • 常规文件上传(图片、音频、视频)
  • 需要进度回调的下载任务
  • 需要断点续传的场景

不适用场景:

  • 实时音视频流传输(需要用WebSocket)
  • 超高频的小文件批量上传(建议用并发池)

和直接用http.request相比:

方案 大文件支持 进度回调 断点续传 内存管理
http.request 容易OOM 需要自己实现 不支持 全量加载
Network Kit 流式写入 原生支持 有API支持 按块处理

对于文件传输类需求,Network Kit是更稳定的选择。

环境说明

DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机、平板

核心实现:上传头像(multipart表单)

第一步:权限配置

module.json5中声明网络权限:

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

第二步:构建上传任务

// UploadManager.ets
import { fileIo } from '@kit.CoreFileKit';
import { netmanager } from '@kit.NetworkKit';

export class UploadManager {
  /**
   * 上传头像文件,使用multipart/form-data格式
   * @param fileUri 文件在沙箱中的URI
   * @param progressCallback 进度回调,参数为已上传字节数
   */
  static async uploadAvatar(
    fileUri: string,
    progressCallback: (sent: number) => void
  ): Promise<netmanager.UploadTask> {
    // 1. 构造上传数据
    const file = fileIo.openSync(fileUri, fileIo.OpenMode.READ_ONLY);
    const fileSize = fileIo.statSync(fileUri).size;

    // 2. 配置上传任务
    const uploadConfig: netmanager.UploadConfig = {
      url: 'https://your-server.com/api/avatar/upload',
      header: {
        'Content-Type': 'multipart/form-data'
      },
      method: 'POST',
      files: [
        {
          filename: 'avatar.jpg',
          name: 'file',       // 表单字段名
          uri: fileUri,
          type: 'image/jpeg'
        }
      ],
      data: [
        {
          name: 'user_id',
          value: '12345'
        }
      ]
    };

    // 3. 创建上传任务
    const uploadTask = await netmanager.createUploadTask(uploadConfig);

    // 4. 注册进度回调
    uploadTask.on('progress', (uploadedSize: number, totalSize: number) => {
      // 注意:totalSize可能为0,需要从文件实际大小获取
      if (totalSize > 0) {
        const percent = Math.round((uploadedSize / totalSize) * 100);
        // eg: 日志输出 "上传进度: 45%"
      }
      progressCallback(uploadedSize);
    });

    // 5. 开始上传
    uploadTask.start();

    return uploadTask;
  }
}

注意:

  • files数组中的name字段对应服务端表单的key
  • totalSize在部分场景可能返回0,建议用fileSize做兜底
  • 创建任务后需要主动调用start()

核心实现:视频下载与断点续传

断点续传的核心逻辑:下载前检查本地是否已有部分文件,如果有,告诉服务端从哪个字节开始传,然后追加写入。

// DownloadManager.ets
import { fileIo } from '@kit.CoreFileKit';
import { netmanager } from '@kit.NetworkKit';

export class DownloadManager {
  // 保存任务状态
  private static taskMap = new Map<string, {
    task: netmanager.DownloadTask;
    filePath: string;
    downloadedBytes: number;
  }>();

  /**
   * 下载视频,支持断点续传
   * @param url 下载URL
   * @param savePath 保存路径(含文件名)
   * @param progressCallback 进度回调
   */
  static async downloadVideo(
    url: string,
    savePath: string,
    progressCallback: (progress: number) => void
  ): Promise<void> {
    // 1. 检查本地是否存在部分下载文件
    let downloadedBytes = 0;
    let fileExists = false;

    try {
      const stat = fileIo.statSync(savePath);
      if (stat.size > 0) {
        downloadedBytes = stat.size;
        fileExists = true;
      }
    } catch {
      // 文件不存在,从头开始下载
    }

    // 2. 创建下载配置
    const downloadConfig: netmanager.DownloadConfig = {
      url: url,
      filePath: savePath,
      header: {
        // 关键:断点续传需要带上Range头
        'Range': `bytes=${downloadedBytes}-`
      },
      enableMetered: true,  // 允许在移动网络下下载
      enableRoaming: true   // 允许漫游时下载
    };

    // 3. 创建下载任务
    const downloadTask = await netmanager.createDownloadTask(downloadConfig);

    // 4. 保存任务引用
    this.taskMap.set(url, {
      task: downloadTask,
      filePath: savePath,
      downloadedBytes: downloadedBytes
    });

    // 5. 注册进度回调
    downloadTask.on('progress', (receivedSize: number, totalSize: number) => {
      // receivedSize是本次下载的增量,不是总大小
      let totalDownloaded = downloadedBytes + receivedSize;
      if (totalSize > 0) {
        const percent = Math.round((totalDownloaded / totalSize) * 100);
        progressCallback(percent);
      }
    });

    // 6. 注册完成回调
    downloadTask.on('complete', () => {
      console.log('下载完成');
      this.taskMap.delete(url);
    });

    // 7. 注册失败回调
    downloadTask.on('fail', (err: Error) => {
      // 网络中断等情况
      // 下次启动时,通过检查本地文件大小自动续传
    });

    // 8. 开始下载
    downloadTask.start();
  }

  /**
   * 暂停下载
   */
  static pauseDownload(url: string): void {
    const entry = this.taskMap.get(url);
    if (entry) {
      entry.task.pause();
    }
  }

  /**
   * 恢复下载
   */
  static resumeDownload(url: string): void {
    const entry = this.taskMap.get(url);
    if (entry) {
      entry.task.resume();
    }
  }
}

为什么这样设计:

  • taskMap保存活跃任务,方便管理暂停和恢复
  • Range头是实现断点续传的关键,服务端需支持HTTP 206 Partial Content
  • enableMeteredenableRoaming是HarmonyOS特有的配置项,不设置可能在某些网络下被拦截

常见问题

问题1:进度回调不触发

现象: 注册了progress事件,但上传/下载过程中回调不触发。

原因: progress回调在子线程执行,如果回调中执行了UI操作(如修改@State变量),ArkUI的线程安全机制会阻止更新。

解决方案: 使用emitBySystem方式或者通过@Watch监听状态变化。推荐做法:

// 使用类变量存储进度值
class DownloadModel {
  progress: number = 0;
}

@State downloadModel: DownloadModel = new DownloadModel();

// 在回调中:
downloadTask.on('progress', (receivedSize: number, totalSize: number) => {
  AppStorage.set<number>('downloadProgress', percent);
});

然后在页面中使用@StorageLink@LocalStorageLink监听。

问题2:断点续传不生效,总是从头下载

现象: 设置了Range头,但响应依然是200而非206,文件从0开始下载。

原因: 官方文档没有明确说明,但实际测试中,header中的Range头如果直接设置,在某些情况下会被底层忽略。正确做法是:不显式设置Range,而是通过filePath参数结合文件是否存在来判断。

解决方案: 先创建空文件,再通过fileIo写入时追加。或者确认服务端支持206:

// 更可靠的方案:下载前先尝试HEAD请求获取服务端支持
const response = await http.httpRequest(url, {
  method: 'HEAD'
});
const acceptRanges = response.header['Accept-Ranges'];
if (acceptRanges !== 'bytes') {
  // 服务端不支持断点续传,重新下载
}

问题3:下载大文件时应用被系统杀死

现象: 下载1GB以上文件时,应用退到后台一段时间后被系统回收。

原因: HarmonyOS为了节省资源,会杀后台应用。下载任务需要注册为长时任务。

解决方案: 使用@ohos.backgroundTaskManager申请长时任务许可:

import { backgroundTaskManager } from '@kit.BackgroundTasksKit';

// 在下载开始前申请
const requestId = backgroundTaskManager.requestBackgroundTaskRunning(
  backgroundTaskManager.BackgroundTaskType.DOWNLOAD,
  {
    begin: 0,
    interval: 180000  // 每3分钟延长一次
  }
);

最佳实践

  1. 文件路径优先使用沙箱路径:不要使用/data/storage/el2/base这样的绝对路径,用getContext().cacheDirgetContext().filesDir。不同应用间路径不可通用。

  2. 下载任务完成后立即销毁监听:频繁注册progress事件但不移除,会在页面销毁后继续触发回调,导致undefined错误。用完记得调用downloadTask.off('progress')

  3. 批量上传时控制并发数:不要让多个上传任务同时发起,建议使用队列或信号量控制并发为3-5个。超过这个数量,ArkUI的渲染线程可能出现卡顿。

入口文件示例

// pages/TransferPage.ets
@Entry
@Component
struct TransferPage {
  @State uploadProgress: number = 0
  @State downloadProgress: number = 0

  build() {
    Column() {
      // 上传头像按钮
      Button('选择头像上传')
        .onClick(async () => {
          // 这里需要配合文件选择器获取fileUri
          const fileUri = await this.getAvatarUri();
          await UploadManager.uploadAvatar(fileUri, (sent) => {
            this.uploadProgress = sent;
          });
        })
      
      // 下载视频按钮
      Button('下载视频')
        .onClick(async () => {
          const savePath = getContext().cacheDir + '/videos/sample.mp4';
          await DownloadManager.downloadVideo(
            'https://example.com/sample.mp4',
            savePath,
            (progress) => {
              this.downloadProgress = progress;
            }
          );
        })
    }
    .padding(20)
  }

  // 获取头像文件URI的占位方法,实际需要调用文件选择器API
  async getAvatarUri(): Promise<string> {
    // 略
    return '';
  }
}

FAQ

Q:为什么真机可以正常下载,模拟器上却提示网络错误?
A:模拟器可能无法正确处理部分网络包的校验。真机环境更接近生产,建议以真机测试结果为准。

Q:上传文件时,totalSize总是返回0怎么回事?
A:这可能是底层实现的一个限制。建议在创建UploadConfig时,从文件系统读取实际大小作为参考值。

Q:页面销毁后,下载任务还在跑,如何清理?
A:在页面的aboutToDisappear生命周期中,调用downloadTask.off()downloadTask.off('complete')移除所有回调。

Logo

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

更多推荐