HarmonyOS 收藏页面优化:从硬编码到数据驱动的重构过程

一个 CategoryList 页面同时承载分类、收藏、个人中心三种模式,核心改动只有三个。

前言

饮品 App 的收藏功能之前一直是个半成品——点"收藏" Tab 跳到 CategoryList,页面能打开,但数据是写死的,收藏和取消收藏的状态不会同步,返回首页后底部 Tab 的选中态还卡在"收藏"上。

最近对收藏页面做了一轮结构优化,把这三个问题都解决了。改动量不大,但涉及的思路值得记录——怎么用最小的代码改动,让一个页面从"能跑"变成"能用"。

先看效果

优化后的首页长这样:搜索框、分类金刚区、热门配方和我的配方两个横向滚动列表,底部是带系统图标的导航栏。点"收藏" Tab 跳到分类列表页,展示收藏的配方;按返回键回到首页,底部 Tab 自动回到"首页"选中态。

整个流程串起来了,但过程中踩了几个坑。
在这里插入图片描述

核心改动一:onPageShow 修复 Tab 状态

最早的问题是这样的:

  1. 用户在首页,底部"首页" Tab 是选中态
  2. 点击"收藏" Tab → currentTab = 2 → 跳转到 CategoryList
  3. 用户在收藏页按返回键回到首页
  4. Bug:底部导航栏的"收藏" Tab 还是选中态,但显示的是首页内容

原因很简单:currentTab 状态变量没有被重置。用户从收藏页返回时,currentTab 仍然是 2

解决方式:

onPageShow(): void {
  this.currentTab = 0
}

在这里插入图片描述

onPageShow 在页面每次出现在屏幕上时触发——不只是首次渲染,从其他页面返回时也会触发。在这里把 currentTab 重置为 0,确保底部导航栏的选中态和实际显示的内容一致。

为什么用 onPageShow 而不是 aboutToAppear

aboutToAppear → 页面首次创建时触发,只触发一次
onPageShow   → 页面每次出现在屏幕上时触发,可触发多次

从收藏页返回时,首页的组件实例还活着,aboutToAppear 不会重新执行。只有 onPageShow 能正确处理"返回时重置"的场景。

核心改动二:onTabClick 集中管理跳转逻辑

底部导航栏的 Tab 点击逻辑从分散的回调改成了集中管理:

private onTabClick(index: number): void {
  this.currentTab = index
  if (index === 1) {
    router.pushUrl({ url: 'pages/DrinkCustomize' })
  } else if (index === 2) {
    router.pushUrl({ url: 'pages/CategoryList', params: { category: '收藏' } })
  } else if (index === 3) {
    router.pushUrl({ url: 'pages/CategoryList', params: { category: '我的' } })
  }
}

在这里插入图片描述

TabItem 构建器不再需要可选回调参数:

@Builder TabItem(index: number, title: string) {
  Column({ space: 4 }) {
    SymbolGlyph(this.getTabSymbol(index, this.currentTab === index))
      .fontSize(24)
      .fontColor([this.currentTab === index ? '#8A5DFA' : '#888888'])
    Text(title)
      .fontSize(11)
      .fontColor(this.currentTab === index ? '#8A5DFA' : '#888888')
  }
  .onClick(() => {
    this.onTabClick(index)
  })
}

在这里插入图片描述

调用方只传索引和标题,不关心跳转逻辑。加一个新 Tab,只需要在 onTabClick 里加一个 else if 分支。

核心改动三:CategoryList 的参数驱动复用

收藏页和"我的"页复用了 CategoryList 页面,通过 category 参数区分模式:

// 首页跳转到收藏页
router.pushUrl({ url: 'pages/CategoryList', params: { category: '收藏' } })

// 首页跳转到"我的"页面
router.pushUrl({ url: 'pages/CategoryList', params: { category: '我的' } })

// 首页跳转到分类列表
router.pushUrl({ url: 'pages/CategoryList', params: { category: '奶茶' } })

CategoryList 页面根据 category 参数选择数据来源:

loadData(): void {
  if (this.searchKeyword) {
    this.loadBySearch(this.searchKeyword)
  } else if (this.category === '收藏') {
    this.loadFavorites()
  } else if (this.category === '我的') {
    this.loadMyRecipes()
  } else {
    this.loadByCategory(this.category)
  }
}

四种数据来源,一个页面,布局完全复用。

为什么不让收藏页和"我的"页各自独立?

因为它们的 UI 布局几乎一样——顶部标题 + 配方卡片列表。区别只是数据来源和标题文字。与其维护三套几乎相同的代码,不如让一个页面通过参数切换模式。

这个决策的前提是:三个功能的 UI 布局 80% 以上相同。如果后续收藏页面需要特殊功能(比如批量管理、拖拽排序),再考虑拆分也不迟。

RecipeData 数据结构

配方数据通过 RecipeData 接口定义:

interface RecipeData {
  id: number
  name: string
  img: string
  base: string
  category: string
  sweetness: number
  time: string
  difficulty: string
}

首页通过 ID 数组引用配方:

private hotRecipeIds: number[] = [1, 2, 6, 8]
private myRecipeIds: number[] = [3, 4, 5, 7]

然后用 getRecipeById() 查找完整数据:

private getRecipeById(id: number): RecipeData | undefined {
  for (let i = 0; i < RECIPES.length; i++) {
    if (RECIPES[i].id === id) { return RECIPES[i] }
  }
  return undefined
}

返回类型是 RecipeData | undefined——ID 可能找不到对应配方,UI 层必须处理这种情况:

Text(this.getRecipeById(id)?.name || '饮品')

可选链 ?. 加 fallback || 是 ArkTS 中处理空值的标准写法。

SafeImage 图片容错组件

网络图片在移动端不可靠——URL 可能失效、加载可能超时。SafeImage 组件用 Stack 双层结构解决这个问题:

Stack() {
  // 占位层:带 emoji 的背景
  Row() {
    Text(this.emoji).fontSize(36).opacity(0.4)
  }
  .width('100%').height('100%')
  .backgroundColor('#EDE8F5')
  .justifyContent(FlexAlign.Center)
  .alignItems(VerticalAlign.Center)
  .borderRadius(this.imgRadius)

  // 图片层:覆盖在占位层上面
  Image(this.imgSrc)
    .width('100%').height('100%')
    .borderRadius(this.imgRadius)
    .objectFit(ImageFit.Cover)
}

图片加载成功时覆盖占位层,加载失败时占位层始终可见。用户不会看到空白区域。

使用方式:

SafeImage({
  imgSrc: this.imgUrl(this.getRecipeById(id)?.img || '', 260),
  imgWidth: 130,
  imgHeight: 140,
  imgRadius: 12,
  emoji: '饮',
  category: this.getRecipeById(id)?.category || ''
})

在这里插入图片描述

imgUrl() 方法动态控制图片尺寸——金刚区用 100px,配方卡片用 260px,避免加载过大的原图浪费流量。

配方卡片的 Stack 布局

"我的配方"列表的卡片用了 Stack 布局实现标签叠加效果:

Stack({ alignContent: Alignment.TopStart }) {
  SafeImage({ ... })

  Text('试试')
    .fontSize(10)
    .fontColor(Color.White)
    .backgroundColor('#8A5DFA')
    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
    .borderRadius({ topLeft: 12, bottomRight: 8 })
}

"试试"标签用 Alignment.TopStart 定位在卡片左上角,叠加在图片上面。borderRadius({ topLeft: 12, bottomRight: 8 }) 让标签的圆角和卡片圆角匹配。

这种 Stack 叠加模式在移动端很常见——角标、标签、播放按钮等悬浮元素都可以用这种方式实现。

完整的导航流程

把所有改动串起来,完整的导航流程是:

首页 (currentTab=0)
  → 点击"收藏" Tab
  → onTabClick(2) → currentTab=2 → pushUrl 跳转到 CategoryList
  → CategoryList 加载收藏数据
  → 用户按返回键
  → 回到首页 → onPageShow 触发 → currentTab=0

底部导航栏的 currentTab 变化:

0 → 2 (点击时瞬间变化) → pushUrl 跳转 → 返回 → 0 (onPageShow 重置)

中间那个 2 是一瞬间的状态,用户看不到选中态变化,因为页面已经切走了。

踩坑记录

1. onPageShow vs aboutToAppear

@Entry
@Component
struct MyPage {
  aboutToAppear() {
    // 页面首次创建时触发,只触发一次
    // 适合:初始化数据、获取路由参数
  }

  onPageShow() {
    // 页面每次出现在屏幕上时触发
    // 适合:刷新状态、重置 UI、同步数据
  }
}

记忆方式aboutToAppear 是"出生",onPageShow 是"每次露面"。

2. router.getParams() 必须类型断言

const params = router.getParams() as Record<string, Object>

router.getParams() 返回 object 类型,不做断言就取不到值。断言成 Record<string, Object> 是最通用的写法,适合简单参数。嵌套对象就断言成对应的接口类型。

3. SymbolGlyph 的 fontColor 是数组

// ❌ 编译错误
.fontColor('#8A5DFA')

// ✅ 正确
.fontColor(['#8A5DFA'])

SymbolGlyph.fontColor() 接收 ResourceColor[],不是 ResourceColor。忘了加数组括号是最常见的编译错误。

4. 参数深拷贝

router.pushUrl 传参时会做深拷贝,目标页面拿到的是副本。修改参数不会影响发送方。如果需要跨页面共享状态,得用 AppStorage 或本地存储。

5. 收藏状态同步

如果用户在 RecipeDetail 页面取消了收藏,返回到 CategoryList(收藏模式)时,列表不会自动更新——因为 loadData() 只在 aboutToAppear 时调用了一次。

解决方式:在 onPageShow 里重新加载数据。

onPageShow() {
  this.loadData()
}

代价是每次回到页面都会重新查询。如果收藏列表很长,可以考虑增量更新。

最后

这次优化的三个核心改动——onPageShow 重置 Tab 状态、onTabClick 集中管理跳转、CategoryList 参数驱动复用——每个改动都不大,但组合在一起让收藏功能从"半成品"变成了"能用"。

Logo

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

更多推荐