【私房菜集 HarmonyOS ArkTS 实战系列 08】收藏中心:我的收藏与想做清单如何共享详情页状态

第 07 篇已经把本地状态仓拆清楚:收藏、想做、最近浏览和笔记都围绕 recipeId 存在 UserRecipeState 中。本篇继续向上看页面组合:收藏中心并不是两个孤立列表,而是同一份状态仓在不同业务视角下的展示。FavoriteService 过滤 isFavoriteTodoService 过滤 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 中维护 selectedFavoriteTabfavoritestodoList 并渲染列表。
entry/src/main/ets/components/common/EmptyStateView.ets 收藏或想做为空时显示可操作空状态。
entry/src/main/ets/components/recipe/RecipeGridCard.etsRecipeListItem.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 列表,减少重复查找。

十五、下一篇衔接

收藏中心完成了对内置菜谱的个人化管理,但菜谱应用不应该只停留在内置内容。下一篇进入添加菜谱:相册选择、动态用料和步骤表单、保存入库以及保存后跳转详情页,继续把“看菜谱”扩展成“管理自己的私房菜”。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐