Flutter 基础体系 · 第 71/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 内存泄漏:Controller、订阅、图片缓存、闭包和 DevTools

Flutter 内存问题通常不是“某个对象没有被手动删除”,而是对象仍然能从运行时根对象(GC roots)访问到,因此 Dart 垃圾回收器(Garbage Collector,GC)无法回收它,或者对象本身已经不可达,但它持有的非 Dart 资源仍由其他系统管理。

在 Flutter 应用中,最容易形成长期引用的对象包括:

  • TextEditingControllerAnimationControllerScrollControllerFocusNode 等 Controller;
  • StreamSubscriptionTimer、事件总线和平台通道监听;
  • 图片解码结果与 ImageCache
  • 注册到长生命周期对象上的闭包;
  • 原生平台、GPU 或浏览器持有的资源。

因此,“调用了 dispose()”不是内存问题的完整定义。需要同时回答三个问题:

  1. 谁持有这个对象?
  2. 它本来应该活多久?
  3. 它结束生命周期后,引用和外部资源是否都被释放?

一、先建立内存泄漏模型:对象为什么不会被回收

1. Dart GC 回收的基本条件

Dart 使用垃圾回收机制管理 Dart 堆。一个对象可以被回收的必要条件是:从 GC roots 出发,无法再访问到它。

可以把对象图表示为:

G=(V,E)G=(V,E)

其中:

  • VV 是对象集合;
  • EE 是对象之间的引用边;
  • RR 是 GC roots,例如线程栈、全局对象、运行时管理对象等。

对象 xx 在某次 GC 后可回收的条件近似为:

xReachable(R)x \notin Reachable(R)

也就是从根集合 RR 沿着引用边无法到达 xx

所谓“内存泄漏”,在 Flutter 工程中通常指:

对象已经超过预期生命周期,但仍然处于可达状态,或者它持有的资源没有随着业务生命周期结束而释放。

例如,一个已经离开页面的 State 仍被全局事件总线保存:

GC Root
  └── EventBus
        └── listener 闭包
              └── State
                    └── TextEditingController

即使页面已经从路由栈移除,只要这条引用链存在,State 和 Controller 都不能被 Dart GC 回收。

2. “内存上涨”不一定等于泄漏

下面几种现象需要区分:

现象 可能原因 是否一定是泄漏
页面打开后内存上涨,返回后稳定 图片缓存、框架缓存、堆扩容
多次进入同一页面,旧页面实例数量持续增加 订阅、闭包、Timer 保留旧 State 高度可疑
Dart heap 稳定,但 RSS 持续上涨 原生图片、GPU、插件资源 不一定是 Dart 泄漏
图片滚动列表内存高,但再次使用图片更快 ImageCache 正常缓存 通常不是
dispose() 已调用,但 Controller 仍可达 外部引用仍存在 仍可能泄漏

Dart 堆、原生堆、GPU 内存和浏览器内存不是同一个统计对象。DevTools Memory 面板主要观察 Dart VM 的堆和相关分配信息,不能把所有操作系统 RSS 的增长都解释为 Dart 对象泄漏。


二、Controller:创建者通常拥有释放责任

1. Controller 不只是普通字段

Flutter 中的 Controller 通常承担至少一种职责:

  • 保存可变状态;
  • 向框架或子组件发送通知;
  • 持有 listener;
  • 连接 ticker、动画、滚动、焦点或输入法;
  • 间接持有原生或框架资源。

常见类型包括:

  • TextEditingController
  • AnimationController
  • ScrollController
  • PageController
  • TabController
  • FocusNode
  • TransformationController
  • UndoHistoryController

它们大多实现了 ChangeNotifier,部分还实现了额外资源管理逻辑。只要 Controller 是当前 State 创建的,就通常由这个 State 负责销毁。

import 'package:flutter/material.dart';

class SearchPage extends StatefulWidget {
  const SearchPage({super.key});

  @override
  State<SearchPage> createState() => _SearchPageState();
}

class _SearchPageState extends State<SearchPage> {
  late final TextEditingController _queryController;
  late final FocusNode _queryFocusNode;

  @override
  void initState() {
    super.initState();
    _queryController = TextEditingController();
    _queryFocusNode = FocusNode();

    _queryController.addListener(_onQueryChanged);
  }

  void _onQueryChanged() {
    final query = _queryController.text;
    debugPrint('query: $query');
  }

  @override
  void dispose() {
    _queryController.removeListener(_onQueryChanged);
    _queryController.dispose();
    _queryFocusNode.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: _queryController,
      focusNode: _queryFocusNode,
    );
  }
}

这里有两层释放关系:

  1. TextField 不再使用 Controller;
  2. Controller 自己不再接收 listener,并释放其内部资源。

如果 listener 是当前 State 的实例方法,Controller 会反向持有 State。在正常情况下,State.dispose() 中释放 Controller 可以断开这条关系。

2. dispose() 的正确顺序

通常应先解除依赖当前对象的监听,再释放 Controller,最后调用 super.dispose()

@override
void dispose() {
  _controller.removeListener(_handleChanged);
  _controller.dispose();
  super.dispose();
}

对大多数 Flutter 对象而言,调用 super.dispose() 不是可选的。基类可能释放框架维护的资源或改变生命周期状态。

不要在 dispose() 之后继续使用对象:

@override
void dispose() {
  _controller.dispose();
  super.dispose();

  // 错误:dispose 后仍然访问
  _controller.text = '';
}

这类错误通常表现为运行时异常,而不是静默泄漏。泄漏和 use-after-dispose 是两个不同问题,但它们经常由同一个生命周期设计错误引起。

3. 不要在 build() 中反复创建 Controller

下面的代码每次重建都创建新的 Controller:

@override
Widget build(BuildContext context) {
  final controller = TextEditingController();

  return TextField(controller: controller);
}

这个 Controller 没有稳定的所有者,也没有对应的 dispose()。即使旧对象最终可能因为没有外部引用而被 GC 回收,这段代码仍然会:

  • 在重建期间频繁分配对象;
  • 丢失输入状态;
  • 产生无法对称管理的资源;
  • 给 listener 和异步任务留下错误空间。

正确方式是放在 State 中,或明确交给外部状态管理对象拥有:

class NameField extends StatefulWidget {
  const NameField({super.key});

  @override
  State<NameField> createState() => _NameFieldState();
}

class _NameFieldState extends State<NameField> {
  late final TextEditingController _controller;

  @override
  void initState() {
    super.initState();
    _controller = TextEditingController();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(controller: _controller);
  }
}

4. AnimationController 的特殊要求

AnimationController 依赖 TickerProvider。在 State 中使用时,通常应混入 SingleTickerProviderStateMixinTickerProviderStateMixin

class FadeBox extends StatefulWidget {
  const FadeBox({super.key});

  @override
  State<FadeBox> createState() => _FadeBoxState();
}

class _FadeBoxState extends State<FadeBox>
    with SingleTickerProviderStateMixin {
  late final AnimationController _animationController;

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 300),
    );
  }

  void startAnimation() {
    _animationController.forward(from: 0);
  }

  @override
  void dispose() {
    _animationController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return FadeTransition(
      opacity: _animationController,
      child: const FlutterLogo(size: 96),
    );
  }
}

如果页面销毁时动画仍在运行而 Controller 没有释放,可能出现 ticker 未停止、对象无法回收或框架生命周期异常。Flutter 在调试模式下对某些 ticker 泄漏有诊断提示,但不能把这些提示当作完整的泄漏检测工具。


三、订阅、Timer 和监听器:canceldispose 更关键

1. 订阅建立了一条长期引用链

StreamSubscription 常见引用路径如下:

StreamController / EventChannel / 全局服务
  └── StreamSubscription
        └── listener 闭包
              └── State

页面销毁后,如果订阅仍在发送事件,listener 仍可能访问旧的 State。即使回调内部使用了:

if (!mounted) return;

也只是避免在已销毁的 State 上调用 setState,并没有解除订阅,也不能释放被闭包保留的对象。

正确做法是保存订阅并取消:

class NetworkStatusPage extends StatefulWidget {
  const NetworkStatusPage({super.key});

  @override
  State<NetworkStatusPage> createState() => _NetworkStatusPageState();
}

class _NetworkStatusPageState extends State<NetworkStatusPage> {
  StreamSubscription<bool>? _subscription;
  bool _online = true;

  @override
  void initState() {
    super.initState();

    _subscription = connectivityStream.listen((online) {
      if (!mounted) return;

      setState(() {
        _online = online;
      });
    });
  }

  @override
  void dispose() {
    _subscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Text(_online ? '在线' : '离线');
  }
}

// 示例数据源
final Stream<bool> connectivityStream =
    Stream<bool>.periodic(const Duration(seconds: 2), (_) => true);

StreamSubscription.cancel() 返回 Future<void>,因为底层资源的清理可能是异步的。State.dispose() 本身不能声明为 async,所以常见写法是启动取消过程:

@override
void dispose() {
  unawaited(_subscription?.cancel());
  super.dispose();
}

使用 unawaited 需要:

import 'dart:async';

如果业务要求“取消完成后才能关闭资源”,则应在更高层的异步生命周期中显式 await,而不是把 dispose() 改成异步方法。Flutter 的 dispose() 签名不能由子类改成 Future<void>

2. 重新订阅时先取消旧订阅

当订阅依赖 Widget 参数时,不能只在 initState() 中订阅一次:

class MessagePage extends StatefulWidget {
  const MessagePage({required this.channel, super.key});

  final Stream<String> channel;

  @override
  State<MessagePage> createState() => _MessagePageState();
}

class _MessagePageState extends State<MessagePage> {
  StreamSubscription<String>? _subscription;

  @override
  void initState() {
    super.initState();
    _subscribe(widget.channel);
  }

  @override
  void didUpdateWidget(covariant MessagePage oldWidget) {
    super.didUpdateWidget(oldWidget);

    if (oldWidget.channel != widget.channel) {
      _subscription?.cancel();
      _subscribe(widget.channel);
    }
  }

  void _subscribe(Stream<String> stream) {
    _subscription = stream.listen((message) {
      if (!mounted) return;
      debugPrint(message);
    });
  }

  @override
  void dispose() {
    _subscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => const SizedBox.shrink();
}

如果忽略 didUpdateWidget,旧数据源可能继续向当前 State 发送消息;如果重新订阅但不取消旧订阅,则每次参数改变都会增加一条监听链。

3. Timer 也会保留闭包

class PollingPage extends StatefulWidget {
  const PollingPage({super.key});

  @override
  State<PollingPage> createState() => _PollingPageState();
}

class _PollingPageState extends State<PollingPage> {
  Timer? _timer;

  @override
  void initState() {
    super.initState();

    _timer = Timer.periodic(const Duration(seconds: 5), (_) {
      if (!mounted) return;
      _refresh();
    });
  }

  void _refresh() {
    // 发起刷新或更新状态
  }

  @override
  void dispose() {
    _timer?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => const SizedBox.shrink();
}

mounted 只能阻止 _refresh() 执行,不能阻止 Timer 继续存在。真正的生命周期操作是 cancel()


四、图片缓存:缓存增长与泄漏不是同义词

1. ImageCache 为什么会占用内存

Flutter 的图片加载通常包含几个阶段:

图片地址或 Asset
    ↓
ImageProvider 解析
    ↓
加载压缩图片数据
    ↓
解码为 ui.Image
    ↓
ImageCache 保存 ImageStreamCompleter / ui.Image
    ↓
Raster/GPU 或平台图形系统继续使用

图片的压缩文件大小和解码后大小可能差别很大。一个近似估算是:

MW×H×BM \approx W \times H \times B

其中:

  • WW 是像素宽度;
  • HH 是像素高度;
  • BB 是每像素字节数,常见 RGBA 图像可近似为 4。

例如,4000 × 3000 的图像,解码后仅像素数据就约为:

4000×3000×4=48,000,0004000 \times 3000 \times 4 = 48,000,000

约 45.8 MiB,尚未计算对象、纹理和其他平台开销。原图文件只有几 MB,并不代表运行时只占几 MB。

ImageCache 的存在是为了避免同一图片反复加载和解码。缓存保留图片,说明它仍被缓存策略有意引用;这不自动构成泄漏。

2. 使用 cacheWidthcacheHeight 控制解码尺寸

如果缩略图只显示为 120 logical pixels,却解码一张数千像素的大图,可以在适合的场景中指定目标解码尺寸:

Image.network(
  imageUrl,
  cacheWidth: 240,
  cacheHeight: 240,
  fit: BoxFit.cover,
)

这里的值是图片解码相关的像素尺寸,不应机械地把所有逻辑尺寸直接当作物理像素。高 DPI 屏幕可能需要根据设备像素比估算:

final pixelRatio = MediaQuery.devicePixelRatioOf(context);
final cacheSize = (120 * pixelRatio).round();

Image.network(
  imageUrl,
  cacheWidth: cacheSize,
  cacheHeight: cacheSize,
)

这只是内存和清晰度之间的取舍:

  • 尺寸过小:放大后模糊;
  • 尺寸过大:解码内存和 GPU 纹理占用增加;
  • 列表中尺寸固定:更容易获得稳定缓存键;
  • 原图需要全屏查看:缩略图和详情图通常应使用不同尺寸。

3. ImageCache 的常见状态

Flutter 图片缓存并不只保存“已经显示的图片”。概念上通常涉及:

  • pending:图片正在加载或解码;
  • live:仍有活跃 listener 的图片;
  • keepAlive:没有活跃 listener,但仍被缓存策略保留的图片。

因此,页面离开后图片不立即从内存消失可能是正常行为。是否回收取决于缓存大小、最近使用情况和实现策略,而不是由页面 dispose() 直接决定。

不要在每个页面销毁时无条件执行:

PaintingBinding.instance.imageCache.clear();

这会清空全局图片缓存,导致其他页面重新加载和解码图片,可能造成卡顿、网络请求增加和更高 CPU 消耗。只有在确实需要释放大量暂时性图片、且能接受重新加载成本时,才应考虑针对性清理。

如果明确知道某个缓存键对应的图片不应继续保留,可以使用相应的 ImageProvider 调用 evict

final provider = NetworkImage(imageUrl);
await provider.evict();

这要求使用的 Provider、配置和缓存键与实际加载时一致;否则清除的可能不是目标条目。

4. 图片缓存与平台差异

  • Android、iOS、桌面:Dart 堆中的图片对象之外,还可能有原生图像对象、Skia 或 GPU 纹理,系统工具看到的 RSS 不等于 Dart heap。
  • Web:浏览器可能管理网络缓存、解码图片、Canvas 或 WebGL 资源。Flutter Web 的 Dart 内存观察不能完全覆盖浏览器进程的图像内存。
  • 桌面:窗口、渲染后端和操作系统图形驱动可能改变原生内存表现,不能只用移动端经验判断。
  • 不同渲染后端:图片上传 GPU 的时机和内存可见性可能不同,短时峰值尤其不能直接判定为泄漏。

五、闭包:真正的问题是捕获了什么、被谁保存

1. 闭包会捕获词法作用域中的变量

闭包是一个函数以及它所捕获的外部变量环境。例如:

void createHandler() {
  final largeData = List<int>.filled(10 * 1024 * 1024, 0);

  void handler() {
    debugPrint('${largeData.length}');
  }

  registerGlobalHandler(handler);
}

虽然 createHandler() 已经返回,但 registerGlobalHandler 如果长期保存 handler,那么引用链仍然存在:

GlobalRegistry
  └── handler
        └── 捕获环境
              └── largeData

闭包本身不是泄漏;“长生命周期对象保存了捕获短生命周期数据的闭包”才可能造成泄漏。

2. 捕获 this 会间接保留整个 State

实例方法天然需要访问 this

_eventBus.on<Event>(_handleEvent);

void _handleEvent(Event event) {
  // 访问 State 字段
}

事件总线保存 _handleEvent 时,也就可能保存当前 State。如果事件总线的生命周期长于页面,必须在 dispose() 中注销:

class EventPage extends StatefulWidget {
  const EventPage({super.key});

  @override
  State<EventPage> createState() => _EventPageState();
}

class _EventPageState extends State<EventPage> {
  @override
  void initState() {
    super.initState();
    appEventBus.addListener(_handleEvent);
  }

  void _handleEvent() {
    if (!mounted) return;
    setState(() {});
  }

  @override
  void dispose() {
    appEventBus.removeListener(_handleEvent);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => const SizedBox.shrink();
}

final appEventBus = ChangeNotifier();

如果注册 API 返回注销函数,也应保存注销函数,而不是依赖匿名闭包“自己消失”:

late final VoidCallback _removeListener;

@override
void initState() {
  super.initState();

  _removeListener = registerListener((event) {
    if (!mounted) return;
    debugPrint('$event');
  });
}

@override
void dispose() {
  _removeListener();
  super.dispose();
}

3. BuildContext 不是泄漏源,但长时间捕获它很危险

BuildContext 通常对应 Element。将它保存到单例、全局队列或长期异步任务中,可能把已经离树的 Element 一起保留:

class BadNavigatorService {
  BuildContext? context;

  void save(BuildContext value) {
    context = value;
  }
}

更稳妥的方式是:

  • 只在需要时使用当前回调参数;
  • 保存业务数据而不是 BuildContext
  • 使用应用级导航器或路由服务时明确其生命周期;
  • 异步间隔后检查 mounted,但不要把它当作释放引用的替代方案。

4. 异步闭包的两种风险

Future<void> load() async {
  final result = await repository.fetch();
  if (!mounted) return;

  setState(() {
    _result = result;
  });
}

这里有两个不同问题:

  1. await 期间,异步操作可能暂时保留闭包和 State;
  2. 页面销毁后,完成回调可能继续执行。

一次性短异步任务通常只会造成暂时保留,不一定是泄漏;但如果底层 Future 永不完成,或者任务不断创建而无法取消,就可能形成长期占用。对于可取消任务,应使用可取消的 API;对于不可取消的 Future,至少避免把巨大的对象或页面上下文无必要地捕获进去。


六、一个完整的生命周期示例

下面的页面同时管理 Controller、订阅、Timer 和异步刷新:

import 'dart:async';

import 'package:flutter/material.dart';

class DashboardPage extends StatefulWidget {
  const DashboardPage({super.key});

  @override
  State<DashboardPage> createState() => _DashboardPageState();
}

class _DashboardPageState extends State<DashboardPage> {
  late final TextEditingController _searchController;
  late final FocusNode _focusNode;

  StreamSubscription<String>? _messageSubscription;
  Timer? _refreshTimer;

  String _message = '尚无消息';
  bool _loading = false;

  @override
  void initState() {
    super.initState();

    _searchController = TextEditingController();
    _focusNode = FocusNode();

    _messageSubscription = messageStream.listen((value) {
      if (!mounted) return;
      setState(() {
        _message = value;
      });
    });

    _refreshTimer = Timer.periodic(
      const Duration(seconds: 10),
      (_) => _refresh(),
    );
  }

  Future<void> _refresh() async {
    if (!mounted || _loading) return;

    setState(() {
      _loading = true;
    });

    try {
      final value = await fetchMessage();

      if (!mounted) return;
      setState(() {
        _message = value;
      });
    } finally {
      if (!mounted) return;
      setState(() {
        _loading = false;
      });
    }
  }

  @override
  void dispose() {
    _messageSubscription?.cancel();
    _refreshTimer?.cancel();

    _searchController.dispose();
    _focusNode.dispose();

    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(
          controller: _searchController,
          focusNode: _focusNode,
        ),
        Text(_message),
        if (_loading) const CircularProgressIndicator(),
      ],
    );
  }
}

final Stream<String> messageStream =
    Stream<String>.periodic(const Duration(seconds: 3), (i) => '消息 $i');

Future<String> fetchMessage() async {
  await Future<void>.delayed(const Duration(milliseconds: 300));
  return '刷新完成';
}

关键路径是:

State.initState
  ├── 创建 TextEditingController
  ├── 创建 FocusNode
  ├── 建立 StreamSubscription
  └── 创建 Timer

State.dispose
  ├── cancel StreamSubscription
  ├── cancel Timer
  ├── dispose TextEditingController
  └── dispose FocusNode

mounted 负责防止异步完成后更新已销毁的 State;cancel()dispose() 负责切断长期资源关系。两者不能互相替代。


七、DevTools:从“内存涨了”定位到“谁保留了谁”

1. 先选择正确的运行模式

内存分析应尽量在接近生产的构建模式下进行:

flutter run --profile

然后打开 DevTools 的 Memory 页面。调试模式包含额外断言、诊断对象和开发辅助逻辑,内存表现不能直接代表发布包。性能和泄漏复现通常还需要固定设备、固定数据、固定操作路径。

2. 建立可重复的复现流程

不要只观察一次页面打开后的内存。应设计重复操作:

记录基线
  ↓
进入页面
  ↓
触发订阅、图片加载和异步任务
  ↓
退出页面
  ↓
主动 GC
  ↓
重复 5~10 次
  ↓
比较 Dart heap 和对象数量

操作时应区分:

  • 页面是 push 后真正 pop,还是仅被覆盖;
  • 图片是否每次使用不同 URL 或不同尺寸;
  • 数据是否持续增长;
  • 后台订阅是否确实仍在发送;
  • 是否发生了热重载。热重载会改变诊断结果,不适合作为严格泄漏实验的唯一流程。

3. Memory 面板中的核心操作

不同 Flutter/Dart/DevTools 版本界面名称可能略有变化,但核心思路一致:

  1. 观察时间线:查看 Dart heap、RSS 等趋势;
  2. 主动 GC:在相同操作点触发 GC,排除暂时性垃圾;
  3. Heap snapshot:查看当前堆中对象类型和数量;
  4. Diff snapshot:比较两次快照之间的对象增量;
  5. Trace instances / allocation traces:对特定类跟踪分配来源;
  6. 查看 retaining path:沿保留路径寻找真正的持有者。

判断泄漏时,不能只看“总内存是否下降”。例如:

  • 页面第一次打开会产生缓存和 JIT/运行时初始化;
  • GC 只回收不可达对象;
  • 图片缓存可能有意保留对象;
  • 内存分配器回收了 Dart 对象,但进程 RSS 不立即归还操作系统。

4. 用对象数量验证 Controller 泄漏

假设页面每次创建一个 _LeakyPageState,重复进入并退出 10 次后:

  • 若页面确实销毁,且没有长期引用,GC 后旧 State 数量应回落;
  • 若某个全局订阅或 Timer 保留它,快照中 State、Controller 数量会随次数增加;
  • 查看 retaining path,通常可以找到 StreamControllerTimer、事件总线或某个单例。

一个故意泄漏的例子:

class LeakyPage extends StatefulWidget {
  const LeakyPage({super.key});

  @override
  State<LeakyPage> createState() => _LeakyPageState();
}

class _LeakyPageState extends State<LeakyPage> {
  late final TextEditingController controller;

  @override
  void initState() {
    super.initState();
    controller = TextEditingController();

    globalStream.listen((_) {
      // 闭包捕获 this,订阅没有保存,也没有取消
      debugPrint(controller.text);
    });
  }

  @override
  void dispose() {
    // controller.dispose() 并不能取消 globalStream 的订阅
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(controller: controller);
  }
}

final Stream<void> globalStream =
    Stream<void>.periodic(const Duration(seconds: 1));

这个例子中的错误不是“忘记 controller.dispose()”,而是:

globalStream
  └── subscription
        └── listener closure
              └── _LeakyPageState
                    └── controller

即使 Controller 被 dispose(),闭包仍保留 State;如果闭包还保留其他数据,整个引用链仍然存在。修复方式是保存订阅并取消:

late final StreamSubscription<void> _subscription;

@override
void initState() {
  super.initState();
  controller = TextEditingController();

  _subscription = globalStream.listen((_) {
    debugPrint(controller.text);
  });
}

@override
void dispose() {
  _subscription.cancel();
  controller.dispose();
  super.dispose();
}

5. 使用日志辅助判断生命周期

可以在构造和销毁时记录对象:

class TracedController extends TextEditingController {
  TracedController() {
    debugPrint('create controller: $hashCode');
  }

  @override
  void dispose() {
    debugPrint('dispose controller: $hashCode');
    super.dispose();
  }
}

日志只能证明 dispose() 是否被调用,不能证明对象已经被 GC。要判断是否仍然可达,仍需结合 DevTools 快照和 retaining path。


八、常见错误判断及其边界

1. “加上 mounted 就不会泄漏”

错误。mounted 只表示 State 当前是否仍挂在 Element 树上。它可以避免:

setState() called after dispose()

但不能取消 Stream、Timer、网络任务,也不能清除闭包已经形成的引用链。

2. “所有缓存都应该在页面退出时清空”

错误。缓存的目的就是跨页面复用。应先确认缓存是否超过合理上限、图片尺寸是否过大、缓存键是否无限增长,再决定调整解码尺寸、缓存策略或针对性驱逐。

3. “调用 Controller 的 dispose() 就一定释放了 State”

错误。Controller 的 dispose() 只处理 Controller 自身的生命周期;如果另一个订阅、闭包或单例仍然保留 State,State 仍然不可回收。

4. “Dart 有 GC,所以不会泄漏”

错误。GC 只能回收不可达对象。错误的全局引用、监听器、缓存、Timer 都能让对象保持可达。另一方面,文件句柄、Socket、平台对象和 GPU 资源也不一定完全由 Dart GC 管理。

5. “RSS 不下降就是泄漏”

错误。内存分配器可能保留已经释放的页以便复用,图形后端可能延迟释放资源,系统也可能缓存文件和图片。需要同时看 Dart heap、对象数量、引用路径和原生工具数据。


九、平台差异与生产取舍

在 Android 和 iOS 上,除了 Dart VM 堆,还要关注:

  • 原生图片解码对象;
  • GPU 纹理;
  • 插件创建的原生对象;
  • WebView、地图、视频播放器等外部组件;
  • 系统分配器和进程 RSS。

在桌面平台上,窗口和图形后端的资源生命周期可能更长。关闭 Flutter 页面并不必然意味着原生窗口或纹理立即归还操作系统。

在 Web 上,浏览器负责更多内存管理,图片可能由浏览器缓存、Canvas、WebGL 或渲染引擎持有。DevTools 中的 Dart heap 只能解释 Flutter/Dart 部分,浏览器开发者工具和任务管理器也可能需要同时使用。

生产环境中应优先解决可证明的长期增长:

  1. 页面重复进入后,同类 State 或 Controller 数量持续增加;
  2. retaining path 指向页面外的全局对象;
  3. 订阅、Timer、事件监听在页面结束后仍然活跃;
  4. 图片尺寸或缓存键无限增长;
  5. 原生插件对象没有对应的关闭、释放或移除调用。

内存峰值本身未必能降到最低。缓存、预加载和图片复用都会换取更好的交互性能。合理目标是让内存占用与数据规模、缓存策略和当前任务相匹配,并在任务结束后不再无限增长。


十、排查时应形成的因果链

面对一次疑似泄漏,可以按以下顺序验证:

  1. 定义预期生命周期
    例如 Controller 应与页面 State 同寿命,订阅应与数据源使用周期同寿命,图片缓存可以长于单个页面。

  2. 记录创建与销毁
    确认 initState() 创建的对象是否在 dispose() 中完成对应操作。

  3. 主动 GC 后比较对象数量
    不比较 GC 前的总内存,先排除暂时性垃圾。

  4. 查看 retaining path
    找到第一个“本来不应该拥有它”的长生命周期对象。

  5. 区分 Dart、原生和 GPU
    Dart 快照找不到增长对象时,应转向插件、图片、视频、地图、WebView 或平台工具。

  6. 修复后重复相同操作
    修复前后的操作次数、数据和运行模式必须一致,否则无法证明修复有效。

可以把最终判断归纳为:

对象数量是否随重复操作增长?
        │
        ├── 否:可能是正常缓存、一次性峰值或分配器行为
        │
        └── 是
             ↓
      对象是否仍可达?
             │
             ├── 否:等待 GC 或检查快照时机
             │
             └── 是
                  ↓
        retaining path 指向谁?
             ├── Controller listener
             ├── StreamSubscription / Timer
             ├── 全局闭包或事件总线
             ├── ImageCache
             └── 原生 / GPU / 插件对象

真正可靠的生命周期设计不是“每个类都写一个 dispose()”,而是让资源有明确的拥有者,并让创建、注册、取消、销毁形成对称关系:

create  ↔  dispose
listen  ↔  cancel
addListener  ↔  removeListener
startTimer  ↔  cancel
loadImage  ↔  合理缓存或针对性 evict
registerClosure  ↔  unregister

当页面退出后,若仍能从某个全局对象沿引用链到达页面 State,就应继续追踪;当内存只是被有界缓存保留且重复操作后趋于稳定,则应将其视为缓存策略问题,而不是直接归类为泄漏。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。