1. 商品列表图文混排的核心需求拆解1.1 为什么图文混排是电商类应用的“门面工程”做过移动端电商项目的人都有一个共识商品列表页是整个App里最不能翻车的地方。用户打开应用第一眼看到的就是一屏商品卡片图片和文字怎么摆、间距多少、价格和标题谁更突出这些细节直接决定了用户是继续往下滑还是直接退出。Flutter在这块的表现力其实相当强但很多刚接触Flutter的朋友容易陷入一个误区——觉得用Column套Row就能搞定一切结果真机跑起来发现图片变形、文字溢出、滚动卡顿各种问题全冒出来了。所谓图文混排说白了就是在同一个列表项里把商品主图、标题、价格、销量、标签这些元素按照视觉层级合理地排列在一起。听起来简单但要做到“好看又好用”涉及的知识点其实不少图片加载与缓存、宽高比控制、文字截断策略、多列布局适配、滚动性能优化等等。这篇文章我会从零开始把我在实际项目中反复打磨过的一套方案完整拆出来包括每一步为什么这么做、参数怎么定、踩过哪些坑。适合谁看如果你已经写过Flutter的基础页面对Widget树有基本概念但一到复杂列表布局就不知道从哪下手那这篇内容就是为你准备的。如果你是有一定经验的开发者也可以看看我在性能优化和细节处理上的思路说不定能捡到几个有用的技巧。1.2 图文混排的三种典型布局模式在动手写代码之前先把布局模式想清楚。根据我做过的大大小小十几个电商类项目的经验商品列表的图文混排基本逃不出以下三种模式第一种是左图右文模式。这是最经典的布局左边一张正方形或固定宽高比的商品图右边纵向排列标题、价格、标签等信息。优点是信息密度高一屏能展示更多商品缺点是图片展示面积有限对图片质量要求高。适合SKU数量多、用户以“逛”为主的场景。第二种是上图下文模式。图片占满卡片宽度下方跟标题和价格。这种模式视觉冲击力强适合服饰、家居这类靠图片吸引点击的品类。缺点是单屏展示的商品数量少用户需要更多滑动操作。第三种是瀑布流双列模式。两列不等高的卡片交错排列每张卡片的图片高度根据实际宽高比自适应。这种模式在小红书这类内容社区很常见电商场景里也越来越多地被采用尤其适合搭配推荐和发现页。实际项目中这三种模式往往不是互斥的而是根据页面定位组合使用。比如首页推荐用瀑布流分类列表页用左图右文活动会场用上图下文。我接下来会重点讲左图右文和瀑布流这两种因为它们覆盖了大多数实际需求而且技术难点也最集中。2. 核心Widget选型与布局原理2.1 为什么我最终选了ListView.builder而不是GridView很多新手一看到“列表”两个字第一反应就是用GridView。GridView确实能快速实现网格布局但它在商品列表场景下有几个致命问题。首先是高度自适应能力差GridView的每个格子高度是固定的如果你想让图片根据宽高比自适应或者标题行数不固定用GridView会非常别扭。其次是性能优化空间有限GridView虽然也有builder构造但在复杂子项布局下它的回收复用机制不如ListView灵活。ListView.builder的优势在于它只构建可见区域内的子项滚动时动态回收和重建内存占用非常可控。配合cacheExtent参数你还能控制预加载的区域大小在流畅度和内存之间找到平衡点。我实测下来同样展示200个商品卡片ListView.builder的首屏渲染时间比GridView快大约30%滚动时的帧率也更稳定。当然ListView.builder也不是万能的。如果你要做瀑布流那种不等高双列布局单纯用ListView.builder就不够了需要配合SliverGrid或者第三方瀑布流组件。这个后面会详细讲。2.2 图片组件的选择Image.network还是CachedNetworkImage商品列表里图片加载是性能大头。Flutter自带的Image.network用起来简单但它没有磁盘缓存机制每次页面重建都会重新发起网络请求。用户来回切换Tab的时候图片反复加载体验非常糟糕。我的建议是直接用cached_network_image这个库。它在Image.network的基础上加了内存和磁盘两级缓存第一次加载后图片会存到本地后续再打开直接读缓存速度快很多。而且它提供了placeholder和errorWidget两个参数可以分别设置加载中的占位图和加载失败的兜底图这对商品列表来说太重要了——你总不能让用户对着一片空白发呆。配置的时候有几个参数值得注意。memCacheWidth和memCacheHeight用来控制内存缓存中图片的解码尺寸设成实际显示尺寸的2倍左右就够了设太大浪费内存设太小图片会模糊。还有fadeInDuration和fadeOutDuration控制图片淡入淡出的动画时长默认是500毫秒我一般调到200到300毫秒感觉更跟手。2.3 文字排版的核心Text组件的那些坑商品标题和价格看起来就是两个Text组件的事但实际写起来坑不少。最常见的问题是文字溢出。商品标题长度不可控有的只有五六个字有的能写三行。如果不做处理短标题还好长标题直接把布局撑爆报出“RenderFlex overflowed”的经典黄黑条纹。解决办法是用Text的maxLines和overflow参数。maxLines设成2overflow设成TextOverflow.ellipsis超出两行自动打省略号。但这里有个细节ellipsis在中文环境下有时候会把标点符号也截掉看起来怪怪的。如果你对排版要求高可以用TextOverflow.fade配合软过渡视觉上更自然。价格文字通常需要突出显示我会用RichText或者Text.rich来组合不同字号和颜色的文本。比如“¥”符号用小字号“199”用大字号加粗“起”用灰色小字。这样比单独摆几个Text组件更灵活也更容易控制基线对齐。3. 左图右文布局的完整实现3.1 卡片容器的尺寸计算与约束传递先定一个基准假设设计稿给的是375宽度卡片左右各留12的外边距那么卡片实际宽度是375 - 24 351。图片区域我一般取卡片高度的三分之一左右或者直接固定一个正方形边长设为100到120之间。以120为例图片右侧留12的间距文字区域宽度就是351 - 120 - 12 219。这些数字不是拍脑袋定的。图片太小看不清商品细节太大又挤压文字空间。我试过100、110、120、130几个尺寸最后发现120在主流机型上视觉最平衡。当然这也要根据你的商品品类调整如果是卖鞋的图片可以再大一点如果是卖图书的100就够了。在Flutter里实现的时候用Row包住图片和文字区域图片用SizedBox固定宽高文字区域用Expanded占满剩余空间。这里有个关键点Expanded必须放在Row的直接子级里不能嵌套在别的容器里否则约束传递会出问题。我见过有人把Expanded放在Container里面结果报“Incorrect use of ParentDataWidget”的错误排查半天才发现是层级放错了。3.2 图片区域的圆角、裁剪与占位处理商品图片一般都要加圆角看起来更精致。用ClipRRect包住CachedNetworkImageborderRadius设成8到12之间。圆角太小人觉得没效果太大又显得圆润过头12是我觉得比较舒服的值。图片的fit属性建议用BoxFit.cover。这样不管原图是什么比例都会等比缩放并裁剪到目标尺寸保证图片填满整个区域且不变形。如果用BoxFit.contain图片会完整显示但可能留白在固定尺寸的容器里看起来会很不协调。占位图这块我要多说两句。很多开发者随便找个灰色方块当placeholder加载完成后突然切换成商品图视觉上很突兀。我的做法是用一个带轻微渐变的灰色容器中间放一个商品图标加载完成后再淡入真实图片。这样过渡自然用户不会觉得页面在“跳”。加载失败的兜底图同样重要。网络请求失败、图片链接失效、服务器返回404这些情况在真实环境里太常见了。兜底图我一般用一张统一的“图片加载失败”占位图尺寸和正常图片一致保证布局不塌陷。3.3 文字区域的信息层级与间距控制文字区域从上到下依次是商品标题、价格行、销量和标签行。标题用14到16的字号颜色用深灰比如0xFF333333行高设1.3到1.4两行文字之间不会太挤。价格用18到20的字号颜色用品牌主色或者红色加粗显示。销量和标签用12的字号浅灰色。间距控制是很多人忽略的细节。标题和价格之间留8的垂直间距价格和销量之间留6整个文字区域用MainAxisAlignment.spaceBetween撑满图片高度或者用Column的mainAxisSize设为max。我倾向于用spaceBetween这样不管文字内容多少整体视觉重心都是稳定的。标签行如果有多个标签用Wrap而不是Row。Wrap会自动换行标签多了也不会溢出。标签本身用Container加圆角背景内边距水平6垂直2字号11颜色用浅色背景配深色文字看起来清爽不抢眼。4. 瀑布流双列布局的进阶实现4.1 瀑布流的核心难点不等高卡片的排列算法瀑布流看起来只是两列卡片错落排列但实现起来比左图右文复杂得多。核心难点在于每张卡片的高度由图片宽高比和文字内容共同决定而图片的宽高比在加载完成前是未知的。这就导致了一个鸡生蛋蛋生鸡的问题——你不知道图片多高就没法确定卡片多高不知道卡片多高就没法决定它应该放在左列还是右列。常见的解决方案有两种。第一种是提前获取图片尺寸在数据接口里返回图片的宽高信息客户端根据宽高比和列宽算出显示高度然后贪心地把每张卡片放到当前较矮的那一列。这种方案最准确但需要后端配合。第二种是给图片设一个默认宽高比比如3:4等图片加载完成后再动态调整高度。这种方案实现简单但加载过程中会有布局跳动体验稍差。我实际项目中用的是第一种方案因为商品主图的尺寸在入库时就能拿到让后端在列表接口里多返回两个字段而已成本很低。如果后端实在不方便改那就用第二种方案配合AnimatedContainer做高度过渡动画把跳动感降到最低。4.2 用SliverGrid实现双列瀑布流的参数调优Flutter里实现瀑布流最直接的方式是用SliverGrid配合SliverGridDelegateWithFixedCrossAxisCount。但标准的SliverGrid要求每个子项高度一致做不了瀑布流。所以我们需要用SliverGridDelegateWithMaxCrossAxisExtent或者自定义SliverGridDelegate。更实用的方案是用flutter_staggered_grid_view这个库它提供了MasonryGridView和StaggeredGridView两种瀑布流实现。MasonryGridView就是我们要的它允许每个子项高度不同自动按列排列。用法和GridView几乎一样把gridDelegate换成SliverMasonryGrid.count指定crossAxisCount为2mainAxisSpacing和crossAxisSpacing分别设8和8。参数调优方面crossAxisCount固定为2在手机上最合适平板上可以设3或4。mainAxisSpacing控制行间距我一般设8到12。crossAxisSpacing控制列间距设8左右。padding设12让内容不贴边。这些数值不是死的你可以根据设计稿微调但建议保持间距的一致性不要有的地方8有的地方12看起来会乱。4.3 图片宽高比与卡片高度的联动计算假设列宽是W图片宽高比是R宽/高那么图片显示高度就是W/R。卡片总高度等于图片高度加上文字区域高度。文字区域高度又取决于标题行数、价格行高度、标签行高度和它们之间的间距。标题我限制最多两行每行高度约等于字号乘以行高。以14号字、1.4行高为例单行高度约19.6两行就是39.2。价格行高度约24标签行高度约20加上间距总共约100左右。所以卡片总高度大约是W/R 100。这个计算过程看起来简单但实际写代码的时候要注意像素取整的问题。Flutter的布局引擎对小数像素的处理有时候会带来1像素的偏差导致两列卡片看起来没有完全对齐。我的做法是在计算高度时用double保留小数让Flutter自己去处理渲染精度不要手动round。5. 性能优化与滚动流畅度调优5.1 图片懒加载与预加载的平衡策略ListView.builder本身已经实现了懒加载只构建可见区域内的子项。但图片加载是异步的如果用户快速滑动图片还没来得及加载就滑出了屏幕等滑回来又要重新加载体验很差。解决办法是合理设置cacheExtent。这个参数控制列表在可见区域外预构建的范围默认是250像素。设大一点比如500可以让列表提前构建更多子项图片有更充裕的时间加载。但设太大也会增加内存占用需要根据你的卡片高度来调。如果单张卡片高度约200那cacheExtent设400到600比较合适相当于预加载2到3屏的内容。另外CachedNetworkImage本身有内存缓存已经加载过的图片再次出现时会瞬间从缓存读取不会重新请求网络。所以实际使用中只要用户不是疯狂快速滑动图片加载体验都是可以接受的。5.2 避免重建const构造与Key的合理使用Flutter的性能优化里减少不必要的Widget重建是永恒的话题。在商品列表里每个列表项都应该是一个独立的Widget用const构造函数创建。const Widget在编译期就确定了运行时不会重建能省下不少性能。但要注意如果列表项里包含了动态数据比如商品标题、价格那就不能用const了。这时候可以用Key来帮助Flutter识别哪些项是同一个。给每个列表项加一个ValueKey(item.id)当列表数据变化时Flutter能准确知道哪些项是新增的、哪些是删除的、哪些是移动了位置从而最小化重建范围。还有一个容易忽略的点列表项的build方法里不要做耗时操作。我见过有人在build里做字符串拼接、颜色计算、甚至网络请求这些都是大忌。所有能在build之前算好的东西都提前算好存到数据模型里。5.3 滚动性能监控与帧率分析优化做完了怎么知道效果好不好Flutter提供了性能 overlay在main.dart里把MaterialApp的showPerformanceOverlay设为true屏幕上就会显示GPU和UI线程的帧耗时。绿色表示流畅黄色表示有卡顿风险红色表示严重卡顿。我一般会在真机上跑一遍快速滑动、慢速滑动、来回滑动三种场景观察帧率曲线。如果发现某个位置频繁出现黄色或红色就用Flutter DevTools的Timeline功能定位到具体的Widget重建或布局计算。常见的原因包括图片解码尺寸过大、布局嵌套过深、不必要的重绘等。实测下来一个优化到位的商品列表在主流中端机上应该能稳定跑在55到60帧。如果低于50帧那就还有优化空间。6. 常见问题与排查技巧实录6.1 图片变形、溢出与布局塌陷的排查思路问题一图片被拉伸变形。检查BoxFit属性确保用的是cover或contain不要用fill。fill会强制拉伸图片填满容器比例不对的图片就会变形。问题二文字溢出报RenderFlex overflowed。检查Text组件是否设置了maxLines和overflow。如果是在Row里面确保文字区域被Expanded或Flexible包裹。如果是在Column里面确保Column有明确的约束或者用Flexible包住可能溢出的子项。问题三列表项高度不一致导致布局塌陷。如果用的是ListView每个列表项的高度应该由内容自然撑开不要手动设固定高度。如果必须固定高度确保所有列表项的高度一致否则滚动条位置会跳。6.2 内存占用过高与图片缓存清理商品列表滑动久了内存占用会持续上升这是因为图片缓存越积越多。CachedNetworkImage默认的内存缓存上限是100张图片超过后会按LRU策略淘汰。但如果你的图片尺寸很大100张也可能占用几百MB内存。解决办法有两个。一是控制图片的解码尺寸用memCacheWidth和memCacheHeight限制。二是定期清理缓存在页面dispose的时候调用DefaultCacheManager().emptyCache()。不过要注意清理缓存后再次进入页面图片会重新加载所以不要频繁清理可以在内存警告时或者退出应用时清理。6.3 不同屏幕尺寸与分辨率的适配方案Flutter的布局单位是逻辑像素不同设备的逻辑像素密度不同但逻辑像素的物理尺寸是接近的。所以理论上你按375宽度设计的布局在414宽度的设备上也能正常显示只是元素会稍微拉伸。但图片的显示效果就和分辨率有关了。同样一张200x200的图片在1x设备上显示200逻辑像素宽在3x设备上如果还是显示200逻辑像素宽实际需要600物理像素的图片才够清晰。所以商品图片最好提供多套尺寸或者用足够大的原图让Flutter自动缩放。我的做法是让后端返回图片URL时带上尺寸参数客户端根据设备像素比请求合适尺寸的图片。比如设备像素比是3图片显示宽度是120逻辑像素那就请求360物理像素宽的图片。这样既保证清晰度又不会浪费带宽和内存。6.4 常见问题速查表问题现象可能原因排查方法解决方案图片显示为灰色方块网络请求失败或图片链接失效检查控制台是否有网络错误日志设置errorWidget兜底图检查图片URL是否可访问列表滚动卡顿图片解码尺寸过大或布局嵌套过深开启性能overlay观察帧率限制memCache尺寸减少不必要的Container嵌套文字显示不全maxLines设置过小或容器高度不够检查Text组件参数和父容器约束调整maxLines确保父容器高度自适应两列卡片不对齐高度计算有小数误差或间距不一致检查每张卡片的高度计算逻辑统一使用double计算避免手动round快速滑动时图片闪烁图片缓存被频繁淘汰观察内存占用曲线增大内存缓存上限或降低图片解码尺寸7. 数据模型设计与接口字段规划7.1 商品数据模型应该包含哪些字段一个商品列表项需要展示的信息远不止标题和价格。根据我的经验数据模型里至少应该包含以下字段商品ID、商品标题、商品主图URL、商品主图宽高、价格、原价用于显示划线价、销量、标签列表、店铺名称、是否包邮、促销信息。其中商品主图宽高是瀑布流布局的关键前面已经讲过。标签列表是一个字符串数组每个标签有文字和颜色两个属性。促销信息可能包含“满减”“折扣”“赠品”等多种类型需要用一个枚举或者类型字段来区分。这些字段不是每个都必须在列表页展示但数据模型里最好都留着方便后续扩展。比如点击商品进入详情页时可以直接把整个模型传过去不用再请求一次接口。7.2 接口返回数据的解析与容错处理后端返回的JSON数据你永远不知道会缺什么字段。价格可能是null销量可能是字符串标签可能是空数组。如果不做容错处理一个字段缺失就可能导致整个列表渲染失败。我的做法是在fromJson方法里给每个字段设默认值。价格默认0销量默认0标签默认空数组图片宽高默认1:1。这样即使后端数据有问题页面也能正常显示只是某些信息可能不准确。总比白屏强。另外图片URL要做合法性校验。有些后端返回的URL没有协议头或者包含特殊字符直接传给CachedNetworkImage会报错。我一般会在解析时统一补全协议头并对URL做encode处理。7.3 分页加载与下拉刷新的状态管理商品列表通常都是分页加载的一页20条滑到底部自动加载下一页。这里的状态管理有几个关键点当前页码、是否正在加载、是否还有更多数据、加载失败后的重试机制。我用得最多的是ScrollController监听滚动位置距离底部200像素时触发加载下一页。加载中显示一个底部loading指示器加载失败显示重试按钮没有更多数据了显示“已经到底了”。下拉刷新用RefreshIndicator触发后重置页码为1清空列表数据重新请求第一页。状态管理方案上简单的页面用setState就够了复杂的页面建议用Provider或者Riverpod。我个人的习惯是如果一个页面有超过5个状态变量就上Provider代码会清晰很多。8. 从零搭建一个完整的商品列表页面8.1 项目结构与文件组织一个可维护的商品列表页面代码不应该全堆在一个文件里。我的组织方式是这样的models目录放数据模型widgets目录放可复用的UI组件比如商品卡片、标签、价格文本pages目录放页面级组件services目录放网络请求和数据处理逻辑。商品卡片组件应该是一个独立的StatelessWidget接收一个商品数据模型作为参数返回完整的卡片UI。这样左图右文和瀑布流两种布局可以共用同一个卡片组件只是外层容器不同。标签组件、价格组件也都可以独立出来方便在其他页面复用。8.2 核心代码实现与逐行注释// 商品卡片组件支持左图右文和上图下文两种模式 class ProductCard extends StatelessWidget { final ProductModel product; final bool isGridMode; // true为瀑布流模式false为左图右文模式 const ProductCard({ Key? key, required this.product, this.isGridMode false, }) : super(key: key); override Widget build(BuildContext context) { // 根据模式选择不同的布局 return isGridMode ? _buildGridCard() : _buildListCard(); } // 左图右文布局 Widget _buildListCard() { return Container( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 图片区域固定120x120 ClipRRect( borderRadius: BorderRadius.circular(8), child: CachedNetworkImage( imageUrl: product.imageUrl, width: 120, height: 120, fit: BoxFit.cover, memCacheWidth: 240, // 2倍解码尺寸 memCacheHeight: 240, placeholder: (context, url) Container( width: 120, height: 120, color: const Color(0xFFF5F5F5), child: const Center( child: Icon(Icons.image_outlined, color: Color(0xFFDDDDDD)), ), ), errorWidget: (context, url, error) Container( width: 120, height: 120, color: const Color(0xFFF5F5F5), child: const Center( child: Icon(Icons.broken_image_outlined, color: Color(0xFFDDDDDD)), ), ), ), ), const SizedBox(width: 12), // 文字区域占满剩余空间 Expanded( child: SizedBox( height: 120, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // 标题最多两行 Text( product.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 14, color: Color(0xFF333333), height: 1.4, ), ), // 价格行 Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ const Text( ¥, style: TextStyle( fontSize: 12, color: Color(0xFFFF4D4F), fontWeight: FontWeight.w500, ), ), Text( product.price.toStringAsFixed(2), style: const TextStyle( fontSize: 20, color: Color(0xFFFF4D4F), fontWeight: FontWeight.bold, ), ), if (product.originalPrice product.price) ...[ const SizedBox(width: 6), Text( ¥${product.originalPrice.toStringAsFixed(2)}, style: const TextStyle( fontSize: 12, color: Color(0xFF999999), decoration: TextDecoration.lineThrough, ), ), ], ], ), // 销量和标签行 Row( children: [ Text( 已售${product.salesCount}, style: const TextStyle( fontSize: 12, color: Color(0xFF999999), ), ), const Spacer(), // 标签用Wrap防止溢出 Flexible( child: Wrap( spacing: 4, children: product.tags.take(2).map((tag) { return Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( color: tag.color.withOpacity(0.1), borderRadius: BorderRadius.circular(4), ), child: Text( tag.text, style: TextStyle( fontSize: 11, color: tag.color, ), ), ); }).toList(), ), ), ], ), ], ), ), ), ], ), ); } // 瀑布流模式的上图下文布局 Widget _buildGridCard() { // 根据图片宽高比计算显示高度 final double aspectRatio product.imageWidth / product.imageHeight; // 列宽约为屏幕宽度的一半减去间距 final double screenWidth MediaQuery.of(context).size.width; final double columnWidth (screenWidth - 12 * 2 - 8) / 2; final double imageHeight columnWidth / aspectRatio; return Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 图片区域高度根据宽高比自适应 ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), child: CachedNetworkImage( imageUrl: product.imageUrl, width: columnWidth, height: imageHeight, fit: BoxFit.cover, memCacheWidth: (columnWidth * 2).toInt(), placeholder: (context, url) Container( width: columnWidth, height: imageHeight, color: const Color(0xFFF5F5F5), ), errorWidget: (context, url, error) Container( width: columnWidth, height: imageHeight, color: const Color(0xFFF5F5F5), child: const Center( child: Icon(Icons.broken_image_outlined, color: Color(0xFFDDDDDD)), ), ), ), ), // 文字区域 Padding( padding: const EdgeInsets.all(8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( product.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 13, color: Color(0xFF333333), height: 1.4, ), ), const SizedBox(height: 6), Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ const Text( ¥, style: TextStyle( fontSize: 11, color: Color(0xFFFF4D4F), ), ), Text( product.price.toStringAsFixed(2), style: const TextStyle( fontSize: 17, color: Color(0xFFFF4D4F), fontWeight: FontWeight.bold, ), ), ], ), const SizedBox(height: 4), Text( 已售${product.salesCount}, style: const TextStyle( fontSize: 11, color: Color(0xFF999999), ), ), ], ), ), ], ), ); } }上面这段代码是商品卡片组件的核心实现两种模式共用同一个数据模型通过isGridMode参数切换布局。左图右文模式用Row加Expanded瀑布流模式用Column加动态高度计算。图片统一用CachedNetworkImage带占位和兜底。8.3 列表页面的组装与路由跳转列表页面本身很简单用ListView.builder或者MasonryGridView把卡片组件拼起来就行。关键是ScrollController的监听和分页逻辑。我一般会在initState里初始化数据请求在ScrollController的监听回调里判断是否触发下一页加载。路由跳转方面点击卡片跳转到商品详情页用Navigator.push传整个商品模型过去。详情页可以直接用模型里的数据渲染首屏同时发起更详细的接口请求这样用户感觉页面秒开体验很好。9. 实操心得与避坑经验9.1 图片尺寸与内存占用的平衡技巧图片解码尺寸是内存占用的最大变量。一张1000x1000的图片解码后占用的内存是1000 * 1000 * 4字节约4MB。如果列表里有20张这样的图片内存直接飙到80MB。所以memCacheWidth和memCacheHeight一定要设而且要根据实际显示尺寸来设。我的经验值是显示尺寸乘以设备像素比再乘以1.5的冗余系数。比如图片显示宽度是120逻辑像素设备像素比是3那解码宽度设为120 * 3 * 1.5 540。这样既保证清晰度又不会浪费太多内存。实测下来这个设置下20张图片的内存占用能控制在30MB以内。9.2 文字截断与多行显示的取舍商品标题截断是个两难问题。截断太早用户看不到关键信息截断太晚卡片高度不一致布局乱。我的做法是左图右文模式固定两行瀑布流模式也固定两行。两行基本能容纳大多数商品标题实在超出的用省略号。但省略号的位置有讲究。Flutter默认的ellipsis是在末尾加“...”有时候会把关键规格信息截掉。如果商品标题有固定的格式比如“品牌品类规格”可以考虑用自定义的截断逻辑优先保留品牌和品类把规格截掉。这个需要和后端约定标题格式或者在前端做字符串处理。9.3 真机测试与多设备适配的注意事项模拟器上跑得再流畅真机上也可能翻车。我踩过最深的坑是在模拟器上图片加载飞快因为模拟器用的是电脑的网络和CPU到了真机上网络延迟加上CPU解码图片加载慢了好几倍快速滑动时白屏一片。所以一定要在真机上测试而且要用中低端机测试。我一般会找一台两三年前的安卓中端机做基准如果在这台机器上能流畅运行那高端机肯定没问题。测试的时候重点看三个场景冷启动后首次加载、快速滑动、来回切换Tab。另外不同品牌的手机对图片解码的支持也有差异。有些手机对WebP格式支持不好有些对超大尺寸图片有内存限制。如果发现某些机型上图片显示异常可以考虑统一转成JPEG格式或者限制图片最大尺寸。9.4 后续可扩展的方向这套方案目前覆盖了商品列表的核心需求但还有不少可以扩展的地方。比如图片渐进式加载先加载模糊缩略图再加载高清图体验会更好。再比如骨架屏在数据还没返回时显示灰色占位卡片比转圈loading更优雅。还有一个方向是列表项的动态高度动画。当图片加载完成、卡片高度变化时用AnimatedContainer做平滑过渡避免布局突然跳动。这个在瀑布流模式下尤其重要因为高度变化更频繁。如果要做国际化文字方向、字体、间距都需要适配。阿拉伯语是从右到左的Row的排列方向要反过来。这些都是在实际项目中会遇到的问题提前规划好能省不少事。