Flutter 内存管理实战:从 GC 原理到 DevTools 泄漏排查

前言

Flutter 项目开发到一定阶段后,经常会遇到一些看起来很像"玄学"的问题:

  • 页面反复进入退出后,内存持续增长
  • 长列表滑动几次后,内存明显升高
  • 加载几张图片,内存突然上涨几十 MB
  • 页面已经退出,但控制器、定时器和页面对象仍然存在
  • 手动触发 GC 后,内存曲线没有明显下降
  • 内存上涨了,却不知道这是正常缓存、临时峰值,还是实际泄漏

这些问题如果只靠经验猜,很容易陷入两个极端:

  • 看到内存上涨就认为发生了泄漏
  • 认为 Dart 有 GC,业务代码不需要主动释放资源

这两个判断都不准确。

Flutter 内存管理并不是只记住一句"控制器需要在 dispose 中释放"就够了。真正需要掌握的是一套完整思路:

  1. Dart GC 会回收哪些对象
  2. 对象为什么仍然可达
  3. dispose 和 GC 分别负责什么
  4. 内存峰值、缓存和内存泄漏有什么区别
  5. 图片为什么特别容易造成高内存
  6. 长列表为什么要使用懒加载
  7. 如何使用 DevTools Memory、Snapshot、Diff 和 Retaining Path 定位问题

为了把这些知识串起来,我整理了一个可以直接运行的 Flutter 内存管理实验室 Demo。

Demo 地址:

gitee.com/TTGF/flutte...

这篇文章不只讲概念,也会结合 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。

它负责主动释放和页面生命周期绑定的资源,例如:

  • TextEditingController
  • AnimationController
  • ScrollController
  • FocusNode
  • Timer
  • StreamSubscription
  • ChangeNotifier 监听器
  • 自定义事件总线监听

典型写法:

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);
}

创建对象时,同时保存:

  • 强引用
  • WeakReference
  • Finalizer
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 发生时间

推荐操作顺序

  1. 打开"GC 回收验证"页面。
  2. 点击"创建 20 个实验对象"。
  3. 在 DevTools Memory 页面手动触发 GC。
  4. 观察强引用数量和弱引用仍可访问数量。
  5. 点击"移除强引用"。
  6. 再次触发 GC,或点击"制造临时内存压力"。
  7. 观察弱引用仍可访问数量下降。
  8. 观察 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 页面中常见的控制器包括:

  • TextEditingController
  • AnimationController
  • ScrollController
  • PageController
  • TabController

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 页面。

基础观察流程

  1. 启动 App。
  2. 打开 Memory 页面。
  3. 进入某个实验页。
  4. 点击制造问题按钮。
  5. 返回首页。
  6. 手动触发 GC。
  7. 观察曲线是否下降。
  8. 点击"释放全部实验对象"。
  9. 再次触发 GC。
  10. 对比前后变化。

使用 Snapshot 排查泄漏

更完整的流程是:

  1. 进入实验页之前,拍摄 Snapshot A
  2. 进入实验页,制造问题,然后退出页面。
  3. 手动触发 GC。
  4. 拍摄 Snapshot B
  5. 使用 Diff 对比对象数量变化。
  6. 按类名筛选目标对象。
  7. 查看 Retaining Path。
  8. 执行清理或修复代码。
  9. 再次触发 GC。
  10. 拍摄 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:

  1. 运行"GC 回收验证",理解强引用、弱引用和回收条件。
  2. 运行"内存峰值与泄漏",区分临时上涨和持续占用。
  3. 运行"页面生命周期",理解 dispose 与 GC 的职责差异。
  4. 运行"控制器释放",观察未释放控制器的对象数量。
  5. 运行"定时器与订阅",理解回调持有关系。
  6. 运行"闭包持有页面引用",观察 BuildContext 被间接持有。
  7. 运行"图片像素内存估算",理解图片内存计算。
  8. 运行"图片缓存",观察 ImageCache 指标。
  9. 运行"长列表构建方式",对比懒加载与一次性构建。
  10. 使用 Snapshot、Diff 和 Retaining Path 完成一次完整排查。

总结

Flutter 内存管理最核心的知识,可以归纳为下面几句话:

text 复制代码
GC 只回收不可达对象。

dispose 负责主动释放资源,不负责直接触发 GC。

解除强引用后,对象才具备被回收条件。

内存峰值、缓存和内存泄漏不是同一件事。

图片文件大小不等于解码后的像素内存。

排查泄漏不能只看曲线,还要看 Snapshot Diff 和 Retaining Path。

掌握这些原则后,很多内存问题就不再神秘。

我们不需要看到内存上涨就紧张,也不能因为 Dart 有 GC 就忽略资源释放。更可靠的方式是:

text 复制代码
先建立复现步骤
再观察 GC 前后变化
筛选异常对象
查看引用链
移除不合理强引用
最后验证对象是否能够回收

技术最终还是服务于稳定性和用户体验。

当页面反复进入退出后仍然保持稳定,当图片列表不再轻易冲高内存,当控制器和监听器都有清晰生命周期,Flutter 应用的性能治理才真正进入可控状态。

Demo 下载地址

Flutter 内存管理实验室 Demo

相关推荐
Rkgua1 小时前
TS中`Function`、`CallableFunction` 和 `NewableFunction`的函数区别
前端
Asize1 小时前
重生之我在 Vibe Coding 时代当程序员:第十一课,JS底层 :变量提升真相
前端·javascript
HYCS1 小时前
用pixi.js实现fabric.js(五):事件系统
前端·javascript·canvas
Momo__1 小时前
Node.js 26 来了:Temporal API 默认启用,Date 终于可以退休了
前端·node.js
雨季mo浅忆1 小时前
记录前端内网开发之新入职篇
前端·内网开发
杨运交1 小时前
[025][Web模块]基于 Spring Boot 的请求日志过滤器设计与实现
前端·spring boot·后端
IT_陈寒1 小时前
React的useEffect里设状态?我又踩雷了
前端·人工智能·后端
恋猫de小郭1 小时前
GSY 史上最全跨平台/架构/语言的项目,七大项目召唤「神龙」
android·前端·flutter
范什么特西2 小时前
狂神Vue
前端·javascript·vue.js