Flutter Sliver协议与自定义滚动—高性能列表实现|新宇宙博客Back to listFlutter Sliver 协议与自定义滚动深度解析
Site Owner
Published on 2026-05-22
详解Flutter Sliver协议设计、Viewport与Scrollable协作、SliverConstraints/Geometry及高性能列表优化
前言
ListView、GridView 足以应付 80% 的滚动场景,但当你需要:
- 滚动时 AppBar 逐渐折叠并吸顶
- 多种列表混合(轮播图 + 分类头 + 商品网格 + 推荐列表)
- 滑动时某个区域固定,其余滚动
- 自定义滚动弹性和物理效果
这时就需要深入理解 Sliver 协议——Flutter 滚动系统的核心设计。
一、Sliver 是什么?
普通布局(Box 协议)是一次性布局所有子节点。Sliver 协议是一种按需布局的协议:
滚动视口(Viewport)只告诉 Sliver "你的约束是什么,当前滚动偏移是多少",Sliver 根据约束只布局可见范围内的内容。
这就是为什么 100 万条数据的 ListView.builder 不会卡死——因为它实现了 Sliver 协议,只有可见的项目才会被布局和绘制。
二、Sliver 协议的核心数据结构
2.1 SliverConstraints(约束向下)
class SliverConstraints {
final AxisDirection axisDirection; // 主轴方向(向下/向上/向左/向右)
final GrowthDirection growthDirection; // 内容增长方向
final double scrollOffset; // 当前滚动偏移(相对于 Sliver 起点)
final double overlap; // 当前 Sliver 被前一个 Sliver 遮挡的量
final double remainingPaintExtent; // 剩余可绘制区域的尺寸
final double crossAxisExtent; // 交叉轴尺寸(如垂直滚动的宽度)
final double viewportMainAxisExtent; // 视口主轴尺寸(屏幕高度)
// ...
}
| 字段 | 含义 | 典型值 |
|---|
scrollOffset | Sliver 的起点已被滚走了多少 | 列表顶部已滚走 100px → 第一个可见 Sliver 的 scrollOffset = 100 |
remainingPaintExtent | 从当前位置到视口底部还有多少空间 | 屏幕高度 800 - 已占用 200 = 600 |
overlap | 前一个 Sliver(如吸顶 AppBar)遮住了多少 | AppBar 高度 56 |
viewportMainAxisExtent | 整个视口的主轴尺寸 | 屏幕高度 800 |
2.2 SliverGeometry(尺寸向上)
class SliverGeometry {
final double scrollExtent; // Sliver 在滚动轴上的总长度(影响滚动范围)
final double paintExtent; // 实际绘制的长度(当前可见部分)
final double layoutExtent; // 布局占用的长度(通常等于 paintExtent)
final double maxPaintExtent; // 最大可绘制长度
final double hitTestExtent; // 命中测试范围
final bool visible; // 是否可见
final double maxScrollObstructionExtent; // 可遮挡滚动的最大量(用于悬浮头部)
// ...
}
CustomScrollView 是 Sliver 的容器,接受一个 slivers 列表:
CustomScrollView(
slivers: [
// 1. 可折叠的 AppBar
SliverAppBar(
expandedHeight: 200,
floating: false,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: const Text('Flutter Slivers'),
background: Image.network('...', fit: BoxFit.cover),
),
),
// 2. 固定标题
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('推荐', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
),
),
// 3. 网格
SliverGrid(
delegate: SliverChildBuilderDelegate(
(context, index) => Card(child: Center(child: Text('$index'))),
childCount: 6,
),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1.5,
),
),
// 4. 列表
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text('Item $index')),
childCount: 20,
),
),
// 5. 底部间距
const SliverPadding(padding: EdgeInsets.only(bottom: 20)),
],
)
4.1 SliverAppBar
SliverAppBar(
// 展开高度(flexibleSpace 的高度)
expandedHeight: 250,
// floating: true → 向下滚动时立即出现(哪怕没滚到顶部)
floating: true,
// pinned: true → 始终吸顶,不会完全消失
pinned: true,
// snap: true → 配合 floating,手势停止后自动完整显示或隐藏
snap: true, // snap 需要 floating: true
// stretch: true → 过度滚动时拉伸背景图
stretch: true,
flexibleSpace: FlexibleSpaceBar(
title: const Text('Title'),
background: Image.asset('assets/header.jpg', fit: BoxFit.cover),
collapseMode: CollapseMode.parallax, // 视差效果
stretchModes: [StretchMode.zoomBackground], // 拉伸时放大背景
),
)
class _StickyHeader extends SliverPersistentHeaderDelegate {
final String title;
_StickyHeader({required this.title});
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
// shrinkOffset: 0(完全展开)到 maxExtent - minExtent(完全折叠)
final progress = shrinkOffset / (maxExtent - minExtent);
return Container(
color: Color.lerp(Colors.transparent, Colors.white, progress),
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
title,
style: TextStyle(
color: Color.lerp(Colors.white, Colors.black, progress),
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
);
}
@override
double get maxExtent => 120; // 完全展开时的高度
@override
double get minExtent => 56; // 折叠后的最小高度(吸顶时)
@override
bool shouldRebuild(_StickyHeader oldDelegate) => title != oldDelegate.title;
}
// 使用:
SliverPersistentHeader(
delegate: _StickyHeader(title: '分类'),
pinned: true, // 吸顶
floating: false,
)
4.3 SliverFillRemaining
// 填充剩余空间(适合做"底部内容")
SliverFillRemaining(
hasScrollBody: false, // false = 内容不可滚动
child: Center(
child: Text('没有更多内容了'),
),
)
4.4 SliverFillViewport
// 每个子节点填满整个视口(适合做横向 PageView 效果)
SliverFillViewport(
delegate: SliverChildBuilderDelegate(
(context, index) => Container(color: Colors.primaries[index % Colors.primaries.length]),
childCount: 5,
),
)
当需要 AppBar 折叠 + 内部 TabBar + 每个 Tab 独立滚动时,使用 NestedScrollView:
NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
// innerBoxIsScrolled: 内部列表是否已经开始滚动
return [
SliverAppBar(
title: const Text('NestedScrollView'),
pinned: true,
floating: true,
forceElevated: innerBoxIsScrolled, // 内部滚动时显示阴影
expandedHeight: 200,
flexibleSpace: FlexibleSpaceBar(
background: Image.asset('assets/header.jpg', fit: BoxFit.cover),
),
),
SliverPersistentHeader(
delegate: _TabBarDelegate(
TabBar(
tabs: const [Tab(text: '推荐'), Tab(text: '最新'), Tab(text: '热门')],
),
),
pinned: true,
),
];
},
body: TabBarView(
children: [
// 每个 Tab 的列表
ListView.builder(
// ⚠️ 关键:必须指定 physics,让 NestedScrollView 接管滚动
physics: const ClampingScrollPhysics(),
itemBuilder: (context, index) => ListTile(title: Text('推荐 $index')),
itemCount: 30,
),
ListView.builder(
physics: const ClampingScrollPhysics(),
itemBuilder: (context, index) => ListTile(title: Text('最新 $index')),
itemCount: 30,
),
ListView.builder(
physics: const ClampingScrollPhysics(),
itemBuilder: (context, index) => ListTile(title: Text('热门 $index')),
itemCount: 30,
),
],
),
)
class _TabBarDelegate extends SliverPersistentHeaderDelegate {
final TabBar tabBar;
_TabBarDelegate(this.tabBar);
@override
Widget build(context, shrinkOffset, overlapsContent) {
return Container(color: Colors.white, child: tabBar);
}
@override
double get maxExtent => tabBar.preferredSize.height;
@override
double get minExtent => tabBar.preferredSize.height;
@override
bool shouldRebuild(_TabBarDelegate oldDelegate) => false;
}
// 内置物理效果:
// ClampingScrollPhysics → Android 风格(到端点时硬止)
// BouncingScrollPhysics → iOS 风格(弹性回弹)
// NeverScrollableScrollPhysics → 禁止滚动
// 自定义:禁止向上滚动(只能向下滚动)
class OnlyDownScrollPhysics extends ScrollPhysics {
const OnlyDownScrollPhysics({super.parent});
@override
OnlyDownScrollPhysics applyTo(ScrollPhysics? ancestor) {
return OnlyDownScrollPhysics(parent: buildParent(ancestor));
}
@override
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
// offset > 0 表示向上滚动(内容向上)
if (offset > 0) return 0; // 禁止向上滚动
return super.applyPhysicsToUserOffset(position, offset);
}
}
// 自定义分页吸附
class PageSnapScrollPhysics extends ScrollPhysics {
final double pageHeight;
const PageSnapScrollPhysics({required this.pageHeight, super.parent});
@override
PageSnapScrollPhysics applyTo(ScrollPhysics? ancestor) {
return PageSnapScrollPhysics(
pageHeight: pageHeight,
parent: buildParent(ancestor),
);
}
@override
Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) {
// 计算最近的吸附点
final currentPage = position.pixels / pageHeight;
double targetPage;
if (velocity > 0) {
targetPage = currentPage.ceil().toDouble();
} else if (velocity < 0) {
targetPage = currentPage.floor().toDouble();
} else {
targetPage = currentPage.round().toDouble();
}
final targetPixels = targetPage * pageHeight;
if (targetPixels == position.pixels) return null;
return ScrollSpringSimulation(
const SpringDescription(mass: 0.5, stiffness: 100, damping: 1),
position.pixels,
targetPixels,
velocity,
);
}
}
实现一个"标签云" Sliver,将标签按行排列,超出视口的不布局:
class SliverTagCloud extends SingleChildRenderObjectWidget {
final List<String> tags;
final double spacing;
const SliverTagCloud({
super.key,
required this.tags,
this.spacing = 8,
});
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderSliverTagCloud(tags: tags, spacing: spacing);
}
@override
void updateRenderObject(BuildContext context, _RenderSliverTagCloud renderObject) {
renderObject
..tags = tags
..spacing = spacing;
}
}
class _RenderSliverTagCloud extends RenderSliver {
List<String> _tags;
double _spacing;
_RenderSliverTagCloud({required List<String> tags, required double spacing})
: _tags = tags,
_spacing = spacing;
set tags(List<String> value) {
if (_tags == value) return;
_tags = value;
markNeedsLayout();
}
set spacing(double value) {
if (_spacing == value) return;
_spacing = value;
markNeedsLayout();
}
@override
void performLayout() {
final constraint = constraints;
final crossAxisExtent = constraint.crossAxisExtent;
// 简化实现:根据标签数量估算总高度
// 实际项目中需要精确计算每行高度
const tagHeight = 32.0;
const tagsPerRow = 4;
final rows = (_tags.length / tagsPerRow).ceil();
final totalHeight = rows * (tagHeight + _spacing);
geometry = SliverGeometry(
scrollExtent: totalHeight,
paintExtent: totalHeight.clamp(0, constraint.remainingPaintExtent),
maxPaintExtent: totalHeight,
);
}
@override
void paint(PaintingContext context, Offset offset) {
// 根据 scrollOffset 决定从哪里开始绘制
// 实际绘制省略...
}
}
八、实战:带吸顶分组头的联系人列表
class ContactsPage extends StatelessWidget {
final Map<String, List<String>> contacts; // A -> [Alice, Alex], B -> [Bob]...
const ContactsPage({super.key, required this.contacts});
@override
Widget build(BuildContext context) {
final sortedKeys = contacts.keys.toList()..sort();
return CustomScrollView(
slivers: [
const SliverAppBar(
title: Text('联系人'),
floating: true,
pinned: true,
),
for (final key in sortedKeys) ...[
// 分组头(吸顶)
SliverPersistentHeader(
pinned: true,
delegate: _GroupHeaderDelegate(title: key),
),
// 该组的联系人列表
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final contact = contacts[key]![index];
return ListTile(
leading: CircleAvatar(child: Text(contact[0])),
title: Text(contact),
);
},
childCount: contacts[key]!.length,
),
),
],
],
);
}
}
class _GroupHeaderDelegate extends SliverPersistentHeaderDelegate {
final String title;
_GroupHeaderDelegate({required this.title});
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return Container(
color: Colors.grey.shade100,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.grey,
),
),
);
}
@override
double get maxExtent => 36;
@override
double get minExtent => 36;
@override
bool shouldRebuild(_GroupHeaderDelegate oldDelegate) => title != oldDelegate.title;
}
九、SliverConstraints 关键字段详解
// 当用户滚动到 200px 时,前三个 Sliver 的情形(假设每个 100px):
// Sliver 1(已完全滚出视口):
// scrollOffset = 200(Sliver1 整体已滚走 200px,超过自身 100px)
// remainingPaintExtent = 0(视口中没有它的空间了)
// → SliverGeometry.paintExtent = 0(不绘制)
// Sliver 2(部分可见):
// scrollOffset = 100(Sliver2 起点已滚走 100px,但自身只有 100px)
// remainingPaintExtent = 视口高度(Sliver2 在视口顶部,剩余空间 = 整个视口)
// → SliverGeometry.paintExtent = 0(scrollOffset >= scrollExtent,也不绘制)
// 注:实际上 Viewport 会跳过这个 Sliver
// Sliver 3(第一个可见的 Sliver,scrollOffset = 0 ~ 100):
// scrollOffset = 0(还未被滚走)
// remainingPaintExtent = 视口高度(它从视口顶部开始)
void _examplePerformLayout() {
final double paintExtent = calculatePaintExtent(
scrollOffset: constraints.scrollOffset,
remainingPaintExtent: constraints.remainingPaintExtent,
);
geometry = SliverGeometry(
scrollExtent: _totalHeight, // 影响总滚动范围
paintExtent: paintExtent, // 实际绘制多少
layoutExtent: paintExtent, // 给下一个 Sliver 留多少位置
maxPaintExtent: _totalHeight,
maxScrollObstructionExtent: _pinnedHeight, // 固定头部高度
);
}
double calculatePaintExtent({
required double scrollOffset,
required double remainingPaintExtent,
}) {
// 已经滚过的部分不绘制
final paintedExtent = _totalHeight - scrollOffset;
// 不超过剩余可绘制区域
return paintedExtent.clamp(0, remainingPaintExtent);
}
十、性能优化:Sliver vs Box 的本质差异
| 场景 | ListView(Sliver) | Column + SingleChildScrollView(Box) |
|---|
| 1000 条数据 | ✅ 只布局可见项,O(visible) | ❌ 布局所有项,O(1000) |
| 内存占用 | ✅ 屏幕外的 Widget 被回收 | ❌ 所有 Widget 常驻内存 |
| 首次加载 | ✅ 快速 | ❌ 慢 |
| 简单少量数据 | 稍复杂 | ✅ 简单直接 |
过关自检
Q:SliverConstraints 中 scrollOffset、remainingPaintExtent、overlap 分别是什么含义?
-
scrollOffset:当前 Sliver 的"起始边"已经被滚动视口滚走了多少像素。比如列表顶部被滚走了 200px,第一个可见 Sliver 的 scrollOffset 就是它相对于视口顶部被遮住的量。Sliver 需要根据 scrollOffset 跳过已滚走的内容,只布局可见部分。
-
remainingPaintExtent:视口中从当前 Sliver 开始往下(主轴方向)还剩多少可以绘制的空间。前面的 Sliver 占用了视口的一部分,剩余的空间传给后续 Sliver。Sliver 的 paintExtent 不能超过这个值。
-
overlap:当前 Sliver 被前面某个吸顶 Sliver(如 pinned: true 的 SliverAppBar)遮挡的像素数。比如吸顶 AppBar 高 56px,则当前 Sliver 的 overlap = 56。SliverToBoxAdapter 的子节点需要根据这个值调整自己的 padding,避免内容被遮挡。
小结
| 概念 | 要点 |
|---|
| Sliver 协议 | 按需布局,O(visible),解决大列表性能 |
| SliverConstraints | 约束向下:scrollOffset + remainingPaintExtent + overlap |
| SliverGeometry | 尺寸向上:scrollExtent + paintExtent + layoutExtent |
| CustomScrollView | Sliver 容器,组合各种 Sliver |
| SliverAppBar | 可折叠/吸顶 AppBar,floating/pinned/snap |
| SliverPersistentHeader | 自定义吸顶组件,实现分组头等效果 |
| NestedScrollView | 嵌套滚动协调,Header + TabBar + 独立 Tab 列表 |
| ScrollPhysics | 自定义滚动物理(弹性、吸附等) |