Flutter 相册整理应用的性能优化实践:从卡顿到丝滑

本文分享在开发「回留」相册整理应用过程中,针对大量图片处理场景的性能优化实践,包括 Isolate 并行计算、智能预加载、感知哈希算法等核心技术。

背景

手机相册动辄几千上万张照片,如何让用户快速、流畅地整理这些照片是一个技术挑战。我们面临的核心问题:

  1. UI 卡顿:图片解码、哈希计算等耗时操作阻塞主线程
  2. 切换闪烁:卡片滑动时图片加载不及时导致白屏闪烁
  3. 相似检测慢:数千张照片的相似度计算耗时过长
  4. 内存压力:大量高清图片同时加载导致 OOM

本文将逐一分享我们的解决方案。

一、Isolate 池:并行计算的正确姿势

问题

Flutter 是单线程模型,耗时计算会阻塞 UI。虽然可以用 compute() 函数开启 Isolate,但频繁创建/销毁 Isolate 的开销很大。

解决方案:Isolate 池

我们实现了一个 Isolate 池,预先创建多个 Isolate 工作者,通过轮询调度分配任务:

class IsolatePool {
  static IsolatePool? _instance;
  static IsolatePool get instance => _instance ??= IsolatePool._();

  final List<_IsolateWorker> _workers = [];
  int _poolSize = 2;
  int _nextWorkerIndex = 0;

  /// 初始化 Isolate 池
  Future<void> initialize({int? poolSize}) async {
    // 根据 CPU 核心数动态设置池大小,最少2个,最多6个
    _poolSize = poolSize ?? (Platform.numberOfProcessors ~/ 2).clamp(2, 6);

    for (int i = 0; i < _poolSize; i++) {
      final worker = await _IsolateWorker.spawn(i);
      _workers.add(worker);
    }
  }

  /// 计算哈希(轮询调度)
  Future<String?> computeHash(Uint8List imageData) async {
    final worker = _workers[_nextWorkerIndex];
    _nextWorkerIndex = (_nextWorkerIndex + 1) % _poolSize;
    return worker.computeHash(imageData);
  }

  /// 批量并行计算
  Future<List<String?>> computeHashBatch(List<Uint8List> imageDataList) async {
    final futures = <Future<String?>>[];
    for (int i = 0; i < imageDataList.length; i++) {
      final workerIndex = i % _poolSize;
      futures.add(_workers[workerIndex].computeHash(imageDataList[i]));
    }
    return Future.wait(futures);
  }
}

效果

  • 避免频繁创建 Isolate 的开销
  • 充分利用多核 CPU 并行计算
  • 1000 张照片的哈希计算从 60s 降到 15s

二、智能预加载:消灭切换闪烁

问题

卡片式滑动整理照片时,下一张照片还没加载完就切换过去,会出现白屏闪烁。

解决方案:智能预加载器

class PhotoPreloader {
  final PhotoCleanController _controller;
  final Set<String> _preloadingIds = {};

  static const int _maxConcurrentPreloads = 6;  // 并发数
  static const int _preloadRadius = 4;          // 预加载半径

  bool _isPaused = false;  // 滑动时暂停预加载

  /// 预加载照片整理页面的相邻照片
  Future<void> preloadForCleanView(List<AssetEntity> photos, int currentIndex) async {
    if (_isPaused) return;  // 滑动时暂停,避免资源竞争

    final indicesToPreload = <int>[];

    // 当前照片(最高优先级)
    indicesToPreload.add(currentIndex);

    // 后续5张(卡片堆叠显示3-4张,多预加载1张)
    for (int i = 1; i <= 5; i++) {
      if (currentIndex + i < photos.length) {
        indicesToPreload.add(currentIndex + i);
      }
    }

    // 前3张(用于撤销功能)
    for (int i = 1; i <= 3; i++) {
      if (currentIndex - i >= 0) {
        indicesToPreload.add(currentIndex - i);
      }
    }

    await _batchPreload(photos, indicesToPreload);
  }

  /// 滑动开始时暂停预加载
  void pause() => _isPaused = true;

  /// 滑动结束时恢复预加载
  void resume({int? currentIndex, List<AssetEntity>? photos}) {
    _isPaused = false;
    if (currentIndex != null && photos != null) {
      Future.microtask(() => preloadForCleanView(photos, currentIndex));
    }
  }
}

关键优化点

  1. 优先级排序:当前 > 下一张 > 上一张 > 其他
  2. 滑动时暂停:避免预加载和滑动动画抢占资源
  3. 并发控制:限制同时预加载数量,避免内存压力
  4. 节流控制:50ms 内不重复触发预加载

三、感知哈希:相似照片检测的核心

问题

如何快速判断两张照片是否相似?逐像素比较太慢,且对缩放、裁剪敏感。

解决方案:多算法组合的感知哈希

我们组合了 5 种哈希算法,生成 320 位(80 字符)的指纹:

class HashComputeCore {
  /// 计算感知哈希
  /// 使用 aHash + dHash + vHash + cHash + pHash 组合
  static String? computeHash(Uint8List imageData) {
    final image = img.decodeImage(imageData);
    final grayscale = img.grayscale(image);

    // 1. aHash (Average Hash) - 64位
    //    缩放到 8x8,比较每个像素与平均亮度
    final aHashBits = _computeAHash(grayscale);

    // 2. dHash (Difference Hash) - 64位
    //    缩放到 9x8,比较相邻像素的水平差异
    final dHashBits = _computeDHash(grayscale);

    // 3. vHash (Vertical Difference Hash) - 64位
    //    缩放到 8x9,比较相邻像素的垂直差异
    final vHashBits = _computeVHash(grayscale);

    // 4. cHash (Color Histogram Hash) - 64位
    //    分块统计颜色直方图特征
    final cHashBits = _computeCHash(image);

    // 5. pHash (DCT Perceptual Hash) - 64位
    //    使用离散余弦变换提取低频特征
    final pHashBits = _computePHash(grayscale);

    return _bitsToHex([...aHashBits, ...dHashBits, ...vHashBits, ...cHashBits, ...pHashBits]);
  }
}

pHash 的 DCT 实现

pHash 是最精确的感知哈希算法,核心是离散余弦变换(DCT):

/// 计算 pHash - 64位
static List<int> _computePHash(img.Image grayscale) {
  // 1. 缩放到 32x32
  final small32x32 = img.copyResize(grayscale, width: 32, height: 32);

  // 2. 构建亮度矩阵
  final matrix = List.generate(32, (y) {
    return List.generate(32, (x) {
      return small32x32.getPixel(x, y).luminance.toDouble();
    });
  });

  // 3. 计算 2D DCT
  final dctMatrix = _computeDCT(matrix);

  // 4. 取左上角 8x8 低频系数(去掉 DC 分量)
  final lowFreq = <double>[];
  for (var y = 0; y < 8; y++) {
    for (var x = 0; x < 8; x++) {
      if (x == 0 && y == 0) continue;  // 跳过 DC 分量
      lowFreq.add(dctMatrix[y][x]);
    }
  }

  // 5. 计算中位数,生成二值哈希
  final sorted = List<double>.from(lowFreq)..sort();
  final median = sorted[sorted.length ~/ 2];

  final bits = <int>[];
  for (var y = 0; y < 8; y++) {
    for (var x = 0; x < 8; x++) {
      bits.add(dctMatrix[y][x] > median ? 1 : 0);
    }
  }
  return bits;
}

相似度计算

使用汉明距离判断相似度:

int hammingDistance(String hash1, String hash2) {
  int distance = 0;
  for (int i = 0; i < hash1.length; i++) {
    if (hash1[i] != hash2[i]) distance++;
  }
  return distance;
}

// 320位哈希,距离 < 32(10%)认为相似
bool isSimilar(String hash1, String hash2) {
  return hammingDistance(hash1, hash2) < 32;
}

为什么组合多种算法?

算法 特点 擅长场景
aHash 简单快速 整体亮度相似
dHash 对缩放鲁棒 水平结构相似
vHash 补充 dHash 垂直结构相似
cHash 保留颜色信息 色彩相似
pHash 最精确 内容相似

组合使用可以大幅降低误判率。

四、其他优化技巧

1. 日期时间的正确获取

Android 系统的 createDateTime 可能是文件添加到相册的时间,而非实际拍摄时间:

/// 获取最佳日期时间
DateTime _getBestDateTime() {
  final createDt = photo.createDateTime;
  final modifiedDt = photo.modifiedDateTime;

  // 如果 modifiedDateTime 早于 createDateTime,使用 modifiedDateTime
  // 这种情况通常发生在图片被复制/移动后
  if (modifiedDt.isBefore(createDt)) {
    return modifiedDt;
  }
  return createDt;
}

2. 成就系统的事件驱动设计

class AchievementService {
  final Rx<UserStats> userStats = UserStats().obs;
  final RxList<AchievementDefinition> newlyUnlocked = <AchievementDefinition>[].obs;

  /// 增加统计值并检查成就
  Future<void> incrementStat(String statKey, {int amount = 1}) async {
    // 更新统计
    await _updateUserStats(...);

    // 检查相关成就
    await _checkAchievementsByStatKey(statKey);
  }

  /// 检查并解锁成就
  Future<void> _checkAndUnlockAchievement(AchievementDefinition achievement) async {
    final currentValue = userStats.value.getStatValue(achievement.statKey!);

    if (currentValue >= achievement.targetValue) {
      // 解锁成就,添加到通知列表
      newlyUnlocked.add(achievement);
    }
  }
}

3. Mixin 分离关注点

将控制器按功能拆分为多个 Mixin,提高可维护性:

class PhotoCleanController extends GetxController
    with PhotoCacheMixin,      // 缓存管理
         PhotoDeleteMixin,     // 删除操作
         PhotoSimilarMixin,    // 相似照片处理
         PhotoUndoMixin,       // 撤销功能
         PhotoSwipeMixin,      // 滑动手势
         PhotoBatchMixin,      // 批量操作
         PhotoUtilsMixin {     // 工具方法
  // 主控制器只负责协调各 Mixin
}

总结

通过以上优化,我们实现了:

  • 流畅的滑动体验:预加载 + 暂停机制消除闪烁
  • 快速的相似检测:Isolate 池 + 多算法哈希,1000 张照片 15s 完成
  • 低内存占用:并发控制 + 缓存淘汰策略
  • 良好的可维护性:Mixin 分离 + 事件驱动架构

希望这些实践对你有所帮助。如果你也在开发图片处理类应用,欢迎交流讨论。


本文基于「回留」相册整理应用的实际开发经验,代码已在生产环境验证。

分类: Flutter实战经验 标签:

评论

-- 评论已关闭 --

全部评论