鸿蒙原生应用实战(五):收藏功能与个人中心
鸿蒙原生应用实战(五):收藏功能与个人中心
系列文章导航:
一、项目初始化与多页面架构设计
二、首页与导航系统开发
三、列表页与标签筛选功能
四、详情页与动态数据展示
五、收藏功能与个人中心 ← 本文
一、前言
这是本系列的最后一篇。我们将完成"环球旅行指南"应用的收藏功能和个人中心页面。
收藏功能涉及多个页面之间的数据联动——用户在详情页点击收藏后,收藏页应该能看到新增的条目。个人中心则是整个应用的"大本营",展示用户的旅行统计数据。
本篇将重点讲解:
- FavPage:收藏列表管理(展示、取消收藏、空状态)
- ProfilePage:个人中心(头像、统计卡片、功能菜单)
- 页面空状态设计:没有内容时的引导提示
- 数组增删操作:响应式更新技巧
- 完整应用导航流程:5个页面的联动
二、收藏页面(FavPage)
2.1 功能需求
| 功能 | 说明 |
|---|---|
| 收藏列表 | 显示已收藏的目的地卡片 |
| 取消收藏 | 点击"取消收藏"按钮从列表中移除 |
| 空状态提示 | 没有收藏时显示引导文字 |
| 点击跳转 | 点击收藏卡片跳转到详情页 |
2.2 数据模型
interface FavItem {
id: number;
name: string;
location: string;
rating: number;
image: string;
tag: string;
}
初始模拟数据:
@State favList: FavItem[] = [
{ id: 1, name: '巴厘岛', location: '印度尼西亚', rating: 4.8, image: '🏝️', tag: '海岛' },
{ id: 7, name: '京都', location: '日本', rating: 4.8, image: '⛩️', tag: '人文' },
{ id: 5, name: '马尔代夫', location: '马尔代夫', rating: 4.9, image: '🌊', tag: '海岛' },
{ id: 8, name: '冰岛', location: '冰岛', rating: 4.9, image: '🌌', tag: '自然' },
];
2.3 页面布局
┌─────────────────────────────────────────┐
│ ← 我的收藏 │ ← 顶部导航(橙色背景)
│ ❤️ 已收藏 4 个目的地 │ ← 统计信息
├─────────────────────────────────────────┤
│ ┌─────────────────────────────┐ │
│ │ 🏝️ 巴厘岛 ⭐ 4.8 │ │
│ │ 印度尼西亚 · 海岛 │ │
│ │ 取消收藏 │ │ ← 收藏卡片列表
│ ├─────────────────────────────┤ │
│ │ ⛩️ 京都 ⭐ 4.8 │ │
│ │ 日本 · 人文 │ │
│ │ 取消收藏 │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────────┘
2.4 顶部导航 + 统计
// 顶部导航栏
Row() {
Text('←').fontSize(24).fontWeight(FontWeight.Bold)
.fontColor($r('app.color.white'))
.onClick(() => router.back());
Text($r('app.string.fav_title'))
.fontSize($r('app.float.title_font_size'))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.white'))
.layoutWeight(1)
.textAlign(TextAlign.Center);
Text('').width(24);
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 8 });
// 收藏统计
Row() {
Text('❤️').fontSize(20).margin({ right: 6 });
Text('已收藏 ' + this.favList.length + ' 个目的地')
.fontSize($r('app.float.small_font_size'))
.fontColor($r('app.color.white'));
}
.margin({ bottom: 16 });
💡 收藏页的整体背景使用
primary橙色,与首页顶部保持一致,形成视觉统一。
2.5 条件渲染:空状态 vs 列表
ArkTS 中使用 if/else 进行条件渲染:
if (this.favList.length === 0) {
// 空状态
Column() {
Text('📭').fontSize(64);
Text('还没有收藏的目的地')
.fontSize($r('app.float.body_font_size'))
.fontColor($r('app.color.text_secondary'))
.margin({ top: 16 });
Text('去首页发现精彩目的地吧 →')
.fontSize($r('app.float.small_font_size'))
.fontColor($r('app.color.primary'))
.margin({ top: 8 })
.onClick(() => {
router.pushUrl({ url: 'pages/Index' });
});
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center);
} else {
// 收藏列表
List({ space: 14 }) {
ForEach(this.favList, (item: FavItem, index: number) => {
ListItem() {
this.FavCard(item, index);
}
}, (item: FavItem) => item.id.toString())
}
.width('100%')
.layoutWeight(1)
.padding({ left: 16, right: 16 });
}
💡 设计思路:空状态不只是一句简单的"暂无数据",而是加入了一个可点击的引导链接,引导用户去首页探索。这在用户体验设计上叫做"空状态引导"。
2.6 收藏卡片 + 取消收藏
@Builder
FavCard(item: FavItem, index: number) {
Row() {
// 左侧图标
Text(item.image).fontSize(40).width(68).height(68)
.textAlign(TextAlign.Center)
.backgroundColor($r('app.color.primary_light'))
.borderRadius(12).margin({ right: 14 });
// 中间信息
Column() {
Text(item.name)
.fontSize($r('app.float.body_font_size'))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'));
Text(item.location + ' · ' + item.tag)
.fontSize($r('app.float.tiny_font_size'))
.fontColor($r('app.color.text_secondary'))
.margin({ top: 2 });
Row() {
Text('⭐').fontSize(12);
Text(item.rating.toString())
.fontSize($r('app.float.tiny_font_size'))
.fontColor($r('app.color.star_yellow'))
.fontWeight(FontWeight.Bold);
}.margin({ top: 4 });
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start);
// 取消收藏按钮
Text('取消收藏')
.fontSize(12)
.fontColor($r('app.color.fav_red'))
.padding({ top: 4, bottom: 4, left: 10, right: 10 })
.border({ width: 1, color: $r('app.color.fav_red'), style: BorderStyle.Solid })
.borderRadius(12)
.onClick(() => {
this.favList.splice(index, 1);
this.favList = [...this.favList]; // 触发响应式更新
});
}
.width('100%').padding(14)
.backgroundColor($r('app.color.bg_card'))
.borderRadius($r('app.float.card_radius'))
.shadow({ radius: 4, offsetX: 0, offsetY: 1, color: $r('app.color.card_shadow') })
.alignItems(VerticalAlign.Center)
.onClick(() => {
router.pushUrl({ url: 'pages/DetailPage', params: { id: item.id } });
});
}
2.7 响应式删除的核心技巧 ⚠️
这是 ArkTS 中操作数组最需要注意的地方:
// ❌ 这样写UI不会更新(虽然数组确实删除了元素)
this.favList.splice(index, 1);
// ✅ 必须重新赋值整个数组才能触发 @State 更新
this.favList.splice(index, 1);
this.favList = [...this.favList]; // 关键:重新赋值
为什么?因为 ArkTS 的 @State 装饰器通过引用比较来检测变化。splice 修改了原数组的内容,但数组的引用没变,所以 @State 认为没有变化。通过 [...this.favList] 创建一个新数组,引用变了,UI 就会刷新。
这个规则同样适用于数组元素的修改——必须先修改元素,再整体重新赋值。
三、个人中心(ProfilePage)
3.1 功能需求
| 功能 | 说明 |
|---|---|
| 用户头像 | 显示用户头像和昵称 |
| 旅行统计 | 已游玩/想去/收藏 三个统计数字 |
| 功能菜单 | 我的收藏、旅行足迹、愿望清单等 |
| 设置区 | 系统设置、关于应用、分享 |
3.2 页面布局
┌─────────────────────────────────────────┐
│ ← 个人中心 │ ← 顶部导航
│ 🧑✈️ │ ← 用户头像
│ 旅行者小明 │
│ ✈️ 足迹遍布 12 个城市 │
├─────────────────────────────────────────┤
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ 🌍 │ │ ⭐ │ │ ❤️ │ │ ← 统计卡片
│ │ 12 │ │ 8 │ │ 4 │ │
│ │ 已游玩│ │ 想去 │ │ 收藏 │ │
│ └──────┘ └──────┘ └──────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ ❤️ 我的收藏 › │ │ ← 功能菜单
│ │ 📋 旅行足迹 › │ │
│ │ 🎯 愿望清单 › │ │
│ │ 📝 旅行笔记 › │ │
│ │ 🔔 消息通知 › │ │
│ ├─────────────────────────────┤ │
│ │ ⚙️ 系统设置 › │ │ ← 设置区
│ │ ℹ️ 关于应用 › │ │
│ │ 📤 分享应用 › │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────────┘
3.3 用户头像区
Column() {
Text(this.userAvatar).fontSize(64).width(88).height(88)
.textAlign(TextAlign.Center)
.backgroundColor('rgba(255,255,255,0.2)')
.borderRadius(44)
.margin({ bottom: 12 });
Text(this.userName).fontSize(20).fontWeight(FontWeight.Bold)
.fontColor($r('app.color.white'));
Text('✈️ 足迹遍布 ' + this.visitedCount + ' 个城市')
.fontSize($r('app.float.small_font_size'))
.fontColor('rgba(255,255,255,0.8)')
.margin({ top: 4 });
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 10, bottom: 24 });
3.4 统计卡片
@Builder
StatCard(icon: string, label: string, count: number) {
Column() {
Text(icon).fontSize(28);
Text(count.toString()).fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.margin({ top: 6 });
Text(label).fontSize($r('app.float.tiny_font_size'))
.fontColor($r('app.color.text_secondary'))
.margin({ top: 2 });
}
.width(96).padding(14)
.backgroundColor($r('app.color.bg_card'))
.borderRadius($r('app.float.card_radius'))
.shadow({ radius: 4, offsetX: 0, offsetY: 2, color: $r('app.color.card_shadow') })
.alignItems(HorizontalAlign.Center);
}
三个统计卡片用 Row 均分排列:
Row() {
this.StatCard('🌍', '已游玩', this.visitedCount);
this.StatCard('⭐', '想去', this.wishCount);
this.StatCard('❤️', '收藏', this.favCount);
}
.width('100%')
.padding({ left: 10, right: 10 })
.justifyContent(FlexAlign.SpaceEvenly);
3.5 功能菜单行
@Builder
MenuRow(icon: string, title: string, desc: string, onClick: () => void) {
Row() {
Text(icon).fontSize(22).margin({ right: 12 });
Column() {
Text(title).fontSize($r('app.float.body_font_size'))
.fontColor($r('app.color.text_primary'))
.fontWeight(FontWeight.Medium).width('100%');
Text(desc).fontSize($r('app.float.tiny_font_size'))
.fontColor($r('app.color.text_secondary'))
.width('100%').margin({ top: 1 });
}
.layoutWeight(1).alignItems(HorizontalAlign.Start);
Text('›').fontSize(22).fontColor($r('app.color.text_secondary'));
}
.width('100%').height(52)
.alignItems(VerticalAlign.Center)
.onClick(() => { onClick(); });
}
功能区和设置区各用一个白色卡片包裹:
// 功能区
Column() {
this.MenuRow('❤️', '我的收藏', '查看收藏的目的地', () => {
router.pushUrl({ url: 'pages/FavPage' });
});
this.DividerLine();
this.MenuRow('📋', '旅行足迹', '记录走过的每一个地方', () => {});
// ...
}
.width('100%').padding(14)
.backgroundColor($r('app.color.bg_card'))
.borderRadius($r('app.float.card_radius'))
.margin({ top: 20 });
// 设置区
Column() {
this.MenuRow('⚙️', '系统设置', '个性化配置与偏好', () => {});
this.DividerLine();
this.MenuRow('ℹ️', '关于应用', '版本 1.0.0', () => {});
this.DividerLine();
this.MenuRow('📤', '分享应用', '推荐给朋友', () => {});
}
.width('100%').padding(14)
.backgroundColor($r('app.color.bg_card'))
.borderRadius($r('app.float.card_radius'))
.margin({ top: 14 });
3.6 页面滚动
个人中心内容较多,使用 Scroll 包裹所有内容:
Scroll() {
Column() {
// 用户头像区
// 统计卡片
// 功能区
// 设置区
// 版权信息
}
.width('100%')
.padding({ left: 20, right: 20, bottom: 24 });
}
.layoutWeight(1);
💡
Scroll组件需要配合layoutWeight(1)或固定高度才能正常工作。这里用layoutWeight(1)让 Scroll 占满剩余空间。
四、完整应用导航流程
4.1 页面关系图
┌─────────────┐
│ 首页 │
│ Index │
└──────┬──────┘
┌─────────────────┼──────────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────────┐
│ 目的地列表 │ │ 目的地详情 │ │ 个人中心 │
│ DestPage │ │ DetailPage │ │ ProfilePage │
└──────┬─────┘ └────────────┘ └────────┬───────┘
│ │
▼ ▼
┌────────────┐ ┌────────────┐
│ 目的地详情 │ │ 我的收藏 │
│ DetailPage │ │ FavPage │
└────────────┘ └────────────┘
│
▼
┌────────────┐
│ 目的地详情 │
│ DetailPage │
└────────────┘
4.2 导航路径汇总
| 起点 | 操作 | 终点 | 参数 |
|---|---|---|---|
| 首页 | 点击分类图标 | DestPage | 无 |
| 首页 | 点击热门卡片 | DetailPage | { id } |
| 首页 | 点击精选卡片 | DetailPage | { id } |
| 首页 | 点击"查看全部" | DestPage | 无 |
| 首页 | 点击"个人中心" | ProfilePage | 无 |
| 列表页 | 点击标签 | —(切换筛选) | — |
| 列表页 | 点击卡片 | DetailPage | { id } |
| 详情页 | 点击 ← | 返回上一页 | — |
| 收藏页 | 点击卡片 | DetailPage | { id } |
| 收藏页 | 点击"取消收藏" | —(删除当前项) | — |
| 个人中心 | 点击"我的收藏" | FavPage | 无 |
五、构建与部署
5.1 完整构建
cd /d "D:\harmonyos\project\6.8.12345\3\MyApplication"
"D:\DevEco Studio\tools\node\node.exe" \
"D:\DevEco Studio\tools\hvigor\bin\hvigorw.js" \
--mode module -p module=entry@default \
-p product=default -p requiredDeviceType=phone \
assembleHap --analyze=normal --parallel --incremental --daemon
预期输出:
> hvigor BUILD SUCCESSFUL in 10 s xxx ms
5.2 文件结构总览
entry/src/main/ets/pages/
├── Index.ets ← 首页(277行)
├── DestPage.ets ← 目的地列表(166行)
├── DetailPage.ets ← 目的地详情(392行)
├── FavPage.ets ← 我的收藏(155行)
└── ProfilePage.ets ← 个人中心(192行)
entry/src/main/resources/base/element/
├── string.json ← 字符串资源(36行)
├── color.json ← 颜色资源(68行)
└── float.json ← 尺寸资源(40行)
entry/src/main/resources/base/profile/
└── main_pages.json ← 页面路由注册
AppScope/resources/base/element/
└── string.json ← 全局应用名
5.3 性能优化建议
- List 懒加载:
List组件默认只渲染可见区域的ListItem,大量数据时性能良好 - ForEach 的 key:始终提供 key 生成器,帮助框架高效复用和重排列表项
- 避免在 build() 中创建对象:不要在
build()方法内创建新对象,会导致不必要的重渲染 - 图片资源优化:实际项目应使用真实图片资源(放在
resources/base/media/下),替代 emoji 占位
六、常见问题总结
6.1 页面白屏/不显示
可能的原因和排查步骤:
- 路由未注册:检查
main_pages.json是否包含该页面路径 - Ability 加载路径:检查
module.json5中srcEntry路径是否正确 - 编译错误:检查
hvigor构建输出,定位具体错误
6.2 列表不更新
// 数组操作后必须重新赋值
this.favList.splice(index, 1);
this.favList = [...this.favList]; // 触发 @State 更新
6.3 页面返回错乱
检查 router.pushUrl 和 router.back 是否成对使用。如果不需要保留当前页面在栈中,可以使用 router.replaceUrl 替代。
6.4 对象字面量编译错误
ArkTS 严格模式下,始终为数组对象声明显式类型:
const MY_ARRAY: MyType[] = [ /* ... */ ];
七、系列总结
7.1 五篇回顾
| 篇次 | 标题 | 核心内容 |
|---|---|---|
| 一 | 项目初始化与多页面架构设计 | SDK配置、Stage模型、资源体系 |
| 二 | 首页与导航系统开发 | 布局组合、@Builder、router导航 |
| 三 | 列表页与标签筛选功能 | 标签切换、ForEach、计算属性 |
| 四 | 详情页与动态数据展示 | 路由传参、动态切换、严格模式 |
| 五 | 收藏功能与个人中心 | 增删操作、空状态、导航联动 |
7.2 关键技术点
- 语言:ArkTS(基于 TypeScript 的严格静态类型语言)
- 模型:Stage 模型(Ability + Page 架构)
- UI:声明式组件 + 链式调用 +
@State响应式 - 路由:
@ohos.router模块(pushUrl / back / getParams) - 构建:hvigor 命令行构建,产出 HAP 包
7.3 延伸思考
如果想继续完善这个应用,可以尝试:
- 真数据接入:接入天气/景点 API,替代模拟数据
- @Storage 持久化:使用 AppStorage 跨页面共享收藏状态
- 动画效果:添加页面转场动画(
pageTransition) - 多设备适配:适配平板或折叠屏布局
- 国际化:添加 en-US 等语言资源

本系列完结,感谢阅读!如果对你有帮助,欢迎点赞收藏 👍
更多推荐



所有评论(0)