老实说,咱们做Flutter开发最头疼的不是写界面,而是App跑着跑着就崩了。
上周咱们组的一个电商App,用户反馈用久了直接闪退。
我打开DevTools一看,内存占用520MB,直接飙红了。

排查下来发现是三个地方的内存泄漏叠加在一起。修完之后稳定在180MB左右,上线一周零崩溃。
今天就跟大家聊聊,Flutter内存占用优化这套活儿,到底怎么干。
一、先用DevTools Memory面板揪出泄漏点
很多咱们排查内存问题的第一反应是"加log看大小",这路子走偏了。
Flutter自带的DevTools Memory面板才是正解,能看到谁在吃内存、吃了多少、吃了多久不释放。
1.1 打开Memory面板
●●●bash flutter run --profile
启动后浏览器自动打开DevTools,点Memory标签。你会看到三个关键指标:
●Heap Size:当前堆内存总量,正常App应该在100-300MB
●External Memory:图片、纹理等外部资源占用,这个最容易爆
●GC Events:垃圾回收频率,如果频繁GC说明内存压力大
1.2 抓快照对比法
这才是排查内存泄漏的核心操作:
1.点"Take Sample"抓一个当前内存快照
2.在App里执行你要测试的操作(比如打开列表页,滚动100条,关闭页面)
3.再点"Take Sample"抓第二个快照
4.点"Diff"对比两个快照
关键看什么? 看# New和# Deleted列。如果某个类在关闭页面后# New持续增长但# Deleted为0,那就是泄漏了。
●●●dart // 泄漏典型案例:StreamSubscription忘记取消
class ProductListPage extends StatefulWidget {
@override
_ProductListPageState createState() => _ProductListPageState();
}
class _ProductListPageState extends State<ProductListPage> {
late StreamSubscription _subscription;
@override
voidinitState() {
super.initState();
_subscription = productStream.listen((data) {
setState(() {
_products = data;
});
});
// 问题:页面关闭后,这个subscription还在监听!
// 每次打开新页面就新建一个,旧的永远不释放
}
@override
voiddispose() {
_subscription.cancel(); // 加上这行就修好了
super.dispose();
}
}
上面的代码看着简单,但咱们在真实项目里太容易漏掉dispose了。
特别是页面嵌套深的时候,根本记不清哪个Controller还没关。
二、图片缓存:内存占用的头号杀手
DevTools External Memory那一栏爆红,十有八九是图片搞的。
Flutter默认的图片缓存没有上限,你滑1000张图它就缓存1000张,内存不爆才怪。
2.1 限制ImageCache大小
直接在全局配置里把缓存上限钉死:
●●●dart voidmain() {
// 图片缓存上限:500张,50MB
PaintingBinding.instance.imageCache.maximumSize = 500;
PaintingBinding.instance.imageCache.maximumSizeBytes = 50 << 20; // 50MB
runApp(constMyApp());
}
这两个参数什么意思?
●maximumSize:最多缓存的图片张数,超出后LRU淘汰
●maximumSizeBytes:缓存总大小上限,单位字节,50 << 20就是50MB
实测效果:一个商品列表页有200张图,不限制缓存时External Memory占280MB,限制后降到45MB。
2.2 列表图片的正确打开方式
用cached_network_image这个包,自带磁盘缓存+内存缓存双重保护:
●●●yaml # pubspec.yaml
dependencies:
cached_network_image: ^3.3.1
●●●dart import'package:cached_network_image/cached_network_image.dart';
import'package:flutter/material.dart';
class ProductImage extends StatelessWidget {
final String imageUrl;
constProductImage({super.key, required this.imageUrl});
@override
Widget build(BuildContext context) {
returnCachedNetworkImage(
imageUrl: imageUrl,
width: 200,
height: 200,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey[200],
child: constCenter(child: CircularProgressIndicator(strokeWidth: 2)),
),
errorWidget: (context, url, error) => constIcon(Icons.error, size: 40),
// 关键:给每个图片分配内存缓存上限,防止单张大图占满
memCacheWidth: 400, // 内存中宽度上限
memCacheHeight: 400, // 内存中高度上限
);
}
}
memCacheWidth和memCacheHeight很多人不知道。
它们的作用是:图片加载到内存时,超过这个尺寸就先缩放到指定大小再缓存。
你手机屏幕就那么大,缓存一张4000x3000的原图纯属浪费。
三、Controller生命周期:关不掉的内存黑洞
AnimationController、TextEditingController、ScrollController……这些玩意儿创建的时候不占多少内存,但你不dispose,它们就一直在后台挂着。
3.1 一个真实的泄漏案例
咱们之前有个页面,里面嵌套了3个Tab,每个Tab里都有ListView加ScrollController加AnimationController。
用户来回切换几次后,内存直接涨了80MB。
问题出在Tab切换时,旧的Widget被标记为deactivated但没有真正dispose,Controller全在后台活着。
修复方案:
●●●dart import'package:flutter/material.dart';
class DetailPage extends StatefulWidget {
constDetailPage({super.key});
@override
State<DetailPage> createState() => _DetailPageState();
}
class _DetailPageState extends State<DetailPage>
with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
late final ScrollController _scrollController;
final TextEditingController _searchController = TextEditingController();
@override
voidinitState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: constDuration(milliseconds: 300),
);
_scrollController = ScrollController();
// 监听滚动做动画
_scrollController.addListener(_onScroll);
}
void_onScroll() {
final offset = _scrollController.offset;
if (offset > 100 && _animationController.status != AnimationStatus.completed) {
_animationController.forward();
} elseif (offset <= 100 && _animationController.status != AnimationStatus.dismissed) {
_animationController.reverse();
}
}
@override
voiddispose() {
// 按创建的反顺序释放,养成习惯
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_animationController.dispose();
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
returnScaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
SliverAppBar(
expandedHeight: 200.0,
flexibleSpace: FlexibleSpaceBar(
title: AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
returnOpacity(
opacity: _animationController.value,
child: constText('商品详情'),
);
},
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text('商品项 $index')),
childCount: 50,
),
),
],
),
);
}
}
这里有个坑要提醒大家:SingleTickerProviderStateMixin只能管一个AnimationController。
如果你一个页面有多个Controller,得换成TickerProviderStateMixin。之前就有同事因为这个报了个"multiple tickers"的错,查了半天。
3.2 自动释放的工具方法
如果你嫌手动dispose太容易忘,可以搞一个Mixin统一管理:
●●●dart mixin AutoDisposeMixin<T extends StatefulWidget> on State<T> {
final List<VoidCallback> _disposers = [];
/// 注册一个dispose回调,页面销毁时自动执行
voidregisterDispose(VoidCallback callback) {
_disposers.add(callback);
}
@override
voiddispose() {
for (final disposer in _disposers) {
disposer();
}
_disposers.clear();
super.dispose();
}
}
// 使用示例
class MyPageState extends State<MyPage> with AutoDisposeMixin {
late StreamSubscription _sub;
@override
voidinitState() {
super.initState();
_sub = someStream.listen(_handleData);
registerDispose(() => _sub.cancel()); // 自动注册,不用在dispose里再写
}
}
这个Mixin的好处是,你在initState里创建资源的同时就注册了释放逻辑,不会忘。
四、验证效果:内存优化前后对比
修完上面三步,咱们用同一套方法再测一遍:
内存指标对比:
性能指标对比:
最关键的是,上线一周OOM崩溃率从2.3%降到了0.1%,基本清零。
老实说,内存优化这事儿没有银弹。核心就三点:
1.用工具说话——DevTools Memory面板抓快照对比,别靠猜
2.限制缓存上限——图片和数据都不能无限存
3.生命周期闭环——创建了就得dispose,养成肌肉记忆
咱们做性能优化,最怕的是"感觉不卡了"这种主观判断。
数据摆在那,Heap Size从320MB降到160MB,这就是实打实的提升。
你的Flutter App内存占用现在是多少?有没有遇到过奇怪的OOM崩溃?在评论区聊聊你的排查经历,咱们一起交流。
夜雨聆风