鸿蒙原生应用实战(五):收藏功能与个人中心

系列文章导航:
一、项目初始化与多页面架构设计
二、首页与导航系统开发
三、列表页与标签筛选功能
四、详情页与动态数据展示
五、收藏功能与个人中心 ← 本文

一、前言

这是本系列的最后一篇。我们将完成"环球旅行指南"应用的收藏功能个人中心页面。

收藏功能涉及多个页面之间的数据联动——用户在详情页点击收藏后,收藏页应该能看到新增的条目。个人中心则是整个应用的"大本营",展示用户的旅行统计数据。

本篇将重点讲解:

  1. FavPage:收藏列表管理(展示、取消收藏、空状态)
  2. ProfilePage:个人中心(头像、统计卡片、功能菜单)
  3. 页面空状态设计:没有内容时的引导提示
  4. 数组增删操作:响应式更新技巧
  5. 完整应用导航流程: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 性能优化建议

  1. List 懒加载List 组件默认只渲染可见区域的 ListItem,大量数据时性能良好
  2. ForEach 的 key:始终提供 key 生成器,帮助框架高效复用和重排列表项
  3. 避免在 build() 中创建对象:不要在 build() 方法内创建新对象,会导致不必要的重渲染
  4. 图片资源优化:实际项目应使用真实图片资源(放在 resources/base/media/ 下),替代 emoji 占位

六、常见问题总结

6.1 页面白屏/不显示

可能的原因和排查步骤:

  1. 路由未注册:检查 main_pages.json 是否包含该页面路径
  2. Ability 加载路径:检查 module.json5srcEntry 路径是否正确
  3. 编译错误:检查 hvigor 构建输出,定位具体错误

6.2 列表不更新

// 数组操作后必须重新赋值
this.favList.splice(index, 1);
this.favList = [...this.favList];  // 触发 @State 更新

6.3 页面返回错乱

检查 router.pushUrlrouter.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 延伸思考

如果想继续完善这个应用,可以尝试:

  1. 真数据接入:接入天气/景点 API,替代模拟数据
  2. @Storage 持久化:使用 AppStorage 跨页面共享收藏状态
  3. 动画效果:添加页面转场动画(pageTransition
  4. 多设备适配:适配平板或折叠屏布局
  5. 国际化:添加 en-US 等语言资源
    在这里插入图片描述

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

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐