HarmonyOS 收藏页面优化:用复用代替新建页面的思路
HarmonyOS 收藏页面优化:用复用代替新建页面的思路
一个 CategoryList 页面同时承担分类列表、收藏列表、个人中心三个角色。
前言
饮品 App 有三个功能区需要展示列表:分类列表(按品类筛选)、收藏列表(用户收藏的配方)、个人中心(用户创建的配方)。最早的想法是每个功能区做一个独立页面——CategoryList、FavoriteList、MyProfile,各写各的。
但仔细一想,这三个页面的布局几乎一样:顶部标题 + 配方卡片列表。区别只是数据来源不同。与其写三个页面然后维护三套几乎相同的代码,不如让一个 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 状态不对
这是整个方案中最坑的一个问题。
场景是这样的:
- 用户在首页,底部导航栏"首页"是选中态
- 点击底部"收藏" Tab → 跳转到
CategoryList(收藏模式) - 在收藏页面浏览完,按返回键回到首页
- 问题来了:底部导航栏的"收藏" Tab 还是选中态
原因:currentTab 状态变量没有被重置。用户从收藏页返回时,currentTab 仍然是 2(收藏),但实际显示的是首页内容。
解决方式——用 onPageShow 生命周期回调:
onPageShow(): void {
this.currentTab = 0
}

onPageShow 在页面每次出现在屏幕上时触发——不只是首次渲染,从其他页面返回时也会触发。在这里把 currentTab 重置为 0(首页),确保底部导航栏的选中态和实际显示的内容一致。
一个容易忽略的细节:onPageShow 和 aboutToAppear 的区别。
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 或者数据库来实现。到时候再写一篇记录。
更多推荐


所有评论(0)