【私房菜集 HarmonyOS ArkTS 实战系列 08】收藏中心:我的收藏与想做清单如何共享详情页状态
【私房菜集 HarmonyOS ArkTS 实战系列 08】收藏中心:我的收藏与想做清单如何共享详情页状态
第 07 篇已经把本地状态仓拆清楚:收藏、想做、最近浏览和笔记都围绕recipeId存在UserRecipeState中。本篇继续向上看页面组合:收藏中心并不是两个孤立列表,而是同一份状态仓在不同业务视角下的展示。FavoriteService过滤isFavorite,TodoService过滤isInTodoList并按todoOrder排序,Index.ets的收藏 Tab 再把它们组织成“我的收藏”和“想做清单”。

一、收藏中心不是两个数据源
收藏和想做清单看起来是两个功能:一个表示“喜欢这道菜”,一个表示“准备之后做”。如果各自建一套存储,很容易出现状态不同步。
- 详情页取消收藏后,收藏页还残留旧卡片。
- 想做清单移出后,详情页底部按钮仍然显示“移出清单”。
- 同一道菜既收藏又想做时,两个列表无法共享标题、封面、时长和难度。
“私房菜集”的收藏中心没有让两个列表各自保存菜谱副本,而是从 UserStateRepository.listStates() 读取状态,再回到 RecipeService.getRecipeById() 查菜谱详情。状态仓只保存用户行为,菜谱内容仍然来自菜谱数据源。
二、源码对象总览
| 源码对象 | 作用 |
|---|---|
entry/src/main/ets/services/FavoriteService.ets |
读取和切换收藏状态,返回收藏菜谱摘要列表。 |
entry/src/main/ets/services/TodoService.ets |
读取和切换想做状态,按 todoOrder 返回清单。 |
entry/src/main/ets/pages/Index.ets |
在收藏 Tab 中维护 selectedFavoriteTab、favorites、todoList 并渲染列表。 |
entry/src/main/ets/components/common/EmptyStateView.ets |
收藏或想做为空时显示可操作空状态。 |
entry/src/main/ets/components/recipe/RecipeGridCard.ets、RecipeListItem.ets |
分别承载收藏宫格和想做列表项。 |
收藏中心的边界可以概括为:服务层只返回 RecipeSummary[],页面决定当前展示哪个子 Tab,组件负责单条菜谱的视觉呈现。
三、FavoriteTab:一个主 Tab,两个子视角
主页面里有四个一级 Tab,收藏是其中之一。进入收藏 Tab 后,内部再用 selectedFavoriteTab 切换“我的收藏”和“想做清单”:
@State selectedFavoriteTab: FavoriteTabKey = 'favorite';
@State favorites: RecipeSummary[] = [];
@State todoList: RecipeSummary[] = [];
刷新入口在 refreshFavoriteData():
private async refreshFavoriteData(): Promise<void> {
this.favorites = await favoriteService.getFavorites();
this.todoList = await todoService.getTodoList();
}
注意这里同时刷新两个列表,而不是只刷新当前可见列表。这样从详情页返回收藏 Tab 或在两个子 Tab 之间切换时,状态更容易保持一致。收藏中心的数据来源也没有直接碰 Preferences,而是通过服务层取。
四、子 Tab 标题:选中状态只影响展示
收藏 Tab 的顶部切换代码如下:
Row() {
Text('我的收藏')
.fontSize(18)
.fontWeight(this.selectedFavoriteTab === 'favorite' ? FontWeight.Bold : FontWeight.Regular)
.fontColor(this.selectedFavoriteTab === 'favorite' ? $r('app.color.primary_orange') : $r('app.color.text_secondary'))
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.selectedFavoriteTab = 'favorite';
this.refreshFavoriteData();
})
Text('想做清单')
.fontSize(18)
.fontWeight(this.selectedFavoriteTab === 'todo' ? FontWeight.Bold : FontWeight.Regular)
.fontColor(this.selectedFavoriteTab === 'todo' ? $r('app.color.primary_orange') : $r('app.color.text_secondary'))
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.selectedFavoriteTab = 'todo';
this.refreshFavoriteData();
})
}
这里没有新路由,也没有新页面。收藏中心仍然在 Index.ets 内部切换状态,底部导航不会变化。用户感觉是在一个收藏中心里切换视角,工程上也避免了多个页面之间传递列表状态。
五、FavoriteService:状态 ID 回查菜谱
收藏列表读取逻辑如下:
async getFavorites(): Promise<RecipeSummary[]> {
const ids = userStateRepository.listStates().filter(item => item.isFavorite).map(item => item.recipeId);
const result: RecipeSummary[] = [];
ids.forEach(id => {
const recipe = recipeService.getRecipeById(id);
if (recipe) {
result.push(recipeService.toSummary(recipe));
}
});
return result;
}
这段逻辑说明收藏状态只保存 recipeId 和行为字段,不保存完整菜谱副本。真正渲染列表时,再通过 recipeService.getRecipeById(id) 回查菜谱。
这样做有两个好处。
第一,菜谱内容只有一份来源。标题、封面、时长、难度如果在数据源中更新,收藏列表下一次刷新自然拿到新摘要。
第二,可以兼容内置菜谱和用户自建菜谱。RecipeService.getRecipeById() 内部会把用户自建菜谱和内置菜谱合并查找,收藏服务不需要知道菜谱来自 rawfile 还是用户保存。
六、toggleFavorite:详情页和收藏中心共享状态
详情页点击收藏按钮时,调用的是同一个 FavoriteService:
private async toggleFavorite() {
if (this.data.recipe.id.length === 0) {
return;
}
await favoriteService.toggleFavorite(this.data.recipe.id);
const detail = await recipeService.getRecipeDetail(this.data.recipe.id);
if (detail !== null) {
this.data = detail;
}
}
服务层反转收藏状态:
async toggleFavorite(recipeId: string): Promise<UserRecipeState> {
const state = userStateRepository.getState(recipeId);
return userStateRepository.saveState({
recipeId: state.recipeId,
isFavorite: !state.isFavorite,
isInTodoList: state.isInTodoList,
todoOrder: state.todoOrder,
lastViewedAt: state.lastViewedAt,
note: state.note,
updatedAt: state.updatedAt
});
}
详情页切换后会重新读取 getRecipeDetail(),收藏中心刷新时会重新读取 getFavorites()。两个页面不互相通知,但它们读写同一份状态仓,因此能保持一致。
七、我的收藏:宫格展示,强调快速回看
“我的收藏”使用宫格展示:
@Builder
private FavoriteRecipes() {
Scroll() {
Column() {
if (this.favorites.length === 0) {
EmptyStateView({ text: '还没有收藏的菜谱', actionText: '去探索', onAction: () => { this.switchTab('explore'); } })
} else {
Flex({ wrap: FlexWrap.Wrap, alignItems: ItemAlign.Start }) {
ForEach(this.favorites, (recipe: RecipeSummary) => {
RecipeGridCard({ recipe, onRecipeClick: (id: string) => this.openDetail(id) })
.width('47%')
.margin({ right: 8, bottom: 10 })
}, (recipe: RecipeSummary) => recipe.id)
}
.width('100%')
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding({ left: 16, right: 16, bottom: 24 })
}
.align(Alignment.TopStart)
.layoutWeight(1)
}
收藏页使用 RecipeGridCard,因为这里更像“个人菜谱书签”,用户通常通过图片和菜名快速回看。截图中只有一张“番茄炒蛋”卡片,说明详情页收藏后已经能在收藏中心回显。
八、TodoService:想做清单按添加顺序排序
想做清单的读取逻辑和收藏相似,但多了排序:
async getTodoList(): Promise<RecipeSummary[]> {
const ids = userStateRepository.listStates()
.filter(item => item.isInTodoList)
.sort((a, b) => a.todoOrder - b.todoOrder)
.map(item => item.recipeId);
const result: RecipeSummary[] = [];
ids.forEach(id => {
const recipe = recipeService.getRecipeById(id);
if (recipe) {
result.push(recipeService.toSummary(recipe));
}
});
return result;
}
isInTodoList 决定是否进入清单,todoOrder 决定列表顺序。这个顺序不是菜谱热度,也不是菜名排序,而是用户加入想做清单的顺序。
加入或移出清单时:
async toggleTodo(recipeId: string): Promise<void> {
const state = userStateRepository.getState(recipeId);
const nextOrder = userStateRepository.listStates().filter(item => item.isInTodoList).length + 1;
userStateRepository.saveState({
recipeId: state.recipeId,
isFavorite: state.isFavorite,
isInTodoList: !state.isInTodoList,
todoOrder: state.isInTodoList ? 0 : nextOrder,
lastViewedAt: state.lastViewedAt,
note: state.note,
updatedAt: state.updatedAt
});
}
当菜谱不在清单中时,todoOrder 取当前清单长度加 1;当菜谱已经在清单中时,移出后 todoOrder 归零。这个实现简单直观,适合当前“轻量待做列表”的需求。

九、想做清单:列表展示,强调执行顺序
想做清单页面使用列表,而不是宫格:
@Builder
private TodoRecipes() {
Scroll() {
Column({ space: 10 }) {
if (this.todoList.length === 0) {
EmptyStateView({ text: '还没有想做的菜', actionText: '去探索', onAction: () => { this.switchTab('explore'); } })
} else {
ForEach(this.todoList, (recipe: RecipeSummary, index: number) => {
Row({ space: 10 }) {
Text(`${index + 1}`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.primary_orange'))
.width(24)
Column() {
RecipeListItem({ recipe, showViewCount: false, onRecipeClick: (id: string) => this.openDetail(id) })
}
.layoutWeight(1)
.constraintSize({ minWidth: 0 })
}
.width('100%')
}, (recipe: RecipeSummary) => recipe.id)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding({ left: 16, right: 16, bottom: 24 })
}
.align(Alignment.TopStart)
.layoutWeight(1)
}
这里有两个设计点。
第一,左侧显示 index + 1,让清单有明确顺序。这个数字来自当前排序后的数组位置,而排序依据来自服务层的 todoOrder。
第二,列表项使用 RecipeListItem,并传入 showViewCount: false。想做清单关注的是接下来要做什么,不需要突出浏览热度。
十、空状态:没有数据时也要给出口
收藏和想做都可能为空。空状态组件是统一的:
@Component
export struct EmptyStateView {
@Prop text: string;
@Prop actionText: string = '';
onAction: () => void = () => {};
build() {
Column({ space: 12 }) {
Text('空')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.primary_orange'))
.width(64)
.height(64)
.textAlign(TextAlign.Center)
.backgroundColor($r('app.color.primary_orange_light'))
.borderRadius(32)
Text(this.text)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (this.actionText.length > 0) {
Button(this.actionText)
.height(38)
.fontSize(14)
.backgroundColor($r('app.color.primary_orange'))
.onClick(() => this.onAction())
}
}
.width('100%')
.padding(24)
.backgroundColor($r('app.color.card_bg'))
.borderRadius(14)
}
}
收藏为空时按钮文案是“去探索”,想做为空时也是“去探索”。它们都通过 this.switchTab('explore') 回到探索页。空状态不是死胡同,而是把用户带回能产生收藏和想做行为的入口。
十一、页面刷新:onPageShow 和子 Tab 点击都刷新
主页面回到前台时会刷新当前 Tab:
async onPageShow() {
await this.refreshCurrentTab();
}
private async refreshCurrentTab(): Promise<void> {
if (this.selectedTab === 'home') {
await this.refreshHomeData();
} else if (this.selectedTab === 'explore') {
await this.refreshExploreData();
} else if (this.selectedTab === 'favorite') {
await this.refreshFavoriteData();
} else {
await this.refreshMineData();
}
}
这对收藏中心很重要。用户从收藏列表进入详情页,可能取消收藏或移出清单;返回主页面时如果当前还是收藏 Tab,refreshFavoriteData() 会重新读取状态仓,列表就能更新。
子 Tab 标题点击时也会调用 refreshFavoriteData()。这让“我的收藏”和“想做清单”之间切换时始终以仓储层最新状态为准。
十二、详情页按钮和收藏中心的闭环
详情页底部按钮切换想做清单:
Button(this.data.isInTodoList ? '移出清单' : '想做')
.layoutWeight(1)
.height(44)
.fontColor($r('app.color.primary_orange'))
.backgroundColor($r('app.color.primary_orange_light'))
.onClick(() => this.toggleTodo())
切换后重新读取详情:
private async toggleTodo() {
if (this.data.recipe.id.length === 0) {
return;
}
await todoService.toggleTodo(this.data.recipe.id);
const detail = await recipeService.getRecipeDetail(this.data.recipe.id);
if (detail !== null) {
this.data = detail;
}
}
闭环路径可以串成这样:
详情页点击“想做”
→ TodoService.toggleTodo(recipeId)
→ UserStateRepository.saveState(...)
→ 详情页重新读取 RecipeDetailData
→ 返回收藏 Tab
→ refreshFavoriteData()
→ TodoService.getTodoList()
→ 想做清单回显
收藏也是同样的路径,只是服务换成 FavoriteService,字段换成 isFavorite。
十三、运行与验收
本篇截图来自本机 HarmonyOS 模拟器真实运行页面,操作路径如下:
hdc shell aa start -a EntryAbility -b com.lesson.myapplicationsfcj
hdc shell uitest uiInput click 820 2665
hdc shell snapshot_display -f /data/local/tmp/sfcj_08_favorite_tab.jpeg
hdc file recv /data/local/tmp/sfcj_08_favorite_tab.jpeg .\SFCJ\screenshots\08_favorite_tab_raw.jpeg
hdc shell uitest uiInput click 980 230
hdc shell snapshot_display -f /data/local/tmp/sfcj_08_todo_tab.jpeg
hdc file recv /data/local/tmp/sfcj_08_todo_tab.jpeg .\SFCJ\screenshots\08_todo_tab_raw.jpeg
验收重点可以按下面清单检查:
- 详情页点击收藏后,收藏 Tab 的“我的收藏”能看到对应菜谱。
- 详情页点击想做后,“想做清单”能看到对应菜谱。
- 想做清单按加入顺序显示编号。
- 点击收藏卡片或想做列表项能重新进入详情页。
- 详情页取消收藏后,返回收藏 Tab 列表会刷新。
- 详情页移出清单后,返回想做清单列表会刷新。
- 收藏或想做为空时,空状态按钮能回到探索页。
十四、问题复盘:列表里不要保存菜谱副本
收藏中心最容易犯的错误,是把收藏菜谱完整对象直接存进收藏列表。短期看很方便,长期会带来内容重复、更新困难和状态不一致。
当前实现只在状态仓里保存 recipeId 和行为字段,展示时再回查菜谱并转成 RecipeSummary。这让收藏中心保持了两个边界。
- 行为状态属于
UserStateRepository。 - 菜谱内容属于
RecipeService和数据源。
这也带来一个可优化点:当前每次读取收藏和想做都会遍历状态并逐个回查菜谱。项目规模较小时完全够用;如果后续用户状态很多,可以在 RecipeService 内部增加按 ID 查找的 Map 缓存,或者让仓储层返回状态时附带排序后的 ID 列表,减少重复查找。
十五、下一篇衔接
收藏中心完成了对内置菜谱的个人化管理,但菜谱应用不应该只停留在内置内容。下一篇进入添加菜谱:相册选择、动态用料和步骤表单、保存入库以及保存后跳转详情页,继续把“看菜谱”扩展成“管理自己的私房菜”。
更多推荐



所有评论(0)