ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

移动端列表容器四件套:List/Grid/Tabs/Swiper实战笔记

移动端列表容器四件套:List/Grid/Tabs/Swiper实战笔记 打卡第 11 天终于开始系统啃移动端最常用的一类组件列表容器。之前写页面总是拿到数据就往 Column 里堆 ForEach滚动全靠 Scroll 包一层页面一复杂就卡到想摔手机。今天把 List、Grid、Tabs、Swiper 四个容器全部过了一遍才明白列表页那点事核心不是“把内容放进去”而是“让系统知道内容长什么样、怎么滚动、怎么复用”。这篇文章不只是当日学习记录更是把这四件套的底层分工、配置参数和踩过的坑整理成一份可复用的经验笔记。适合三种人看一是刚开始接触声明式开发、分不清 List 和 Scroll 区别的初学者二是已经会用 List 写列表但遇到 Grid 排列错乱、Tabs 切换丢状态、Swiper 嵌套手势冲突的老问题需要排查的开发者三是想系统梳理移动端容器组件选型思路的人。1. 先搞清楚这四种容器到底谁管谁1.1 从一段“暴力堆组件”的代码说起我以前写信息流页面第一反应是这样的Scroll() { Column() { ForEach(this.feedList, (item: FeedItem) { FeedCard({ item: item }) }, (item: FeedItem) item.id) } }看起来没什么问题逻辑也对。数据量小的时候一切正常但一旦列表变成 8000 条页面就废了启动白屏好几秒滑动掉帧严重甚至直接闪退。这里的问题不在 Scroll而在 Column。Scroll 的职责非常单一——它只负责让超出屏幕的内容可以被拖动查看它不会减少任何子节点的创建和测量工作量。Column 会把传入的 8000 个 FeedCard 全部创建出来算出它们各自的位置和高度哪怕这些卡片根本不在屏幕内。等于你请人吃席明明桌子只能坐十个人你却把 8000 道菜全部下锅炒熟了端上来摆着。这就是列表容器存在的意义让系统只创建正在看和即将看到的那部分内容。1.2 各容器在页面里的分工边界今天学的四个容器名字容易记混但它们的职责边界其实非常清晰List负责“同构数据的序列化展示”比如一列文章、一列消息、一列订单。它处理的是长列表场景核心能力是懒加载和行复用。Grid负责“二维网格排列”比如首页四宫格、商品瀑布流。它关心的是列数怎么算、行间距怎么控制、单元格怎么对齐。Tabs负责“页面级分组切换”比如底部导航、顶部分类页签。它解决的是多个页面如何切换、切换时状态怎么保持的问题。Swiper负责“同一区域内内容的轮播展示”比如顶部 Banner、推荐位。它强调的是自动播放、循环切换、指示器反馈。如果一个页面里又要有轮播图、又要有分类宫格、又要滚动信息流、底部还要切页签正确做法不是把四个容器全部堆进一个 Scroll 里而是要有一个清晰的层级关系。具体的组合方式本文第六部分会单独展开。1.3 选型对照速查表先把四个容器的基础差异放在一张表里后面每个容器的细节再逐个拆容器直属子组件默认滚动方向懒加载典型场景ListListItem垂直支持默认启用信息流、聊天记录、订单列表GridGridItem垂直支持按单元格复用首页宫格、商品墙、图片瀑布流TabsTabContent横向切换无传统滚动条延迟构建页面底部导航、顶部分类页签Swiper任意子组件横向翻页按页缓存Banner 轮播、推荐卡片切换注意Tabs 和 Swiper 虽然视觉上也是“滑动”但它们不是传统意义上的滚动列表容器。有人会把 Tabs 当成 Scroll 来用用 Tabs 包一长串内容这是不对的。2. List 列表容器的性能关键数据懒加载与滚动行缓存2.1 同样是 ForEach为什么有人滑动卡顿List 和 Column 最大的区别在于List 是一个带有虚拟渲染机制的容器。它只会构建当前视口附近的内容当用户往上滑时屏幕顶部的行会被销毁或复用给即将出现的新行。这个机制在文档里叫“懒加载”在社区里更多人直接叫它“虚拟列表”。我用一个直白的生活类比Scroll Column 就像把一整卷胶卷全部冲洗出来一张一张摊在地上给你看List 则是只裁出你眼前这一小段展示你卷一下它才把下一段冲洗出来。所以判断一个页面该不该用 List标准很简单数据量是否可能超过一屏任何可能超过一屏的重复数据列表都应该用 List 而不是 Scroll Column。这不只是性能问题还是内存问题——8000 个带图片的节点同时存在内存占用会非常难看。2.2 item 复用的核心ForEach 的第三个参数List 的性能来自复用而复用的前提是每一项都能被系统准确识别。ForEach 一共有三个参数数据数组、item 构造函数、key 生成函数。大多数人只写前两个第三个参数不写或随手写成(item, index) index。问题就出在这。用数组下标当 key一旦列表中间插入一条数据后面所有 item 的 key 都会发生位移。系统按 key 做 diff 时会认为原来的 item 还是原来的 item只是位置变了于是直接复用旧的组件实例。如果这个 item 内部有输入框、Switch 开关、滚动位置这类自身状态状态就会跟着旧组件跑造成数据错乱。我在一个设置列表里踩过这个坑用户在第 3 项开了一个开关往前插入一条新数据后开关的高亮跑到了第 4 项上。正确写法是给每一项用一个稳定且唯一的业务 IDList() { ForEach(this.articleList, (article: ArticleModel) { ListItem() { ArticleCard({ article: article }) } }, (article: ArticleModel) article.id) }如果服务端没有返回唯一 ID可以在数据进入页面时主动生成一个不要嫌麻烦。这个 key 是复用的命脉图省事后面就得花更多时间排查莫名其妙的 UI 状态问题。2.3 实测Scroll 直出 8000 条 vs List 懒加载为了搞清楚差距到底有多大我今天拿同一个数据源做了个对比。8000 条简版卡片每条包含一张网络图、两行文字。Scroll Column ForEach 的实测结果是页面加载耗时大约 1.4 秒期间主线程一直处于高负载状态滑动时明显能看到“先卡一下再动”的现象。改成 List ListItem 之后首帧时间压到 400 毫秒左右滑动稳定在 60 帧附近肉眼几乎感觉不到卡顿。除了默认的懒加载List 还有一个参数值得记住cachedCount。它表示视口之外提前缓存的行数默认值在不同版本里不完全一致建议显式设置成 3 到 5。缓存太少快速滑动时会出现白屏闪烁缓存太多会浪费内存。从实际体验来看3 是一个很均衡的值。List() { // ListItem... } .cachedCount(3) .width(100%) .layoutWeight(1)3. Grid 网格布局的坑列数计算、滚动方向与子项尺寸3.1 columnsTemplate 与 rowsTemplate 的正确姿势Grid 和 List 最大的不同在于它需要告诉系统“一行放几列”。这个信息通过columnsTemplate声明格式是一个空格分隔的字符串Grid() { // GridItem... } .columnsTemplate(1fr 1fr 1fr 1fr) .columnsGap(12) .rowsGap(12)1fr 1fr 1fr 1fr表示四列每列宽度占比相等。这里的1fr是分数单位可以混合使用比如1fr 2fr 1fr表示中间列宽度是两侧的两倍。有个细节需要注意columnsTemplate里写几列最终就渲染几列系统不会根据屏幕宽度自动调整。如果你需要根据屏幕宽度动态决定列数正确做法是用屏幕宽度除以单个单元格的目标宽度算出一个整数列数再动态拼出模板字符串。另外当你的数据只有 9 条但模板写了 12 格时剩下的格子会留白不会自动移除。所以动态数据最好每次计算模板不要硬编码一个永远不变的列数。3.2 GridItem 的内容为什么不铺满这是一个几乎每个人都会遇到的问题Grid 布局正常但 GridItem 里的文字和图片挤在左上角撑不开整个单元格。原因在于 GridItem 本身是容器单元格的尺寸是由 Grid 的行列模板决定的但它的子组件不会自动继承这个尺寸。子组件默认会按照自己的内容大小布局。解决办法是让子组件显式撑满GridItem() { Column({ space: 8 }) { Image(menu.icon) .width(48) .height(48) Text(menu.name) .fontSize(14) .fontColor(#333333) } .width(100%) .height(100%) .justifyContent(FlexAlign.Center) }这段代码里Column 设置了宽高都占满 100%再配合justifyContent(FlexAlign.Center)让内容居中。如果不写这两个百分比Column 就会缩在左上角看起来像“内容不居中”实际是“容器没撑开”。3.3 数据更新后“图片不变”的排查第三个常见坑是Grid 数据源更新了页面上的图片却不刷新。排查下来往往是两个原因叠加。第一个原因和 ForEach 的 key 有关。key 没有变化时GridItem 会被直接复用子组件内部图片地址的更新可能被跳过。解决方法是把图片 URL 参与进 key 的生成(gridItem: GridItemModel) ${gridItem.id}_${gridItem.iconUrl}第二个原因是网络图片缓存。同样的 URL 换了新图但本地缓存没失效看起来就是“没更新”。这种问题会迷惑很多人因为从代码层面看数据明明已经改了。遇到图片不变时先判断是 key 的问题还是缓存的问题再去动代码。经验Grid 的按单元格复用机制比 List 更激进。List 每一行至少是一个完整行Grid 的单元格更小、复用更频繁key 写错引起的诡异问题在 Grid 里会被放大。4. Tabs 底部标签页的系统默认行为与定制思路4.1 为什么不要用 if/else 自己写切换没系统学 Tabs 之前我做过一个“伪多页签”一个 currentIndex 变量一个 if/else 判断当前显示哪个页面按钮点击时改索引。这个方案问题不大但它失去了三个系统能力手势滑动切换、页面状态保持、切换动画。你自己如果要实现同样的体验得额外处理滑动监听、动画状态、页面缓存工程量一点不少。Tabs 相当于一个封装好的页面容器管理器它帮我们处理好了这些事情。四个容器里Tabs 的“容器感”最强因为它管理的是一整套页面结构而不是简单的列表行。基础写法是Tabs({ barPosition: BarPosition.End, controller: this.tabsController }) { TabContent() { HomePage() } .tabBar(首页) TabContent() { ProfilePage() } .tabBar(我的) } .barHeight(56) .onChange((index: number) { this.currentTab index })4.2 TabContent 必须直系于 Tabs 的原因Tabs 对子组件的要求非常严格TabContent必须是 Tabs 的直接子组件中间不能再包任何容器。错误的写法Tabs() { Column() { TabContent() { HomePage() } .tabBar(首页) } }一旦中间包了 ColumnTabs 就找不到 TabContent 了表现是页签文字能显示但内容区空白或者点击页签没有任何反应。这个报错信息不一定很明显经常会让人怀疑是样式问题。如果 TabContent 里面还需要做整体的上下留白或背景色可以在 TabContent 内部再放 Column/List而不是包在 TabContent 外面。4.3 修改 tabBar 高亮样式时容易混淆的属性Tabs 的 tabBar 相关样式分散在两个位置一部分在 Tabs 根节点上比如barHeight、barBackgroundColor、scrollable一部分在.tabBar()的入参配置里比如文字颜色。很多人想改选中文字颜色时会在 Tabs 上找selectedColor但实际应该看 tabBar 的配置方式。如果是系统默认 tabBar可以直接通过.tabBar()传入 TabBar 样式的配置参数。常见做法有两种简单场景直接传字符串标题复杂场景用 Builder 自定义整个页签视图。用 Builder 自定义时需要自己根据选中状态切换颜色Builder tabBuilder(title: string, index: number) { Column({ space: 4 }) { Text(title) .fontSize(this.currentTab index ? 16 : 14) .fontColor(this.currentTab index ? #FF6A00 : #999999) .fontWeight(this.currentTab index ? FontWeight.Bold : FontWeight.Normal) } .width(100%) .justifyContent(FlexAlign.Center) }这里要注意Builder 里的状态切换依赖this.currentTab的变化。如果 onChange 里更新了 currentTab但页面没有重建页面内的 TabContent那么 tabBuilder 需要依赖 Tabs 自身的重新渲染来刷新。实际使用时把 currentTab 定义成 State 才能驱动刷新。网上搜“tabs标签页 样式修改”能看到大量类似问题很大一部分是因为选的是自定义 tabBar但又试图用系统属性去改。自定义和系统属性是两条路别混着写。5. Swiper 轮播图里的手势冲突与循环播放设置5.1 autoPlay、loop、interval 的配合关系Swiper 是四个容器里属性最“实心”的一个因为它自带定时器和动画系统。核心参数有三个autoPlay是否自动轮播loop是否循环播放也就是最后一张之后是否回到第一张interval自动轮播的间隔时间这三个参数的配合关系是loop是循环的硬件开关autoPlay是自动播放的总开关interval只对 autoPlay 生效。如果把loop设为 false即使 autoPlay 为 true播到最后一张也会停下来。一个常用的 Banner 配置Swiper() { ForEach(this.bannerList, (banner: BannerModel) { Stack() { Image(banner.imageUrl) .width(100%) .height(160) .borderRadius(12) } .width(100%) .height(160) }, (banner: BannerModel) banner.id) } .autoPlay(true) .interval(4000) .loop(true) .indicator(true) .cachedCount(1)cachedCount(1)表示当前页左右各缓存一页。Banner 这种轻量内容不需要缓存太多缓存过多反而会让首屏加载变慢。5.2 轮播方向与外部容器同向时的冲突这是今天花时间最长的一个问题。Swiper 默认横向轮播外面套一个垂直 List两个方向垂直手势各管各的相安无事。但如果把 Swiper 设成vertical(true)再把它包进一个垂直滚动的 List 里冲突就来了。表现非常典型手指上下滑动时页面抖一下但轮播没翻页或者反过来想翻页时列表先滚动了。根本原因在于两个容器都在监听纵向手势系统没法判断你到底想滚列表还是想翻轮播。解决思路不是调手势优先级而是避免同向嵌套。常见的做法保持 Swiper 横向List 垂直两个方向天然不冲突。确实需要纵向翻页的组件建议用 Tabs 或单独页面承载不要塞进 List。如果只是想在 List 里放一个多屏内容模块比如“猜你喜欢”横向滑动卡片那应该用横向的 List 或 Scroll而不是竖向 Swiper。这个原则不仅适用于 Swiper也适用于其他容器同向嵌套是移动端滚动冲突的万恶之源。5.3 指示器和数据更新的坑Swiper 自带指示器可以通过indicator属性配置参数而不是只置 true.indicator({ color: #FFFFFFB3, selectedColor: #FF6A00 })这样在亮度高的图片上也能看清指示器。但要注意系统的默认指示器样式比较固定如果产品要求“指示器是进度条样式或者在轮播图底部居中悬浮”这种定制需求一般还是要做自定义指示器用onChange同步当前页索引。数据更新方面Swiper 和 Grid 有同样的坑。直接修改数组里某个对象的图片 URL但 key 没变时Swiper 可能不会刷新这一页。今天我把图片地址直接拼进 key 之后才解决(banner: BannerModel) ${banner.id}_${banner.imageUrl}如果你遇到“换成新图但页面还是旧图”的问题先检查是不是这个原因再去怀疑图片缓存。6. 今日实战一个“首页信息流 底部标签 顶部轮播”的组合页面6.1 先定层级再写布局四层结构的正确顺序单独学四个容器都不难难的是组合。今天我把一个典型的首页拆开来看顶部有 Banner 轮播中间有分类宫格下面是无限滚动的信息流整个页面挂在底部导航的第一个 Tab 上。一开始我脑子里的结构是Column() { Swiper() // 轮播 Grid() // 宫格 Scroll() { List() // 信息流 } }这个方案跑起来就出问题了外层没有滚动容器内部又没有统一的滚动逻辑。信息流的 List 能滚动但顶部轮播和宫格会一直钉在屏幕顶部信息流只在下面自己滚不是常见的“头部跟着一起滚上去”的效果。能实现“头部滚上去”的方案有两种。第一种是用 Scroll 包 Column里面放 Swiper、Grid、卡片组件放弃懒加载适合数据量小且确定的情况。第二种是把 Swiper 和 Grid 作为 List 的第一个 ListItem让 List 成为整个页面的唯一滚动主体这样既能懒加载又能实现头部跟随滚动。今天推荐的是第二种。6.2 为什么只写一个 Scroll 不够懒加载失效问题有人会问那我不用 List直接在 Scroll 里把 Swiper、Grid、ForEach 都写出来不行吗数据量小的时候可以数据量大的时候就废了原因在本文第二部分已经讲过——Scroll 本身不具备虚拟化能力。还有一种更高阶的错误姿势是从反面踩出来的用 Scroll 包 List希望外层 Scroll 统一滚动一切。实际表现是一层卡住另一层List 经常只显示一屏因为外层 Scroll 在计算高度时无法正确预知 List 这个懒加载容器到底有多高于是把 List 的内容高度当成固定值处理导致 List 内部的滚动和外部滚动互相打架。所以正确的组合结构是整个页面只有一个滚动容器。要么是 List要么是 Scroll。不要在外面再套一个冗余的滚动层。6.3 组合页面的代码骨架能用的结构是这样的Tabs({ barPosition: BarPosition.End }) { TabContent() { List() { // 第一个 ListItem 放轮播和宫格 ListItem() { Column({ space: 12 }) { this.buildBannerSwiper() this.buildMenuGrid() } .width(100%) .padding({ left: 12, right: 12 }) } // 后续 ListItem 放信息流卡片 ForEach(this.feedList, (feed: FeedModel) { ListItem() { FeedCard({ feed: feed }) } .padding({ left: 12, right: 12 }) }, (feed: FeedModel) feed.id) } .cachedCount(5) } .tabBar(首页) TabContent() { List() { ForEach(this.attentionList, (item: AttentionModel) { ListItem() { AttentionItem({ item: item }) } }, (item: AttentionModel) item.id) } } .tabBar(关注) }注意轮播和宫格塞进 ListItem 之后它们的高度应该是明确的或者至少是固定比例计算出来的否则 List 在计算内容高度时会出现跳动。Banner 高度我固定为 160宫格高度固定为 200这样整个 List 的滚动节奏才稳定。6.4 三个 Bug 复盘今天实际跑这个页面时踩了三个 Bug记录一下排查链路这个思路比 Bug 本身更值得复用。Bug 1外层的 Scroll 包住 List 之后List 只显示一屏无法继续滚动。排查时我先用“最小可复现”的思路逐层删除组件删掉外层 Scroll 后 List 恢复正常基本确认是嵌套滚动冲突。修复方式就是上面代码里的方案去掉外层 Scroll把头部内容并进 ListItem。Bug 2TabContent 外层包了一层 Column 之后tabBar 文字能显示但页面内容空白。排查时打开布局检查器发现 TabContent 根本没有被 Tabs 识别。对照文档确认 Tabs 的直系子组件必须是 TabContent去掉中间层后正常。Bug 3Grid 数据更新后界面上的图片和文字还是旧的。排查时先看数据源是否有变化确认有变化后检查 ForEach key。原来的 key 是index改成业务 ID 拼图片 URL 后刷新正常。这个 Bug 在 Grid 和 Swiper 里都会遇到属于复用机制与不规范 key 的典型冲突。排查思路总结遇到容器行为诡异时先做“剥洋葱”——把嵌套的容器一层层拆掉每次拆掉后看问题是否消失。这比盯着代码干想要快得多。7. 今天过完四件套之后记下来的几行备忘7.1 回到“选型”层面的一张总结表四个容器学完最后还是要回到选型。以后写页面先拿着需求对比一下这张表能省很多试错时间场景首选容器理由单列长列表数据可能超过一屏List懒加载 行复用性能和内存最好网格宫格、商品铺排Grid内置列模板和行列间距控制多个页面之间的切换Tabs自带状态保持、手势滑动、动画固定区域的图片/内容轮播Swiper自动播放、循环、指示器一体化7.2 今天最值钱的几条经验容器组件选对了滚动性能问题少一半。Scroll Column 只能用来写内容总量确定、不可能超过一屏的小模块真正撑起业务的长列表一定走 List。同向嵌套是万恶之源。Scroll 包 List、List 里套纵向 Swiper都是同一方向滚动的冲突场景能拆就拆拆不掉就换结构。ForEach 的 key 必须稳定且唯一。这个原则在 List、Grid、Swiper 三个容器里统统适用。用 index 当 key 等于把一个定时炸弹埋在未来某个排序操作上。Tabs 是一个页面结构容器不是普通列表容器。它管的是页面切换和状态保持内部应该放 List、Grid 这些真滚动容器而不是把内容平铺在 TabContent 里硬撑。这些备忘我直接贴到了项目文档首页。下次再犯同样的错先回来打脸再改代码。
返回列表