Dart/Flutter StatefulWidget 生命周期 + ChangeNotifier Listener 泄漏的经典坑。

🚨 错误写法 vs 正确写法(直接对比)

❌ 错误写法:AnimatedBuilder 参数里直接 new(你们第二轮 Diff 未生效前的写法)

dart 复制代码
// ai_chat_demo_page.dart 未修复版本
class _AiChatDemoPageState extends State<AiChatDemoPage> {
  // ⚠️ 没有把 Notifier 声明为 State 字段!

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // ========== 消息列表 ==========
        Expanded(
          child: AnimatedBuilder(
            // 🔴 反模式:每次 build() 都 new 一个新的实例!
            animation: _MessageListChangeNotifier(widget.controller),
            builder: ...,
          ),
        ),
        // ========== 输入栏 ==========
        AnimatedBuilder(
          // 🔴 反模式:每次 build() 都 new 一个新的实例!
          animation: _InputBarChangeNotifier(widget.controller),
          builder: ...,
        ),
      ],
    );
  }

  @override
  void dispose() {
    // ⚠️ 因为是 build() 里的局部变量,这里根本拿不到引用去 dispose
    // 所以 removeListener() 永远不会调用!
    super.dispose();
  }
}

✅ 正确写法:State 字段 + initState 创建 + dispose 销毁(你们最终版本)

dart 复制代码
// ai_chat_demo_page.dart 修复版本
class _AiChatDemoPageState extends State<AiChatDemoPage> {
  // ✅ 声明为 State 字段,整个 State 生命周期只创建一次
  late final MessageListChangeNotifier _messageNotifier;
  late final InputBarChangeNotifier _inputNotifier;

  @override
  void initState() {
    super.initState();
    // ✅ initState 中创建(State 生命周期只调用 1 次)
    _messageNotifier = MessageListChangeNotifier(widget.controller);
    _inputNotifier = InputBarChangeNotifier(widget.controller);
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Expanded(
          child: AnimatedBuilder(
            // ✅ 复用同一个实例
            animation: _messageNotifier,
            builder: ...,
          ),
        ),
        AnimatedBuilder(
          // ✅ 复用同一个实例
          animation: _inputNotifier,
          builder: ...,
        ),
      ],
    );
  }

  @override
  void dispose() {
    // ✅ 能拿到引用!正确 removeListener + 释放资源
    _messageNotifier.dispose();
    _inputNotifier.dispose();
    super.dispose();
  }
}

🔬 体现在哪里?------ 逐帧时序拆解(WebSocket 高频场景)

2.1 修复前(build 内 new)的时序灾难

scss 复制代码
场景:WebSocket 每 300ms 推一次 SessionEvent,触发顶层 controller.notifyListeners()
      → 触发 AiChatDemoPage 顶层 build()(因为 controller 是 Page State 的依赖源)
      → 每 300ms 整个 Column 重 build 一次

时间轴 (ms)   执行动作                         副作用
───────────────────────────────────────────────────────────────────
T=0         第 1 次 build()
              ↳ new _InputBarChangeNotifier(ctrl) #1
                ↳ 构造函数内执行 ctrl.addListener(_onChange#1)
                  → 注册 Listener 回调地址 0x12345
              ↳ AnimatedBuilder 监听 #1

T=300       第 2 次 build()(SessionEvent 触发)
              ↳ new _InputBarChangeNotifier(ctrl) #2  ← 🔥 又 new 了一个!
                ↳ ctrl.addListener(_onChange#2)
                  → 注册 Listener 回调地址 0x12346
              ↳ #1 实例成为 orphan(局部变量出作用域),但!
                ⚠️ listener 0x12345 还在 ctrl 的 _listeners 数组里!
                ⚠️ #1 实例没有被 GC 回收(因为被 ctrl._listeners 强引用)
              ↳ AnimatedBuilder 现在监听 #2

T=600       第 3 次 build()
              ↳ new _InputBarChangeNotifier(ctrl) #3
                ↳ ctrl.addListener(_onChange#3) → 回调地址 0x12347
              ↳ #2 又 orphan,但 listener 0x12346 还在!
              ↳ 此时 ctrl._listeners 数组大小 = 3 个
              ↳ AnimatedBuilder 监听 #3

T=900       第 4 次 build() → #4 产生,listeners = 4
T=1200      第 5 次 build() → #5 产生,listeners = 5
...

T=17100     第 58 次 build()(空档期最后一帧)
              ↳ #58 产生,ctrl._listeners 数组大小 = 58 个!
              ↳ 此时一次 notifyListeners() 要遍历 58 个 onChange 回调!
                → 58 次字符串比较(runtimeType.toString() / messages.length ...)
                → 遍历耗时从 <1ms 涨到 50-80ms
                → 这部分额外 CPU 时间又挤占了 Event Queue!
                → ⚠️ 这就是为什么第二轮空档期从 17s 涨到 76s 的核心原因之一

T=17100     用户退出聊天页,dispose() 被调用
              ↳ _messageNotifier / _inputNotifier 是啥?不存在!
              ↳ 58 个 orphan 的 notifier 没有任何一个执行 dispose()
              ↳ 所有 58 个 listener(58 个闭包)永久泄漏!
              ↳ ctrl(即 AiChatController)生命周期更长(全局单例),
                → 它的 _listeners 数组永远持有 58 个闭包的强引用
                → 最终内存泄漏 = 58 × ~128 bytes(闭包上下文) ≈ 7.4KB
                → 每次进聊天页累加,反复进出 100 次 = 740KB 常驻内存不可回收

2.2 修复后(State 字段)的正确时序

scss 复制代码
时间轴 (ms)   执行动作                         副作用
───────────────────────────────────────────────────────────────────
T=0         initState()
              ↳ new _InputBarChangeNotifier(ctrl) #唯一实例
                ↳ ctrl.addListener(_onChange#唯一)
                  → listeners 数组大小 = 1(固定)

T=0 / T=300 / T=600 ...
            build() 被调用 N 次
              ↳ AnimatedBuilder(animation: _inputNotifier ← #唯一实例)
              ↳ ❌ 没有任何 new!
              ↳ ❌ 没有任何 addListener!
              ↳ listeners 数组大小永远 = 1

T=17100     一次 notifyListeners()
              ↳ 遍历 1 个 listener(不是 58 个)
              ↳ _onChange 执行 1 次,耗时 <0.1ms
              ↳ 没有任何性能问题

T=退出页     dispose()
              ↳ _inputNotifier.dispose()
                → ctrl.removeListener(_onChange#唯一)
                → listeners 数组清空
              ↳ #唯一实例变成可回收(没有任何强引用链)
              ↳ GC 下一次周期正常回收 ✅ 零泄漏

🧪 量化症状对照表(就是你们日志里看到的现象)

观察到的现象 build 内 new(错误)的贡献 修复后(State 字段)
日志中每次 SessionEvent 后都看到 [⑨AiChatInputBar] build() 被调用 N 次 ❌ 每次 new 的 Notifier 首次 attach 会触发 1 次「初始同步」build → N×SessionEvent 数的额外 build 只在 selectedImages 等关键字段真正变化时才 build,SessionEvent 不触发
第二轮空档期从 17s → 76.8s(恶化 4.5 倍) 58 个 listener 链式通知风暴 = notifyListeners() 遍历 58 个 onChange × 每个 onChange 内部又会触发自己的 notifyListeners() → 通知链条从 O(1) 变成 O(N²) 通知链条 O(1),空档期 = 0ms
第二次进聊天页明显比第一次卡顿 ❌ 上一次退出泄漏的 58 个 listener 还在 ctrl 里,这次又加 58 个 → 累计 116 个,越跑越慢 每次进出 listeners 数清零,N 次进出性能一致
flutter analyze --memory 观察到老年代 GC 频率升高 ❌ 每次 build 产生 2 个 orphan 对象(Message/Input),SessionEvent 17 秒内产生 ~116 个无法回收的对象(因为被 ctrl 强引用) → 老年代碎片增加 只产生 2 个对象,100% 可回收
⑨-Thumb 单张解码耗时波动大(574ms / 918ms / 241ms) CPU 争用:解码的 isolate 线程拿到的 CPU 时间片被主线程 58 个 onChange 遍历抢走,解码延迟不可预测 解码耗时稳定在 40-60ms 区间
热重载后偶发 setState() or markNeedsBuild called during build ❌ 旧 Notifier 新 Notifier 交替触发 build,同一帧内嵌套调用 markNeedsBuild 100% 消除

🔬 代码级实锤:XNotifier 构造函数内部的 addListener

让我们再把 _InputBarChangeNotifier 的实现拿出来看为什么 build() 内 new 是万恶之源

dart 复制代码
class _InputBarChangeNotifier extends ChangeNotifier {
  // ⚠️ 构造函数里立即做了 addListener!!!
  // 这意味着:只要你 new 它,不管用不用,都会往 ctrl._listeners 里塞一个闭包
  _InputBarChangeNotifier(this._source) {
    _source.addListener(_onChange);  // ← 每次 new 都塞一个
  }

  final AiChatController _source;

  // ⚠️ 闭包:_onChange 隐式捕获了 this(_InputBarChangeNotifier 实例)
  // 这意味着:_listeners 里存的每个回调都强引用了对应的 XNotifier 实例
  // → XNotifier 实例永远不会被 GC,除非显式 removeListener
  void _onChange() {
    final iLen = _source.selectedImages.length;
    ...
    if (... changed ...) {
      notifyListeners();  // ← 自己的 notifyListeners 也会被链式调用
    }
  }

  @override
  void dispose() {
    _source.removeListener(_onChange);  // ← 只有调用这里才能解除强引用链
    super.dispose();
  }
}

内存引用链(build 内 new 导致无法回收)

csharp 复制代码
引用方向(强引用):
GlobalAiChatControllerSingleton (生命周期=App)
    │
    ├── _listeners: List<VoidCallback>  ← 这个数组永远在内存里
    │     ├── [0] _onChange 闭包 #1
    │     │     └── (隐式 this) → XNotifier 实例 #1 🔒 无法 GC
    │     │           └── ...
    │     ├── [1] _onChange 闭包 #2
    │     │     └── (隐式 this) → XNotifier 实例 #2 🔒 无法 GC
    │     ├── [2] _onChange 闭包 #3
    │     │     └── (隐式 this) → XNotifier 实例 #3 🔒 无法 GC
    │     ...
    │     └── [57] _onChange 闭包 #58   ← 17 秒就积累了 58 个!
    │           └── (隐式 this) → XNotifier 实例 #58 🔒 无法 GC
    │
    └── ...

⚠️ 这是一条「App 生命周期级」的强引用链,
   除非调用 removeListener,否则这些对象直到 App 被杀才会释放。

🧠 类比 iOS:一句话秒懂

Flutter 概念 iOS 对应概念 本次 build 内 new 的反模式对应
XNotifier() 构造函数内 source.addListener() [target addObserver:selector:forKeyPath:options:context:] (KVO add) 每次 cellForRowAtIndexPath: 都调用一次 addObserver,从不 removeObserver
dispose()removeListener() deallocremoveObserver: iOS 上 dealloc 不 removeObserver → **** 崩溃 (EXC_BAD_ACCESS on KVO notification) **
Flutter build 内 new 从不 dispose iOS cellForRowAtIndexPath addObserver 从不 remove 虽然不直接崩溃,但闭包上下文泄漏 + KVO 通知 N² 链式广播 = 列表滚动每帧 58 次 KVO 回调 = 掉帧
State 字段 + dispose 里 remove VC init addObserver / dealloc removeObserver(标准写法) iOS 标准做法,无泄漏无性能问题

💡 iOS 架构师直觉联动 :你在 iOS 上做 KVO 时,绝对不会在 cellForRowAtIndexPath: 里 addObserver 而不 remove ------Flutter 的 ChangeNotifier 原理一模一样,只是名字换成了 addListener/removeListener。写这篇时我甚至怀疑:AddObserver 会 crash 是好事,它让你在开发阶段就发现错误;Flutter addListener 不 crash 反而是坏事,导致错误直到性能雪崩(17s 空档期 / 76s 空档期)才被发现。


✅ 可复用的 4 条 Coding Rule(架构级强制约束)

把这 4 条写进团队 analysis_options.yamlcustom_lint 或 Code Review Checklist:

编码规则 检查手段 违反处罚
① 任何 ChangeNotifier 子类,如果构造函数里调用了 source.addListener(...),必须提供 dispose() 方法且内部调用 source.removeListener(...) Lint 规则:must_call_dispose(自定义) Code Review 打回
AnimatedBuilder / ListenableBuilder / ValueListenableBuilderanimation: / listenable: 参数,禁止传入 runtimeType 之前未出现过的新实例表达式 (即禁止 animation: FooNotifier(...),只能传字段、局部变量 _fooNotifier 这种复用标识 Lint 规则:no_constructor_tearoff_in_animation_param(自定义) CI 门禁失败
③ State 中所有 ChangeNotifier 子类字段,必须在 dispose() 中出现对应的 .dispose() 调用 Lint 规则:dispose_all_listenable_fields(自定义) CI 门禁失败
④ 全局/长生命周期 Controller(如 AiChatController 单例)的 _listeners 数,debug 构建下若 5 分钟内增长 > 5,debugPrint 内存泄漏警告并堆栈 debugAssert 检查 identical(_source.listeners.length, prevLen) Debug 模式控制台弹红色警告

相关推荐
大龄秃头程序员4 小时前
Flutter踩坑记-WebSocket 通知挤占事件循环
flutter
恋猫de小郭6 小时前
Gradle 9.7.0 将提速 Android 构建,Sync 提升接近一倍
android·前端·flutter
iFlyCai6 小时前
Flutter三棵树核心详解之Widget树完全解析(二)
flutter·statelesswidget
GitLqr19 小时前
Flutter 实战:使用 local_auth 实现生物识别(指纹/Face ID)
安全·flutter·全栈
大龄秃头程序员21 小时前
【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编程·客户端