1. 从一张商品卡片说起:图文混排到底难在哪
做移动端电商项目的人,迟早都会撞上「商品列表图文混排」这堵墙。表面上看,无非就是左边一张图、右边几行字,或者上面一张大图、下面标题价格,用Row和Column拼一拼就完事了。但真到落地的时候,你会发现坑一个接一个:图片高度不一致导致列表参差不齐、长标题把价格挤到屏幕外、图片加载时列表疯狂跳动、滚动起来掉帧卡顿、不同机型上文字换行位置完全不一样。
我在几个 Flutter 项目里都做过商品列表,从最早的ListView裸写,到后来用SliverGrid混搭,再到现在比较成熟的CustomScrollView + SliverList方案,中间踩的坑足够写一篇长文。这篇就把「Flutter 实现商品列表图文混排」这件事从头到尾拆开讲清楚:核心思路怎么定、布局怎么选、图片怎么处理、性能怎么保、常见问题怎么排查。不管你是刚接触 Flutter 的新手,还是已经写过几个列表但总觉得不够顺滑的老手,应该都能从里面捞到点能直接抄的东西。
先说清楚这个内容适合谁看。如果你正在做电商、内容流、社交动态这类「图片 + 文字」混合排列的滚动列表,那这篇基本就是为你写的。核心关键词就几个:Flutter 列表布局、图文混排、图片自适应、滚动性能、Sliver 体系。我会尽量用大白话把每个决策背后的原因讲透,而不是甩一堆代码让你自己猜。
2. 整体设计思路:为什么不能只用 ListView 硬拼
2.1 图文混排的三种典型形态
在动手之前,得先搞清楚你要做的是哪种混排。不同形态对应的技术方案差别很大,选错了后面改起来非常痛苦。
第一种是左右结构,左边固定宽度的商品图,右边是标题、描述、价格、标签纵向排列。这是最常见的电商列表样式,像很多购物 App 的搜索结果页都是这种。它的特点是图片宽度固定、高度也基本固定,右侧文字区域需要处理换行和溢出。
第二种是上下结构,上面一张通栏大图,下面跟着标题和价格。这种多见于内容种草流、活动商品位。图片通常是固定宽高比,比如 16:9 或者 4:3,文字区域相对简单。
第三种是瀑布流 / 不规则混排,图片高度不一,卡片错落排列,像某些灵感类、家居类 App。这种最复杂,需要处理不同高度卡片的对齐问题。
我这次主要围绕前两种来讲,因为它们覆盖了 80% 的实际需求,瀑布流会单独提一下思路。
2.2 为什么 ListView 直接拼 Row 会出问题
很多人第一反应是这么写:
ListView.builder( itemCount: products.length, itemBuilder: (context, index) { return Row( children: [ Image.network(products[index].imageUrl), Column( children: [ Text(products[index].title), Text(products[index].price), ], ), ], ); }, )这段代码能跑,但问题一堆。首先Image.network不给宽高约束,图片会按原始尺寸渲染,一张 2000px 宽的图直接把布局撑爆。其次Column里的Text没有约束,长标题会无限换行把卡片撑得很高。再者Row里的Column默认是MainAxisSize.max,会尝试占满剩余空间,配合Expanded用错了就是各种溢出警告。
更关键的是性能问题。ListView.builder本身是懒加载的,但如果你在itemBuilder里做了重计算、没有给图片缓存、没有固定 item 高度,滚动起来就会明显掉帧。我实测过,一个没优化的图文列表,在中端机上滚动帧率能掉到 40 以下,肉眼可见地卡。
2.3 我最终选定的方案骨架
经过几轮迭代,我现在的标准做法是这样的:
- 用
CustomScrollView作为滚动容器,方便后续加吸顶、分组、下拉刷新 - 列表部分用
SliverList或SliverGrid,根据布局形态选 - 每个商品卡片封装成独立 Widget,内部用
Row+Expanded控制文字区域 - 图片统一用
AspectRatio或固定宽高约束,配合cached_network_image做缓存 - 文字区域用
maxLines+overflow控制溢出,价格用FittedBox兜底 - 整个卡片高度尽量可预测,避免滚动时跳动
这个骨架的好处是扩展性强。后面要加骨架屏、要加分组标题、要做吸顶分类栏,都能在CustomScrollView体系里平滑加进去,不用推倒重来。
3. 核心细节拆解:图片、文字、约束三件套
3.1 图片处理:别让一张图毁掉整个列表
图片是图文混排里最容易出问题的地方。我总结了几个必须处理的点。
第一,必须给图片明确的尺寸约束。左右结构里,图片宽度固定,比如 100 逻辑像素,高度也固定,比如 100。用SizedBox包起来最直接:
SizedBox( width: 100, height: 100, child: CachedNetworkImage( imageUrl: product.imageUrl, fit: BoxFit.cover, placeholder: (context, url) => Container(color: Colors.grey[200]), errorWidget: (context, url, error) => Icon(Icons.broken_image), ), )BoxFit.cover保证图片填满区域且不变形,多余部分裁掉。这里千万别用BoxFit.fill,会把图片拉变形,商品图变形了用户一眼就能看出来,非常影响观感。
第二,占位图很重要。图片没加载出来之前,如果区域是空白的,列表会先渲染成一个小高度,图片加载完再撑开,造成明显的跳动。给一个和最终尺寸一致的灰色占位块,就能避免这个问题。cached_network_image的placeholder参数就是干这个的。
第三,缓存策略。网络图片一定要走缓存,否则每次滚动出屏幕再滚回来都重新请求,流量和性能都扛不住。cached_network_image默认会把图片缓存到磁盘和内存,基本够用。如果项目对缓存有更高要求,可以自己配CacheManager,控制缓存大小和过期时间。
注意:图片 URL 如果带签名或者时效参数,缓存命中率会很低,最好让后端提供稳定的图片地址,或者在前端做 URL 归一化处理。
3.2 文字区域:换行、溢出、对齐的博弈
文字区域的核心矛盾是:内容长度不可控,但布局空间有限。标题可能只有 5 个字,也可能有 50 个字;价格可能就一个数字,也可能带「券后」「到手价」「原价划线」一堆信息。
我的处理原则是分层控制:
- 标题:
maxLines: 2,overflow: TextOverflow.ellipsis,超过两行省略号 - 描述:
maxLines: 1,同样省略 - 价格:不设
maxLines,但用FittedBox缩放,保证再长也能显示完整
Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( product.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), ), Text( product.description, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: Colors.grey), ), FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: Text( '¥${product.price}', style: TextStyle(fontSize: 16, color: Colors.red, fontWeight: FontWeight.bold), ), ), ], ), )这里Expanded是关键,它让文字区域占据Row里除图片外的所有剩余宽度。mainAxisAlignment: spaceBetween让标题、描述、价格在垂直方向均匀分布,卡片看起来更整齐。
3.3 约束传递:理解 Flutter 的布局规则
Flutter 布局的核心规则是「约束向下传递,尺寸向上传递」。父 Widget 给子 Widget 一个约束范围,子 Widget 在这个范围内决定自己的尺寸,再汇报给父级。
在图文混排里,最常见的错误就是约束冲突。比如你把一个没有尺寸约束的Image放进Row,Row给它的约束是「宽度 0 到无穷大」,图片就会按原始尺寸渲染,直接溢出。再比如Column里放一个Expanded的Text,如果Column本身高度不受限,Expanded就会报错。
理解这一点之后,你就能预判大部分布局问题。我的经验是:凡是放进 Row 或 Column 的子项,要么给它固定尺寸,要么用 Expanded/Flexible 明确它的伸缩行为,不要留模糊地带。
4. 实操过程:从零搭一个可复用的商品卡片
4.1 数据模型与项目结构
先定义商品数据模型,字段按实际需求来,这里给一个典型版本:
class Product { final String id; final String imageUrl; final String title; final String description; final double price; final double? originalPrice; final List<String> tags; Product({ required this.id, required this.imageUrl, required this.title, required this.description, required this.price, this.originalPrice, this.tags = const [], }); }项目结构上,我习惯把商品卡片单独抽成一个文件product_card.dart,列表页放在product_list_page.dart。卡片组件保持纯粹,只接收Product对象,不依赖外部状态,这样复用和测试都方便。
4.2 商品卡片组件的完整实现
下面是我现在用的卡片实现,左右结构,带标签和划线价:
class ProductCard extends StatelessWidget { final Product product; final VoidCallback? onTap; const ProductCard({Key? key, required this.product, this.onTap}) : super(key: key); @override Widget build(BuildContext context) { return InkWell( onTap: onTap, child: Container( padding: const EdgeInsets.all(12), color: Colors.white, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildImage(), const SizedBox(width: 12), Expanded(child: _buildInfo()), ], ), ), ); } Widget _buildImage() { return ClipRRect( borderRadius: BorderRadius.circular(8), child: SizedBox( width: 100, height: 100, child: CachedNetworkImage( imageUrl: product.imageUrl, fit: BoxFit.cover, placeholder: (_, __) => Container(color: const Color(0xFFF0F0F0)), errorWidget: (_, __, ___) => Container( color: const Color(0xFFF0F0F0), child: const Icon(Icons.image_not_supported, color: Colors.grey), ), ), ), ); } Widget _buildInfo() { return SizedBox( height: 100, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( product.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, height: 1.3), ), if (product.tags.isNotEmpty) _buildTags(), _buildPrice(), ], ), ); } Widget _buildTags() { return Wrap( spacing: 4, children: product.tags.take(3).map((tag) { return Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), decoration: BoxDecoration( border: Border.all(color: Colors.red.withOpacity(0.5)), borderRadius: BorderRadius.circular(2), ), child: Text(tag, style: const TextStyle(fontSize: 10, color: Colors.red)), ); }).toList(), ); } Widget _buildPrice() { return Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ Text( '¥${product.price.toStringAsFixed(2)}', style: const TextStyle(fontSize: 16, color: Colors.red, fontWeight: FontWeight.bold), ), if (product.originalPrice != null) ...[ const SizedBox(width: 6), Text( '¥${product.originalPrice!.toStringAsFixed(2)}', style: const TextStyle( fontSize: 12, color: Colors.grey, decoration: TextDecoration.lineThrough, ), ), ], ], ); } }这段代码有几个细节值得说。_buildInfo外面套了一个SizedBox(height: 100),和图片高度对齐,这样卡片高度就是确定的 124(100 + 上下 padding 24)。确定高度对列表性能非常重要,后面会讲。mainAxisAlignment: spaceBetween让标题、标签、价格三段在 100 高度内均匀分布,不管有没有标签,布局都不会乱。
4.3 列表页的组装与滚动容器选择
列表页用CustomScrollView,方便后续扩展:
class ProductListPage extends StatelessWidget { final List<Product> products; const ProductListPage({Key? key, required this.products}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF5F5F5), body: CustomScrollView( slivers: [ const SliverAppBar( title: Text('商品列表'), floating: true, snap: true, ), SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 12), sliver: SliverList( delegate: SliverChildBuilderDelegate( (context, index) { return Padding( padding: const EdgeInsets.only(bottom: 8), child: ProductCard( product: products[index], onTap: () => _onProductTap(context, products[index]), ), ); }, childCount: products.length, ), ), ), ], ), ); } void _onProductTap(BuildContext context, Product product) { // 跳转详情页 } }用SliverList而不是ListView,是因为CustomScrollView体系能更灵活地组合多种 sliver。比如你想在列表顶部加一个横向滚动的分类栏,或者每隔 10 个商品插一个广告位,用 sliver 都很自然。SliverChildBuilderDelegate保证懒加载,只构建可见区域的 item。
4.4 图片预加载与滚动优化
列表滚动卡顿,很大一部分原因是图片解码在主 isolate 上做,阻塞了 UI 线程。Flutter 的Image组件默认会在后台 isolate 解码,但如果你同时加载大量图片,还是会有压力。
我的做法是控制同时加载的图片数量,配合precacheImage预加载即将进入视口的图片。不过预加载要谨慎,预加载太多反而浪费资源。一个折中方案是监听滚动位置,在距离底部还有一屏的时候,预加载接下来几个 item 的图片。
另外,CachedNetworkImage的memCacheWidth参数值得一用。它会在解码时把图片缩放到指定宽度,减少内存占用。比如你的图片显示宽度是 100 逻辑像素,设备像素比是 3,那memCacheWidth设成 300 就够了,没必要解码原图。
CachedNetworkImage( imageUrl: product.imageUrl, memCacheWidth: 300, fit: BoxFit.cover, // ... )这个参数对内存的优化非常明显。我实测过一个列表,不设memCacheWidth时内存峰值能到 200MB 以上,设了之后降到 60MB 左右,低端机上差别尤其大。
5. 常见问题与排查技巧实录
5.1 布局溢出与跳动问题速查
图文混排最常报的错就是RenderFlex overflowed,黄色黑色条纹一出来,基本就是约束没处理好。我整理了一个速查表:
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 右侧文字溢出屏幕 | Column 没有用 Expanded 包裹 | 用 Expanded 或 Flexible 包住文字区域 |
| 图片撑爆布局 | Image 没有尺寸约束 | 用 SizedBox 或 AspectRatio 固定尺寸 |
| 卡片高度忽高忽低 | 文字行数不固定 | 给 Text 设 maxLines,给卡片设固定高度 |
| 滚动时图片跳动 | 没有占位图 | 加 placeholder,尺寸与最终一致 |
| 底部内容被遮挡 | 没有处理安全区域 | 用 SafeArea 或给列表加底部 padding |
| 长价格显示不全 | 价格区域宽度不够 | 用 FittedBox 缩放或调整布局比例 |
这个表基本覆盖了我遇到过的 90% 的布局问题。遇到溢出先看约束,遇到跳动先看尺寸是否可预测,思路就清晰了。
5.2 性能问题的定位方法
滚动卡顿怎么定位?我一般用 Flutter 自带的性能 overlay。在main.dart里打开:
void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return MaterialApp( showPerformanceOverlay: true, home: const ProductListPage(products: []), ); } }打开后屏幕顶部会出现两个图表,上面是 UI 线程,下面是 GPU 线程。如果 UI 线程的柱子经常超过 16ms,说明 build 或 layout 太重;如果 GPU 线程超,说明绘制压力大,可能是图片太多或者有复杂裁剪。
我遇到过一次 GPU 线程爆表,最后发现是ClipRRect用在了每个卡片上,圆角裁剪在滚动时开销不小。后来改成给图片本身加圆角(用BoxDecoration的borderRadius),性能就好了很多。这个坑比较隐蔽,分享出来给大家提个醒。
5.3 不同屏幕适配的坑
图文混排在不同屏幕宽度上表现差异很大。窄屏上文字区域小,标题可能一行都放不下;宽屏上图片和文字比例失调,看起来很空。
我的处理方式是用比例而不是固定值。比如图片宽度不写死 100,而是取屏幕宽度的 28%:
final screenWidth = MediaQuery.of(context).size.width; final imageWidth = screenWidth * 0.28;这样在 360 宽的手机上图片约 100,在 480 宽的设备上约 134,比例保持一致。文字区域用Expanded自动占剩余空间,整体观感就统一了。
不过要注意,图片高度最好还是固定,或者用AspectRatio保持宽高比。如果图片高度也跟着宽度变,卡片高度就不确定了,列表性能会受影响。
提示:如果产品要求图片必须是正方形,用
AspectRatio(aspectRatio: 1)包住图片,宽度由外部约束决定,高度自动算出来,既保持比例又不需要手动计算。
6. 进阶玩法:瀑布流与分组混排
6.1 瀑布流布局的实现思路
瀑布流的核心是让卡片按列排列,每列高度独立累积。Flutter 里可以用flutter_staggered_grid_view这个库,它提供了MasonryGridView,用起来和普通 GridView 差不多:
MasonryGridView.count( crossAxisCount: 2, mainAxisSpacing: 8, crossAxisSpacing: 8, itemBuilder: (context, index) { return ProductCard(product: products[index]); }, itemCount: products.length, )但瀑布流有个天然问题:卡片高度不一致,滚动时如果图片加载慢,布局会不断重排,视觉上很乱。我的建议是给每个卡片一个预估高度,图片没加载出来时先用占位块撑住,加载完再替换。这样布局稳定,体验好很多。
6.2 分组混排与吸顶效果
实际项目里经常需要按分类分组,每组有个标题,滚动时标题吸顶。这个用CustomScrollView的SliverPersistentHeader就能实现。每个分组是一个SliverList,分组标题用SliverPersistentHeader包起来,设置pinned: true就能吸顶。
SliverPersistentHeader( pinned: true, delegate: _CategoryHeaderDelegate(title: '热销商品'), )_CategoryHeaderDelegate需要自己实现,控制 header 的高度和内容。这个稍微复杂一点,但网上模板很多,核心就是实现minExtent、maxExtent和build三个方法。
分组混排的难点在于数据组织。我一般把数据整理成List<Section>,每个Section有标题和商品列表,然后遍历生成 sliver。这样逻辑清晰,加分组、删分组都很方便。
6.3 骨架屏的接入
列表首次加载时,直接显示空白或者转圈都不够优雅。骨架屏能显著提升感知速度。实现方式很简单,就是用一个和真实卡片布局一致的灰色占位组件,在数据加载完成前显示。
class ProductCardSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(12), color: Colors.white, child: Row( children: [ Container(width: 100, height: 100, color: const Color(0xFFF0F0F0)), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container(height: 14, color: const Color(0xFFF0F0F0)), const SizedBox(height: 8), Container(height: 14, width: 100, color: const Color(0xFFF0F0F0)), const SizedBox(height: 8), Container(height: 16, width: 60, color: const Color(0xFFF0F0F0)), ], ), ), ], ), ); } }骨架屏的布局要和真实卡片尽量一致,这样数据加载完切换时不会有大跳动。如果想让骨架屏有呼吸效果,可以加一个透明度动画,但别做太花哨,列表里几十个骨架屏同时动画,性能会吃不消。
7. 一些踩坑之后的经验之谈
做图文混排这几年,有几个教训是花钱买来的,这里直接分享。
第一,别在 itemBuilder 里做重活。我见过有人在 itemBuilder 里做字符串格式化、日期解析、甚至网络请求,结果滚动直接卡死。itemBuilder 应该只做最轻量的组装,数据预处理放在拿到数据之后统一做。
第二,图片尺寸一定要和 UI 对齐。后端返回 800x800 的图,你显示 100x100,白白浪费带宽和解码时间。最好让后端提供多尺寸图片,或者用图片处理服务动态裁剪。如果后端不给力,前端用memCacheWidth兜底。
第三,列表 item 高度尽量可预测。高度不确定的 item 会让 Flutter 的滚动估算失效,滚动条乱跳,性能也差。能用固定高度就用固定高度,实在不行用AspectRatio保证比例固定。
第四,测试要在真机上做。模拟器性能太好,很多卡顿在模拟器上根本看不出来。我习惯在中低端安卓真机上测列表,能跑顺了才算过关。
第五,别过度优化。有些项目列表就几十个 item,用户根本滚不到底,你花大力气做懒加载、做预加载,收益很低。先保证功能正确、布局稳定,性能问题等真出现了再针对性优化。
最后分享一个小技巧:如果你不确定某个布局改动会不会影响性能,用 Flutter DevTools 的 Widget Rebuild 统计看一下。打开后滚动列表,如果发现某个 Widget 重建次数异常高,那就是优化点。我靠这个工具定位过好几次隐蔽的性能问题,比瞎猜高效多了。
这个内容后续还可以往几个方向扩展:比如接入下拉刷新和上拉加载更多、做商品卡片的点击涟漪和转场动画、支持多语言下的文字长度适配。每个方向都够单独写一篇,等后面有机会再展开。