【鸿蒙应用开发实战·食光篇】第四篇:收藏功能与个人中心——数据同步与统计面板
【鸿蒙应用开发实战·食光篇】第四篇:收藏功能与个人中心——数据同步与统计面板
一、前言
经过前三篇的开发,我们已经完成了首页、列表页和详情页。本篇将完成最后两个页面——收藏页(FavPage) 和个人中心(ProfilePage),同时深入探讨鸿蒙的全局状态同步机制。
与「阅迹」相比,「食光」的收藏页和个人中心在UI细节和数据展示上有所不同,体现了同一技术框架下的不同设计思路。
1.1 技术背景与设计哲学
在鸿蒙应用开发中,状态管理是构建复杂应用的核心挑战之一。ArkUI 3.0 提供了多种状态管理方案,从简单的 @State 到跨页面的 @StorageLink,再到全局的 AppStorage,形成了一个完整的状态管理生态。本篇的两个页面正是这个生态的典型应用场景:
- 收藏页:展示了如何将全局状态(收藏ID列表)与本地状态(收藏菜谱详情)高效结合
- 个人中心:展示了如何聚合多个数据源,提供统一的用户视图
1.2 数据流架构设计
「食光」应用采用了分层的数据流架构:
┌─────────────────────────────────────┐
│ UI 层 (View) │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ 收藏页 │ │ 个人中心 │ │
│ └─────────────┘ └─────────────┘ │
├─────────────────────────────────────┤
│ 状态管理层 (ViewModel) │
│ ┌─────────────────────────────┐ │
│ │ @StorageLink + @State │ │
│ │ 本地缓存 + 全局同步 │ │
│ └─────────────────────────────┘ │
├─────────────────────────────────────┤
│ 数据源层 (Model) │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ AppStorage │ │ 本地数据 │ │
│ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────┘
这种架构确保了数据的一致性和UI的响应性,同时保持了代码的可维护性。
二、收藏页(FavPage)
2.1 页面功能与用户体验设计
收藏页的核心功能不仅仅是展示已收藏的菜谱列表,更重要的是提供流畅的用户交互体验:
- 智能展示:根据收藏时间、菜系类型或用户偏好进行排序
- 快速操作:点击✕按钮可立即移除收藏,无需二次确认(考虑到操作频率)
- 无缝跳转:点击卡片直接跳转到详情页,保持浏览连续性
- 情感化空状态:当用户未收藏任何菜谱时,提供友好的引导而非冷冰冰的空白
2.1.1 设计思考
与「阅迹」的收藏页相比,「食光」在以下方面做了优化:
- 信息密度更高:每张卡片展示了菜系、烹饪时长、难度和评分
- 视觉层次更清晰:通过颜色、字体大小和间距区分主次信息
- 操作更直观:移除按钮使用醒目的红色,但位置相对隐蔽,避免误触
2.2 状态管理:原理与最佳实践
收藏页的状态管理是鸿蒙状态管理体系的典型应用。我们使用了两种状态装饰器:
@Entry
@Component
struct FavPage {
// 全局状态:与AppStorage双向绑定
@StorageLink('favoriteIds') favoriteIds: number[] = [];
// 本地状态:仅在本组件内响应变化
@State favoriteRecipes: Recipe[] = [];
aboutToAppear(): void {
this.loadFavorites();
}
loadFavorites(): void {
// 性能优化:避免在每次渲染时都重新计算
this.favoriteRecipes = RECIPES.filter(
r => this.favoriteIds.indexOf(r.id) >= 0
);
}
removeFavorite(recipeId: number): void {
const idx = this.favoriteIds.indexOf(recipeId);
if (idx >= 0) {
// 注意:直接修改数组不会触发UI更新
const newIds = [...this.favoriteIds];
newIds.splice(idx, 1);
// 正确做法:创建新数组并赋值
this.favoriteIds = newIds;
// 同步到AppStorage(@StorageLink会自动同步)
// AppStorage.set<number[]>('favoriteIds', this.favoriteIds);
this.loadFavorites();
}
}
}
2.2.1 @StorageLink 与 @State 的区别
| 特性 | @StorageLink | @State |
|---|---|---|
| 作用范围 | 跨页面全局 | 组件内部 |
| 数据源 | AppStorage | 组件内部变量 |
| 同步机制 | 双向自动同步 | 单向组件内响应 |
| 适用场景 | 需要跨页面共享的数据 | 仅当前组件使用的临时状态 |
| 性能影响 | 可能触发多个页面更新 | 仅影响当前组件 |
2.2.2 性能优化技巧
- 避免不必要的重新渲染:
loadFavorites()只在数据变化时调用 - 使用记忆化过滤:对于大型数据集,可以考虑缓存过滤结果
- 分批加载:如果收藏数量很多,可以实现虚拟滚动或分页加载
2.3 收藏列表卡片:组件化设计
每张卡片都是一个独立的UI单元,展示了菜系、烹饪时长和难度,比「阅迹」的卡片信息更丰富。这里我们采用了组件化设计思想:
@Component
struct RecipeCard {
@Prop item: Recipe;
@Link favoriteIds: number[];
build() {
Row() {
// 封面区域 - 使用Builder封装可复用样式
this.renderCover()
// 信息区域 - 主内容
Column() {
Text(this.item.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#2D1F14')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
// 元信息行
Row() {
Text('⏱ ' + this.item.cookTime)
.fontSize(12)
.fontColor('#8B7355')
Text('| ' + this.item.difficulty)
.fontSize(12)
.fontColor(getDifficultyColor(this.item.difficulty))
.margin({ left: 8 })
}.margin({ top: 4 })
// 评分显示
Text(getStarString(this.item.rating))
.fontSize(12)
.fontColor('#F39C12')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 12 })
// 操作区域
this.renderActionButton()
}
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(14)
.shadow({ radius: 4, color: '#0A000000', offsetY: 2 })
.onClick(() => {
router.pushUrl({
url: 'pages/RecipeDetailPage',
params: { recipeId: this.item.id }
});
})
}
@Builder
renderCover() {
Column() {
Text(this.item.name.substring(0, 1))
.fontSize(26)
.fontColor('#FFFFFF')
Text(this.item.cuisine)
.fontSize(10)
.fontColor('rgba(255,255,255,0.7)')
.margin({ top: 4 })
}
.width(72)
.height(88)
.backgroundColor(this.item.color)
.borderRadius(12)
.justifyContent(FlexAlign.Center)
}
@Builder
renderActionButton() {
Column() {
Text('✕')
.fontSize(18)
.fontColor('#E74C3C')
.padding(10)
}
.onClick((e: ClickEvent) => {
e.stopPropagation(); // 阻止事件冒泡,避免触发卡片点击
this.removeFavorite();
})
}
removeFavorite(): void {
// 移除逻辑...
}
}
2.3.1 设计系统一致性
卡片设计遵循「食光」的设计系统:
- 圆角:14px,与整个应用的圆角风格一致
- 阴影:4px 轻微阴影,提升层次感
- 颜色:使用应用主色系(#2D1F14、#8B7355、#E67E22)
- 间距:12px 内边距,符合8px网格系统
2.4 空状态设计:情感化交互
空状态设计是用户体验的重要环节。一个好的空状态应该:
- 提供情感连接:使用emoji和温暖的语言
- 明确下一步:告诉用户可以做什么
- 保持品牌一致性:使用应用的颜色和设计语言
@Builder
emptyState() {
Column() {
// 情感化图标
Text('🍳')
.fontSize(80)
.opacity(0.8)
// 主要提示
Text('还没有收藏的菜谱')
.fontSize(18)
.fontColor('#8B7355')
.margin({ top: 20 })
// 次要说明
Text('去首页发现美食吧')
.fontSize(14)
.fontColor('#E67E22')
.margin({ top: 12 })
// 行动召唤按钮
Button('去逛逛')
.backgroundColor('#E67E22')
.fontColor('#FFFFFF')
.borderRadius(20)
.width(140)
.height(42)
.margin({ top: 20 })
.onClick(() => {
router.pushUrl({ url: 'pages/Index' });
})
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.padding(40)
}
2.4.1 空状态设计原则
- 减少认知负荷:信息简洁明了
- 提供价值:即使是空状态也要为用户提供价值
- 保持一致性:与应用的视觉风格一致
- 可操作性:提供明确的下一步操作
2.4.2 进阶空状态
对于更复杂的应用,可以考虑:
- 个性化推荐:根据用户浏览历史推荐可能喜欢的菜谱
- 教育性内容:解释收藏功能的价值
- 进度指示:如果正在加载,显示加载状态
EDE0`。
四、AppStorage 数组同步的注意事项
这是开发中最容易踩的坑——当使用 @StorageLink 绑定数组时:
// ❌ 这样修改不会触发其他页面刷新
this.favoriteIds.splice(idx, 1); // 数组变了,但没有通知AppStorage
// ✅ 必须手动同步
this.favoriteIds.splice(idx, 1);
AppStorage.set<number[]>('favoriteIds', this.favoriteIds);
为什么需要手动set? @StorageLink 对数组的监听是浅层的——它检测到数组引用没变(还是同一个数组对象),就不会触发同步。而 splice 是在原数组上修改,引用不变。必须通过 AppStorage.set() 强制通知所有监听者。
五、与「阅迹」的对比总结
| 特性 | 「阅迹」阅读App | 「食光」美食App |
|---|---|---|
| 主题色 | #C4956A / #FFF8F0 | #E67E22 / #FFF5EE |
| 封面形状 | 矩形 | 圆形/正圆 |
| 详情Tab | 无 | 食材+步骤 |
| 数据复杂度 | 单层文本 | 嵌套数组 |
| 新增功能 | — | 难度颜色映射 |
| 导航icon | 🏠📚❤️👤 | 🍜📋❤️👤 |
六、效果预览(请插入截图位置)

七、小结
本篇完成了:
✅ 收藏页的列表展示与取消收藏
✅ 空状态设计(引导用户去首页)
✅ 个人中心统计面板(总菜谱/已收藏/菜系)
✅ 功能菜单与关于设置
✅ AppStorage 数组同步的正确方式
至此,「食光」的5个页面全部开发完成!下篇将是系列的收官之作——构建优化与发布准备,敬请期待!
#鸿蒙开发 #ArkTS #状态管理 #HarmonyOS #全局数据同步
更多推荐



所有评论(0)