Flutter 列表页卡顿,问题往往不在渲染,在重建
Flutter 列表页卡顿,问题往往不在渲染,在重建
Flutter 列表页卡顿,刚入手的开发者第一反应往往是"Flutter 渲染太慢"或者"手机性能不够"。
实际上 Flutter 处理几千条列表数据绑绑有余。大多数卡顿问题,根本原因是页面在反复重建,而不是渲染吃不消。
一个典型的慢列表
页面大概长这样:
- 聊天消息列表
- 订单商品列表
- 动态信息流
初版写法很朴素:
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ChatBubble(
message: items[index],
onTap: () => _handleTap(items[index]),
);
},
)
数据量上来之后开始卡。再看 ChatBubble:
class ChatBubble extends StatelessWidget {
final Message message;
final VoidCallback onTap;
const ChatBubble({
super.key,
required this.message,
required this.onTap,
});
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: EdgeInsets.all(12),
child: Row(
children: [
Avatar(url: message.avatar),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(message.nickname),
SizedBox(height: 4),
Text(message.content),
],
),
),
Text(message.time),
],
),
),
);
}
}
看起来也没问题,StatelessWidget,build 逻辑也不复杂。
但只要父页面有任何状态变化,整个列表就会跟着重建。
为什么卡
原因 1:父组件状态变化导致整页重建
比如页面顶部有个筛选按钮,选中状态用 setState 管理:
class ChatListPage extends StatefulWidget {
State<ChatListPage> createState() => _ChatListPageState();
}
class _ChatListPageState extends State<ChatListPage> {
String _filterType = 'all';
Widget build(BuildContext context) {
return Column(
children: [
FilterChips(
selected: _filterType,
onChanged: (v) => setState(() => _filterType = v),
),
Expanded(
child: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return ChatBubble(
message: items[index],
onTap: () => _handleTap(items[index]),
);
},
),
),
],
);
}
}
每次切换筛选,_filterType 变化,触发 _ChatListPageState.build(),进而触发 ListView.builder。
ListView.builder 虽然只渲染可见项,但每次 build 都会创建新的 ChatBubble widget 实例。Flutter 需要对比新旧 widget 树来判断是否需要更新。这个比对本身就有开销,如果列表项组件本身比较复杂,卡顿会非常明显。
原因 2:ItemBuilder 里直接调父组件方法
itemBuilder: (context, index) {
return ChatBubble(
message: items[index],
onTap: () => _handleTap(items[index]), // 每次build都创建新闭包
);
},
闭包会捕获 _ChatListPageState,只要父组件 rebuild,onTap 这个参数就变成一个新的匿名函数对象。虽然实际逻辑没变,但 Flutter 的 Widget.canUpdate 会认为这是个新 widget,触发更新。
原因 3:列表项包含频繁变化的状态
比如消息列表里有已读未读状态、发送中状态:
itemBuilder: (context, index) {
return ChatBubble(
message: items[index],
isSending: _sendingIds.contains(items[index].id), // 列表外状态
isRead: _readIds.contains(items[index].id), // 列表外状态
);
},
只要 _sendingIds 或 _readIds 变化,即使列表数据本身没变,整个列表也会被迫重建。
先定位:到底是重建还是渲染慢
Flutter DevTools 的 Performance 视图能清晰展示这一点:
- 如果
FrameRendering 阶段很长,说明是渲染问题 - 如果 Widget Rebuild 阶段很长,说明是重建问题
大多数场合下,你看到的卡顿是后者。
快速自检:把列表项 const 化,看卡顿是否有改善。
itemBuilder: (context, index) {
return const ChatBubble(
message: null, // 只是测试,实际不可行
);
},
如果改 const 之后流畅度明显提升,说明根因在重建。
解决方式 1:用 const 构造减少重建
widget 的构造如果能走 const,Flutter 可以在重建时直接复用实例。
class ChatBubble extends StatelessWidget {
final Message message;
final VoidCallback onTap;
const ChatBubble({
super.key,
required this.message,
required this.onTap,
});
只要 message 和 onTap 没变,Flutter 会认为这个 widget 和上次一样,跳过更新。
但 onTap 这个闭包每次 build 都是新创建的,所以上面的写法还不够。
解决方式 2:用 RepaintBoundary 隔离渲染区域
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return RepaintBoundary(
child: ChatBubble(
message: items[index],
onTap: () => _handleTap(items[index]),
),
);
},
)
RepaintBoundary 的作用是把这个区域和父组件的重建隔离开。父组件 rebuild 时,只要 ChatBubble 自身不需要更新,Flutter 就不需要重绘这一块。
消息列表这类场景,RepaintBoundary 效果通常很明显。
解决方式 3:列表项状态提到列表外,用 itemExtent 优化
如果列表项高度固定,可以指定 itemExtent,省去 Flutter 计算高度的开销:
ListView.builder(
itemCount: items.length,
itemExtent: 72, // 每个列表项固定高度
itemBuilder: (context, index) {
return ChatBubble(
message: items[index],
onTap: () => _handleTap(items[index]),
);
},
)
这个优化对长列表效果显著,尤其在快速滚动时。
解决方式 4:用 GlobalKey 缓存列表项状态
对于聊天列表这种场景,消息状态(发送中、已发送、失败)需要在列表项内部管理,但父组件又需要能更新这些状态。
常见的做法是给列表项加 GlobalKey,通过 key 直接操作子组件状态:
class ChatBubbleState extends State<ChatBubble> {
bool isSending = false;
void setSending(bool sending) {
setState(() => isSending = sending);
}
}
class ChatListPageState extends State<ChatListPage> {
final Map<String, GlobalKey<ChatBubbleState>> _itemKeys = {};
GlobalKey<ChatBubbleState> _getKey(Message msg) {
return _itemKeys.putIfAbsent(msg.id, () => GlobalKey());
}
void _onSendResult(String msgId, bool success) {
final key = _itemKeys[msgId];
if (key?.currentState != null) {
key!.currentState!.setSending(!success);
}
}
}
这样列表项内部状态变化不会触发父组件重建。父组件只需要在发送结果返回时,通过 key 通知对应列表项更新。
解决方式 5:大列表考虑 ListView.custom 或 SliverList
如果列表数据量很大(超过几百条),标准 ListView.builder 仍然会有压力。可以考虑用 SliverList 配合 SliverChildBuilderDelegate,并设置 addAutomaticKeepAlives: false 和 addRepaintBoundaries: false 来进一步减少开销:
return CustomScrollView(
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ChatBubble(
message: items[index],
onTap: () => _handleTap(items[index]),
),
childCount: items.length,
addAutomaticKeepAlives: false,
addRepaintBoundaries: false,
),
),
],
)
但这要谨慎用。只有在列表项本身很轻量且没有状态依赖时才能这么做。
解决方式 6:分页数据用 ListView.builder 的正确姿势
加载更多数据时,不要直接往现有列表头部插入数据:
// 错误做法
items.insertAll(0, newMessages);
setState(() {});
这会导致整个列表重建。正确做法是管理两个列表:
List<Message> _newMessages = [];
List<Message> _oldMessages = [];
void loadMore() {
_newMessages.addAll(newData);
setState(() {
_newMessages = List.from(_newMessages);
});
}
或者用 AnimatedList 配合分页,让新数据的加入有明确的可动画边界。
最后总结
Flutter 列表卡顿,大多数时候是这几个问题叠加:
- 父组件状态变化导致整页 rebuild
- ItemBuilder 里创建了新的闭包或对象
- 列表项依赖了外部状态但没有隔离
- 列表项没有声明
const构造 - 没有用
RepaintBoundary隔离渲染区域
性能优化的顺序通常是:
- 先用 DevTools 确认是重建问题还是渲染问题
- 加
RepaintBoundary,成本最低效果最明显 - 用
const构造和稳定 keys 减少无效重建 - 考虑
itemExtent优化滚动性能 - 大列表考虑 Sliver 方案
把这些检查项过一遍,大多数 Flutter 列表卡顿问题都能找到根因并解决。
