HarmonyOS 收藏页面优化:用复用代替新建页面的思路

一个 CategoryList 页面同时承担分类列表、收藏列表、个人中心三个角色。

前言

饮品 App 有三个功能区需要展示列表:分类列表(按品类筛选)、收藏列表(用户收藏的配方)、个人中心(用户创建的配方)。最早的想法是每个功能区做一个独立页面——CategoryListFavoriteListMyProfile,各写各的。

但仔细一想,这三个页面的布局几乎一样:顶部标题 + 配方卡片列表。区别只是数据来源不同。与其写三个页面然后维护三套几乎相同的代码,不如让一个 CategoryList 页面同时支持三种模式。

这篇文章讲的就是这个复用方案的实现过程,以及过程中踩到的页面生命周期的坑。

先看效果:一个页面三种形态

在这里插入图片描述

CategoryList 页面接收一个 category 参数,根据参数值决定展示什么内容:

router.pushUrl({ url: 'pages/CategoryList', params: { category: '奶茶' } })     → 奶茶分类列表
router.pushUrl({ url: 'pages/CategoryList', params: { category: '收藏' } })     → 收藏列表
router.pushUrl({ url: 'pages/CategoryList', params: { category: '我的' } })     → 个人配方列表
router.pushUrl({ url: 'pages/CategoryList', params: { search: '柠檬' } })       → 搜索结果

同一个页面,四种不同的数据来源,布局复用,逻辑独立。

数据路由:category 参数决定数据来源

CategoryList 页面的核心逻辑是根据 category 参数选择不同的数据加载方式:

@Entry
@Component
struct CategoryList {
  @State category: string = ''
  @State searchKeyword: string = ''
  @State recipes: RecipeData[] = []

  aboutToAppear() {
    const params = router.getParams() as Record<string, Object>
    if (params) {
      this.category = (params.category as string) || ''
      this.searchKeyword = (params.search as string) || ''
    }
    this.loadData()
  }

  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)
    }
  }

  loadBySearch(keyword: string) {
    this.recipes = RECIPES.filter(r => r.name.indexOf(keyword) >= 0)
  }

  loadFavorites() {
    // 实际项目中从 AppStorage 或本地存储读取收藏列表
    this.recipes = RECIPES.filter(r => r.id === 1 || r.id === 3 || r.id === 5)
  }

  loadMyRecipes() {
    // 实际项目中从 AppStorage 或本地存储读取用户创建的配方
    this.recipes = RECIPES.filter(r => r.id === 2 || r.id === 4)
  }

  loadByCategory(category: string) {
    this.recipes = RECIPES.filter(r => r.category === category)
  }
}

loadData() 方法像一个路由器——根据参数把请求分发到不同的数据加载函数。每个函数只关心自己的数据逻辑,互不干扰。

这种设计的好处:

  • 新增数据来源只需要加一个 else if 分支和对应的加载函数
  • 每个加载函数可以独立测试
  • UI 层完全不关心数据从哪来,只管渲染 this.recipes 数组

收藏和"我的":状态管理的两种方案

收藏列表和"我的"列表的数据来源比较特殊——它们不是从固定数据源筛选的,而是和用户行为相关的(收藏了哪些、创建了哪些)。

这里有两种常见的实现方案:

方案一:AppStorage 全局状态

// 在 AppStorage 中维护收藏列表
AppStorage.SetOrCreate<number[]>('favoriteIds', [1, 3, 5])

// CategoryList 页面读取
aboutToAppear() {
  const favIds = AppStorage.Get<number[]>('favoriteIds') || []
  this.recipes = RECIPES.filter(r => favIds.indexOf(r.id) >= 0)
}

// 收藏/取消收藏时更新
// 在 RecipeDetail 页面
toggleFavorite(recipeId: number) {
  let favIds = AppStorage.Get<number[]>('favoriteIds') || []
  if (favIds.indexOf(recipeId) >= 0) {
    favIds = favIds.filter(id => id !== recipeId)
  } else {
    favIds.push(recipeId)
  }
  AppStorage.Set<number[]>('favoriteIds', favIds)
}

优点:数据全局共享,任何页面都能读写。
缺点:数据不持久化,App 重启就没了。

方案二:本地持久化存储

import dataPreferences from '@ohos.data.preferences'

// 读取收藏列表
async loadFavorites() {
  const prefs = await dataPreferences.getPreferences(this.context, 'favorites')
  const favIdsStr = await prefs.get('ids', '[]')
  const favIds: number[] = JSON.parse(favIdsStr as string)
  this.recipes = RECIPES.filter(r => favIds.indexOf(r.id) >= 0)
}

// 保存收藏
async toggleFavorite(recipeId: number) {
  const prefs = await dataPreferences.getPreferences(this.context, 'favorites')
  const favIdsStr = await prefs.get('ids', '[]')
  const favIds: number[] = JSON.parse(favIdsStr as string)
  
  const idx = favIds.indexOf(recipeId)
  if (idx >= 0) {
    favIds.splice(idx, 1)
  } else {
    favIds.push(recipeId)
  }
  
  await prefs.put('ids', JSON.stringify(favIds))
  await prefs.flush()
}

优点:数据持久化,App 重启不丢失。
缺点:异步操作,代码稍复杂。

实际项目中,两个方案可以组合使用——AppStorage 做实时状态同步,dataPreferences 做持久化。页面加载时从持久化存储读取到 AppStorage,操作时同时更新两者。

onPageShow:回来时 Tab 状态不对

这是整个方案中最坑的一个问题。

场景是这样的:

  1. 用户在首页,底部导航栏"首页"是选中态
  2. 点击底部"收藏" Tab → 跳转到 CategoryList(收藏模式)
  3. 在收藏页面浏览完,按返回键回到首页
  4. 问题来了:底部导航栏的"收藏" Tab 还是选中态

原因:currentTab 状态变量没有被重置。用户从收藏页返回时,currentTab 仍然是 2(收藏),但实际显示的是首页内容。

解决方式——用 onPageShow 生命周期回调:

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

在这里插入图片描述

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

一个容易忽略的细节onPageShowaboutToAppear 的区别。

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

如果用 aboutToAppear 来重置 currentTab,从其他页面返回时不会触发——因为页面实例还活着,没有重新创建。只有 onPageShow 能正确处理"返回时重置"的场景。

完整的导航流程

把首页和收藏页的导航流程串起来:

首页 (currentTab=0)
  → 点击"收藏" Tab
  → router.pushUrl({ url: 'pages/CategoryList', params: { category: '收藏' } })
  → CategoryList 加载收藏数据,渲染列表
  → 用户按返回键
  → 回到首页,onPageShow 触发,currentTab 重置为 0

整个流程中,currentTab 的变化:

0 (首页) → 2 (点击收藏Tab,但还没跳转) → pushUrl 跳转 → 返回 → 0 (onPageShow 重置)

在这里插入图片描述

中间那个 2 是一瞬间的状态——用户点击"收藏" Tab 时,onTabClick 会先设置 currentTab = 2,然后立刻跳转到 CategoryList。这个瞬间用户看不到选中态变化,因为页面已经切走了。

CategoryList 的 UI 结构

数据层搞定了,UI 就简单了——标准的列表渲染:

build() {
  Column() {
    // 顶部标题栏
    Row() {
      Text('<').fontSize(20).onClick(() => { router.back() })
      Text(this.getTitle()).fontSize(18).fontWeight(FontWeight.Bold)
    }
    .width('100%')
    .padding(16)

    // 配方列表
    if (this.recipes.length > 0) {
      List({ space: 12 }) {
        ForEach(this.recipes, (recipe: RecipeData) => {
          ListItem() {
            // 配方卡片
          }
        })
      }
      .width('100%')
      .padding({ left: 16, right: 16 })
    } else {
      // 空状态
      Text('暂无相关配方')
        .fontSize(14)
        .fontColor('#999999')
        .margin({ top: 100 })
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#FAFAFC')
}

private getTitle(): string {
  if (this.searchKeyword) return `搜索:${this.searchKeyword}`
  return this.category
}

标题栏根据模式显示不同文字:

  • 搜索模式 → “搜索:柠檬”
  • 分类模式 → “奶茶”
  • 收藏模式 → “收藏”
  • 个人模式 → “我的”

列表为空时显示"暂无相关配方"的空状态提示,避免白屏。

踩坑记录

1. onPageShow vs aboutToAppear

这是最容易搞混的两个生命周期回调:

@Entry
@Component
struct CategoryList {
  aboutToAppear() {
    console.log('页面首次创建')  // 只触发一次
  }

  onPageShow() {
    console.log('页面出现在屏幕')  // 每次从后台切到前台都触发
  }

  aboutToDisappear() {
    console.log('页面即将销毁')  // 页面被销毁前触发
  }
}

执行时机:

首次进入:aboutToAppear → onPageShow
从其他页面返回:onPageShow
按 Home 键切后台再回来:onPageShow
页面被 router.back() 销毁:aboutToDisappear

记住:要处理"每次回到页面"的逻辑,用 onPageShow;要处理"页面首次初始化"的逻辑,用 aboutToAppear

2. router.getParams() 的类型断言

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

router.getParams() 返回 object 类型,必须做类型断言才能取值。如果参数结构是嵌套对象,断言时要对应好类型:

// 简单参数
const category = params.category as string

// 嵌套参数
interface SearchParams {
  keyword: string
  filters: string[]
}
const searchParams = params as SearchParams

3. 路由参数的深拷贝

router.pushUrl 传参时会做深拷贝,目标页面拿到的是副本,不是原始对象的引用。修改参数不会影响发送方。

// 首页
const data = { category: '奶茶' }
router.pushUrl({ url: 'pages/CategoryList', params: data })

// CategoryList 页面
const params = router.getParams() as Record<string, Object>
// params.category 是 '奶茶' 的副本
// 修改 params.category 不会影响首页的 data

4. 收藏状态的跨页面同步

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

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

@Entry
@Component
struct CategoryList {
  @State category: string = ''

  aboutToAppear() {
    const params = router.getParams() as Record<string, Object>
    this.category = (params?.category as string) || ''
    this.loadData()
  }

  onPageShow() {
    // 每次回到页面都重新加载,确保收藏状态同步
    this.loadData()
  }

  loadData() {
    if (this.category === '收藏') {
      this.loadFavorites()
    }
    // ...
  }
}

代价是每次回到页面都会重新查询数据。如果收藏列表很长,可以考虑用缓存或增量更新来优化。

复用的取舍

把三个功能合并到一个页面,好处是代码量减少、维护成本降低。但也有代价:

可以接受的取舍:

  • loadData() 方法会变长(分支越来越多)
  • 页面标题需要根据模式动态生成

需要注意的风险:

  • 如果三个功能的需求差异越来越大(比如收藏页面需要特殊布局),强行复用反而会增加复杂度
  • 收藏和"我的"需要状态同步,逻辑比普通分类列表复杂

经验法则:如果三个功能的 UI 布局 80% 以上相同,优先复用。如果差异超过 30%,考虑拆分。

目前我们的场景——收藏、我的、分类列表的布局几乎一样,数据来源不同——复用是合理的选择。

最后

这个复用方案的核心思路是"参数驱动"——一个 category 参数决定页面的数据来源和展示模式。再加上 onPageShow 解决返回时的 Tab 状态问题,整个导航流程就通了。

HarmonyOS 的页面生命周期回调不多,但用对地方能解决很多问题。onPageShow 就是典型的例子——一个简单的回调,解决了 Tab 状态不同步的烦恼。

收藏功能的数据持久化还没做完,后面要用 dataPreferences 或者数据库来实现。到时候再写一篇记录。

Logo

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

更多推荐