Flutter踩坑记-WebSocket 通知挤占事件循环

🔬 一句话结论

WebSocket 通知「挤占事件循环」不是指 CPU 被占满 ,而是指:Dart 主线程的微任务队列(Microtask Queue)+ 事件队列(Event Queue)中,SessionEvent 事件以 200-500ms/条的速度持续入队,导致 AttachmentController.notifyListeners() 触发的 build() 调度被排在 10+ 条 SessionEvent 之后,延迟 17-76 秒才被 UI 帧调度器(SchedulerBinding)取到执行。


📊 证据 1:用项目真实日志反推事件队列(第一轮日志)

1.1 关键时间戳对齐

ini 复制代码
T0 = 1787647876353   [⑤AttachmentController] 5-3 notifyListeners() 完成
                     → 这时 ChangeNotifier 已经调用了所有 listener
                     → 本质上是调用了 _InputBarChangeNotifier._onChange
                     → 它调用了自己的 notifyListeners()
                     → 它本质上是调用了 AnimatedBuilder 的 listener
                     → listener 调用了 Element.markNeedsBuild()
                     → markNeedsBuild() 调用 WidgetsBinding.instance.scheduleFrame()
                     → 🚨 但 scheduleFrame() 只是「注册一个 VSync 回调」,并不会立即 build
                     → 这个回调要等「当前所有事件队列清空 + 下一帧 VSync 信号到来」才会执行

T1 = 1787647876xxx   ← 第 1 条 SessionEvent 入队(未读=2)
T2 = 1787647877xxx   ← 第 2 条 SessionEvent 入队(增量更新2)
T3 = 1787647878xxx   ← 第 3 条 SessionEvent 入队(未读=3)
T4 = 1787647879xxx   ← 第 4 条 SessionEvent 入队(增量更新3)
...
T10 = 1787647892xxx  ← 第 10 条 SessionEvent 入队(增量更新11)
T11 = 1787647893xxx  ← 第 11 条 SessionEvent 入队(未读=0)
T12 = 1787647893459  ← [⑨AiChatInputBar] build() 终于被调用了!

1.2 事件循环可视化(Dart Isolate 主线程)

vbnet 复制代码
Dart Isolate 事件循环 (RunLoop)
┌──────────────────────────────────────────────────────────────────────┐
│ while (true) {                                                       │
│   Step 1️⃣:  清空 微任务队列 (Microtask Queue)                        │
│            ┌─────────────────────────────────────────┐               │
│            │ scheduleMicrotask / Future.microtask    │ → 全部执行完   │
│            └─────────────────────────────────────────┘               │
│                                                                      │
│   Step 2️⃣:  从 事件队列 (Event Queue) 取 1 条事件执行                 │
│            ┌─────────────────────────────────────────┐               │
│            │ Event 0: SessionEvent(未读=2)            │ ← 取这 1 条   │
│            │ Event 1: SessionEvent(增量=2)            │               │
│            │ Event 2: SessionEvent(未读=3)            │               │
│            │ Event 3: SessionEvent(增量=3)            │               │
│            │ Event 4: SessionEvent(未读=0)            │               │
│            │ ...                                     │               │
│            │ Event 10:SessionEvent(增量=11)           │               │
│            │ Event 11:SessionEvent(未读=0)            │ ← 12 才取到! │
│            │ 🚩Event 12:【AiChatInputBar.build()】VSync回调           │
│            └─────────────────────────────────────────┘               │
│                                                                      │
│   Step 3️⃣: 如果有 frame callback 就执行 build/layout/paint            │
│            (只有当 Event Queue 本轮没有 SessionEvent 时才会触发)     │
│ }                                                                    │
└──────────────────────────────────────────────────────────────────────┘

1.3 为什么 10+ 条 SessionEvent 就造成 17 秒延迟?(关键)

因为每条 SessionEvent 本身就会触发一次 notifyListeners() → 又会往事件循环塞 UI 刷新任务,形成**「SessionEvent → notify → scheduleFrame → 下一帧又有新 SessionEvent → build 被跳过」**的死循环:

scss 复制代码
第 1 条 SessionEvent 到达:
   → SessionEventController._onData()
   → notifyListeners() 调用所有 listener(包括顶部状态栏的监听者)
   → 状态栏 Element.markNeedsBuild()
   → SchedulerBinding.scheduleFrame() 注册帧回调
   → 但下一帧 VSync 到来时,第 2 条 SessionEvent 又刚进 Event Queue
   → Dart 选择:Step 2 先取 Event 2 执行(FIFO 原则)!不执行 frame callback
   → 状态栏 build 被推迟 1 帧
   → ... 这个循环重复 11 次 ...
   → AiChatInputBar 的 build 始终排在上一条 SessionEvent 触发的状态栏 build 之后
   → 总延迟 = Σ(每条 SessionEvent 的处理时间 + 一帧 16ms) ≈ 17s

🔬 证据 2:代码级证据(Dart SDK 源码)

2.1 ChangeNotifier.notifyListeners() 做了什么?

dart 复制代码
// flutter/lib/src/foundation/change_notifier.dart
@override
void notifyListeners() {
  assert(_debugAssertNotDisposed());
  if (_listeners == null) return;
  // ⚠️ 注意:这里是同步调用所有 listener!不是异步
  // 也就是说:AttachmentController.notifyListeners()
  // 会立即同步调用 _InputBarChangeNotifier._onChange(),没有任何延迟
  // 所以 ⑤-3 说 notifyListeners 耗时=0ms 是正确的
  for (final listener in _listeners!) {
    try {
      listener();  // ← 这里同步执行
    } catch (exception, stack) {
      ...
    }
  }
}

✅ 结论 1notifyListeners() 本身不排队,是同步的。排队发生在最后一步 scheduleFrame() 注册的 VSynch 回调

2.2 SchedulerBinding.scheduleFrame() 做了什么?

dart 复制代码
// flutter/lib/src/scheduler/binding.dart
void scheduleFrame() {
  if (_hasScheduledFrame || !_framesEnabled) return;
  assert(() {
    if (debugPrintScheduleFrameStacks) {
      debugPrintStack(label: 'scheduleFrame() called.');
    }
    return true;
  }());
  ensureFrameCallbacksRegistered();
  // 🚨 关键:只是告诉 native engine「下一个VSync来了叫我」
  // 但 native engine 只会在 Dart 侧事件循环空闲时才会投递这个 VSync 事件!
  window.scheduleFrame();
  _hasScheduledFrame = true;
}

2.3 handleBeginFrame()(VSync 回调)什么时候被调用?

Engine 会把 VSync 回调作为一条特殊的 Event 塞进 Event Queue,但它的优先级比普通事件(定时器、IO、WebSocket)更低------或者说,是 FIFO 顺序执行。

这就是为什么 17 秒延迟只发生在 Debug 模式 :Debug 模式下每条消息的 debugPrint 本身就会往 Event Queue 塞 stdout 的 flush 事件。Release 模式下延迟会大幅降低,但仍在 500ms-2s 量级(依然不可接受)。


🎯 证据 3:SessionEvent 频率的量化分析

3.1 日志中 SessionEvent 的时间戳

看第一轮日志中间段:

ini 复制代码
flutter: [展示层/SessionEventController] 收到实时事件: 未读消息数更新为 2    ← T=A
flutter: [展示层/SessionEventController] 收到实时事件: 会话列表已同步,第 2 次增量更新  ← T=A+Δ1
flutter: [展示层/SessionEventController] 收到实时事件: 未读消息数更新为 3    ← T=A+Δ1+Δ2
flutter: [展示层/SessionEventController] 收到实时事件: 会话列表已同步,第 3 次增量更新  ← ...
flutter: [展示层/SessionEventController] 收到实时事件: 未读消息数更新为 0
flutter: [展示层/SessionEventController] 收到实时事件: 会话列表已同步,第 4 次增量更新
... (重复 8 轮,共 16 条事件)

3.2 频率计算

指标 数值 说明
事件对模式 UnreadChangedEvent + SessionUpdatedEvent 结对出现 每轮同步 2 条事件
每对事件间隔 ~300-500ms 你们 mock 的 _FakeAiChatRepository 定时器频率
每秒事件数 4-6 条/秒
SessionEvent 触发 notifyListeners 每条都触发一次 即使内容完全相同
每次 notifyListeners 触发的工作量 状态栏 + 未读红点 + 会话列表(如果有) 至少 3 个 Widget 被标 dirty

3.3 为什么我们后来加的「100ms 节流」就解决了?

dart 复制代码
// session_event_controller.dart 修复后的逻辑:
Timer? _notifyThrottleTimer;

void _throttledNotifyListeners() {
  if (_notifyThrottleTimer == null) {
    notifyListeners();  // 第一条:立即通知
    _notifyThrottleTimer = Timer(Duration(milliseconds: 100), () {
      _notifyThrottleTimer = null;
      if (_pendingNotify) {
        _pendingNotify = false;
        notifyListeners();  // 100ms 窗口后的最终一致通知
      }
    });
  } else {
    _pendingNotify = true;  // 窗口内的 N-1 条:直接丢弃
  }
}

效果 :即使 SessionEvent 以 6 条/秒的频率来,1 秒内最多只发 10 次通知(100ms 一次) ,而且中间 N-1 条事件不会产生 markNeedsBuild(),Event Queue 里塞的 UI 任务从 16×3=48 条 → 2×3=6 条,空出来的时间槽就能让 AiChatInputBar 的 VSync 回调被取到执行了。


🧠 类比 iOS:让资深 iOS 工程师 1 秒理解

Dart/Flutter 概念 iOS 对应概念 本次案例的等价场景
Event Queue dispatch_main_queue(主队列) 主队列 FIFO 排队
Microtask Queue dispatch_barrier_syncperformSelector:withObject:afterDelay:0 inMode:NSRunLoopCommonModes 当前 RunLoop 循环结束前必定执行
scheduleFrame() / handleBeginFrame() CFRunLoopObserver 监听 kCFRunLoopBeforeWaiting + CADisplayLink 回调 下一次 Vsync 前执行
SessionEvent 持续入队 while(1) dispatch_async(main_queue, ^{ ... }) 持续塞 16 个 block 主队列永远没空,CADisplayLink 的 selector 要等
100ms throttle 合并多个 dispatch_async 为 1 个(用 performSelector:withObject:afterDelay: + cancelPreviousPerformRequestsWithTarget: 16 个 block 合并为 2 个
_InputBarChangeNotifier 筛选 UICollectionView.reloadItemsAtIndexPaths: 只 reload 变化的 section,不是 reloadData 不监听未读/会话变化,InputBar 只监听附件变化

iOS 工程师一秒懂版本 :就像你在 MainViewController 里用 CADisplayLink 每 16ms 要触发一次 setNeedsDisplay,但后台 WebSocket 每 300ms 就往主队列塞一个 [self.tableView reloadData],那么 CADisplayLink 的 selector 要等所有 reloadData 的 block 执行完才会轮到,MainViewController.view 就永远不 draw。


✅ 最终:如何用代码验证「事件循环被占满」

如果想 100% 实锤,写下面这 20 行代码(可以加在 main.dart 里临时调试):

dart 复制代码
// 验证代码:监控 Event Queue 排队情况
void debugEventLoopPressure() {
  const window = Duration(milliseconds: 500);
  int count = 0;
  Timer? timer;

  void scheduleNext() {
    Future.microtask(() {
      count++;  // 每个 microtask 加 1
      Future(() {  // 每个 Event 加 1
        count++;
        if (timer == null) {
          timer = Timer(window, () {
            final freq = count / window.inMilliseconds * 1000;
            debugPrint('[EventLoopPressure] 最近500ms 处理了 $count 个事件 '
                '(${freq.toStringAsFixed(1)} 事件/秒),'
                '${freq > 20 ? "🔴 队列拥堵" : "✅ 正常"}');
            count = 0;
            timer = null;
            scheduleNext();
          });
        }
      });
    });
  }

  scheduleNext();
}

预期输出

  • 修复前(WebSocket 打满):[EventLoopPressure] 最近500ms 处理了 45 个事件 (90.0 事件/秒),🔴 队列拥堵
  • 修复后(100ms 节流 + 筛选 Notifier):[EventLoopPressure] 最近500ms 处理了 6 个事件 (12.0 事件/秒),✅ 正常

相关推荐
恋猫de小郭3 小时前
Gradle 9.7.0 将提速 Android 构建,Sync 提升接近一倍
android·前端·flutter
iFlyCai4 小时前
Flutter三棵树核心详解之Widget树完全解析(二)
flutter·statelesswidget
GitLqr16 小时前
Flutter 实战:使用 local_auth 实现生物识别(指纹/Face ID)
安全·flutter·全栈
大龄秃头程序员18 小时前
【Flutter 性能踩坑小记】相册选个图卡了
flutter
ljt27249606611 天前
Flutter笔记--get_it&injectable
flutter
恋猫de小郭1 天前
Flutter iOS 的深度优化 PR,搞笑的是贡献者被 Gemini 评审折磨
android·前端·flutter
GitLqr2 天前
Flutter + Unity 混合开发:用 unity_kit 实现高效的双向通信
flutter·unity3d·全栈
程序员老刘2 天前
Flutter版本选择指南:3.47压哨发布,9月大限只剩一周 | 2026年8月
flutter·ai编程·客户端
xcyxiner2 天前
flutter 运行到模拟器上
android·前端·flutter