Vincent's Essays

博客园 首页 新随笔 联系 订阅 管理

Memoria 开发记事 2 客户端性能调优 - 并行、异步和线程池

前言

客户端在开发初期,由于更侧重于功能的实现,性能随着功能的增加而逐渐下降。在本人的测试设备(Samsung Galaxy A52 5G, SOC: Snapdragon 750G, One UI 6.1)上,性能极其糟糕,CPU 单核长时间满载,以至于界面会完全卡死,不能进行任何交互操作,阻塞等待某些计算逻辑完成后才会恢复响应。为了提升性能,减少界面卡死的情况,我们需要对客户端进行性能调优,主要想法包括:

  1. 将一些计算密集型的任务放到后台中执行,避免阻塞主线程。
  2. 充分利用多核并行计算,降低等待时间。
  3. 将渲染相关的计算和主进程降低耦合度,优先保证界面流畅。

首先分析代码的逻辑,项目分为四个界面,首页的组件数量很少、逻辑也比较简单,性能表现一般不会出现问题;第二个界面主要是相册图片按标签的展示,其中包括大量的图片缩略图,并且会涉及到一些计算密集型的任务,如特征提取、聚类算法等;第三个界面是聚类结果的展示,也涉及到从数据库中读取数据并展示图片的逻辑;最后一个界面是用户信息和设置界面。因此,性能调优的重点主要放在第二和第三个界面上。

一个常见的误区:async/await 不是"后台线程"

在着手修改之前,有必要先厘清一个 Flutter 开发中非常容易踩的坑。

很多开发者认为,只要把耗时操作写成 async 函数,界面就不会卡。这个理解是不准确的。async/await 的本质是对 Dart 事件循环的语法糖——它只是把任务分割成若干片段,在 I/O 等待期间让出事件循环的控制权。但代码的恢复点(resume)仍然在主 isolate 上执行。

// 看起来是"异步",实则推理期间主线程完全阻塞
Future<List<double>> runInference(Uint8List imageBytes) async {
  final result = await onnxSession.run(inputs); // ← 数百毫秒,主 isolate 无法渲染帧
  return result;
}

对于网络请求,等待期间主线程空闲,这没什么问题。但 ONNX 推理是纯 CPU 密集型运算——await 的背后不是等待 I/O,而是在主 isolate 上跑了数百毫秒的矩阵运算,Flutter 渲染帧自然无法按时交付。

Dart Isolate 的正确理解:Dart 中的真正并发单位是 Isolate,每个 Isolate 有独立的内存堆和事件循环,互相之间只能通过消息传递通信。async/await 不会创建新的 Isolate,也不会把任务扔给线程池——它只是把任务分片挂在同一个事件循环的不同 tick 上。

这个认知是后续所有优化工作的前提。

把 AI 分析状态从 UI 中剥离出来

解决问题的第一步,是建立一条清晰的 UI 与分析任务之间的通信边界。

早期的实现中,分析逻辑和界面构建逻辑掺杂在一起:分析进度直接存在 Widget 的 State 里,进度更新触发 setState,setState 又触发整棵子树重建。这带来两个问题:分析过程中 UI 频繁重建;分析任务和 Widget 的生命周期绑定,用户切页面就会打断任务。

我们引入了一个核心数据结构 AIAnalysisProgress,它是一个完全不可变的状态快照:

class AIAnalysisProgress {
  const AIAnalysisProgress({
    required this.isRunning,
    required this.isPaused,
    required this.isStopping,
    required this.total,
    required this.completed,
    required this.failed,
    required this.currentStep,
    required this.elapsedMs,
  });

  // 每种状态对应独立的工厂方法,语义清晰,不容易出错
  factory AIAnalysisProgress.idle() { ... }
  factory AIAnalysisProgress.running({ ... }) { ... }
  factory AIAnalysisProgress.paused({ ... }) { ... }
  factory AIAnalysisProgress.stopping({ ... }) { ... }

  // 计算属性:不存储派生字段,按需计算,ETA 也由模型层负责
  double get fraction => (completed / total).clamp(0, 1).toDouble();

  Duration? get estimatedRemainingDuration {
    final avg = averageSecondsPerItem;
    final remaining = total - completed;
    if (avg == null || remaining <= 0) return null;
    return Duration(milliseconds: (avg * remaining * 1000).round());
  }

  bool get isVisible => (isRunning || isPaused || isStopping) && total > 0;
}

状态的传递通过 ValueNotifier<AIAnalysisProgress> 完成,AIService 对外只暴露只读的 ValueListenable:

// AIService 内部持有可写的 notifier
final ValueNotifier<AIAnalysisProgress> _progressNotifier =
    ValueNotifier<AIAnalysisProgress>(AIAnalysisProgress.idle());

// 对外只暴露只读接口,UI 层无法绕过 service 直接写状态
ValueListenable<AIAnalysisProgress> get progressListenable => _progressNotifier;

这样,AI 分析层和 UI 层之间形成严格的单向数据流:

AI 分析协程
    │  写入(_progressNotifier.value = ...)
    ▼
ValueNotifier<AIAnalysisProgress>
    │  监听(ValueListenableBuilder / addListener)
    ▼
  UI Widget(只读,只渲染)

UI 永远不会调用分析逻辑,分析逻辑也从不操作 Widget。切页面不会打断任务,界面重建也不会影响分析进度。

ETA 的计算属性同样体现了这个原则——如果把剩余时间估算放在 Widget 的 build() 里,每次重建都要重算,多个 Widget 之间还容易出现数值不一致。把它下沉到数据模型,UI 层直接消费结果即可。

通知栏:后台运行时的"第二个 UI"

解决了 App 内的状态问题,还有一个场景没有覆盖:用户切到后台怎么办?

打标是一个可能持续数分钟的任务。用户在这段时间里会切出 App,他需要知道任务还在跑,也需要能随时暂停。这就需要系统通知栏参与进来。

我们专门抽象了 AIProgressNotificationService,它和 App 内的 ValueNotifier 是并联关系,都由同一个进度状态驱动:

_progressNotifier 变更
         │
         ├──► ValueListenable listeners(App 内 UI)
         │
         └──► AIProgressNotificationService.syncProgress()(通知栏)

这个服务的实现里有几个值得深挖的细节。

签名节流:防止高频更新打爆通知渲染

_progressNotifier 每处理一张照片就可能变更一次。通知栏每次 show() 都有系统开销,频率过高轻则通知栏闪烁,重则触发 Android 通知速率限制被系统静默丢弃:

final signature =
    '$isRunning|$isPaused|$isStopping|$completed|$total|$failed|$progressPercent|$currentStep';

// 两个条件缺一不可:内容没变不发;900ms 内不重复发
if (signature == _lastSignature && nowMs - _lastShownAtMs < 900) {
  return;
}
_lastSignature = signature;
_lastShownAtMs = nowMs;

签名去重保证内容相同不重复发送;时间窗口保证同一秒内即使内容变了也不会打得过于密集。

通知动作按钮的 Isolate 陷阱

Android 通知栏的动作按钮(暂停/继续)有一个容易踩的坑:动作回调默认走后台 Isolate。后台 Isolate 有独立的内存堆,访问不到主 Isolate 里的 _pauseRequested 变量,回调发出去什么都不会发生。

解决方法是强制回调走前台 UI Isolate:

AndroidNotificationAction(
  isPaused ? actionResume : actionPause,
  isPaused ? '继续' : '暂停',
  // 关键参数:强制动作走前台 isolate 回调
  // 若为 false,会走后台 isolate,无法命中主流程的 action handler
  showsUserInterface: true,
  cancelNotification: false,
),

showsUserInterface: true 会把 App 带到前台,确保回调在主 Isolate 上执行,从而正确命中 AIService 里绑定的 _actionHandler。

冷启动竞态:_pendingAction 队列

通知动作还可能在 App 刚冷启动、AIService 尚未初始化完成时触发。如果此时 _actionHandler 还没绑定就直接丢弃这个动作,用户会发现"点了暂停没反应":

void _dispatchOrQueueAction(String actionId) {
  final handler = _actionHandler;
  if (handler != null) {
    handler(actionId);
    return;
  }
  _pendingAction = actionId; // handler 还没绑定,先存起来
}

void bindActionHandler(ValueChanged<String> handler) {
  _actionHandler = handler;
  final pending = _pendingAction;
  if (pending != null) {
    _pendingAction = null;
    handler(pending); // 绑定时立即消费积压的动作
  }
}

这是一个简单的"延迟投递"模式,覆盖了从通知点击进入的冷启动场景。

暂停与继续:命令模式,不要直接操作协程

UI 层需要暂停或继续 AI 分析,应该怎么做?一种不好的方式是 UI 直接操作分析协程的状态——比如取消一个 Future、注入一个 CancellationToken。这会制造强耦合,在 Dart 的异步模型里也很难安全地"打断"一个正在执行的 Future 链。

我们采用的方式是命令模式:UI 只写一个 flag,分析协程在每个安全检查点轮询这个 flag 并自主决定是否挂起:

// UI 层:只改一个布尔值,并立刻给出视觉反馈
void pauseAnalysis() {
  _pauseRequested = true;
  _progressNotifier.value = current.copyWith(
    isRunning: false,
    isPaused: true,
    currentStep: '暂停中,等待当前任务收尾…',
  );
}

// 分析协程:在每个安全检查点轮询 flag
Future<bool> _waitIfPaused() async {
  while (_pauseRequested && !_stopRequested) {
    await Future.delayed(const Duration(milliseconds: 300));
  }
  return !_stopRequested;
}

这个设计有几个优点:

  • 没有锁,没有跨 Isolate 通信 — _pauseRequested 是主 Isolate 内的普通布尔值,读写天然安全
  • 暂停是"协作式"的 — 协程跑完当前照片的推理才会暂停,不会在中间截断,避免数据不一致
  • UI 立刻有反馈 — 即使协程还没挂起,进度条文案已经切换成"暂停中",用户不会认为操作没生效

Album Page 的界面层优化

搞定了 AI 分析与 UI 的解耦,我们再来看界面层本身的性能问题。

该界面的功能主要是:按预定义的常见语义标签对图片进行分类并展示缩略图,以及按时间空间特征进行简单的聚类展示。最初实现中,读取和切割图片的逻辑直接写在界面的构建函数里,缩略图加载会直接卡死界面,直到所有图片处理完才恢复响应。

增量导入策略:由于用户通常有数千张图片,我们引入了增量更新,支持一次性导入 100、500 张最近未处理的图片。用户无需等待所有图片处理完即可开始使用功能。

分时 yield 策略:在处理标签数据时,通过 i % _tagBrowserYieldChunk == 0 配合 Future.delayed(Duration.zero) 将耗时的 CPU 密集型计算切割成多个小块:

for (var i = 0; i < photos.length; i++) {
  // 每处理一批就主动让出事件循环
  if (i % _tagBrowserYieldChunk == 0) {
    await Future.delayed(Duration.zero);
  }
  // 处理标签...
}

Future.delayed(Duration.zero) 不会真正等待任何时间,但它会把后续代码推入下一个事件循环 tick,让 Flutter 引擎有机会处理帧渲染和用户输入。即便在耗时数秒的聚类逻辑中,UI 依然能响应用户的滑动和点击。

数据库防抖:AI 后台打标时,Isar 数据库会高频写入,Stream 监听会极高频地触发 UI 更新。我们对数据库监听设置了 420ms–700ms 的防抖,大幅降低界面无意义重建的频率。

IndexedStack 保留滚动状态:用 IndexedStack 维护"标签"和"时刻"两个视图,切换时 Widget 实例不被销毁,滚动位置和内存缓存得以保留。

图片解码限流:通过 _maxConcurrent = 4 手动接管图片的"排队权",避免数百张缩略图同时解码导致的 CPU 峰值。配合 ListView.builder 和 SliverGrid,只有视口内的 Widget 才会被构建和解码。

PathImage 组件还有一个值得一提的细节——cacheWidth 和 cacheHeight 的设置:

// 绝不能同时限制宽高——否则会破坏原图比例
// 取较长的一边作为缓存基准,另一边传 null
if (cw != null && ch != null) {
  if (cw > ch) {
    return (cw, null);
  } else {
    return (null, ch);
  }
}

这个"只限一边"的策略确保 Flutter 解码器在缩放图片时能正确保持宽高比,避免图片拉伸变形,同时把解码内存压缩到实际显示尺寸所需的最小值。

Theme Cluster Page 的界面层优化

这个界面展示聚类算法的结果,引入了更多特征维度和更复杂的计算逻辑。最初加载需要数分钟,完全不可接受。

复用特征向量:最重要也最简单的一刀——直接复用预处理阶段生成的嵌入向量,彻底取消二次特征提取计算。这一改动把加载时间从分钟级直接拉到秒级。

波浪式加载延迟:在 _DeferredThemeImage 中,利用路径哈希 widget.path.hashCode % 11 制造微小的错峰延迟,让封面图以"波浪式"而非"爆炸式"呈现,有效分散 CPU 负载:

final delay = Duration(milliseconds: (widget.path.hashCode % 11) * 30);
await Future.delayed(delay);

显露屏障防闪烁:在 initState 和刷新时设置约 260ms 的显露屏障,避免 FutureBuilder 在数据加载瞬间产生的闪烁,确保用户看到的是排版完成、状态稳定的界面。

ValueListenableBuilder 局部刷新:加载进度条、并发计数器均使用 ValueListenableBuilder 绑定,当后台调度器更新状态时,只有顶部细微的进度条在重绘,下方的复杂聚类列表完全不动,极大地节省了重绘开销。

内聚度感知 (Cohesion) 的 UI 布局:通过计算簇内均值距离(meanDistance)来判断一组照片的"内聚度",由此决定 UI 展示方式:

  • 精选模式 (Compact):如果照片极度相似(如连拍),自动切换为水平滑动的"胶片条"(_CompactTimelineStrip)
  • 平铺模式 (Flat):如果照片内容丰富多样,则使用 GridView 平铺,单个时间段最多渲染 36 张。对于拥有上千张照片的主题,这种硬限制保证了 CustomScrollView 的内存占用不会因某个巨型簇而崩溃。

后台分析任务的并发基础

经过以上改造,UI 层已经和 AI 分析任务完全解耦。在此基础上,我们才能安全地引入并发。

具体的工作方式是:AIService 维护一个任务队列,分析任务在后台协程中按批次取出图片执行推理,结果通过 _progressNotifier 广播给 UI。进度条和并发计数器实时反映任务状态。任务状态也会持久化到 SharedPreferences,程序退出后重启可以恢复上次的进度,保证计算任务的连续性。

更深层的优化——自适应并发调节器、Producer-Consumer 双轨模型、基于设备性能的动态 Worker 数量计算——将在后续的记录5中详细展开,那里的变化更有针对性,也更需要对本篇建立的状态管理基础有充分理解。

总结

这部分工作的核心,是通过三个层次的解耦来提升性能:

  1. 状态解耦:AIAnalysisProgress 不可变快照 + ValueNotifier 单向数据流,分析层写状态,UI 层读状态,互不干扰。
  2. 通知解耦:AIProgressNotificationService 让通知栏和 App 内 UI 由同一份状态驱动,处理了 Isolate 陷阱和冷启动竞态两个细节。
  3. 控制解耦:命令模式的 pauseAnalysis / resumeAnalysis,UI 不直接操作协程,协程协作式地响应控制信号。

通过这些优化措施,界面卡死的情况大幅减少,用户能够更流畅地使用功能——将一个被我评价为"三流垃圾程序"的应用优化到了及格以上水准。

posted on 2026-04-14 00:50  Vincentson  阅读(27)  评论(0)    收藏  举报