列表与滚动:ListView、GridView 与 Sliver
列表是移动端最核心的交互载体,也最容易出性能问题。Flutter 提供了从简到繁的四层工具:SingleChildScrollView、ListView、GridView、CustomScrollView + Sliver。选错层次就会出现"一次性构建上千个 Widget"或"整页只有列表能滚动"两类典型故障。
先搞清性能差异
| 组件 | 构建方式 | 适用数据量 | 内存特征 |
|---|---|---|---|
SingleChildScrollView + Column | 一次性构建全部子级 | 10 项以内 | 数据越多内存越高 |
ListView(children: [...]) | 一次性构建全部子级 | 20 项以内 | 同上 |
ListView.builder | 按需构建可见项 | 千项以上 | 恒定 |
ListView.separated | 按需构建 + 分隔线 | 需分割线的长列表 | 恒定 |
GridView.builder | 按需构建网格项 | 图片墙、商品宫格 | 恒定 |
只要列表长度不确定或可能超过一屏,就用 builder 写法(ListView.builder/ListView.separated);SingleChildScrollView 只留给"表单 + 少量内容",它在构建时就创建全部子级,1000 条数据必然掉帧。
ListView 的三种常用形态
itemExtent 用于固定行高,框架无需逐项测量即可算出滚动位置,长列表建议给上。
// 1. 数据少可直接列子级
ListView(children: const [ListTile(title: Text('第一项'))])
// 2. 长列表标准写法
ListView.builder(itemCount: 200, itemBuilder: (context, i) => ListTile(title: Text('第 $i 条')))
// 3. 带分隔线
ListView.separated(
itemCount: 50,
separatorBuilder: (context, i) => const Divider(height: 1),
itemBuilder: (context, i) => ListTile(title: Text('示范 $i')),
)
GridView
GridView.count 直接指定列数,适合固定数量的宫格入口;数据来自接口时用 GridView.builder 配 SliverGridDelegateWithFixedCrossAxisCount,按需构建。
GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, mainAxisSpacing: 12, crossAxisSpacing: 12,
childAspectRatio: 3 / 4, // 宽高比算错会让格子内部溢出
),
itemCount: 40,
itemBuilder: (context, index) => Image.network(
'https://picsum.photos/seed/$index/300/400',
fit: BoxFit.cover,
),
)
CustomScrollView 与 Sliver
需要"可折叠头部 + 列表 + 网格"参与同一套滚动时,用 CustomScrollView,它的子级必须是 Sliver。
| Sliver 组件 | 对应普通组件 | 说明 |
|---|---|---|
SliverAppBar | AppBar | 可折叠,支持 floating/pinned/snap |
SliverList | ListView | 配 SliverChildBuilderDelegate 按需构建 |
SliverGrid | GridView | 同上,需给 gridDelegate |
CustomScrollView(
slivers: [
const SliverAppBar(
pinned: true, // 滚到顶后固定在顶部
expandedHeight: 200, // 展开高度
flexibleSpace: FlexibleSpaceBar(title: Text('商品列表')),
),
const SliverToBoxAdapter(child: Padding(padding: EdgeInsets.all(16), child: Text('分类筛选'))),
SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3),
delegate: SliverChildBuilderDelegate(
(context, i) => Center(child: Text('$i')),
childCount: 12,
),
),
],
)
下拉刷新与上拉加载
class _FeedPageState extends State<FeedPage> {
// 监听滚动位置:距底 200 像素就预加载,避免用户看到"卡住"
final _controller = ScrollController()
..addListener(() {
final p = _controller.position;
if (p.pixels >= p.maxScrollExtent - 200 && !_loading) _loadMore();
});
final List<String> _items = List.generate(20, (i) => '数据 $i');
bool _loading = false;
Future<void> _loadMore() async {
setState(() => _loading = true);
await Future<void>.delayed(const Duration(seconds: 1)); // 换成真实分页请求
if (!mounted) return; // 页面已销毁就不再 setState
setState(() {
_items.addAll(List.generate(10, (i) => '新增 ${_items.length + i}'));
_loading = false;
});
}
@override
void dispose() {
_controller.dispose(); // 不释放会内存泄漏
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
body: RefreshIndicator(
onRefresh: () async => setState(() => _items.shuffle()), // 下拉刷新
child: ListView.builder(
controller: _controller,
physics: const AlwaysScrollableScrollPhysics(), // 不满一屏也能下拉
itemCount: _items.length + (_loading ? 1 : 0),
itemBuilder: (context, i) => i >= _items.length
? const Center(child: CircularProgressIndicator())
: ListTile(title: Text(_items[i])),
),
),
);
}
列表性能要点
| 要点 | 原因 | 做法 |
|---|---|---|
不在 itemBuilder 里做重计算 | 每帧都可能被调用 | 提前算好放进模型 |
| 图片限制解码尺寸 | 大图解码吃内存 | 用 cacheWidth/cacheHeight 或缩略图 |
静态部分用 const | 减少重建 | 图标、间距、固定行 |
flutter run --profile # 用 DevTools 的 Performance 看滚动帧率
小结:长列表一律用 ListView.builder 或 SliverList,网格用 GridView.builder;需要折叠头部、多段混排时上 CustomScrollView + Sliver;下拉刷新用 RefreshIndicator、上拉加载用 ScrollController 阈值判断,并记得在 dispose 里释放 Controller。