Flutter 基础体系 · 第 71/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 内存泄漏:Controller、订阅、图片缓存、闭包和 DevTools
Flutter 内存问题通常不是“某个对象没有被手动删除”,而是对象仍然能从运行时根对象(GC roots)访问到,因此 Dart 垃圾回收器(Garbage Collector,GC)无法回收它,或者对象本身已经不可达,但它持有的非 Dart 资源仍由其他系统管理。
在 Flutter 应用中,最容易形成长期引用的对象包括:
TextEditingController、AnimationController、ScrollController、FocusNode等 Controller;StreamSubscription、Timer、事件总线和平台通道监听;- 图片解码结果与
ImageCache; - 注册到长生命周期对象上的闭包;
- 原生平台、GPU 或浏览器持有的资源。
因此,“调用了 dispose()”不是内存问题的完整定义。需要同时回答三个问题:
- 谁持有这个对象?
- 它本来应该活多久?
- 它结束生命周期后,引用和外部资源是否都被释放?
一、先建立内存泄漏模型:对象为什么不会被回收
1. Dart GC 回收的基本条件
Dart 使用垃圾回收机制管理 Dart 堆。一个对象可以被回收的必要条件是:从 GC roots 出发,无法再访问到它。
可以把对象图表示为:
其中:
- 是对象集合;
- 是对象之间的引用边;
- 是 GC roots,例如线程栈、全局对象、运行时管理对象等。
对象 在某次 GC 后可回收的条件近似为:
也就是从根集合 沿着引用边无法到达 。
所谓“内存泄漏”,在 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、动画、滚动、焦点或输入法;
- 间接持有原生或框架资源。
常见类型包括:
TextEditingControllerAnimationControllerScrollControllerPageControllerTabControllerFocusNodeTransformationControllerUndoHistoryController
它们大多实现了 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,
);
}
}
这里有两层释放关系:
TextField不再使用 Controller;- 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 中使用时,通常应混入 SingleTickerProviderStateMixin 或 TickerProviderStateMixin:
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 和监听器:cancel 比 dispose 更关键
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 或平台图形系统继续使用
图片的压缩文件大小和解码后大小可能差别很大。一个近似估算是:
其中:
- 是像素宽度;
- 是像素高度;
- 是每像素字节数,常见 RGBA 图像可近似为 4。
例如,4000 × 3000 的图像,解码后仅像素数据就约为:
约 45.8 MiB,尚未计算对象、纹理和其他平台开销。原图文件只有几 MB,并不代表运行时只占几 MB。
ImageCache 的存在是为了避免同一图片反复加载和解码。缓存保留图片,说明它仍被缓存策略有意引用;这不自动构成泄漏。
2. 使用 cacheWidth 和 cacheHeight 控制解码尺寸
如果缩略图只显示为 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;
});
}
这里有两个不同问题:
await期间,异步操作可能暂时保留闭包和 State;- 页面销毁后,完成回调可能继续执行。
一次性短异步任务通常只会造成暂时保留,不一定是泄漏;但如果底层 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 版本界面名称可能略有变化,但核心思路一致:
- 观察时间线:查看 Dart heap、RSS 等趋势;
- 主动 GC:在相同操作点触发 GC,排除暂时性垃圾;
- Heap snapshot:查看当前堆中对象类型和数量;
- Diff snapshot:比较两次快照之间的对象增量;
- Trace instances / allocation traces:对特定类跟踪分配来源;
- 查看 retaining path:沿保留路径寻找真正的持有者。
判断泄漏时,不能只看“总内存是否下降”。例如:
- 页面第一次打开会产生缓存和 JIT/运行时初始化;
- GC 只回收不可达对象;
- 图片缓存可能有意保留对象;
- 内存分配器回收了 Dart 对象,但进程 RSS 不立即归还操作系统。
4. 用对象数量验证 Controller 泄漏
假设页面每次创建一个 _LeakyPageState,重复进入并退出 10 次后:
- 若页面确实销毁,且没有长期引用,GC 后旧 State 数量应回落;
- 若某个全局订阅或 Timer 保留它,快照中 State、Controller 数量会随次数增加;
- 查看 retaining path,通常可以找到
StreamController、Timer、事件总线或某个单例。
一个故意泄漏的例子:
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 部分,浏览器开发者工具和任务管理器也可能需要同时使用。
生产环境中应优先解决可证明的长期增长:
- 页面重复进入后,同类 State 或 Controller 数量持续增加;
- retaining path 指向页面外的全局对象;
- 订阅、Timer、事件监听在页面结束后仍然活跃;
- 图片尺寸或缓存键无限增长;
- 原生插件对象没有对应的关闭、释放或移除调用。
内存峰值本身未必能降到最低。缓存、预加载和图片复用都会换取更好的交互性能。合理目标是让内存占用与数据规模、缓存策略和当前任务相匹配,并在任务结束后不再无限增长。
十、排查时应形成的因果链
面对一次疑似泄漏,可以按以下顺序验证:
-
定义预期生命周期
例如 Controller 应与页面 State 同寿命,订阅应与数据源使用周期同寿命,图片缓存可以长于单个页面。 -
记录创建与销毁
确认initState()创建的对象是否在dispose()中完成对应操作。 -
主动 GC 后比较对象数量
不比较 GC 前的总内存,先排除暂时性垃圾。 -
查看 retaining path
找到第一个“本来不应该拥有它”的长生命周期对象。 -
区分 Dart、原生和 GPU
Dart 快照找不到增长对象时,应转向插件、图片、视频、地图、WebView 或平台工具。 -
修复后重复相同操作
修复前后的操作次数、数据和运行模式必须一致,否则无法证明修复有效。
可以把最终判断归纳为:
对象数量是否随重复操作增长?
│
├── 否:可能是正常缓存、一次性峰值或分配器行为
│
└── 是
↓
对象是否仍可达?
│
├── 否:等待 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 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 卡顿诊断:UI/Raster 线程、Shader、图片和 Timeline
- 下一篇:Flutter 包体积优化:AOT、资源、字体、符号、拆分和分析
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论