HarmonyOS 鸿蒙Next图片上传与压缩完整实现

HarmonyOS 鸿蒙Next图片上传与压缩完整实现 在开发旅行记录应用时,需要实现图片上传功能:

  1. 用户从相册选择图片后上传到服务器
  2. 大图片需要先压缩再上传(减少流量和上传时间)
  3. 上传使用 multipart/form-data 格式
  4. 支持从媒体库 URI(file://media/...)读取图片
  5. 需要处理大文件的分块读取,避免内存溢出
3 回复

1. 整体流程

用户选择图片 → 读取图片文件 → 压缩图片 → 构造 multipart 请求体 → HTTP POST 上传

2. 创建上传服务类

// services/UploadService.ets
import fs from '[@ohos](/user/ohos).file.fs'
import http from '[@ohos](/user/ohos).net.http'
import image from '[@ohos](/user/ohos).multimedia.image'
import { BusinessError } from '@kit.BasicServicesKit'
import common from '[@ohos](/user/ohos).app.ability.common'

/**
 * 后端上传响应接口
 */
export interface UploadResponse {
  success: boolean
  file_id: string
  file_url: string
  file_name: string
  file_size: number
  content_type: string
  created_at: string
}

/**
 * 上传服务类(单例模式)
 */
class UploadServiceClass {
  private static instance: UploadServiceClass

  private constructor() {}

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

// 导出单例
export const uploadService = UploadServiceClass.getInstance()

3. 实现图片上传主方法

/**
 * 上传图片
 * @param imageUri 图片 URI(来自 PhotoViewPicker,格式如 file://media/...)
 * @param category 分类:documents, footprints, avatars
 * @param token 用户认证 token
 * @param context 应用上下文
 * @returns 上传结果
 */
public async uploadImage(
  imageUri: string,
  category: string,
  token: string,
  context: common.UIAbilityContext
): Promise<UploadResponse | null> {
  try {
    console.info('[UploadService] 开始上传图片')
    console.info('[UploadService] 图片 URI:', imageUri)

    // 步骤1:读取并压缩图片
    console.info('[UploadService] 读取并压缩图片...')
    const fileContent: Uint8Array = await this.compressImage(imageUri)
    console.info('[UploadService] 图片压缩完成,大小:', fileContent.length, '字节')

    // 步骤2:构造 multipart/form-data 请求
    const timestamp: number = Date.now()
    const fileName: string = `upload_${timestamp}.jpg`
    const boundary: string = `----WebKitFormBoundary${timestamp.toString(36)}`
    
    const uploadUrl: string = `https://your-api.com/api/uploads/image?category=${encodeURIComponent(category)}`
    
    console.info('[UploadService] 上传 URL:', uploadUrl)

    // 构造请求体
    const bodyParts: Uint8Array[] = []
    
    // 文件字段头部
    const fileHeader: string = 
      `--${boundary}\r\n` +
      `Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n` +
      `Content-Type: image/jpeg\r\n\r\n`
    bodyParts.push(this.stringToUint8Array(fileHeader))
    
    // 文件内容
    bodyParts.push(fileContent)
    bodyParts.push(this.stringToUint8Array('\r\n'))
    
    // 结束边界
    const endBoundary: string = `--${boundary}--\r\n`
    bodyParts.push(this.stringToUint8Array(endBoundary))
    
    // 合并所有部分
    const mergedBody: ArrayBuffer = this.mergeUint8Arrays(bodyParts)
    console.info('[UploadService] 请求体大小:', mergedBody.byteLength)

    // 步骤3:发送 HTTP POST 请求
    const httpRequest: http.HttpRequest = http.createHttp()
    
    const response: http.HttpResponse = await httpRequest.request(uploadUrl, {
      method: http.RequestMethod.POST,
      header: {
        'Content-Type': `multipart/form-data; boundary=${boundary}`,
        'Authorization': `Bearer ${token}`
      },
      extraData: mergedBody,
      expectDataType: http.HttpDataType.STRING,
      connectTimeout: 60000,
      readTimeout: 60000
    })

    httpRequest.destroy()

    console.info('[UploadService] 响应状态码:', response.responseCode)

    if (response.responseCode === 200 || response.responseCode === 201) {
      const responseData: string = response.result as string
      const result: UploadResponse = JSON.parse(responseData) as UploadResponse
      result.success = true
      console.info('[UploadService] ✅ 上传成功, file_url:', result.file_url)
      return result
    } else {
      console.error('[UploadService] ❌ 上传失败,状态码:', response.responseCode)
      return null
    }

  } catch (error) {
    const err = error as BusinessError
    console.error(`[UploadService] ❌ 上传异常: ${err.code} - ${err.message}`)
    return null
  }
}

4. 实现图片压缩(核心)

/**
 * 压缩图片
 * @param imageUri 图片 URI
 * @returns 压缩后的图片数据(Uint8Array)
 */
private async compressImage(imageUri: string): Promise<Uint8Array> {
  // ===== 步骤1:分块读取原始文件 =====
  // 媒体库 URI 需要通过 fs.open 打开
  const sourceFile: fs.File = await fs.open(imageUri, fs.OpenMode.READ_ONLY)
  console.info('[UploadService] 源文件已打开, fd:', sourceFile.fd)
  
  // 分块读取(避免大文件内存溢出)
  const chunks: ArrayBuffer[] = []
  const chunkSize: number = 1024 * 1024 // 每块 1MB
  let bytesRead: number = 0
  
  while (true) {
    const chunkBuffer: ArrayBuffer = new ArrayBuffer(chunkSize)
    const readResult: number = await fs.read(sourceFile.fd, chunkBuffer)
    
    if (readResult === 0) {
      break  // 读取完毕
    }
    
    if (readResult < chunkSize) {
      // 最后一块,只取实际读取的部分
      chunks.push(chunkBuffer.slice(0, readResult))
    } else {
      chunks.push(chunkBuffer)
    }
    bytesRead += readResult
  }
  
  await fs.close(sourceFile)
  
  // 合并所有块
  const buffer: ArrayBuffer = new ArrayBuffer(bytesRead)
  const bufferView = new Uint8Array(buffer)
  let offset: number = 0
  for (const chunk of chunks) {
    bufferView.set(new Uint8Array(chunk), offset)
    offset += chunk.byteLength
  }
  
  const originalSize = bytesRead
  console.info('[UploadService] 原始文件大小:', originalSize, '字节')
  
  // ===== 步骤2:判断是否需要压缩 =====
  // 小于 500KB 的图片不压缩
  if (originalSize < 500 * 1024) {
    console.info('[UploadService] 文件较小,无需压缩')
    return new Uint8Array(buffer)
  }
  
  // ===== 步骤3:创建 ImageSource 并解码 =====
  try {
    const imageSource: image.ImageSource = image.createImageSource(buffer)
    const imageInfo = await imageSource.getImageInfo()
    console.info('[UploadService] 原始图片尺寸:', imageInfo.size.width, 'x', imageInfo.size.height)
    
    // ===== 步骤4:计算缩放尺寸 =====
    // 目标:最大边长 1920px
    const maxSize = 1920
    let targetWidth = imageInfo.size.width
    let targetHeight = imageInfo.size.height
    
    if (imageInfo.size.width > maxSize || imageInfo.size.height > maxSize) {
      if (imageInfo.size.width > imageInfo.size.height) {
        targetWidth = maxSize
        targetHeight = Math.round(imageInfo.size.height * maxSize / imageInfo.size.width)
      } else {
        targetHeight = maxSize
        targetWidth = Math.round(imageInfo.size.width * maxSize / imageInfo.size.height)
      }
    }
    console.info('[UploadService] 目标图片尺寸:', targetWidth, 'x', targetHeight)
    
    // ===== 步骤5:解码为 PixelMap(带缩放)=====
    const decodingOptions: image.DecodingOptions = {
      desiredSize: { width: targetWidth, height: targetHeight }
    }
    const pixelMap: image.PixelMap = await imageSource.createPixelMap(decodingOptions)
    
    // ===== 步骤6:使用 ImagePacker 压缩 =====
    const imagePacker: image.ImagePacker = image.createImagePacker()
    
    // 根据原始大小动态调整质量
    let quality = 80
    if (originalSize > 5 * 1024 * 1024) {
      quality = 60  // 大于 5MB,质量设为 60
    } else if (originalSize > 2 * 1024 * 1024) {
      quality = 70  // 大于 2MB,质量设为 70
    }
    
    const packingOptions: image.PackingOption = {
      format: 'image/jpeg',
      quality: quality
    }
    
    const compressedBuffer: ArrayBuffer = await imagePacker.packing(pixelMap, packingOptions)
    
    console.info('[UploadService] 压缩后大小:', compressedBuffer.byteLength, '字节')
    console.info('[UploadService] 压缩率:', Math.round((1 - compressedBuffer.byteLength / originalSize) * 100), '%')
    
    // 释放资源
    pixelMap.release()
    imageSource.release()
    imagePacker.release()
    
    return new Uint8Array(compressedBuffer)
  } catch (compressError) {
    console.warn('[UploadService] 图片压缩失败,使用原始数据:', compressError)
    return new Uint8Array(buffer)
  }
}

5. 工具方法

/**
 * 将字符串转换为 Uint8Array
 */
private stringToUint8Array(str: string): Uint8Array {
  const arr: number[] = []
  for (let i: number = 0; i < str.length; i++) {
    arr.push(str.charCodeAt(i))
  }
  return new Uint8Array(arr)
}

/**
 * 合并多个 Uint8Array 为一个 ArrayBuffer
 */
private mergeUint8Arrays(arrays: Uint8Array[]): ArrayBuffer {
  // 计算总长度
  let totalLength: number = 0
  for (const arr of arrays) {
    totalLength += arr.length
  }
  
  // 合并数组
  const merged: Uint8Array = new Uint8Array(totalLength)
  let offset: number = 0
  for (const arr of arrays) {
    merged.set(arr, offset)
    offset += arr.length
  }
  
  return merged.buffer
}

/**
 * 获取完整的文件 URL
 * @param fileUrl 相对路径或完整 URL
 * @returns 完整 URL
 */
public getFullUrl(fileUrl: string): string {
  if (!fileUrl) {
    return ''
  }
  if (fileUrl.startsWith('http://') || fileUrl.startsWith('https://')) {
    return fileUrl
  }
  if (fileUrl.startsWith('/')) {
    return `https://your-api.com${fileUrl}`
  }
  return `https://your-api.com/${fileUrl}`
}

6. 在页面中使用

// pages/ImageUploadPage.ets
import { picker } from '@kit.CoreFileKit'
import { uploadService, UploadResponse } from '../services/UploadService'
import { common } from '@kit.AbilityKit'

@Entry
@Component
struct ImageUploadPage {
  @State selectedImage: string = ''
  @State uploadedUrl: string = ''
  @State isUploading: boolean = false
  @State uploadProgress: string = ''
  
  private context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext
  private token: string = 'your-auth-token'  // 从登录状态获取
  
  build() {
    Column({ space: 20 }) {
      Text('图片上传示例')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
      
      // 选择的图片预览
      if (this.selectedImage) {
        Image(this.selectedImage)
          .width(200)
          .height(200)
          .objectFit(ImageFit.Cover)
          .borderRadius(8)
      }
      
      // 选择图片按钮
      Button('选择图片')
        .width('80%')
        .height(48)
        .onClick(() => this.selectImage())
      
      // 上传按钮
      Button(this.isUploading ? '上传中...' : '上传图片')
        .width('80%')
        .height(48)
        .enabled(!this.isUploading && !!this.selectedImage)
        .backgroundColor(this.selectedImage ? '#A9846A' : '#CCCCCC')
        .onClick(() => this.uploadImage())
      
      // 上传状态
      if (this.uploadProgress) {
        Text(this.uploadProgress)
          .fontSize(14)
          .fontColor('#666666')
      }
      
      // 上传成功后显示 URL
      if (this.uploadedUrl) {
        Text(`上传成功:${this.uploadedUrl}`)
          .fontSize(12)
          .fontColor('#4CAF50')
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
      }
    }
    .width('100%')
    .height('100%')
    .padding(20)
  }
  
  /**
   * 选择图片
   */
  private async selectImage(): Promise<void> {
    try {
      const photoSelectOptions = new picker.PhotoSelectOptions()
      photoSelectOptions.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE
      photoSelectOptions.maxSelectNumber = 1
      
      const photoPicker = new picker.PhotoViewPicker()
      const result = await photoPicker.select(photoSelectOptions)
      
      if (result.photoUris && result.photoUris.length > 0) {
        this.selectedImage = result.photoUris[0]
        this.uploadedUrl = ''
        this.uploadProgress = ''
        console.info('[ImageUploadPage] 选择图片:', this.selectedImage)
      }
    } catch (error) {
      console.error('[ImageUploadPage] 选择图片失败:', error)
    }
  }
  
  /**
   * 上传图片
   */
  private async uploadImage(): Promise<void> {
    if (!this.selectedImage) {
      return
    }
    
    this.isUploading = true
    this.uploadProgress = '正在压缩图片...'
    
    try {
      this.uploadProgress = '正在上传...'
      
      const result: UploadResponse | null = await uploadService.uploadImage(
        this.selectedImage,
        'footprints',  // 上传分类
        this.token,
        this.context
      )
      
      if (result && result.success) {
        this.uploadedUrl = result.file_url
        this.uploadProgress = `上传成功!文件大小: ${(result.file_size / 1024).toFixed(1)} KB`
      } else {
        this.uploadProgress = '上传失败,请重试'
      }
    } catch (error) {
      console.error('[ImageUploadPage] 上传失败:', error)
      this.uploadProgress = '上传异常,请检查网络'
    } finally {
      this.isUploading = false
    }
  }
}

multipart/form-data 格式详解

POST /api/uploads/image HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary1234567

------WebKitFormBoundary1234567
Content-Disposition: form-data; name="file"; filename="upload_xxx.jpg"
Content-Type: image/jpeg

[二进制图片数据]
------WebKitFormBoundary1234567--
部分 说明
boundary 分隔符,用于区分不同字段
Content-Disposition 指定字段名和文件名
Content-Type 文件 MIME 类型
--boundary-- 结束标记(多两个 -

效果

控制台日志:

[UploadService] 开始上传图片
[UploadService] 图片 URI: file://media/Photo/1/IMG_001.jpg
[UploadService] 源文件已打开, fd: 48
[UploadService] 原始文件大小: 4521984 字节
[UploadService] 原始图片尺寸: 4032 x 3024
[UploadService] 目标图片尺寸: 1920 x 1440
[UploadService] 压缩后大小: 312576 字节
[UploadService] 压缩率: 93%
[UploadService] 请求体大小: 312698
[UploadService] 响应状态码: 201
[UploadService] ✅ 上传成功, file_url: /uploads/footprints/abc123.jpg

压缩效果示例:

原始大小 压缩后 压缩率 质量参数
4.5 MB 305 KB 93% 70
2.1 MB 198 KB 91% 70
800 KB 156 KB 81% 80
400 KB 400 KB 0% 不压缩

关键点总结

步骤 技术要点
读取文件 fs.open() + 分块读取,兼容媒体库 URI
图片解码 image.createImageSource() 从 ArrayBuffer 创建
尺寸缩放 DecodingOptions.desiredSize 指定目标尺寸
质量压缩 ImagePacker.packing() 指定 JPEG 质量
请求构造 手动拼接 multipart/form-data 格式
资源释放 pixelMap.release() 等避免内存泄漏

常见问题

Q1: 为什么要分块读取?

媒体库中的大图片(如 10MB+)如果一次性读入内存可能导致 OOM。分块读取每次只占用 1MB 内存。

Q2: 为什么不使用 request.upload?

`@ohos

更多关于HarmonyOS 鸿蒙Next图片上传与压缩完整实现的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html


鸿蒙Next图片上传与压缩可通过以下步骤实现:

  1. 选择图片:使用@ohos.file.picker模块的PhotoViewPicker选择图片,获取文件URI。
  2. 压缩图片:使用@ohos.image模块的imagePackerimage.Component进行压缩。通过createImageSource创建图像源,使用createPixelMap获取PixelMap,然后设置压缩格式(如JPEG)和质量参数,最后调用packing生成压缩后的ArrayBuffer。
  3. 上传图片:将压缩后的ArrayBuffer或文件URI通过@ohos.requestuploadFile方法上传至服务器。

关键API涉及文件选择、图像处理与网络上传。

在HarmonyOS Next中实现图片上传与压缩,可按照以下步骤进行:

1. 选择图片

使用 PhotoViewPicker 选择图片,获取媒体库URI:

import { photoViewPicker } from '@kit.MediaKit';

let photoSelectOptions = new photoViewPicker.PhotoSelectOptions();
photoSelectOptions.MIMEType = photoViewPicker.PhotoViewMIMETypes.IMAGE_TYPE;
photoSelectOptions.maxSelectNumber = 1;

let photoPicker = new photoViewPicker.PhotoViewPicker();
photoPicker.select(photoSelectOptions).then((photoSelectResult) => {
  let uri = photoSelectResult.photoUris[0];
  // 处理图片
});

2. 图片压缩

使用 ImagePacker 进行图片压缩:

import { image } from '@kit.ImageKit';

async function compressImage(uri: string): Promise<image.Packer> {
  let imagePackerApi = image.createImagePacker();
  let packOpts: image.PackingOptions = {
    format: "image/jpeg",
    quality: 80, // 压缩质量 0-100
    size: { width: 1024, height: 1024 } // 限制最大尺寸
  };
  
  return await imagePackerApi.packing(uri, packOpts);
}

3. 分块读取大文件

使用 File API 分块读取,避免内存溢出:

import { fileIo } from '@kit.CoreFileKit';

async function readFileInChunks(uri: string, chunkSize: number = 1024 * 1024) {
  let file = await fileIo.open(uri, fileIo.OpenMode.READ_ONLY);
  let fileSize = (await file.stat()).size;
  let chunks = [];
  
  for (let offset = 0; offset < fileSize; offset += chunkSize) {
    let buffer = new ArrayBuffer(Math.min(chunkSize, fileSize - offset));
    await file.read(buffer, { offset });
    chunks.push(buffer);
  }
  
  await file.close();
  return chunks;
}

4. 上传图片

使用 http 模块进行 multipart/form-data 上传:

import { http } from '@kit.NetworkKit';
import { fileIo } from '@kit.CoreFileKit';

async function uploadImage(compressedImage: image.Packer) {
  let request = http.createHttp();
  
  // 创建 FormData
  let formData = new FormData();
  let fileData = await compressedImage.getData();
  
  // 从 Packer 获取 ArrayBuffer
  let buffer = fileData.buffer;
  let blob = new Blob([buffer], { type: 'image/jpeg' });
  formData.append('file', blob, 'image.jpg');
  
  // 设置请求头
  let headers = {
    'Content-Type': 'multipart/form-data'
  };
  
  // 发送请求
  let response = await request.request(
    'https://your-server.com/upload',
    {
      method: http.RequestMethod.POST,
      header: headers,
      extraData: formData
    }
  );
  
  return response;
}

5. 完整流程整合

async function handleImageUpload() {
  try {
    // 1. 选择图片
    let photoPicker = new photoViewPicker.PhotoViewPicker();
    let result = await photoPicker.select(photoSelectOptions);
    let uri = result.photoUris[0];
    
    // 2. 压缩图片
    let compressedImage = await compressImage(uri);
    
    // 3. 分块读取(针对大文件)
    let fileSize = (await compressedImage.getData()).buffer.byteLength;
    if (fileSize > 10 * 1024 * 1024) { // 大于10MB
      // 保存临时文件再分块读取
      let tempUri = await saveTempFile(compressedImage);
      let chunks = await readFileInChunks(tempUri);
      // 分块上传逻辑
    } else {
      // 4. 直接上传
      let response = await uploadImage(compressedImage);
      console.log('Upload success:', response.code);
    }
  } catch (error) {
    console.error('Upload failed:', error);
  }
}

关键点说明:

  • 使用 PhotoViewPicker 获取媒体库URI
  • ImagePacker 支持质量压缩和尺寸调整
  • 通过 fileIo 分块读取大文件,避免内存问题
  • FormData 直接支持 multipart/form-data 格式
  • 注意资源释放,及时关闭文件和释放内存

此实现考虑了内存管理、大文件处理和网络传输效率,适合旅行记录类应用场景。

回到顶部