前言
Flutter 项目开发到一定阶段后,经常会遇到一些看起来很像"玄学"的问题:
- 页面反复进入退出后,内存持续增长
- 长列表滑动几次后,内存明显升高
- 加载几张图片,内存突然上涨几十 MB
- 页面已经退出,但控制器、定时器和页面对象仍然存在
- 手动触发 GC 后,内存曲线没有明显下降
- 内存上涨了,却不知道这是正常缓存、临时峰值,还是实际泄漏
这些问题如果只靠经验猜,很容易陷入两个极端:
- 看到内存上涨就认为发生了泄漏
- 认为 Dart 有 GC,业务代码不需要主动释放资源
这两个判断都不准确。
Flutter 内存管理并不是只记住一句"控制器需要在 dispose 中释放"就够了。真正需要掌握的是一套完整思路:
- Dart GC 会回收哪些对象
- 对象为什么仍然可达
dispose和 GC 分别负责什么- 内存峰值、缓存和内存泄漏有什么区别
- 图片为什么特别容易造成高内存
- 长列表为什么要使用懒加载
- 如何使用 DevTools Memory、Snapshot、Diff 和 Retaining Path 定位问题
为了把这些知识串起来,我整理了一个可以直接运行的 Flutter 内存管理实验室 Demo。
Demo 地址:
这篇文章不只讲概念,也会结合 Demo 中的实验页面,一步步验证 Flutter 内存管理的核心原理。
运行环境
当前 Demo 使用的环境如下:
text
Flutter SDK:3.41.6 stable
Dart SDK:3.11.4
DevTools:2.54.2
开发系统:macOS
第三方插件:无
拉取并运行项目:
bash
git clone https://gitee.com/TTGF/flutter_memory_lab.git
cd flutter_memory_lab
flutter pub get
flutter run
验证命令:
bash
flutter analyze
flutter test
图片缓存实验默认使用网络图片,也支持切换到本地图片。即使网络请求失败,页面也会自动回退到本地资源。
Demo 包含哪些实验
Demo 首页按照三个层级组织实验。
基础原理
- GC 回收验证
- 内存峰值与泄漏
常见泄漏
- 控制器释放
- 定时器与订阅
- 闭包持有页面引用
- 页面生命周期
高内存场景
- 图片缓存
- 图片像素内存估算
- 长列表构建方式
项目结构如下:
text
lib/
├── main.dart
├── pages/
│ ├── gc_lab_page.dart
│ ├── memory_peak_lab_page.dart
│ ├── controller_lab_page.dart
│ ├── timer_lab_page.dart
│ ├── reference_lab_page.dart
│ ├── lifecycle_lab_page.dart
│ ├── image_cache_lab_page.dart
│ ├── image_memory_lab_page.dart
│ └── list_lab_page.dart
├── store/
│ ├── gc_probe_store.dart
│ ├── memory_peak_store.dart
│ ├── memory_lab_store.dart
│ ├── lifecycle_lab_store.dart
│ └── lab_cleanup_service.dart
└── widgets/
└── lab_scaffold.dart
首页还提供了统一清理入口,可以释放:
- 控制器
- 定时器
- 闭包引用
- 持续占用内存
- GC 实验记录
- 生命周期计数
- 图片缓存
这样可以反复执行实验,并对比清理前后的内存变化。
Flutter 内存管理的核心模型
在进入实验之前,先建立一个简单但足够实用的模型。
Dart GC 负责什么
Dart 使用垃圾回收机制管理内存。
GC 的基本目标是:
text
回收已经不可达的 Dart 对象
所谓"不可达",可以理解为:从程序仍然存活的根对象出发,已经无法继续找到这个对象。
反过来说,只要某个对象仍然被强引用持有,它就还是可达对象,GC 不会回收它。
例如:
dart
final objects = <Uint8List>[];
void retainMemory() {
objects.add(Uint8List(1024 * 1024));
}
即使手动触发 GC,objects 中的数据也不会被回收。
原因不是 GC 失效,而是这些对象仍然可达。
dispose 负责什么
dispose 的职责不是直接执行 GC。
它负责主动释放和页面生命周期绑定的资源,例如:
TextEditingControllerAnimationControllerScrollControllerFocusNodeTimerStreamSubscriptionChangeNotifier监听器- 自定义事件总线监听
典型写法:
dart
class _ExamplePageState extends State<ExamplePage> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
需要注意:
text
调用 dispose != 对象立即被 GC 回收
调用 dispose 后,资源被主动释放。对象是否被 GC 回收,还要看是否仍然存在强引用。
一句话总结
可以这样记:
text
dispose 负责主动释放资源
GC 负责回收不可达对象
解除强引用后,对象才具备被回收条件
实验一:使用 WeakReference 和 Finalizer 验证 GC 原理
Demo 中的"GC 回收验证"页面,用来观察对象从"仍然可达"到"具备回收条件"的过程。
核心代码位于:
text
lib/store/gc_probe_store.dart
创建实验对象
每个实验对象携带约 256 KB 数据:
dart
class GcProbeObject {
final String label;
final Uint8List payload = Uint8List(256 * 1024);
GcProbeObject(this.label);
}
创建对象时,同时保存:
- 强引用
WeakReferenceFinalizer
dart
void createObjects({int count = 20}) {
for (var index = 0; index < count; index++) {
final label = '实验对象 ${_nextId++}';
final object = GcProbeObject(label);
_strongReferences.add(object);
_weakReferences.add(WeakReference(object));
_finalizer.attach(object, label);
}
}
强引用
dart
final _strongReferences = <GcProbeObject>[];
只要对象还在这个集合中,它就是可达对象。
即使在 DevTools Memory 页面手动触发 GC,对象也不会被回收。
WeakReference
dart
final _weakReferences = <WeakReference<GcProbeObject>>[];
WeakReference 可以观察对象是否仍然存活,但不会阻止对象被 GC 回收。
统计仍然存活的弱引用:
dart
int get aliveWeakReferenceCount =>
_weakReferences.where((reference) => reference.target != null).length;
Finalizer
dart
late final Finalizer<String> _finalizer = Finalizer<String>((label) {
_collectedLabels.add(label);
});
Finalizer 会在目标对象被 GC 回收后异步执行回调。
它适合做实验观察,但不要依赖它完成关键业务逻辑。
原因是:
- 回调时机不确定
- 回调不是同步执行
- 应用代码不能精确控制 GC 发生时间
推荐操作顺序
- 打开"GC 回收验证"页面。
- 点击"创建 20 个实验对象"。
- 在 DevTools Memory 页面手动触发 GC。
- 观察强引用数量和弱引用仍可访问数量。
- 点击"移除强引用"。
- 再次触发 GC,或点击"制造临时内存压力"。
- 观察弱引用仍可访问数量下降。
- 观察
Finalizer回调数量增加。
这个实验验证了最重要的一点:
text
GC 不会回收仍然可达的对象。
移除强引用后,对象只是具备回收条件。
实际回收时机仍然由 Dart 运行时决定。
实验二:内存上涨不等于内存泄漏
很多排查误区都来自一个错误判断:
text
内存上涨 = 内存泄漏
实际上,内存上涨可能来自:
- 临时大对象
- 图片解码
- 缓存
- 页面预加载
- 数据批量解析
- 真正无法释放的强引用
Demo 中的"内存峰值与泄漏"页面,提供两种按钮。
制造临时峰值
dart
void allocateTemporary({int megabytes = 50}) {
final blocks = List.generate(
megabytes,
(_) => Uint8List(1024 * 1024),
);
blocks.first[0] = 1;
}
这里申请约 50 MB 临时数据,但不会保存到全局变量中。
方法执行结束后,blocks 不再被引用。这些对象具备被 GC 回收的条件。
在 DevTools 中可以观察:
text
内存先上涨
触发 GC 后通常可以下降
制造持续占用
dart
final _retainedBlocks = <Uint8List>[];
void retainMemory({int megabytes = 50}) {
_retainedBlocks.addAll(
List.generate(megabytes, (_) => Uint8List(1024 * 1024)),
);
}
这里同样申请约 50 MB 数据,但会保存到长生命周期集合中。
即使触发 GC,内存也不会正常下降,因为对象仍然可达。
移除强引用
dart
void releaseRetainedMemory() {
_retainedBlocks.clear();
}
执行 clear() 后,对象才具备被回收条件。
实验结论
text
临时峰值:对象不可达后,GC 可以回收
缓存:可能长期存在,但通常可以主动清理
泄漏:对象仍然被不合理的强引用持有
排查时,不要只看曲线上涨,还要看:
- 触发 GC 后是否下降
- 对象数量是否持续增加
- 哪些类的实例没有释放
- Retaining Path 指向谁
实验三:dispose 和 GC 不是一回事
"页面生命周期"实验通过计数器展示:
- 页面创建次数
- 页面释放次数
- 控制器创建次数
- 控制器释放次数
- 仍活跃页面数量
- 仍活跃控制器数量
页面创建时记录:
dart
@override
void initState() {
super.initState();
LifecycleLabStore.instance.recordPageCreated();
}
页面退出时释放控制器并记录:
dart
@override
void dispose() {
_controller.dispose();
LifecycleLabStore.instance.recordPageDisposed();
super.dispose();
}
反复进入、退出示例页,可以观察计数是否始终保持一致。
这里要理解两个阶段
第一阶段:主动释放资源。
dart
_controller.dispose();
第二阶段:对象变成不可达后,由 GC 决定何时回收。
因此,正确写法不是"等待 GC 帮忙释放控制器",而是在生命周期结束时主动 dispose。
实验四:控制器未释放
Flutter 页面中常见的控制器包括:
TextEditingControllerAnimationControllerScrollControllerPageControllerTabController
Demo 中的控制器实验保留两组对象。
规范写法
dart
final _safeController = TextEditingController(
text: '页面退出时会自动释放',
);
@override
void dispose() {
_safeController.dispose();
super.dispose();
}
问题写法
dart
final controllers = List.generate(
1000,
(index) => TextEditingController(text: '未释放控制器 $index'),
);
MemoryLabStore.instance.retainControllers(controllers);
控制器被全局集合持有后,页面退出也不会自动回收。
主动释放时需要:
dart
void clearControllers() {
for (final controller in _controllers) {
controller.dispose();
}
_controllers.clear();
}
这个实验很适合配合 Snapshot Diff 使用。
可以重点筛选:
text
TextEditingController
观察创建前后对象数量变化。
实验五:Timer、订阅和监听器需要取消
Timer、StreamSubscription 和监听器有一个共同特点:
text
它们通常会持有回调
回调又可能间接持有页面对象
规范写法
dart
Timer? _safeTimer;
@override
void initState() {
super.initState();
_safeTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() => _safeTicks++);
});
}
@override
void dispose() {
_safeTimer?.cancel();
super.dispose();
}
问题写法
dart
final timers = List.generate(
100,
(_) => Timer.periodic(const Duration(seconds: 30), (_) {}),
);
MemoryLabStore.instance.retainTimers(timers);
主动清理:
dart
void clearTimers() {
for (final timer in _timers) {
timer.cancel();
}
_timers.clear();
}
真实项目中,还需要注意:
dart
subscription.cancel();
notifier.removeListener(listener);
eventBus.off(event);
原则很简单:
text
在哪里注册,就要明确在哪里取消。
实验六:闭包长期持有 BuildContext
闭包引用问题比较隐蔽。
下面这段代码看起来只是保存一个回调:
dart
final pageContext = context;
MemoryLabStore.instance.retainCallback(() {
debugPrint(
'被持有页面:${ModalRoute.of(pageContext)?.settings.name}',
);
});
但闭包捕获了 pageContext。
如果闭包被全局集合长期持有,BuildContext 以及它关联的页面对象可能无法及时回收。
Demo 中通过下面的方法移除引用:
dart
void clearCallbacks() {
_callbacks.clear();
}
真实项目中常见来源
- 单例保存页面回调
- 全局事件总线未取消监听
- 静态集合保存闭包
- 长生命周期服务保存页面
BuildContext - 异步任务执行时间过长
推荐避免在长生命周期对象中保存:
dart
BuildContext
State
Widget
页面闭包
如果确实需要回调,应建立清晰的注册和取消机制。
实验七:图片文件大小不等于内存占用
图片是 Flutter 项目中非常常见的高内存来源。
很多开发者看到一张图片文件只有 300 KB,会认为它加载到内存后也只占 300 KB 左右。
实际上,图片显示前通常需要解码成像素数据。
粗略估算公式:
text
图片解码内存 ≈ 宽 × 高 × 4 字节
例如:
text
图片尺寸:3000 × 2000
像素数量:6,000,000
解码内存:3000 × 2000 × 4
结果约:22.9 MB
如果列表中同时显示多张大图,内存上涨会非常明显。
Demo 中提供了可调节的宽度、高度和目标解码宽度。
限制解码尺寸
对于列表缩略图,可以使用 cacheWidth:
dart
Image.network(
url,
fit: BoxFit.cover,
cacheWidth: 450,
)
如果原图是:
text
3000 × 2000
限制解码宽度为:
text
750
那么等比例高度约为:
text
500
估算内存变为:
text
750 × 500 × 4 ≈ 1.4 MB
差距非常明显。
实际项目建议
- 列表缩略图不要按原始尺寸解码
- 详情页大图按展示尺寸加载
- 控制预加载数量
- 避免一次性加载大量图片
- 根据业务需求清理缓存
实验八:观察 ImageCache
Flutter 提供了图片缓存:
dart
PaintingBinding.instance.imageCache
Demo 中可以查看:
dart
缓存图片数量:${_cache.currentSize}
当前缓存字节:${_cache.currentSizeBytes}
缓存上限:${_cache.maximumSizeBytes}
清理缓存:
dart
void clearCache() {
_cache.clear();
_cache.clearLiveImages();
}
这里需要注意:
text
缓存占用不一定是泄漏
缓存本身是性能优化手段。
真正要判断的是:
- 缓存上限是否合理
- 图片尺寸是否过大
- 是否存在持续增长
- 是否有业务场景需要主动清理
Demo 默认使用网络图片,同时支持本地资源模式。网络请求失败时,也会自动使用本地资源兜底。
实验九:长列表要使用懒加载
列表页面也很容易造成不必要的内存占用。
推荐写法
dart
ListView.builder(
itemCount: 5000,
itemBuilder: (_, index) => MemoryRow(index: index),
)
ListView.builder 会根据屏幕可见区域按需创建 Widget。
不推荐写法
dart
SingleChildScrollView(
child: Column(
children: List.generate(
5000,
(index) => MemoryRow(index: index),
),
),
)
这种写法会一次性创建全部子节点。
数据量较大时,会增加:
- 首帧耗时
- Widget 数量
- Element 数量
- 内存占用
观察方法
分别进入两个页面,在 DevTools 中对比:
- 内存曲线
- Widget 数量
- 页面打开速度
- Snapshot 中的节点数量
使用 DevTools Memory 排查内存问题
Demo 的价值不仅是点按钮,还要配合 DevTools 观察。
推荐使用调试模式启动:
bash
flutter run
然后打开 Flutter DevTools 的 Memory 页面。
基础观察流程
- 启动 App。
- 打开 Memory 页面。
- 进入某个实验页。
- 点击制造问题按钮。
- 返回首页。
- 手动触发 GC。
- 观察曲线是否下降。
- 点击"释放全部实验对象"。
- 再次触发 GC。
- 对比前后变化。
使用 Snapshot 排查泄漏
更完整的流程是:
- 进入实验页之前,拍摄
Snapshot A。 - 进入实验页,制造问题,然后退出页面。
- 手动触发 GC。
- 拍摄
Snapshot B。 - 使用 Diff 对比对象数量变化。
- 按类名筛选目标对象。
- 查看 Retaining Path。
- 执行清理或修复代码。
- 再次触发 GC。
- 拍摄
Snapshot C。
可以把这个过程理解为:
text
Snapshot A:基线
Snapshot B:制造问题后的对象保留情况
Snapshot C:解除引用后的释放情况
重点看哪些类
在当前 Demo 中,可以重点筛选:
text
GcProbeObject
TextEditingController
Timer
Uint8List
真实项目中,还可以关注:
text
AnimationController
ScrollController
FocusNode
StreamSubscription
自定义页面 State
业务实体对象
图片相关对象
Retaining Path 为什么重要
仅仅看到对象数量增长,还不足以定位问题。
真正重要的是:
text
谁仍然持有这个对象?
Retaining Path 可以帮助我们从目标对象一路找到引用链。
例如:
text
TextEditingController
<- 全局 List
<- MemoryLabStore 单例
或者:
text
BuildContext
<- Closure
<- 全局回调集合
<- 单例服务
当引用链清晰后,修复位置通常也会变得明确。
首页统一清理服务
为了便于反复实验,Demo 增加了统一清理服务:
dart
class LabCleanupService {
const LabCleanupService._();
static void clearAll() {
MemoryLabStore.instance.clearAll();
MemoryPeakStore.instance.releaseRetainedMemory();
GcProbeStore.instance.resetRecords();
LifecycleLabStore.instance.reset();
final cache = PaintingBinding.instance.imageCache;
cache.clear();
cache.clearLiveImages();
}
}
这段代码也可以作为业务项目中"明确清理边界"的参考。
当然,真实项目不一定要做一个全局清理按钮。但需要明确:
- 页面退出时清理什么
- 用户退出登录时清理什么
- 图片缓存何时清理
- 单例服务如何注销监听
- 长任务如何停止
常见误区
误区一:Dart 有 GC,所以不需要 dispose
错误。
GC 负责回收不可达对象,dispose 负责主动释放资源。两者职责不同。
误区二:内存上涨就是泄漏
错误。
临时对象、缓存、图片解码和列表预加载都可能造成内存上涨。
判断泄漏要看:
- GC 后是否下降
- 对象数量是否持续增长
- 是否存在不合理强引用
- Retaining Path 指向哪里
误区三:调用 clear 后内存必须立刻下降
错误。
解除引用只是让对象具备回收条件。GC 何时执行,由 Dart 运行时决定。
误区四:图片文件很小,内存占用也会很小
错误。
图片解码后通常按像素数据占用内存,可以粗略按:
text
宽 × 高 × 4
估算。
误区五:页面退出后,页面对象一定会释放
错误。
如果页面对象仍然被 Timer、闭包、监听器或全局集合持有,它仍然可达。
误区六:所有高内存都应该清空缓存
不准确。
缓存可以提升体验。更合理的目标是控制缓存边界,而不是无差别清理。
推荐的项目规范
在业务项目中,可以落地下面这些规范。
页面资源成对管理
text
创建 Controller -> dispose
注册 Listener -> removeListener
创建 Timer -> cancel
订阅 Stream -> cancel
注册事件总线 -> 注销
启动长任务 -> 停止任务
避免全局保存页面对象
尽量不要在单例中长期保存:
text
BuildContext
State
Widget
页面闭包
页面 Controller
图片按展示尺寸加载
列表缩略图使用合适的 cacheWidth,不要默认按原图尺寸解码。
长列表使用懒加载
优先使用:
dart
ListView.builder
GridView.builder
SliverList
SliverGrid
避免一次性构建大量节点。
建立稳定复现步骤
排查内存问题时,先明确:
text
进入哪个页面
执行什么操作
重复多少次
何时返回
何时触发 GC
对比哪些对象
没有稳定复现步骤,内存排查很容易变成反复猜测。
建议的学习顺序
如果第一次系统学习 Flutter 内存管理,可以按下面顺序操作 Demo:
- 运行"GC 回收验证",理解强引用、弱引用和回收条件。
- 运行"内存峰值与泄漏",区分临时上涨和持续占用。
- 运行"页面生命周期",理解
dispose与 GC 的职责差异。 - 运行"控制器释放",观察未释放控制器的对象数量。
- 运行"定时器与订阅",理解回调持有关系。
- 运行"闭包持有页面引用",观察
BuildContext被间接持有。 - 运行"图片像素内存估算",理解图片内存计算。
- 运行"图片缓存",观察
ImageCache指标。 - 运行"长列表构建方式",对比懒加载与一次性构建。
- 使用 Snapshot、Diff 和 Retaining Path 完成一次完整排查。
总结
Flutter 内存管理最核心的知识,可以归纳为下面几句话:
text
GC 只回收不可达对象。
dispose 负责主动释放资源,不负责直接触发 GC。
解除强引用后,对象才具备被回收条件。
内存峰值、缓存和内存泄漏不是同一件事。
图片文件大小不等于解码后的像素内存。
排查泄漏不能只看曲线,还要看 Snapshot Diff 和 Retaining Path。
掌握这些原则后,很多内存问题就不再神秘。
我们不需要看到内存上涨就紧张,也不能因为 Dart 有 GC 就忽略资源释放。更可靠的方式是:
text
先建立复现步骤
再观察 GC 前后变化
筛选异常对象
查看引用链
移除不合理强引用
最后验证对象是否能够回收
技术最终还是服务于稳定性和用户体验。
当页面反复进入退出后仍然保持稳定,当图片列表不再轻易冲高内存,当控制器和监听器都有清晰生命周期,Flutter 应用的性能治理才真正进入可控状态。