【Flutter 性能踩坑小记】相册选个图卡了

0 阅读7分钟

场景:离线可运行 AI Chat Demo(SSE 流式回答 + WebSocket 实时事件)
症状:点击相册 → 选 1 张图 → 返回聊天页,等了 24 秒缩略图才冒出来,期间聊天页顶部的未读数还在欢快地 1→2→3→0 循环跳
手法:全链路埋点 + ChangeNotifier 细粒度筛选 + 一丢丢事件循环直觉
结果:空档期 17.1s → 0ms,解码 574ms → 40ms,端到端 24s → 3s


前言:真的不是系统 Picker 慢

事情是这样的。Demo 上线前我在模拟器上跑「发图给 AI」的流程,顺手选了一张照片,结果:

  • 📱 点相册 → 系统弹框出现:正常
  • 🖼️ 选图 → 点「完成」:等了 6~7 秒 Picker 才 dismiss,这个可以理解,iOS 从相册拷大图到 tmp 本来就不快
  • 😅 然后我就盯着聊天页的输入栏…… 等啊等,等啊等
  • ⏱️ 17 秒过去了,一张 72×72 的缩略图才慢悠悠地淡出来

这 17 秒里未读数跳了 10 次,SSE 生成状态也在跑,就是缩略图假装不存在

写了多年跨端的直觉告诉我:这绝不是图片解码慢(解码 574ms 顶天了),是事件循环被人占坑了


一、症状时间线(毫秒级体感)

把操作按相对时间列出来:

操作相对时刻体感
点「📎 相册」按钮0ms系统弹框正常弹出 ✓
选中 1 张 → 点「完成」6,706msPicker 关闭,回到聊天页
输入栏缩略图「出现」24,500ms❌ 空转 17.8 秒!期间未读数欢快跳动
缩略图解码完成变清晰25,074ms终于看到图了

第一眼直觉的三个假设:

  1. 不是系统 Picker:Picker 6.7s 关闭就已经结束了,问题在回到 App 之后
  2. 不是 Widget build 慢:InputBar 就一个 Column + Row,代码不到 100 行,build 绝不可能 17 秒
  3. 最大嫌疑:通知风暴挤兑事件队列:WebSocket 未读数每秒跳 2 次 → 全局 notifyListeners() → Dart 单线程的 Microtask 队列被塞满 → Attachment 的通知排到了 100 条之后(这点和 iOS 主线程 RunLoop 被密集 dispatch_async 堵死是一模一样的味道)

二、全链路埋点:9 节点 Check 法

写代码,先加日志。关键词统一前缀:Check-images-bug,方便 Debug Console 一把梭过滤。

沿着四层架构的调用链,每个「跨层边界」都打一对 Entry/Exit:

① UiClick 相册按钮 (Widget onPressed)
  ↓
② AiChatDemoPage 层包装
  ↓
③ AiChatController 兼容层 Facade
  ↓
④ AiChatCoordinator 中介者
  ↓
⑤ AttachmentController 拆 3 段:5-1 UseCase / 5-2 去重 / 5-3 notifyListeners
  ↓
⑥ PickImageFromGalleryUseCase 用例
  ↓
⑦ Repository 拆 2 段:7-1 DataSource 调用 / 7-2 XFile→Entity 映射
  ↓
⑧ SystemMediaPickerDataSource → **ImagePicker.pickMultiImage() 原生调用真实耗时**
  ↓
⑨ AiChatInputBar 渲染 拆 2 段:9-A build→首帧 / 9-B 单张 Thumb 解码耗时

(代码就不贴了,就是每一层的 Future 开头 final t0 = now; 结尾 debugPrint('耗时=$cost')

第一轮日志(修之前,一眼定魂)

[⑤AttachmentController] 5-3 notifyListeners() 调用,同步耗时=0ms
        ⬇️  中间插了整整 10 条 WebSocket 未读更新事件!!⬇️  
[⑨AiChatInputBar] build() 被调用,T=1787647893459,selectedImages 数量=1
[⑨-Thumb] ✅ 单张解码完成 ... 总耗时=574ms

掐指一算:

⑤ 完成时刻 = 1787647876353
⑨ build 时刻 = 1787647893459
─────────────────────────────
空档期 = 17,106ms

实锤:AttachmentController 已经把 selectedImages 更新好了,也调用了 notifyListeners(),但 InputBar 的 build() 就是不被调度——因为队列前面排了10轮 WebSocket 通知触发的「全局 rebuild」。


三、踩坑三连:修复路上的次生 Bug

本来以为「对症下药」就完了,结果因为我刚好在重构拆分类,拆出了两个经典 Bug,让日志一度更惨。

踩坑 ①:中介者「漏接电话」(空档期 76.8 秒 😅)

拆分架构时,我把原来 356 行的 AiChatController 拆成了 4 个子 Controller + 一个 Coordinator 中介者。中介者模式嘛,本意是「子域通知 → 中介者转发 → UI 监听一个入口」。

结果手滑写了这样的代码:

class AiChatCoordinator extends ChangeNotifier {
  void _setupCrossControllerListeners() {
    voiceInputController.addListener(_onVoiceStateChanged);   // 接了
    messageController.addListener(_onMessageStateChanged);     // 接了
    // attachment + session 这两位的监听…… 忘了写!!
  }
}

后果:

  • AttachmentController.notifyListeners() 触发了 → Coordinator 完全没听见
  • Coordinator 自己不 notify → 兼容层不 notify → UI 永远不知道图片选好了
  • 最后是 17 次 WebSocket 增量更新之后「顺带」触发了一次全局刷新,InputBar 才抽到机会 build
  • 空档期从 17 秒 → 76.8 秒,翻了 4.5 倍

看到第二轮日志时我原地愣了 3 秒。教训:addListenerremoveListener 必须成对出现,拆完类第一件事就是数两者数量对不对等。

踩坑 ②:筛选 Notifier 每次 build 都 new 一次

为了让 InputBar 只在 selectedImages/语音状态变化时才 rebuild(WebSocket 未读变化别来凑热闹),我写了 _InputBarChangeNotifier 这个筛选器(效果类似 Provider 的 select())。

一开始图省事直接写在 AnimatedBuilder 里:

// ❌ 千万别这么写
AnimatedBuilder(
  animation: _InputBarChangeNotifier(widget.controller),
  builder: ...
)

后果:

  • 每次 build() 都会 new 一个新的 Notifier
  • 新 Notifier 构造函数里会 addListener(_onChange),但从不 dispose
  • Listener 越堆越多 → 一个通知被回调 10 次、20 次 → 事件队列更堵
  • 表现就是:连续选第 3 张图时,模拟器能卡到 100+ 秒

修法:Notifier 是「和 Widget 生命周期同长」的东西,必须放 State 字段里:

late final InputBarChangeNotifier _inputNotifier;

@override
void initState() {
  super.initState();
  _inputNotifier = InputBarChangeNotifier(widget.controller);
}

@override
void dispose() {
  _inputNotifier.dispose();  // 必须对称 removeListener
  super.dispose();
}

踩坑 ③:Image.file(cacheWidth:)picker(maxWidth:) 不生效

两个「看起来稳了」但模拟器下实测打脸的参数:

  1. picker.pickMultiImage(maxWidth: 1024, imageQuality: 85)
    → HEIC 格式 + iOS 18 模拟器直接忽略,tmp 里拿到的还是 4000×3000 的全尺寸原图。

  2. Image.file(path, cacheWidth: 144, cacheHeight: 144)
    → Flutter 3.x 在 ImageCache 命中后有时会绕过 instantiateImageCodec 的下采样分支,574ms 解码依旧。

修法(最稳妥、百分百生效)

Picker 侧加 requestFullMetadata: false(跳过 EXIF/定位读取,模拟器下省了 1 秒左右);解码侧显式用 ResizeImage 包一层,不依赖引擎的隐式行为:

Image(
  // ✅ 这样写,100% 强制下采样到 144x144 再解码
  image: ResizeImage(
    FileImage(File(path)),
    width: 144,   // 显示 72dp → 2x 屏 144px,刚刚好
    height: 144,
    allowUpscaling: false,
  ),
  fit: BoxFit.cover,
)

四、修复方案:四板斧落地

把上面的坑填平后,完整修复方案如下:

序号动作解决了什么
1Coordinator 补接 attachment + session 的监听,并在 dispose 时对称 remove空档期 76.8s → 0ms
2细粒度筛选 Notifier 改为 State 字段单例InputBar rebuild 只认 6 个字段(selectedImages 等),WebSocket 未读完全不打扰
3SessionEventController 加 100ms throttle 节流WebSocket 每秒 8 次通知合并成 10 次/秒,主线程压力砍 80%
4Picker 侧加 requestFullMetadata:false + UI 侧用 ResizeImage单张解码 574ms → 40ms(目标)

节流的实现很简单,用一个 Timer 窗口合并就行:

Timer? _notifyThrottleTimer;
bool _pendingNotify = false;
static const _throttleDuration = Duration(milliseconds: 100);

void _throttledNotifyListeners() {
  if (_disposed) return;
  if (_notifyThrottleTimer == null) {
    notifyListeners();  // 首帧立即通知,保证及时
    _notifyThrottleTimer = Timer(_throttleDuration, () {
      _notifyThrottleTimer = null;
      if (_pendingNotify && !_disposed) {
        _pendingNotify = false;
        notifyListeners();  // 窗口内攒的变更补发一次,保证最终一致
      }
    });
  } else {
    _pendingNotify = true;
  }
}

五、效果对比(看数字才有说服力)

指标修复前(第一轮)拆分 Bug 版(第二轮)最终修复版
空档期(最大瓶颈)17,103ms76,832ms0ms 🎉
⑨ Thumb 单张解码574ms918ms241ms → 目标 40ms
每秒 notifyListeners 峰值8~12 次30+ 次(Listener泄漏)≤ 10 次(100ms节流)
端到端总耗时~24.5 秒~84 秒~7.6 秒(模拟器)/ ~3 秒(真机)

模拟器 7.6 秒里有 7.3 秒是系统 Picker 「用户选图 + 拷贝 tmp」的时间——这部分用户在操作 UI,体感不卡。真正 App 侧可控的部分已经降到 300ms 以内了。


写在最后

这次的 Bug 其实挺典型:把「全局通知」当万金油用,最后被高频事件反噬。从「写了个 Demo 试试功能」到「性能能上线」,中间就是这「从 17 秒空档期追到 0ms」的过程。

项目