【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就够了。

除了存储空间,还有两个重要原因:

  1. 加载速度:小图片加载快,App响应更流畅
  2. 内存占用:显示图片时需要解码到内存,大图片会占用大量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);
  }
}

八、真机验证清单

![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/5468b0c8aa1f49d18abfeec318566562.png

九、踩坑总结

坑1:压缩后图片方向变了

这个问题在前面提过了。原因是有些手机拍摄的图片有EXIF旋转信息,但压缩时丢失了。解决方法是可以启用keepExif选项,或者在压缩后手动旋转图片。

坑2:压缩后文件反而变大了

这听起来很不科学对吧?但确实发生了。原因是我选的quality值太高了,而且原图本身就是JPEG格式,再次压缩反而会增加一些元数据开销。解决方法是在压缩前检查图片格式和质量,选择合适的压缩参数。

坑3:异步操作阻塞UI

压缩是耗时操作,如果不处理好会让界面卡住。解决方法是用async/await包装压缩操作,加上Loading状态提示用户正在处理。


十、学习心得

做这个图片压缩功能,让我学到了很多关于图片处理的知识。

以前我一直觉得图片就是图片,没什么特别的。但真正用起来才发现,图片涉及的知识点太多了:格式(JPEG、PNG、WebP)、编码、压缩算法、EXIF信息、缩放算法、内存管理…

特别是在移动端开发,图片处理更要谨慎。手机性能有限,内存有限,存储空间也有限。如果不注意图片优化,轻则App卡顿,重则直接闪退。

另外一点感悟就是,工具虽小,但很关键。图片压缩看起来是个很小的功能,但做好了能给用户带来实实在在的好处——App更快、占空间更少、加载更流畅。用户可能不会注意到你做了优化,但一定会注意到App"变快了"。

最后,多动手尝试真的很重要。光是看文档总觉得懂了,但真正写代码的时候才会发现各种问题。我建议大家都去实际试试这些代码,遇到问题再回来查,这样学得最快。

好了,关于图片压缩就讲到这里。有问题欢迎留言!


作者:IntMainJHy
上海本科大一计算机专业学生
Flutter OpenHarmony 开发初学者

首发于 CSDN Flutter for OpenHarmony 专题
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

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

更多推荐