如果你发布 Flutter 应用,那你肯定关心这些:画面不卡顿 (不掉帧)、屏幕打开要够快 ,以及用户能留得住。
但大多数人忽略了一个不起眼却非常关键 的点:你的应用是怎么分配和释放内存的。
如果你不理它 ,你会一直追着那些治标不治本 的"假优化"跑(比如狂写 setState、搞各种"小聪明"),但你的应用依然会卡顿。
如果你搞懂了它 ,你就能彻底消除界面卡顿 ,阻止内存泄漏 ,然后自信满满地发布你的应用!
来都来了,快关注公众号:OpenFlutter吧

🏗️ 快速真相:Widgets 是蓝图 ,不是大石头!
Flutter 的 Widgets (小部件)其实只是界面的"不可变描述" ,相当于设计图纸 。真正又重又"长寿" (长时间存在)的东西,都藏在底层的 Element (元素)和 RenderObject(渲染对象)层。
框架会重复使用 这些底层对象,所以就算你频繁重建(rebuild)界面,也不会让内存爆炸。
❓ 随堂测验 #1
Q: StatelessWidget 和 StatefulWidget 哪个更耗内存?
A: StatefulWidget 更耗内存。因为它多了一个 State 对象,这个对象会一直粘在元素树 上,并且它会持有 (引用)控制器、监听器 和数据等等。
🛠️ 今天就试试这样做:
- 尽量使用
const构造函数。这样可以避免重复创建完全一样的 Widget 对象来浪费内存。 - 能用无状态 (Stateless)就用无状态;真需要有状态 (Stateful)时,要有意识地 控制你在
State里放了什么东西。
💨 为什么你不该再害怕重建(Rebuilds)
如果你想要更流畅的画面(smoother frames),你就应该放下对重建的恐惧。
Dart 语言的垃圾回收机制(GC)是"分代" (generational)的:
- 它会把小而短命的对象放在**"新生代"**(new space),用一个很快的"清道夫"机制迅速清理掉。
- 那些**"长寿"的对象会被提升到"老年代"**(old space),用一个并发的"标记-清除/整理"机制来收集。
翻译一下: 创建大量短命的 Widget 对象是没问题的 !真正伤内存的是那些体积大 、被提升 (Promoted)到老年代,然后长期存在 或不断地创建/销毁的分配。
📝 举个例子:
- 在
build()方法里创建一个小 Widget ≈ 很便宜(内存消耗低)。 - 在
build()里创建一个 10k 项的模型列表 ,或者解码一张 4K 大图 ≈ 很昂贵 (内存消耗高),而且很可能被提升到老年代。
🛠️ 今天就试试这样做:
-
把重度分配 (heavy allocations)移出热点路径(off the hot path):
- 在
initState里提前算好列表。 - 使用
memoized selectors(记忆化选择器,避免重复计算)。 - 不要在渲染时(paint time)才去解码巨大的图片。
- 在
🗑️ 2025 年依然能拯救应用的"神技":dispose()
任何你创建出来、用于监听、计时 ,或者持有原生资源 (native resources)的对象,都必须 在 dispose() 方法里释放掉!
这包括:AnimationController、ScrollController、TextEditingController、FocusNode、StreamSubscription、Timer、platform channels等等。
做不到这点 ,就会导致内存泄漏 ,以及烦人的 "setState() called after dispose()" 错误。
dart
class ProfileScreenState extends State<ProfileScreen> {
final _controller = ScrollController();
StreamSubscription<User>? _sub;
@override
void initState() {
super.initState();
_sub = userStream.listen((u) => setState(() => _user = u));
}
@override
void dispose() {
_sub?.cancel();
_controller.dispose();
super.dispose();
}
}
}
针对 (异步/非同步)更新的 一个额外保护措施
dart
if (!mounted) return; // before setState after an await
- 图片 是你应用中偷偷占用大量内存的东西,很多人都忽略了它。
Flutter 的 ImageCache (图片缓存)采用的是 LRU (最近最少使用)机制,默认情况下最多能存 1,000 张图片 ,总大小约 100 MB。
注意! 这个 100 MB 只是压缩后 的缓存大小;图片解码 成位图(bitmap)后,在内存中会大得多!
举个例子,一张 7,500x5,000 像素 的图片,一旦解码,它在内存中的占用能膨胀到 100+ MB ,直接撑爆 你的内存预算,并可能导致应用不得不重新解码 (re-decodes)或者卡顿(jank)。你必须管理好它。
🛠️ 今天就试试这样做:
- 在加载图片时,提供
cacheWidth或cacheHeight参数。这能让 Flutter 按设备需要的尺寸来解码图片,而不是解码图片的完整尺寸。
dart
Image.network(url, cacheWidth: 1080)
- 如果你的应用有很多图片,就**(应该)调整(图片)**缓存的大小。
dart
void main() {
PaintingBinding.instance.imageCache.maximumSizeBytes = 80 * 1024 * 1024;
runApp(const MyApp());
}
优化实战篇:让列表更高效、消除"神秘"卡顿
📜 列表与内存优化
- 列表创建: 优先使用
ListView.builder,而不是一次性创建巨大的子部件列表 (ListView(children: [...]))。前者只会创建屏幕上可见的部分,大幅节省内存。 - 别滥用
KeepAlive: 避免过度使用AutomaticKeepAliveClientMixin。它会把那些已经滑出屏幕的 Widget "钉"在内存里不释放。
🔑 Keys:防止无谓的重建和"抽搐"
Key 是一个**防止无意中"汰旧换新"**的小细节。
-
问题: 错误或缺失的 Key 会让 Flutter 误以为 组件变了,然后把 Element 扔掉,并重建 你原本想保留的
State。 -
作用: 正确的 Key 能稳定 子树结构,减少 内存分配,避免滚动位置丢失和控制器被重复绑定。
-
用法:
- 需要稳定 ID 时,用
ValueKey。 - 滚动组件需要保存滚动位置时,用
PageStorageKey。 - 别 在每次构建时都用随机(Random)的 Key。
- 需要稳定 ID 时,用
📝 两则来自日志的小故事
1. 2025 年 1 月 14 日 --- 聊天界面的"神秘膨胀"
- 现象: 经过 10 次页面进出(push-pops)后,内存增加了 42 MB ,垃圾回收(GC)每 2 秒就运行一次。内存图表显示
AnimationController实例从未被释放。 - 修复: 释放(
dispose)了两个控制器,并取消了一个StreamSubscription。 - 结果: 内存稳定下来,GC 间隔延长到 12 秒,卡顿帧数从 2.8% 降到 0.4%。
2. 2025 年 4 月 3 日 --- 低端安卓机的引导轮播图
- 现象: 加载 4K PNG 图片时,内存飙升到 410 MB ;图片重复解码导致画面长时间卡顿。
- 修复: 添加了
cacheWidth: 1080(按需解码),并将ImageCache上限设为 80 MB。 - 结果: 内存稳定在 170 MB 左右,零卡顿,滑动流畅。
可选工具:
leak_tracker可以在测试 时帮你找出那些漏掉dispose的对象。在debug/profile模式下启用,及早发现问题。
🛑 大多数人会忽略的 3 个内存"坑"
- 在
build()里做大量耗时工作: 它重复运行的次数比你想象的要多得多。请提前计算 (Precompute)、使用记忆化 (Memoize),或者移到initState里去做。 - 让图片全尺寸解码: 设置解码尺寸到你实际需要的像素大小。你的 GPU 和内存堆(Heap)会感谢你。
- 忘了释放"隐形"对象:
StreamSubscription和Timer造成的内存泄漏,跟控制器一样严重。错误往往晚点才爆发成随机卡顿或崩溃。
⏰ 10 分钟快速行动计划 (现在就做!)
- 打开 DevTools → Memory 视图,在你最卡顿 的屏幕上导航 30 秒 。寻找持续上升 或基线不断抬高的锯齿状内存图。
- 在你的代码库中搜索所有需要释放 的对象:
AnimationController|ScrollController|TextEditingController|FocusNode|StreamSubscription|Timer。确保每个都有对应的dispose()或cancel()。 - 给你最大的几张图片加上
cacheWidth/cacheHeight;如果媒体内容多,就设置ImageCache.maximumSizeBytes上限。 - 把那些超大的
ListView(children: [...])替换成ListView.builder。 - 在稳定的子树构造函数上加上
const,并在需要保持身份稳定性的地方修复缺失的 Key。
🗓️ 本周计划 (3 个重点步骤)
- 性能分析 (Profile): 在
profile模式下,针对两个**"慢"的屏幕,捕捉 30--60 秒的内存和性能时间线。记录 内存峰值**、GC 运行频率 和掉帧百分比。 - 修复泄漏: 用
leak_tracker给一个模块(如聊天或媒体)添加单元测试/Widget 测试。当控制器或订阅在dispose后仍然存活时,让测试失败。 - 调整媒体尺寸: 检查前 10 大 的图片和视频缩略图。添加解码尺寸限制 ,并根据需要调整缓存。重新测量。
✅ 可粘贴的自检清单 (一行版)
性能分析内存 → 搜索需要释放的对象 → 添加/确认
dispose()→ 用cacheWidth/height调整图片大小 → 如有需要,限制ImageCache→ 修复 Keys → 将重度工作移出build()→ 在 DevTools 中重新测量。
🔄 最终总结:带走这个观念
重建 (Rebuilds) 不是敌人。
"泄漏"的状态 (Leaky state)和尺寸过大的媒体资源才是!
您觉得哪个优化点对您当前的项目最实用,需要我提供更多示例代码吗?