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

场景:离线可运行 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,706ms Picker 关闭,回到聊天页
输入栏缩略图「出现」 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:

css 复制代码
① 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')

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

scss 复制代码
[⑤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 监听一个入口」。

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

dart 复制代码
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 里:

dart 复制代码
// ❌ 千万别这么写
AnimatedBuilder(
  animation: _InputBarChangeNotifier(widget.controller),
  builder: ...
)

后果:

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

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

dart 复制代码
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 包一层,不依赖引擎的隐式行为:

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

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

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

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

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

dart 复制代码
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,103ms 76,832ms 0ms 🎉
⑨ Thumb 单张解码 574ms 918ms 241ms → 目标 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」的过程。

项目

相关推荐
ljt27249606618 小时前
Flutter笔记--get_it&injectable
flutter
恋猫de小郭10 小时前
Flutter iOS 的深度优化 PR,搞笑的是贡献者被 Gemini 评审折磨
android·前端·flutter
GitLqr1 天前
Flutter + Unity 混合开发:用 unity_kit 实现高效的双向通信
flutter·unity3d·全栈
程序员老刘1 天前
Flutter版本选择指南:3.47压哨发布,9月大限只剩一周 | 2026年8月
flutter·ai编程·客户端
xcyxiner1 天前
flutter 运行到模拟器上
android·前端·flutter
末代iOS程序员华仔1 天前
iOS 语聊直播/聊天APP4.3条例被拒解决
flutter·ios·swift
恋猫de小郭1 天前
Dart 3.13 的到底改了什么?为什么很重要?有什么坑?
android·前端·flutter
xcyxiner2 天前
flutter wsl2安装记录(使用宿主机虚拟机)
前端·flutter
Kevin Coding2 天前
JsonConvert:适用于 Android、鸿蒙与 Flutter 的 JSON 转 Model 插件
android·flutter·harmonyos