
中式美食里面有不少列表:菜谱列表、搜索结果、收藏页、购物清单、推荐卡片流。
列表这种页面看起来简单,其实很容易越写越重。刚开始数据少,直接在一个页面里把图片、标题、标签、按钮、收藏状态都写出来,问题不明显。等页面开始接搜索、收藏、最近浏览、购物清单这些状态以后,卡片会越来越复杂,刷新也会越来越频繁。
这篇只讲一个具体问题:ArkUI 列表怎么写得更稳一点。重点不是炫写法,而是把列表卡片拆清楚、把 key 设稳定、把状态范围收窄,让用户滑动时少抖动,开发调试时也能知道是哪一层在刷新。
本章位置
| 项目 | 内容 |
|---|---|
| 应用名称 | 中式美食 |
| 涉及页面 | 菜谱列表、搜索结果、收藏页、购物清单、推荐列表 |
| 主要问题 | 列表卡片复杂后,整页刷新、滑动抖动、状态互相影响 |
| 技术关键词 | HarmonyOS、ArkUI、ArkTS、ForEach、@Builder、组件拆分、状态管理 |
| 本文目标 | 让列表更新更可控,卡片职责更清楚,性能问题更容易定位 |
本文导读
| 问题 | 本文做法 |
|---|---|
| 卡片代码写在页面里太长 | 拆成 RecipeCard、RecipeMetaRow、RecipeActionRow |
| 列表刷新时容易整块重建 | ForEach 使用稳定业务 id,不用 index 当核心 key |
| 收藏、勾选、推荐理由都挤在一层 | 把页面状态、卡片状态、业务状态分开 |
| 滑动体验不好判断 | 用固定数据、快速筛选、连续滑动做验收 |
| 后续要接推荐排序 | 让列表卡片只消费展示模型,不直接算业务分数 |
列表页最怕什么
列表页最怕的不是代码多,而是"所有东西都混在一起"。
比如一个菜谱卡片里会有封面、标题、描述、标签、收藏按钮、最近浏览提示、购物清单入口。搜索结果页还要高亮关键词,推荐页还要显示推荐理由,购物清单页还要显示数量和勾选状态。如果这些全部塞在一个 build() 里,后面任何一个状态变化,都可能牵动整页。
我更倾向先把列表拆成三层:
| 层级 | 负责什么 |
|---|---|
| Page | 负责加载、筛选、空态、滚动容器 |
| ViewModel | 负责把业务数据转成列表展示模型 |
| Card Component | 只负责展示一条数据和触发用户动作 |
页面不要直接拼一堆业务判断。页面应该只问 ViewModel:现在要展示哪些卡片?卡片上要展示什么?用户点了收藏以后调用哪个动作?
不要让页面直接背所有字段
中式美食的菜谱数据本身字段不少:菜名、封面、分类、食材、步骤、营养、收藏状态、浏览状态、清单状态。列表页不需要知道所有字段。
更稳的做法是先做一个列表展示模型:
ts
type RecipeListItem = {
id: string
title: string
cover: ResourceStr
desc: string
tags: string[]
favorite: boolean
reason?: string
actionText?: string
}
这层模型的好处很直接:页面不再关心菜谱完整结构,只关心列表需要的字段。详情页需要食材和步骤,列表页不一定需要。推荐排序需要分数,列表页也不应该直接拿分数做判断。
如果后续推荐页要显示"购物清单里已有部分食材",只需要给 reason 填一句话,卡片照样能展示。
RecipeCard 要拆出来
卡片一复杂,就不要继续留在页面里。中式美食这种内容型应用,菜谱卡片会在很多页面复用,拆组件能少很多重复。
一个基本拆法可以是这样:
ts
@Component
struct RecipeCard {
@Prop item: RecipeListItem
onOpen: (id: string) => void = () => {}
onFavorite: (id: string) => void = () => {}
build() {
Row() {
Image(this.item.cover)
.width(96)
.height(96)
.borderRadius(14)
.objectFit(ImageFit.Cover)
Column() {
Text(this.item.title)
.fontSize(18)
.fontWeight(FontWeight.Medium)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.item.desc)
.fontSize(13)
.fontColor('#6F6A5F')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
RecipeTagRow({ tags: this.item.tags })
if (this.item.reason) {
Text(this.item.reason)
.fontSize(12)
.fontColor('#9A5A24')
}
}
.layoutWeight(1)
}
.padding(12)
.onClick(() => this.onOpen(this.item.id))
}
}
这里有一个原则:卡片组件可以接收动作,但不要自己决定业务规则。比如"收藏以后写 Preferences 还是 RDB",这不是卡片的职责。卡片只负责把点击事件交出去。
ForEach 的 key 要稳定
列表刷新时,ForEach 的 key 很关键。很多人一开始会顺手用 index,这在静态列表里问题不大,但中式美食这种页面会筛选、排序、搜索、插入推荐位,用 index 就容易出问题。
比如搜索关键词变了,列表顺序变了。如果 key 只是 index,框架看到的可能是"第 1 个元素还是第 1 个元素",但实际内容已经换了。这样在复杂卡片里就容易出现状态错位、局部刷新不准、动画不自然。
更稳的写法是用业务 id:
ts
ForEach(this.viewModel.items, (item: RecipeListItem) => {
RecipeCard({
item,
onOpen: (id: string) => this.openDetail(id),
onFavorite: (id: string) => this.toggleFavorite(id)
})
}, (item: RecipeListItem) => item.id)
item.id 是菜谱自己的稳定标识。筛选、排序、推荐重排以后,只要还是同一道菜,key 就不变。这样框架更容易复用正确的节点,页面状态也更容易保持一致。
状态范围要收窄
列表页还有一个常见问题:一个很小的变化,引发整页跟着变。
比如用户点了某一道菜的收藏,如果页面只维护一个大数组,然后每次都重新生成所有卡片,短期能跑,长期会难调。更好的方式是把状态变化收窄到具体业务动作:
ts
async toggleFavorite(recipeId: string) {
await this.favoriteRepository.toggle(recipeId)
this.items = this.items.map(item => {
if (item.id !== recipeId) {
return item
}
return {
...item,
favorite: !item.favorite
}
})
}
这段代码看起来普通,但它传达了一个重点:收藏变化只影响命中的那一条展示模型。页面不用重新去算搜索关键词,不用重建全部推荐理由,也不用让卡片自己去读仓储。
如果后面数据量再变大,可以继续把 ViewModel 的更新逻辑拆得更细,但第一步先把"整页状态"和"单条卡片状态"分开。
标签行和操作行也可以继续拆
卡片内部也不要无限堆。标签行、按钮行、推荐理由都可以拆成更小的组件。
ts
@Builder
function RecipeTagRow(tags: string[]) {
Row() {
ForEach(tags.slice(0, 3), (tag: string) => {
Text(tag)
.fontSize(11)
.fontColor('#8A5A2B')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor('#FFF3E4')
.borderRadius(10)
}, (tag: string) => tag)
}
.gap(6)
}
这里用 @Builder 就够了,不一定每个小片段都要做成完整 @Component。我的判断方式很简单:
| 写法 | 适合场景 |
|---|---|
| @Builder | 纯 UI 片段,没有自己的复杂状态 |
| @Component | 需要独立参数、事件、生命周期或复用频率高 |
| 普通函数 | 只做数据转换,不渲染 UI |
中式美食里,RecipeCard 适合做组件,RecipeTagRow 可以先用 Builder。这样不会过度设计,也能保持卡片代码清爽。
图片加载要给稳定尺寸
列表里的图片一定要给稳定尺寸。菜谱卡片如果等图片加载完才撑开高度,滑动时就容易跳。
中式美食的封面图在列表里可以固定为 96x96 或 112x88,推荐流里可以统一为 16:9。关键不是哪个尺寸最好,而是同一个列表里的卡片尺寸要稳定。
ts
Image(item.cover)
.width(96)
.height(96)
.borderRadius(14)
.objectFit(ImageFit.Cover)
.backgroundColor('#F3EFE7')
背景色也要给。图片没加载出来时,卡片不会突然露白,也不会显得像坏图。
推荐列表也应该吃同一套卡片模型
第 29 篇讲了推荐排序。推荐排序算出来以后,不要让推荐页重新写一套卡片。
推荐页可以把排序结果转成同一个 RecipeListItem:
ts
function toListItem(item: RecipeRecommendation): RecipeListItem {
return {
id: item.recipe.id,
title: item.recipe.name,
cover: item.recipe.cover,
desc: item.recipe.description,
tags: item.recipe.tags,
favorite: item.recipe.favorite,
reason: item.reasons.slice(0, 2).join(' · '),
actionText: '查看做法'
}
}
这样做有两个收益:
| 收益 | 说明 |
|---|---|
| UI 一致 | 推荐、搜索、收藏页的卡片不会长得各不相同 |
| 维护成本低 | 卡片样式改一次,多处一起生效 |
| 测试更清楚 | 只要测展示模型,不用每个页面重复测卡片细节 |
| 后续扩展容易 | 推荐理由只是模型字段,不会侵入卡片结构 |
工程验收记录
| 验收动作 | 预期结果 |
|---|---|
| 打开菜谱列表快速滑动 | 卡片高度稳定,没有图片加载后跳动 |
| 搜索关键词后刷新列表 | 列表顺序变化后,收藏状态不串行 |
| 连续点击收藏按钮 | 只更新当前卡片状态,不影响其它卡片 |
| 切换分类筛选 | ForEach key 仍使用菜谱 id,卡片状态不乱 |
| 推荐列表展示理由 | 卡片能显示一到两条推荐理由,不挤压标题 |
| 删除或新增一条数据 | 列表能稳定增删,不依赖 index 识别业务项 |
这张表很重要。列表性能不能只靠"感觉还行"。至少要用这些动作去压一下,看看有没有抖动、串状态、错位、空白闪烁。
推荐阅读
| 主题 | 为什么建议一起看 | 链接 |
|---|---|---|
| 列表筛选体验 | 关键词命中和排序切换会直接触发列表刷新 | 阅读文章 |
| 购物清单合并 | 清单页也是列表,数量合并后更需要稳定 key | 阅读文章 |
| 推荐排序 | 推荐结果最终还是要落到列表卡片里展示 | 阅读文章 |
| 本地持久化 | 收藏和最近浏览状态来自本地仓储 | 阅读文章 |
本章小结
中式美食的列表页要想稳,不能只看页面有没有渲染出来。真正要看的,是列表数据变化时,卡片是不是还稳定,收藏状态是不是还准确,图片尺寸是不是还撑得住,推荐理由会不会把卡片挤乱。
我会先做三件事:第一,把 RecipeCard 从页面里拆出来;第二,ForEach 使用菜谱 id 做稳定 key;第三,把页面加载状态、卡片展示模型和业务仓储动作分开。这样列表后面接搜索、收藏、购物清单、推荐排序时,不会每加一个能力就把页面改成一团。
最后留一个实际问题:如果你的 HarmonyOS 页面里也有复杂列表,你现在用的是 index 当 key,还是稳定业务 id?如果列表会筛选、排序或插入推荐位,我建议尽早改成业务 id,这个改动越早做,后面越省事。