场景:离线可运行 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 | 终于看到图了 |
第一眼直觉的三个假设:
- 不是系统 Picker:Picker 6.7s 关闭就已经结束了,问题在回到 App 之后
- 不是 Widget build 慢:InputBar 就一个 Column + Row,代码不到 100 行,build 绝不可能 17 秒
- 最大嫌疑:通知风暴挤兑事件队列 :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 秒。教训:addListener 和 removeListener 必须成对出现,拆完类第一件事就是数两者数量对不对等。
踩坑 ②:筛选 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:) 不生效
两个「看起来稳了」但模拟器下实测打脸的参数:
-
picker.pickMultiImage(maxWidth: 1024, imageQuality: 85)→ HEIC 格式 + iOS 18 模拟器直接忽略,tmp 里拿到的还是 4000×3000 的全尺寸原图。
-
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」的过程。