资讯详情 Flutter ListView 垂直与水平滚动实战:性能优化与嵌套冲突解决
📅 2026/10/21 13:12:15
1. 从一个“列表卡顿”的深夜排查说起做移动端开发的人几乎都绕不开一个东西列表。不管是商品流、聊天记录、设置项还是朋友圈那种图文混排本质上都是一个可以滚动的长条容器。我在早期做一个内容聚合类应用时首页要展示几百条图文卡片当时图省事直接用一个Column套SingleChildScrollView把数据全渲染出来。结果测试机上一跑滑动像拖着沙袋内存曲线一路往上飙低端机上直接白屏。后来把整个首页改成ListView.builder同样的数据量滑动瞬间顺滑内存也稳住了。那次之后我才真正意识到ListView不是一个“能滚动就行”的组件它是 Flutter 里处理长列表的核心武器用得好和用得对完全是两码事。这篇内容就围绕Flutter 开发中 ListView 的垂直滚动与水平滚动展开。我会把ListView的构造方式、滚动方向控制、性能优化、嵌套滚动冲突、滚动监听与控制器这些实战里绕不开的点全部拆开讲清楚。适合已经能写基础 Flutter 页面、但对列表性能和滚动机制还停留在“能用”阶段的开发者。看完之后你至少能做到三件事知道什么时候该用哪种ListView构造函数、能自己控制垂直和水平滚动方向、遇到嵌套滚动和卡顿时有明确的排查方向。ListView在 Flutter 里属于ScrollView家族和SingleChildScrollView、CustomScrollView、GridView是亲戚。它的核心职责是把一堆子组件放进一个可滚动的视口里并且只构建“当前可见区域附近”的那部分。这个“按需构建”是它和Column SingleChildScrollView最本质的区别也是性能差异的根源。很多人第一次接触ListView时会被它那一堆构造函数搞晕ListView()、ListView.builder()、ListView.separated()、ListView.custom()到底什么时候用哪个垂直和水平滚动又是靠什么参数切换的这些我都会在下面逐个讲透。2. ListView 的四种构造方式与选型逻辑2.1 默认构造函数适合少量、固定的子项ListView()这个默认构造函数接收的是一个children列表也就是说所有子组件在构建时就已经全部创建好了。它的写法最直观ListView( children: [ ListTile(title: Text(第一项)), ListTile(title: Text(第二项)), ListTile(title: Text(第三项)), ], )这种写法适合子项数量少、且基本固定的场景比如设置页面的几个选项、个人中心的几个入口。它的优点是代码简单、可读性高。但缺点也很明显所有子项都会被一次性构建如果列表有几百上千条内存和首帧时间都会爆炸。我个人的经验是子项数量在 20 条以内、且不涉及动态增删时用默认构造函数完全没问题一旦超过这个量级就该考虑builder了。这里有个容易被忽略的点即使你用默认构造函数ListView依然只会渲染可见区域不可见的子项虽然被创建了 widget 对象但不会真正参与布局和绘制。不过 widget 对象的创建本身也是有成本的数据量大时这个成本不可忽视。2.2 ListView.builder长列表的标准答案ListView.builder是我在项目里用得最多的一个。它接收itemCount和itemBuilder两个核心参数只有当某个位置的子项即将进入可见区域时itemBuilder才会被调用去构建它。这就意味着哪怕你有十万条数据同一时刻真正存在的 widget 也只有屏幕上那十几个。ListView.builder( itemCount: dataList.length, itemBuilder: (context, index) { return ListTile( title: Text(dataList[index].title), subtitle: Text(dataList[index].subtitle), ); }, )itemBuilder的签名是Widget Function(BuildContext context, int index)index从 0 到itemCount - 1。这里有个坑itemCount必须准确如果你传了一个比实际数据大的值itemBuilder里访问数据就会越界传小了则后面的数据永远显示不出来。我见过有同事在数据异步加载时忘了更新itemCount导致列表只显示了一半排查了半天。提示itemBuilder里不要做耗时操作比如网络请求、复杂计算、大图解码。这个函数会被频繁调用任何耗时逻辑都会被放大成明显的卡顿。2.3 ListView.separated带分割线的列表ListView.separated本质上是在builder的基础上多了一个separatorBuilder用来在每两个子项之间插入一个分割组件。它的itemCount语义和builder一样但实际渲染的子项数量是itemCount * 2 - 1最后一项后面没有分割线。ListView.separated( itemCount: dataList.length, itemBuilder: (context, index) { return ListTile(title: Text(dataList[index].title)); }, separatorBuilder: (context, index) { return Divider(height: 1, color: Colors.grey[300]); }, )什么时候用separated而不是自己在itemBuilder里加Divider我的判断标准是如果分割线样式统一、且每条数据之间都需要用separated更清晰如果分割线只在特定条件下出现比如分组之间那还是自己在itemBuilder里控制更灵活。另外separated的separatorBuilder同样会被按需调用性能上和builder没有本质区别。2.4 ListView.custom需要精细控制时的选择ListView.custom接收一个SliverChildDelegate最常用的是SliverChildBuilderDelegate。它比builder更底层能控制addAutomaticKeepAlives、addRepaintBoundaries、addSemanticIndexes这些参数。绝大多数业务场景用不到它但如果你需要自定义子项的缓存策略、或者要控制重绘边界它就有价值了。ListView.custom( childrenDelegate: SliverChildBuilderDelegate( (context, index) ListTile(title: Text(第 $index 项)), childCount: 100, addAutomaticKeepAlives: false, ), )addAutomaticKeepAlives默认是true它会让子项在滚出屏幕后仍然保留状态比如输入框里的文字。如果你确定子项不需要保活把它设为false能省下一些内存。这个参数在长列表里影响不小尤其是子项本身比较重的时候。3. 垂直滚动与水平滚动的切换机制3.1 scrollDirection 参数是唯一开关ListView默认是垂直滚动的也就是内容从上往下排手指上下滑动。要改成水平滚动只需要把scrollDirection设为Axis.horizontalListView.builder( scrollDirection: Axis.horizontal, itemCount: 10, itemBuilder: (context, index) { return Container( width: 120, margin: EdgeInsets.all(8), color: Colors.blue[100], child: Center(child: Text(卡片 $index)), ); }, )这里有个关键点水平滚动时子项必须有明确的宽度。因为水平方向的约束是无限的如果子项没有固定宽度或者没有SizedBox、Container之类的宽度约束Flutter 会直接报“RenderBox was not laid out”或者子项宽度变成 0。垂直滚动时子项的高度通常由内容决定但水平滚动时宽度必须由你指定这是新手最容易踩的坑。3.2 垂直与水平滚动的布局差异垂直滚动时ListView给子项的约束是宽度等于视口宽度高度不限。所以子项可以自由决定自己的高度ListTile、Card这些组件都能正常工作。水平滚动时约束反过来了高度等于视口高度宽度不限。这时候子项的高度会被强制撑满整个ListView的高度如果你想要一个固定高度的横向卡片列表通常需要给ListView本身套一个SizedBox来限制高度SizedBox( height: 150, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: 10, itemBuilder: (context, index) { return Container( width: 120, margin: EdgeInsets.symmetric(horizontal: 8), child: Card(child: Center(child: Text(卡片 $index))), ); }, ), )如果不限制高度水平ListView会试图撑满父级给它的所有垂直空间在Column里就会直接报无界高度错误。这个错误信息通常是“Vertical viewport was given unbounded height”看到它基本就是忘了给水平列表加高度约束。3.3 反向滚动与滚动物理效果reverse参数可以控制滚动方向是否反转。垂直滚动时设为true列表会从底部开始往上滑才能看到后面的内容聊天界面常用这个效果。水平滚动时设为true则从右往左排列。ListView.builder( reverse: true, itemCount: 20, itemBuilder: (context, index) ListTile(title: Text(消息 $index)), )physics参数控制滚动的物理效果。默认在 Android 上是ClampingScrollPhysics到边界就停住在 iOS 上是BouncingScrollPhysics到边界会回弹。如果你想让两个平台表现一致可以显式指定ListView.builder( physics: BouncingScrollPhysics(), itemCount: 20, itemBuilder: (context, index) ListTile(title: Text(第 $index 项)), )还有一个常用的NeverScrollableScrollPhysics它会让列表本身不可滚动通常用在嵌套滚动场景里把滚动权交给外层容器。这个后面讲嵌套冲突时会详细说。4. 滚动控制器与监听实战4.1 ScrollController 的基本用法ScrollController是控制滚动位置的核心工具。你可以用它读取当前滚动偏移、跳转到指定位置、监听滚动事件。class MyListPage extends StatefulWidget { override _MyListPageState createState() _MyListPageState(); } class _MyListPageState extends StateMyListPage { final ScrollController _controller ScrollController(); override void initState() { super.initState(); _controller.addListener(() { print(当前偏移: ${_controller.offset}); print(最大可滚动距离: ${_controller.position.maxScrollExtent}); }); } override void dispose() { _controller.dispose(); super.dispose(); } override Widget build(BuildContext context) { return ListView.builder( controller: _controller, itemCount: 100, itemBuilder: (context, index) ListTile(title: Text(第 $index 项)), ); } }这里必须强调ScrollController一定要在dispose里释放。它内部持有监听器和位置信息不释放会造成内存泄漏。我见过不少项目里ScrollController创建了却从不 dispose页面反复进出后内存缓慢增长最后定位到就是这个问题。4.2 用控制器实现“回到顶部”有了ScrollController实现回到顶部就很简单FloatingActionButton( onPressed: () { _controller.animateTo( 0, duration: Duration(milliseconds: 300), curve: Curves.easeOut, ); }, child: Icon(Icons.arrow_upward), )animateTo会平滑滚动到指定偏移jumpTo则是瞬间跳转。如果列表还没完成布局_controller.position会抛异常所以调用前最好判断一下_controller.hasClientsif (_controller.hasClients) { _controller.animateTo(0, duration: Duration(milliseconds: 300), curve: Curves.easeOut); }4.3 监听滚动到底部加载更多下拉刷新和上拉加载是列表的经典需求。上拉加载的核心逻辑是当滚动偏移接近最大可滚动距离时触发加载。_controller.addListener(() { if (_controller.position.pixels _controller.position.maxScrollExtent - 200) { if (!_isLoading) { _loadMore(); } } });这里的200是一个阈值表示距离底部还有 200 像素时就开始加载给用户一个“无感加载”的体验。阈值太小会导致用户滑到底才加载体验割裂太大则可能还没滑到底就触发多次加载。我一般用 200 到 300 之间具体看单条 item 的高度。注意addListener会在每一帧滚动时都触发所以里面的逻辑一定要轻量。_isLoading这种标志位判断是必须的否则会重复触发加载请求。5. 嵌套滚动冲突与解决方案5.1 垂直列表里套水平列表这是最常见的嵌套场景一个垂直的商品列表每个商品卡片里又有一个横向的图片轮播或者标签列表。这种嵌套本身是没问题的因为方向不同手势不会冲突ListView.builder( itemCount: 20, itemBuilder: (context, index) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(商品 $index), SizedBox( height: 100, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: 5, itemBuilder: (context, i) Container( width: 80, margin: EdgeInsets.all(4), color: Colors.orange[100], ), ), ), ], ); }, )垂直方向的手势归外层水平方向的手势归内层Flutter 的手势竞技场会自动处理。但要注意内层水平列表必须限制高度否则会报无界高度错误。5.2 同方向嵌套的麻烦如果垂直列表里再套一个垂直列表问题就来了。两个列表都想响应垂直滑动手势Flutter 默认会让内层列表消费掉手势导致外层列表滑不动。常见的解决思路有三种第一种是给内层列表设置shrinkWrap: true和physics: NeverScrollableScrollPhysics()让内层列表不滚动、完全展开滚动权交给外层ListView.builder( itemCount: 10, itemBuilder: (context, index) { return Column( children: [ Text(分组 $index), ListView.builder( shrinkWrap: true, physics: NeverScrollableScrollPhysics(), itemCount: 5, itemBuilder: (context, i) ListTile(title: Text(子项 $i)), ), ], ); }, )shrinkWrap: true会让内层列表根据子项内容计算自己的高度而不是试图撑满可用空间。但要注意shrinkWrap会强制内层列表一次性布局所有子项性能上是有代价的。如果内层子项很多这个方案就不合适了。第二种是用CustomScrollView配合多个SliverList把所有内容放在同一个滚动体系里。这是性能最好的方案但改造成本也最高。第三种是给内层列表一个固定高度让它自己滚动外层不参与。这种适合内层内容确实需要独立滚动的场景比如一个固定高度的评论区。5.3 shrinkWrap 的性能代价shrinkWrap: true的原理是让ListView在布局阶段计算所有子项的总高度这就意味着所有子项都会被构建和测量builder的按需构建优势就没了。我在一个项目里见过有人在长列表里用了shrinkWrap数据量一上来直接卡死。所以我的建议是shrinkWrap只在内层子项数量少比如 10 条以内时使用超过这个量级就要考虑换方案。6. 性能优化与常见问题排查6.1 给子项加 const 和 RepaintBoundaryListView的子项如果能在构建时加上constFlutter 就能复用同一个 widget 实例减少重建开销。另外如果某个子项的重绘很频繁比如有动画可以用RepaintBoundary把它包起来避免影响其他子项itemBuilder: (context, index) { return RepaintBoundary( child: MyComplexItem(data: dataList[index]), ); }RepaintBoundary会让子项拥有独立的图层重绘时不会牵连父级和其他兄弟节点。但它也不是越多越好每个图层都有内存成本只在对重绘性能有明确要求时才加。6.2 图片列表的内存控制图文列表最容易出问题的就是图片。如果每张图都是原图加载内存很快就会爆。实战里通常要做三件事使用cacheWidth限制解码尺寸、使用占位图、及时释放不可见图片。Image.network( url, width: 100, height: 100, fit: BoxFit.cover, cacheWidth: 200, )cacheWidth会让图片按指定宽度解码而不是按原始尺寸。一张 4000 像素宽的图如果显示区域只有 100 像素用cacheWidth: 200能省下大量内存。这个参数在长列表里几乎是必加的。6.3 常见问题速查表问题现象可能原因排查方向水平列表报无界高度错误没有限制高度用 SizedBox 包裹并指定 height列表滑动卡顿子项构建太重检查 itemBuilder 里是否有耗时操作嵌套列表滑不动同方向手势冲突内层加 NeverScrollableScrollPhysics滚动监听不触发controller 未绑定确认 controller 传给了 ListView内存持续增长controller 未释放在 dispose 里调用 controller.dispose()列表只显示一半itemCount 不准确检查数据长度和 itemCount 是否一致回到顶部报错position 未就绪调用前判断 hasClients6.4 我踩过的几个坑第一个坑是itemCount用了异步数据的长度但数据还没加载完就构建了列表导致itemCount为 0列表空白。后来改成数据加载完再构建列表或者用FutureBuilder包一层。第二个坑是在itemBuilder里做了DateTime.now()之类的调用导致每次重建结果都不同列表频繁刷新。这类不稳定的值应该提前算好或者用const固定。第三个坑是水平列表里放了Expanded直接报错。Expanded只能在Row、Column、Flex里用水平ListView的子项宽度必须显式指定不能用Expanded去分配。第四个坑是滚动监听里直接调用了setState导致每帧都重建整个页面滑动直接卡死。正确做法是只更新需要变化的那部分状态或者用ValueNotifier配合ValueListenableBuilder做局部刷新。7. 一个完整的垂直水平混合列表示例把前面讲的东西串起来写一个实际可跑的页面外层是垂直的商品列表每个商品卡片里有一个横向的标签列表底部有加载更多右上角有回到顶部按钮。class ProductListPage extends StatefulWidget { override _ProductListPageState createState() _ProductListPageState(); } class _ProductListPageState extends StateProductListPage { final ScrollController _controller ScrollController(); final ListString _products List.generate(50, (i) 商品 $i); bool _isLoading false; override void initState() { super.initState(); _controller.addListener(_onScroll); } void _onScroll() { if (_controller.position.pixels _controller.position.maxScrollExtent - 200) { _loadMore(); } } Futurevoid _loadMore() async { if (_isLoading) return; setState(() _isLoading true); await Future.delayed(Duration(seconds: 1)); setState(() { _products.addAll(List.generate(10, (i) 新商品 $i)); _isLoading false; }); } override void dispose() { _controller.removeListener(_onScroll); _controller.dispose(); super.dispose(); } override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(商品列表)), body: ListView.builder( controller: _controller, itemCount: _products.length 1, itemBuilder: (context, index) { if (index _products.length) { return _isLoading ? Padding( padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()), ) : SizedBox.shrink(); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: EdgeInsets.all(12), child: Text(_products[index], style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), ), SizedBox( height: 80, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: 6, itemBuilder: (context, i) Container( width: 70, margin: EdgeInsets.symmetric(horizontal: 6), decoration: BoxDecoration( color: Colors.blue[50], borderRadius: BorderRadius.circular(8), ), child: Center(child: Text(标签 $i)), ), ), ), Divider(), ], ); }, ), floatingActionButton: FloatingActionButton( onPressed: () { if (_controller.hasClients) { _controller.animateTo(0, duration: Duration(milliseconds: 300), curve: Curves.easeOut); } }, child: Icon(Icons.arrow_upward), ), ); } }这个例子里有几个细节值得说。itemCount用了_products.length 1多出来的那一项用来显示加载指示器这是上拉加载的常见写法。_onScroll里判断了距离底部的阈值并且_loadMore内部用_isLoading做了防重。dispose里先移除监听再释放 controller顺序不能反。水平列表用SizedBox限制了高度子项用固定宽度。8. 关于 ListView 的一些个人体会ListView看起来简单但真正把它用到位需要对 Flutter 的布局约束、手势竞技场、渲染管线都有一定理解。我刚开始写 Flutter 的时候觉得列表不就是把数据铺开吗后来才发现性能问题、嵌套冲突、状态管理这些坑一个接一个。现在回头看最值得花时间搞清楚的其实是三件事builder的按需构建机制、scrollDirection带来的约束变化、以及ScrollController的生命周期管理。这三件事搞明白了绝大多数列表相关的需求都能从容应对。另外提一句Flutter 的列表体系里还有CustomScrollView和Sliver系列组件它们能实现更复杂的滚动效果比如吸顶、折叠、混合列表。ListView能覆盖的场景CustomScrollView基本都能做但后者的学习曲线更陡。我的建议是先把ListView吃透遇到它解决不了的问题再往Sliver方向走不要一上来就追求“高级方案”那样反而容易把自己绕进去。