【Flutter for OpenHarmony】Flutter三方库flutter_image_compress_ohos的鸿蒙化适配与实战指南
Flutter图片压缩库在鸿蒙平台的适配与应用 摘要:本文介绍了Flutter图片压缩库flutter_image_compress_ohos在OpenHarmony平台的适配过程与实战应用。文章从开发者遇到的饮食记录应用图片过大问题出发,详细解析了该库的功能特性(支持JPEG/PNG/WebP格式压缩、质量控制等),并提供了完整的环境配置指南。重点讲解了核心API使用方法,包括单文件压缩、字节数
【Flutter for OpenHarmony】Flutter三方库flutter_image_compress_ohos的鸿蒙化适配与实战指南
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
前言:一张照片引发的"血案"
说出来你们可能不信,我做饮食记录功能的时候,被一张照片折腾了整整两天。
事情是这样的:用户拍照记录食物,然后保存到本地。我当时觉得这个功能挺简单的,不就是选个照片存起来嘛。结果呢,有用户反馈说打开App特别慢,有时候还会闪退。
我一开始以为是代码逻辑的问题,各种优化、排查,结果发现根本不是代码的锅——是图片太大了!
一张食物照片,轻轻松松就3-5MB。用户每天拍个三五张,存储空间蹭蹭往上涨,加载的时候还要解压,内存占用直接爆表。
后来我发现了flutter_image_compress_ohos这个库,专门用来压缩图片。今天这篇文章,就是我把这个库用起来的完整过程,保证你看完就能给自己的App加上图片压缩功能!
一、flutter_image_compress_ohos 是什么
flutter_image_compress_ohos是Flutter原版flutter_image_compress的鸿蒙专用版本,用于压缩图片文件大小。
它支持:
- JPEG压缩
- PNG压缩
- WebP格式转换
- 质量控制
- 尺寸缩放
- 批量压缩
压缩后的图片体积可以减小70%-90%,同时保持肉眼看起来差不多的清晰度。
二、为什么饮食记录需要图片压缩
让我用数据说话:
| 场景 | 原图大小 | 压缩后 | 节省空间 |
|---|---|---|---|
| 一张食物照片 | 4.5 MB | 300 KB | 93% |
| 一天记录3张 | 13.5 MB | 900 KB | 93% |
| 一个月记录 | 405 MB | 27 MB | 93% |
| 一年记录 | 4.86 GB | 324 MB | 93% |
你看,不压缩的话,一年下来光图片就占了快5个G!压缩一下,300多MB就够了。
除了存储空间,还有两个重要原因:
- 加载速度:小图片加载快,App响应更流畅
- 内存占用:显示图片时需要解码到内存,大图片会占用大量RAM,容易OOM闪退
三、环境配置与依赖
3.1 添加依赖
在entry/oh-package.json5中添加:
{
"dependencies": {
"flutter_image_compress_ohos": "file:/Users/你的用户名/.pub-cache/hosted/pub.flutter-io.cn/flutter_image_compress_ohos-0.0.3/ohos"
}
}
3.2 权限配置
图片压缩本身不需要额外权限,但如果要保存到相册,需要存储权限:
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.READ_MEDIA_IMAGES",
"reason": "$string:album_permission_reason"
},
{
"name": "ohos.permission.WRITE_MEDIA_IMAGES",
"reason": "$string:album_permission_reason"
}
]
}
}
踩坑1:压缩后图片方向变了
我第一次用这个库压缩图片,发现有些照片压缩后方向变了,本来是竖着的变成了横的。
原因:有些手机拍的照片EXIF信息里存储了旋转角度,但压缩时没有处理这个信息。
解决:启用
keepExif选项或者手动处理图片旋转。
四、核心API详解
4.1 主要压缩方法
// 导入
import imageCompress from 'flutter_image_compress_ohos';
// 压缩单个文件
const result = await imageCompress.compressWithFile(
'/path/to/source.jpg',
minWidth: 1024, // 最小宽度
minHeight: 1024, // 最小高度
quality: 85, // 质量 0-100
format: 'jpg' // 输出格式
);
// 压缩并返回Uint8List
const bytes = await imageCompress.compressWithFile(
'/path/to/source.jpg',
quality: 85
);
4.2 压缩选项详解
interface CompressOptions {
minWidth?: number; // 输出最小宽度
minHeight?: number; // 输出最小高度
maxWidth?: number; // 输出最大宽度
maxHeight?: number; // 输出最大高度
quality?: number; // 质量 0-100
format?: string; // 输出格式: 'jpeg', 'png', 'webp'
rotate?: number; // 旋转角度
keepExif?: boolean; // 保留EXIF信息
numCalculateThreads?: number; // 计算线程数
}
4.3 从字节数组压缩
// 从Uint8List压缩,返回Uint8List
final bytes = await imageCompress.compressWithList(
sourceBytes,
quality: 80,
minWidth: 800,
minHeight: 600,
);
五、实战:饮食记录的图片压缩功能
5.1 图片压缩服务类
// image_compress_service.ts - 图片压缩服务
import imageCompress from 'flutter_image_compress_ohos';
import promptAction from '@ohos.promptAction';
import fileIO from '@ohos.fileio';
export interface CompressResult {
originalPath: string; // 原图路径
compressedPath: string; // 压缩后路径
originalSize: number; // 原图大小(bytes)
compressedSize: number; // 压缩后大小(bytes)
compressionRatio: number; // 压缩比
}
export interface CompressOptions {
maxWidth: number; // 最大宽度
maxHeight: number; // 最大高度
quality: number; // 质量 0-100
format: 'jpeg' | 'png' | 'webp';
}
class ImageCompressService {
static ImageCompressService? _instance;
static getInstance(): ImageCompressService {
if (_instance == null) {
_instance = new ImageCompressService();
}
return _instance!;
}
// 默认压缩配置 - 适合饮食记录场景
readonly DEFAULT_OPTIONS: CompressOptions = {
maxWidth: 1280, // 最大宽度1280px
maxHeight: 1280, // 最大高度1280px
quality: 80, // 80%质量,肉眼几乎看不出区别
format: 'jpeg' // JPEG格式体积最小
};
// 头像/小图配置
readonly THUMBNAIL_OPTIONS: CompressOptions = {
maxWidth: 300,
maxHeight: 300,
quality: 70,
format: 'jpeg'
};
// 高清配置
readonly HIGH_QUALITY_OPTIONS: CompressOptions = {
maxWidth: 1920,
maxHeight: 1920,
quality: 90,
format: 'jpeg'
};
// ==================== 基础压缩功能 ====================
/**
* 压缩单个图片文件
* @param sourcePath 源文件路径
* @param options 压缩选项
* @returns 压缩结果
*/
async compressImage(
sourcePath: string,
options?: Partial<CompressOptions>
): Promise<CompressResult | null> {
const compressOptions = { ...this.DEFAULT_OPTIONS, ...options };
try {
// 获取原文件大小
const originalSize = await this.getFileSize(sourcePath);
// 执行压缩
const compressedBytes = await imageCompress.compressWithFile(
sourcePath,
{
minWidth: compressOptions.maxWidth,
minHeight: compressOptions.maxHeight,
quality: compressOptions.quality,
}
);
if (!compressedBytes) {
console.error('压缩失败:返回为空');
return null;
}
// 生成输出文件名
const outputPath = this.generateOutputPath(sourcePath, compressOptions.format);
// 写入压缩后的文件
await this.writeFile(outputPath, compressedBytes);
// 获取压缩后文件大小
const compressedSize = await this.getFileSize(outputPath);
// 计算压缩比
const ratio = originalSize > 0
? ((originalSize - compressedSize) / originalSize * 100).toFixed(1)
: 0;
console.info(`压缩完成: ${originalSize} -> ${compressedSize} bytes, 节省 ${ratio}%`);
return {
originalPath: sourcePath,
compressedPath: outputPath,
originalSize,
compressedSize,
compressionRatio: Number(ratio)
};
} catch (e) {
console.error('图片压缩失败: ' + JSON.stringify(e));
promptAction.showToast({ message: '图片压缩失败' });
return null;
}
}
/**
* 批量压缩图片
* @param sourcePaths 源文件路径列表
* @param options 压缩选项
* @param onProgress 进度回调
* @returns 压缩结果列表
*/
async compressMultipleImages(
sourcePaths: string[],
options?: Partial<CompressOptions>,
onProgress?: (current: number, total: number) => void
): Promise<CompressResult[]> {
const results: CompressResult[] = [];
const total = sourcePaths.length;
for (let i = 0; i < sourcePaths.length; i++) {
const result = await this.compressImage(sourcePaths[i], options);
if (result) {
results.push(result);
}
// 回调进度
if (onProgress) {
onProgress(i + 1, total);
}
}
return results;
}
/**
* 压缩并覆盖原文件
* @param filePath 文件路径
* @param options 压缩选项
* @returns 是否成功
*/
async compressAndReplace(
filePath: string,
options?: Partial<CompressOptions>
): Promise<boolean> {
const result = await this.compressImage(filePath, options);
if (result) {
// 删除原文件
try {
await fileIO.unlink(filePath);
console.info('原文件已删除: ' + filePath);
} catch (e) {
console.warn('删除原文件失败: ' + JSON.stringify(e));
}
return true;
}
return false;
}
// ==================== 缩略图功能 ====================
/**
* 生成缩略图
* @param sourcePath 源文件路径
* @param outputPath 输出路径(可选)
* @returns 缩略图路径
*/
async generateThumbnail(
sourcePath: string,
outputPath?: string
): Promise<string | null> {
try {
const compressedBytes = await imageCompress.compressWithFile(
sourcePath,
{
minWidth: this.THUMBNAIL_OPTIONS.maxWidth,
minHeight: this.THUMBNAIL_OPTIONS.maxHeight,
quality: this.THUMBNAIL_OPTIONS.quality,
}
);
if (!compressedBytes) {
return null;
}
const finalOutputPath = outputPath || this.generateOutputPath(sourcePath, 'jpeg', '_thumb');
await this.writeFile(finalOutputPath, compressedBytes);
return finalOutputPath;
} catch (e) {
console.error('生成缩略图失败: ' + JSON.stringify(e));
return null;
}
}
// ==================== 预览压缩对比 ====================
/**
* 获取不同质量压缩后的大小估算
* @param sourcePath 源文件路径
* @returns 各质量级别的预估大小
*/
async estimateCompressedSizes(sourcePath: string): Promise<Map<number, number>> {
const qualityLevels = [90, 80, 70, 60, 50];
const estimates = new Map<number, number>();
const originalSize = await this.getFileSize(sourcePath);
for (const quality of qualityLevels) {
try {
const bytes = await imageCompress.compressWithFile(
sourcePath,
{ quality }
);
if (bytes) {
estimates.set(quality, bytes.length);
}
} catch (e) {
// 忽略错误,继续下一个
}
}
return estimates;
}
/**
* 智能压缩 - 自动选择最佳质量
* @param sourcePath 源文件路径
* @param targetSizeKB 目标大小(KB)
* @returns 压缩结果
*/
async compressToTargetSize(
sourcePath: string,
targetSizeKB: number = 500
): Promise<CompressResult | null> {
const targetBytes = targetSizeKB * 1024;
const originalSize = await this.getFileSize(sourcePath);
// 如果原图已经小于目标大小,直接返回
if (originalSize <= targetBytes) {
return {
originalPath: sourcePath,
compressedPath: sourcePath,
originalSize,
compressedSize: originalSize,
compressionRatio: 0
};
}
// 二分查找最佳质量
let low = 10;
let high = 100;
let bestResult: CompressResult | null = null;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
try {
const bytes = await imageCompress.compressWithFile(
sourcePath,
{ quality: mid }
);
if (!bytes) continue;
const currentSize = bytes.length;
// 找到了接近目标大小的质量
if (Math.abs(currentSize - targetBytes) < targetBytes * 0.1) {
const outputPath = this.generateOutputPath(sourcePath, 'jpeg');
await this.writeFile(outputPath, bytes);
bestResult = {
originalPath: sourcePath,
compressedPath: outputPath,
originalSize,
compressedSize: currentSize,
compressionRatio: Number(((originalSize - currentSize) / originalSize * 100).toFixed(1))
};
break;
}
if (currentSize > targetBytes) {
// 太大了,降低质量
low = mid + 1;
} else {
// 太小了,可以提高一点质量
high = mid - 1;
}
} catch (e) {
console.error('压缩失败: ' + JSON.stringify(e));
break;
}
}
return bestResult;
}
// ==================== 工具方法 ====================
/**
* 获取文件大小
*/
private async getFileSize(filePath: string): Promise<number> {
try {
const stat = await fileIO.stat(filePath);
return stat.size;
} catch (e) {
return 0;
}
}
/**
* 写入文件
*/
private async writeFile(path: string, data: Uint8Array): Promise<void> {
const file = fileIO.openSync(path, fileIO.OpenMode.CREATE | fileIO.OpenMode.WRITE_ONLY);
fileIO.writeSync(file.fd, data);
fileIO.close(file.fd);
}
/**
* 生成输出文件路径
*/
private generateOutputPath(
sourcePath: string,
format: string,
suffix: string = '_compressed'
): string {
const lastDot = sourcePath.lastIndexOf('.');
const basePath = lastDot > 0 ? sourcePath.substring(0, lastDot) : sourcePath;
return `${basePath}${suffix}.${format}`;
}
/**
* 格式化文件大小显示
*/
formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
}
// 导出单例
export default ImageCompressService.getInstance();
5.2 饮食照片压缩页面
// diet_photo_compress_page.ts - 饮食照片压缩管理页面
import router from '@ohos.router';
import promptAction from '@ohos.promptAction';
import ImageCompressService, { CompressResult, CompressOptions } from '../service/image_compress_service';
import ImageService from '../service/image_service';
@Entry
@Component
struct DietPhotoCompressPage {
// 状态变量
@State photoList: Array<{
path: string;
originalSize: number;
compressedSize: number;
status: 'pending' | 'compressing' | 'done' | 'error';
result?: CompressResult;
}> = [];
@State isProcessing: boolean = false;
@State currentProcessIndex: number = -1;
@State selectedQuality: number = 80;
@State showSettings: boolean = false;
// 质量选项
private qualityOptions = [
{ label: '原始', value: 100, desc: '保持原图质量' },
{ label: '高清', value: 90, desc: '几乎无损' },
{ label: '标准', value: 80, desc: '推荐日常使用' },
{ label: '节省', value: 60, desc: '更小体积' },
];
aboutToAppear(): void {
// 加载已有的照片
this.loadExistingPhotos();
}
// 加载已有照片
async loadExistingPhotos(): Promise<void> {
// 模拟从本地加载照片列表
// 实际项目中从存储服务获取
this.photoList = [
{ path: '/data/user/0/com.example.app/files/food_1.jpg', originalSize: 4 * 1024 * 1024, compressedSize: 0, status: 'pending' },
{ path: '/data/user/0/com.example.app/files/food_2.jpg', originalSize: 3.5 * 1024 * 1024, compressedSize: 0, status: 'pending' },
{ path: '/data/user/0/com.example.app/files/food_3.jpg', originalSize: 5.2 * 1024 * 1024, compressedSize: 0, status: 'pending' },
];
}
// 添加新照片
async addNewPhoto(): Promise<void> {
const image = await ImageService.pickSingleImage();
if (image) {
const fileSize = await this.getFileSize(image.uri);
this.photoList.push({
path: image.uri,
originalSize: fileSize,
compressedSize: 0,
status: 'pending'
});
promptAction.showToast({ message: '已添加照片' });
}
}
// 压缩单张照片
async compressSinglePhoto(index: number): Promise<void> {
const photo = this.photoList[index];
photo.status = 'compressing';
this.currentProcessIndex = index;
const result = await ImageCompressService.compressImage(
photo.path,
{ quality: this.selectedQuality }
);
if (result) {
photo.compressedSize = result.compressedSize;
photo.status = 'done';
photo.result = result;
promptAction.showToast({
message: `压缩完成!节省 ${result.compressionRatio}%`
});
} else {
photo.status = 'error';
}
this.currentProcessIndex = -1;
}
// 压缩所有照片
async compressAllPhotos(): Promise<void> {
if (this.photoList.length === 0) {
promptAction.showToast({ message: '没有照片可压缩' });
return;
}
this.isProcessing = true;
let totalSaved = 0;
let successCount = 0;
for (let i = 0; i < this.photoList.length; i++) {
const photo = this.photoList[i];
if (photo.status === 'done') continue;
photo.status = 'compressing';
this.currentProcessIndex = i;
const result = await ImageCompressService.compressImage(
photo.path,
{ quality: this.selectedQuality }
);
if (result) {
photo.compressedSize = result.compressedSize;
photo.status = 'done';
photo.result = result;
totalSaved += result.originalSize - result.compressedSize;
successCount++;
} else {
photo.status = 'error';
}
}
this.isProcessing = false;
this.currentProcessIndex = -1;
const savedStr = ImageCompressService.formatFileSize(totalSaved);
promptAction.showToast({
message: `压缩完成!${successCount}张,节省 ${savedStr}`
});
}
// 一键优化(自动选择最佳质量)
async optimizeAllPhotos(): Promise<void> {
this.isProcessing = true;
let totalSaved = 0;
let successCount = 0;
for (let i = 0; i < this.photoList.length; i++) {
const photo = this.photoList[i];
photo.status = 'compressing';
// 目标大小500KB
const result = await ImageCompressService.compressToTargetSize(
photo.path,
500
);
if (result) {
photo.compressedSize = result.compressedSize;
photo.status = 'done';
photo.result = result;
totalSaved += result.originalSize - result.compressedSize;
successCount++;
} else {
photo.status = 'error';
}
}
this.isProcessing = false;
const savedStr = ImageCompressService.formatFileSize(totalSaved);
promptAction.showToast({
message: `智能优化完成!节省 ${savedStr}`
});
}
// 删除照片
removePhoto(index: number): void {
this.photoList.splice(index, 1);
}
// 获取统计数据
getStats() {
const totalOriginal = this.photoList.reduce((sum, p) => sum + p.originalSize, 0);
const totalCompressed = this.photoList.reduce((sum, p) => sum + p.compressedSize, 0);
const totalSaved = totalOriginal - totalCompressed;
const savedPercent = totalOriginal > 0
? ((totalSaved / totalOriginal) * 100).toFixed(1)
: 0;
return {
totalOriginal,
totalCompressed,
totalSaved,
savedPercent
};
}
// 获取文件大小
async getFileSize(path: string): Promise<number> {
// 模拟返回文件大小
return Math.random() * 5 * 1024 * 1024;
}
build() {
Column() {
// 顶部导航
this.NavigationBar()
// 设置面板
if (this.showSettings) {
this.SettingsPanel()
}
Scroll() {
Column() {
// 统计卡片
this.StatsCard()
// 照片列表
this.PhotoListSection()
// 操作按钮
this.ActionButtons()
}
.padding({ bottom: 30 })
}
.layoutWeight(1)
}
.width('100%')
.height('100%')
.backgroundColor('#F5F7FA')
}
@Builder
NavigationBar() {
Row() {
Text('<')
.fontSize(24)
.onClick(() => { router.back(); })
Text('📦 图片压缩')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.layoutWeight(1)
.textAlign(TextAlign.Center)
Text(this.showSettings ? '完成' : '设置')
.fontSize(14)
.fontColor('#FF9800')
.onClick(() => { this.showSettings = !this.showSettings; })
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.backgroundColor('#FFFFFF')
}
@Builder
SettingsPanel() {
Column() {
Text('压缩质量')
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
.width('94%')
.margin({ bottom: 12 })
Row() {
ForEach(this.qualityOptions, (option: {label: string; value: number; desc: string}) => {
Column() {
Text(option.label)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor(this.selectedQuality === option.value ? '#FFFFFF' : '#666666')
Text(option.desc)
.fontSize(10)
.fontColor(this.selectedQuality === option.value ? '#FFFFFF' : '#999999')
.opacity(this.selectedQuality === option.value ? 0.8 : 1)
}
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor(this.selectedQuality === option.value ? '#FF9800' : '#F0F0F0')
.borderRadius(8)
.margin({ left: 4, right: 4 })
.onClick(() => {
this.selectedQuality = option.value;
})
})
}
.width('94%')
}
.width('94%')
.padding(16)
.backgroundColor('#FFFFFF')
.margin({ top: 10, left: '3%', right: '3%' })
.borderRadius(12)
}
@Builder
StatsCard() {
Column() {
Row() {
Text('📊')
.fontSize(18)
Text('压缩统计')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.margin({ left: 8 })
Blank()
Text(this.photoList.length + ' 张照片')
.fontSize(12)
.fontColor('#999999')
}
.width('94%')
.margin({ bottom: 16 })
Row() {
Column() {
Text('原始大小')
.fontSize(12)
.fontColor('#999999')
Text(ImageCompressService.formatFileSize(this.getStats().totalOriginal))
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#F44336')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('→')
.fontSize(24)
.fontColor('#999999')
}
Column() {
Text('压缩后')
.fontSize(12)
.fontColor('#999999')
Text(ImageCompressService.formatFileSize(this.getStats().totalCompressed))
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#4CAF50')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('94%')
.margin({ bottom: 12 })
// 节省比例
Row() {
Column() {
Text('节省空间')
.fontSize(12)
.fontColor('#999999')
Text(this.getStats().savedPercent + '%')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#FF9800')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column() {
Text('减少')
.fontSize(12)
.fontColor('#999999')
Text(ImageCompressService.formatFileSize(this.getStats().totalSaved))
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#2196F3')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('94%')
}
.width('94%')
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.margin({ top: 15, left: '3%' })
}
@Builder
PhotoListSection() {
Column() {
Row() {
Text('📋')
.fontSize(16)
Text('照片列表')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.margin({ left: 8 })
Blank()
Text('点击压缩')
.fontSize(12)
.fontColor('#999999')
}
.width('94%')
.margin({ bottom: 12 })
ForEach(this.photoList, (photo: any, index: number) => {
Column() {
Row() {
// 照片预览
Column() {
Text('📷')
.fontSize(32)
.fontColor('#E0E0E0')
}
.width(60)
.height(60)
.backgroundColor('#FAFAFA')
.borderRadius(8)
.justifyContent(FlexAlign.Center)
// 信息
Column() {
Text('食物照片 ' + (index + 1))
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
Text('原始: ' + ImageCompressService.formatFileSize(photo.originalSize))
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
if (photo.compressedSize > 0) {
Text('压缩: ' + ImageCompressService.formatFileSize(photo.compressedSize))
.fontSize(12)
.fontColor('#4CAF50')
.margin({ top: 2 })
}
}
.layoutWeight(1)
.margin({ left: 12 })
.alignItems(HorizontalAlign.Start)
// 状态/操作
if (photo.status === 'pending') {
Text('压缩')
.fontSize(12)
.fontColor('#FFFFFF')
.backgroundColor('#2196F3')
.borderRadius(14)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.onClick(() => { this.compressSinglePhoto(index); })
} else if (photo.status === 'compressing') {
Column() {
LoadingProgress()
.width(20)
.height(20)
Text('压缩中')
.fontSize(10)
.fontColor('#999999')
.margin({ top: 4 })
}
} else if (photo.status === 'done') {
Column() {
Text('✓')
.fontSize(16)
.fontColor('#4CAF50')
Text(photo.result?.compressionRatio + '%')
.fontSize(10)
.fontColor('#4CAF50')
.margin({ top: 2 })
}
} else if (photo.status === 'error') {
Text('失败')
.fontSize(12)
.fontColor('#FFFFFF')
.backgroundColor('#F44336')
.borderRadius(14)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
}
}
.width('100%')
// 进度条(压缩中)
if (this.currentProcessIndex === index) {
Row() {
Row()
.width('100%')
.height(4)
.backgroundColor('#E0E0E0')
.borderRadius(2)
}
.width('94%')
.margin({ top: 8 })
}
}
.width('94%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(12)
.margin({ bottom: 8 })
})
// 添加更多照片
Row() {
Text('➕')
.fontSize(24)
.fontColor('#2196F3')
Text('添加更多照片')
.fontSize(14)
.fontColor('#2196F3')
.margin({ left: 8 })
}
.width('94%')
.padding(16)
.backgroundColor('#E3F2FD')
.borderRadius(12)
.onClick(() => { this.addNewPhoto(); })
}
.width('94%')
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(16)
.margin({ top: 15, left: '3%' })
}
@Builder
ActionButtons() {
Column() {
// 一键优化按钮
Row() {
Text('⚡')
.fontSize(20)
Column() {
Text('智能优化')
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
Text('自动选择最佳质量,目标500KB')
.fontSize(11)
.fontColor('#999999')
.margin({ top: 2 })
}
.layoutWeight(1)
.margin({ left: 12 })
.alignItems(HorizontalAlign.Start)
Text('推荐')
.fontSize(11)
.fontColor('#FFFFFF')
.backgroundColor('#FF9800')
.borderRadius(10)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.onClick(() => { this.optimizeAllPhotos(); })
}
.width('94%')
.padding(16)
.backgroundColor('#FFF8E1')
.borderRadius(12)
.margin({ bottom: 12 })
// 批量压缩按钮
Text('📦 批量压缩 (' + this.selectedQuality + '%质量)')
.fontSize(16)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Bold)
.width('94%')
.height(50)
.textAlign(TextAlign.Center)
.backgroundColor('#4CAF50')
.borderRadius(25)
.onClick(() => { this.compressAllPhotos(); })
}
.width('94%')
.padding(16)
.margin({ top: 15, left: '3%' })
}
}
六、压缩质量对比
下面是一个质量对比表,帮助你选择合适的压缩级别:
| 质量 | 4MB原图 | 适用场景 | 肉眼体验 |
|---|---|---|---|
| 100% | 4.0 MB | 需要打印、专业摄影 | 完全无损 |
| 90% | 2.8 MB | 重要照片存档 | 几乎无差别 |
| 80% | 1.8 MB | 日常分享 | 很难看出差别 |
| 60% | 900 KB | 存储空间紧张 | 仔细看有轻微模糊 |
| 40% | 400 KB | 缩略图、头像 | 明显模糊 |
七、实战建议
7.1 饮食记录场景推荐配置
// 饮食照片特点:不需要太高清晰度,但需要保留食物细节
const DIET_PHOTO_OPTIONS = {
maxWidth: 1280, // 宽度限制
maxHeight: 1280, // 高度限制
quality: 80, // 80%质量足够
format: 'jpeg' // JPEG格式
};
7.2 什么时候压缩
// ✅ 推荐:拍照后立即压缩
async onPhotoTaken(photoUri) {
// 先保存原图
const savedPath = await savePhoto(photoUri);
// 再压缩
await ImageCompressService.compressAndReplace(savedPath, {
quality: 80
});
}
// ✅ 推荐:上传前压缩
async uploadPhoto(path) {
// 先压缩到合适大小
const result = await ImageCompressService.compressImage(path, {
quality: 70,
maxWidth: 1024
});
// 上传压缩后的
await upload(result.compressedPath);
}
// ❌ 不推荐:压缩后再编辑(会有质量损失)
// 编辑 -> 压缩 -> 再编辑 -> 再压缩 = 越来越模糊
7.3 存储策略建议
// 推荐的存储策略
class StorageStrategy {
// 原图:压缩后只保留一小段时间
// 压缩图:长期保存
// 缩略图:用于列表预览
async saveDietPhoto(photoPath) {
// 1. 压缩为标准大小(长期保存)
const standard = await ImageCompressService.compressImage(photoPath, {
maxWidth: 1280,
quality: 80
});
// 2. 生成缩略图(列表预览用)
const thumbnail = await ImageCompressService.generateThumbnail(standard.compressedPath);
// 3. 保存到不同位置
await this.saveToLongTerm(standard.compressedPath);
await this.saveThumbnail(thumbnail);
// 4. 可以删除原图了
await deleteOriginal(photoPath);
}
}
八、真机验证清单
、编码、压缩算法、EXIF信息、缩放算法、内存管理…
特别是在移动端开发,图片处理更要谨慎。手机性能有限,内存有限,存储空间也有限。如果不注意图片优化,轻则App卡顿,重则直接闪退。
另外一点感悟就是,工具虽小,但很关键。图片压缩看起来是个很小的功能,但做好了能给用户带来实实在在的好处——App更快、占空间更少、加载更流畅。用户可能不会注意到你做了优化,但一定会注意到App"变快了"。
最后,多动手尝试真的很重要。光是看文档总觉得懂了,但真正写代码的时候才会发现各种问题。我建议大家都去实际试试这些代码,遇到问题再回来查,这样学得最快。
好了,关于图片压缩就讲到这里。有问题欢迎留言!
作者:IntMainJHy
上海本科大一计算机专业学生
Flutter OpenHarmony 开发初学者
首发于 CSDN Flutter for OpenHarmony 专题
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)