HarmonyOS 鸿蒙Next图片上传与压缩完整实现
HarmonyOS 鸿蒙Next图片上传与压缩完整实现 在开发旅行记录应用时,需要实现图片上传功能:
- 用户从相册选择图片后上传到服务器
- 大图片需要先压缩再上传(减少流量和上传时间)
- 上传使用
multipart/form-data格式 - 支持从媒体库 URI(
file://media/...)读取图片 - 需要处理大文件的分块读取,避免内存溢出
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?
更多关于HarmonyOS 鸿蒙Next图片上传与压缩完整实现的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html
鸿蒙Next图片上传与压缩可通过以下步骤实现:
- 选择图片:使用
@ohos.file.picker模块的PhotoViewPicker选择图片,获取文件URI。 - 压缩图片:使用
@ohos.image模块的imagePacker和image.Component进行压缩。通过createImageSource创建图像源,使用createPixelMap获取PixelMap,然后设置压缩格式(如JPEG)和质量参数,最后调用packing生成压缩后的ArrayBuffer。 - 上传图片:将压缩后的ArrayBuffer或文件URI通过
@ohos.request的uploadFile方法上传至服务器。
关键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格式- 注意资源释放,及时关闭文件和释放内存
此实现考虑了内存管理、大文件处理和网络传输效率,适合旅行记录类应用场景。

