鸿蒙 PC 端作为办公、开发等场景的核心设备,图片上传是后台管理系统、文档编辑、数据提交等业务的高频需求。其后台图片上传能力基于鸿蒙系统的文件访问能力网络请求能力实现,核心需解决 PC 端本地图片路径获取、文件流读取、跨端网络适配三大问题。本文结合华为官方开发指南,从核心原理、前置准备、分步实战(含完整可运行代码)、异常处理四个维度,详解鸿蒙 PC 端后台图片上传的标准实现方案,适配 ArkTS 语言与 Stage 模型,兼顾实用性与规范性。

一、PC 端后台图片上传核心原理

鸿蒙 PC 端后台图片上传与移动端核心逻辑一致,但针对 PC 端本地文件目录访问、键鼠文件选择、大文件分块等特性做了适配,整体执行流程为:

  1. 文件选择:通过鸿蒙FilePicker组件实现 PC 端本地图片选择(支持键鼠点击、路径输入),获取图片的沙箱内访问路径(鸿蒙为应用分配独立沙箱,禁止直接访问系统绝对路径);
  2. 文件读取:通过fs文件管理 API 将图片文件读取为ArrayBuffer 二进制流(后台接口通用传输格式),同时获取图片名称、大小、格式等元信息;
  3. 参数封装:将二进制流封装为FormData表单数据(符合 HTTP 文件上传标准),添加文件名、文件类型等请求参数;
  4. 网络请求:通过鸿蒙fetchnetAPI 发起 POST 请求,将 FormData 数据提交至后台接口,处理接口返回的上传结果;
  5. 结果回调:根据后台响应状态,完成上传成功提示、失败重试、进度展示等业务逻辑。

核心注意点:鸿蒙 PC 端应用无系统绝对路径访问权限,所有本地文件操作均基于沙箱路径,FilePicker选择的文件会被临时映射到应用沙箱,这是 PC 端文件操作与传统 PC 开发的核心区别。

二、前置准备:权限与配置声明

实现图片上传前,需在项目中配置文件访问权限网络访问权限,否则会出现文件读取失败、网络请求被拦截的问题,步骤如下:

1. 配置module.json5权限声明

entry/src/main/module.json5abilities节点下添加文件读取 / 写入权限requestPermissions节点添加网络权限(PC 端需明确声明ohos.permission.INTERNET):

{
  "module": {
    "name": "entry",
    "type": "entry",
    "description": "$string:module_desc",
    "mainElement": "EntryAbility",
    "deviceTypes": ["pc"], // 限定PC端设备
    "abilities": [
      {
        // 其他配置保持不变
        "permissions": [
          "ohos.permission.READ_USER_STORAGE", // 读取用户存储
          "ohos.permission.WRITE_USER_STORAGE" // 写入用户存储
        ]
      }
    ],
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET", // 网络访问权限
        "reason": "$string:internet_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "always"
        }
      }
    ]
  }
}

2. 配置字符串资源(可选)

entry/src/main/resources/base/string.json中补充权限说明文案,提升用户体验:

{
  "string": [
    {
      "name": "internet_reason",
      "value": "应用需要网络权限以提交图片至后台"
    },
    {
      "name": "upload_success",
      "value": "图片上传成功"
    },
    {
      "name": "upload_fail",
      "value": "图片上传失败:%s"
    }
  ]
}

三、分步实战:PC 端后台图片上传完整代码实现

本次实战基于ArkTS + Stage 模型 + fetch 网络请求实现,包含文件选择组件封装、文件流读取、FormData 封装、网络上传、结果处理全流程,代码结构清晰,可直接嵌入鸿蒙 PC 端项目。

步骤 1:封装图片选择工具类(复用性更强)

创建utils/FileSelectUtil.ets,封装FilePicker的图片选择逻辑,仅返回 PC 端可访问的图片文件信息,解耦页面与文件选择逻辑:

// utils/FileSelectUtil.ets
import filePicker from '@ohos.file.filePicker';
import fs from '@ohos.file.fs';

// 定义图片选择返回结果类型
export interface ImageFileInfo {
  uri: string; // 图片沙箱URI
  name: string; // 图片名称(如test.png)
  size: number; // 图片大小(字节)
  type: string; // 图片MIME类型(如image/png)
}

/**
 * PC端图片选择工具:仅支持选择单张jpg/png/webp格式图片
 * @returns 选中的图片文件信息,取消选择则返回undefined
 */
export async function selectPcImage(): Promise<ImageFileInfo | undefined> {
  try {
    // 创建PC端文件选择器,限定文件类型为图片
    const fileOpenOptions: filePicker.FileOpenOptions = {
      fileTypes: ['image/jpg', 'image/png', 'image/webp'], // 支持的图片格式
      singleSelect: true // 单张选择(后台单文件上传,多文件可改为false)
    };
    const picker = filePicker.createFilePicker();
    // 打开文件选择器,获取选中文件信息
    const selectResult = await picker.open(fileOpenOptions);
    if (selectResult.files.length === 0) {
      console.info('用户取消图片选择');
      return undefined;
    }
    // 获取选中的第一张图片信息
    const file = selectResult.files[0];
    // 通过fs获取文件详细信息(大小、类型)
    const fileStat = await fs.stat(file.uri);
    // 解析文件MIME类型
    const fileExt = file.name.split('.').pop()?.toLowerCase() || 'png';
    const mimeType = `image/${fileExt}`;
    // 封装并返回图片信息
    return {
      uri: file.uri,
      name: file.name,
      size: fileStat.size,
      type: mimeType
    } as ImageFileInfo;
  } catch (error) {
    console.error('PC端图片选择失败:', error);
    return undefined;
  }
}

步骤 2:封装图片上传网络工具类

创建utils/UploadUtil.ets,封装文件流读取、FormData 封装、fetch POST 请求逻辑,作为后台上传的核心工具,与页面解耦,便于全局复用:

// utils/UploadUtil.ets
import fs from '@ohos.file.fs';
import { ImageFileInfo } from './FileSelectUtil';

/**
 * 图片上传配置项
 */
export interface UploadConfig {
  baseUrl: string; // 后台接口基础地址
  uploadApi: string; // 图片上传接口路径(如/api/file/upload)
  timeout?: number; // 请求超时时间(默认5000ms)
  headers?: Record<string, string>; // 自定义请求头(如Token、Content-Type)
}

/**
 * 后台上传返回结果类型(根据实际后台接口调整)
 */
export interface UploadResult {
  code: number; // 接口状态码(200为成功)
  msg: string; // 接口提示信息
  data?: {
    fileUrl: string; // 上传后的图片在线地址
    fileName: string; // 后台存储的文件名
  };
}

/**
 * PC端图片上传核心方法
 * @param imageFile 选中的图片文件信息
 * @param config 上传配置
 * @returns 后台接口返回结果
 */
export async function uploadPcImage(
  imageFile: ImageFileInfo,
  config: UploadConfig
): Promise<UploadResult> {
  try {
    // 1. 读取图片文件为ArrayBuffer二进制流(核心步骤)
    const file = await fs.open(imageFile.uri, fs.OpenMode.READ_ONLY); // 以只读模式打开文件
    const fileBuffer = new ArrayBuffer(imageFile.size); // 根据文件大小创建缓冲区
    await fs.read(file.fd, fileBuffer); // 将文件内容读取到缓冲区
    await fs.close(file.fd); // 关闭文件句柄,避免内存泄漏

    // 2. 封装FormData表单数据(符合HTTP文件上传标准)
    const formData = new FormData();
    // 添加文件流:参数名(与后台约定)、文件流、文件名、文件类型
    formData.append('file', new Blob([fileBuffer], { type: imageFile.type }), imageFile.name);
    // 可选:添加其他自定义参数(如用户ID、业务类型,根据后台需求添加)
    formData.append('businessType', 'pc_background');
    formData.append('fileType', 'image');

    // 3. 配置请求参数
    const requestUrl = `${config.baseUrl}${config.uploadApi}`;
    const requestOptions: RequestInit = {
      method: 'POST',
      body: formData,
      timeout: config.timeout || 5000,
      headers: {
        'Accept': 'application/json',
        ...config.headers // 合并自定义请求头(如Token鉴权)
      }
    };

    // 4. 发起fetch网络请求
    const response = await fetch(requestUrl, requestOptions);
    if (!response.ok) {
      throw new Error(`网络请求失败,状态码:${response.status}`);
    }
    // 解析后台返回结果
    const result: UploadResult = await response.json();
    return result;
  } catch (error) {
    console.error('PC端图片上传失败:', error);
    // 统一异常返回格式,便于页面处理
    return {
      code: -1,
      msg: error instanceof Error ? error.message : '图片上传未知错误'
    };
  }
}

步骤 3:页面层实现上传交互(含 UI 与逻辑)

创建业务页面pages/ImageUploadPage.ets,实现图片选择按钮、上传按钮、上传状态展示等 UI 交互,调用上述工具类完成完整上传流程,适配 PC 端大屏、键鼠操作特性:

// pages/ImageUploadPage.ets
import { selectPcImage, ImageFileInfo } from '../utils/FileSelectUtil';
import { uploadPcImage, UploadConfig } from '../utils/UploadUtil';
import promptAction from '@ohos.promptAction'; // 弹窗提示

@Entry
@Component
struct PcImageUploadPage {
  // 响应式状态:选中的图片信息、是否正在上传、上传结果
  @State selectedImage: ImageFileInfo | undefined = undefined;
  @State isUploading: boolean = false;
  @State uploadResultTip: string = '';

  // 后台上传配置(根据实际项目修改为自己的后台地址)
  private uploadConfig: UploadConfig = {
    baseUrl: 'https://your-backend-domain.com', // 替换为实际后台基础地址
    uploadApi: '/api/file/pc/upload', // 替换为实际上传接口
    timeout: 10000,
    headers: {
      'Token': 'your-user-token', // 后台鉴权Token,根据实际需求获取
      'X-Request-From': 'harmonyos-pc'
    }
  };

  /**
   * 选择图片按钮点击事件
   */
  async onSelectImage() {
    this.uploadResultTip = ''; // 清空之前的上传结果
    const image = await selectPcImage();
    if (image) {
      this.selectedImage = image;
      this.uploadResultTip = `已选择图片:${image.name}(${(image.size / 1024).toFixed(2)}KB)`;
    }
  }

  /**
   * 上传图片按钮点击事件
   */
  async onUploadImage() {
    // 校验:未选择图片或正在上传时,禁止重复操作
    if (!this.selectedImage || this.isUploading) {
      return;
    }
    try {
      this.isUploading = true;
      this.uploadResultTip = '正在上传图片,请稍候...';
      // 调用上传工具类
      const result = await uploadPcImage(this.selectedImage, this.uploadConfig);
      // 处理上传结果
      if (result.code === 200) {
        this.uploadResultTip = `上传成功!图片地址:${result.data?.fileUrl}`;
        promptAction.showToast({ message: '图片上传成功', duration: 2000 });
        // 可选:上传成功后清空选中状态,便于继续上传
        // this.selectedImage = undefined;
      } else {
        this.uploadResultTip = `上传失败:${result.msg}`;
        promptAction.showToast({ message: `上传失败:${result.msg}`, duration: 3000, type: 'error' });
      }
    } catch (error) {
      this.uploadResultTip = `上传异常:${(error as Error).message}`;
      promptAction.showToast({ message: '上传异常,请重试', duration: 3000, type: 'error' });
    } finally {
      this.isUploading = false; // 无论成败,结束上传状态
    }
  }

  build() {
    Column({ space: 20 }) {
      // 页面标题(适配PC端大屏字体)
      Text('鸿蒙PC端后台图片上传')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 50 });

      // 按钮区域(适配PC端键鼠点击,设置最小宽度)
      Row({ space: 15 }) {
        Button('选择本地图片')
          .width(180)
          .height(45)
          .fontSize(16)
          .onClick(() => this.onSelectImage());

        Button(this.isUploading ? '上传中...' : '提交至后台')
          .width(180)
          .height(45)
          .fontSize(16)
          .backgroundColor('#007DFF')
          .enabled(!this.isUploading && !!this.selectedImage) // 未选择/上传中时置灰
          .onClick(() => this.onUploadImage());
      }

      // 上传状态/结果展示(适配PC端大屏,左对齐,换行显示)
      Text(this.uploadResultTip)
        .fontSize(14)
        .width('80%')
        .textAlign(TextAlign.Start)
        .fontColor(this.uploadResultTip.includes('成功') ? '#00C48C' : '#FF5252')
        .margin({ top: 10 });

    }
    .width('100%')
    .height('100%')
    .padding(40)
    .justifyContent(FlexAlign.Start);
  }
}

四、关键拓展:大文件图片分块上传(PC 端高频需求)

鸿蒙 PC 端常需上传高清截图、设计稿等大尺寸图片(如几 M 甚至几十 M),直接单文件上传易出现超时、断连问题,因此需实现分块上传功能。以下是基于上述代码的分块上传核心改造(关键代码补充):

1. 工具类新增分块读取方法(FileSelectUtil.ets

// FileSelectUtil.ets 新增方法
/**
 * 分块读取图片文件
 * @param uri 图片沙箱URI
 * @param chunkSize 每块大小(默认512KB)
 * @returns 分块数据数组及总块数
 */
export async function readImageByChunk(uri: string, chunkSize: number = 512 * 1024): Promise<{
  chunks: ArrayBuffer[]; // 分块二进制流数组
  totalChunks: number; // 总块数
  fileSize: number; // 文件总大小
}> {
  const file = await fs.open(uri, fs.OpenMode.READ_ONLY);
  const fileStat = await fs.stat(uri);
  const fileSize = fileStat.size;
  const totalChunks = Math.ceil(fileSize / chunkSize); // 计算总块数
  const chunks: ArrayBuffer[] = [];

  for (let i = 0; i < totalChunks; i++) {
    const currentChunkSize = i === totalChunks - 1 ? fileSize - i * chunkSize : chunkSize;
    const buffer = new ArrayBuffer(currentChunkSize);
    await fs.read(file.fd, buffer, { offset: i * chunkSize }); // 按偏移量读取分块
    chunks.push(buffer);
  }

  await fs.close(file.fd);
  return { chunks, totalChunks, fileSize };
}

2. 新增分块上传方法(UploadUtil.ets

分块上传需与后台约定文件唯一标识、当前块号、总块数,后台接收所有分块后进行合并,核心代码如下:

// UploadUtil.ets 新增分块上传方法
/**
 * 大图片分块上传方法
 * @param imageFile 图片文件信息
 * @param config 上传配置
 * @param chunkSize 分块大小(默认512KB)
 * @returns 后台合并后的结果
 */
export async function uploadImageByChunk(
  imageFile: ImageFileInfo,
  config: UploadConfig,
  chunkSize: number = 512 * 1024
): Promise<UploadResult> {
  try {
    // 1. 生成文件唯一标识(后台用于合并分块,可使用时间戳+文件名哈希)
    const fileMd5 = `${Date.now()}-${imageFile.name.replace(/\./g, '')}`;
    // 2. 分块读取文件
    const { chunks, totalChunks, fileSize } = await readImageByChunk(imageFile.uri, chunkSize);
    // 3. 逐块上传
    for (let i = 0; i < totalChunks; i++) {
      const formData = new FormData();
      formData.append('file', new Blob([chunks[i]], { type: imageFile.type }), imageFile.name);
      formData.append('fileMd5', fileMd5); // 文件唯一标识
      formData.append('chunkIndex', (i + 1).toString()); // 当前块号(从1开始)
      formData.append('totalChunks', totalChunks.toString()); // 总块数
      formData.append('fileSize', fileSize.toString()); // 文件总大小

      // 发起分块上传请求
      const response = await fetch(`${config.baseUrl}${config.uploadApi}/chunk`, {
        method: 'POST',
        body: formData,
        headers: config.headers,
        timeout: config.timeout || 10000
      });
      const chunkResult = await response.json();
      if (chunkResult.code !== 200) {
        throw new Error(`第${i+1}块上传失败:${chunkResult.msg}`);
      }
    }
    // 4. 所有分块上传完成,请求后台合并文件
    const mergeResponse = await fetch(`${config.baseUrl}${config.uploadApi}/merge`, {
      method: 'POST',
      headers: {
        ...config.headers,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        fileMd5,
        fileName: imageFile.name,
        totalChunks,
        fileType: imageFile.type
      })
    });
    return await mergeResponse.json();
  } catch (error) {
    console.error('分块上传失败:', error);
    return {
      code: -1,
      msg: error instanceof Error ? error.message : '大文件分块上传未知错误'
    };
  }
}

3. 页面层调用分块上传

只需将页面中onUploadImage方法的uploadPcImage替换为uploadImageByChunk即可,无需修改其他逻辑,适配性极强。

五、常见问题与解决方案(PC 端专属)

结合鸿蒙 PC 端开发特性,图片上传过程中易出现以下问题,针对性解决方案如下:

1. 问题:FilePicker 选择图片后,提示 “文件打开失败”

  • 原因:应用未获取文件访问权限,或选择的图片为系统保护目录文件;
  • 解决方案:① 确保module.json5已配置READ_USER_STORAGE/WRITE_USER_STORAGE权限;② 避免选择系统盘根目录、Program Files 等保护目录的图片,选择用户文档、桌面等可访问目录。

2. 问题:上传大图片时,请求超时 / 网络断开

  • 原因:单文件上传体积过大,网络波动导致请求中断;
  • 解决方案:使用上述分块上传方案,将大文件拆分为小分块逐块上传,即使某块失败也可单独重试,无需重新上传整个文件。

3. 问题:PC 端模拟器上传正常,真机上传失败

  • 原因:真机未开启网络,或后台接口开启了 IP 白名单,真机 IP 未加入;
  • 解决方案:① 确保 PC 真机连接网络,且能访问后台接口地址;② 检查后台 Nginx / 网关配置,将真机 IP 加入白名单,或关闭 IP 限制(开发环境)。

4. 问题:fetch 请求提示 “Cross-Origin Request Blocked”

  • 原因:后台接口未配置跨域允许,鸿蒙 PC 端 fetch 遵循浏览器跨域规则;
  • 解决方案:在后台接口响应头中添加跨域配置:
    Access-Control-Allow-Origin: *(开发环境)/你的PC应用域名(生产环境)
    Access-Control-Allow-Methods: POST, GET, OPTIONS
    Access-Control-Allow-Headers: Token, Content-Type
    

5. 问题:文件读取后未关闭句柄,导致应用内存泄漏

  • 原因:使用fs.open打开文件后,未调用fs.close关闭文件描述符;
  • 解决方案:所有fs.open操作必须搭配fs.close,且放在finally块中,确保无论是否报错都能关闭句柄(本文代码已做此处理)。

六、总结

鸿蒙 PC 端后台图片上传的核心是适配鸿蒙沙箱文件模型遵循 HTTP 文件上传标准,整体实现围绕「选择文件(FilePicker)→ 读取流(fs)→ 封装 FormData → 网络请求(fetch)」四大核心步骤展开。

本文提供的代码实现了工具类解耦、UI 交互友好、异常处理完善的特性,可直接应用于实际项目;同时针对 PC 端大文件上传需求,补充了分块上传方案,解决了大文件上传的稳定性问题。开发过程中只需注意权限配置、沙箱路径限制、跨域处理三个 PC 端专属要点,即可实现稳定、高效的图片上传功能。

Logo

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

更多推荐