旅行探索 App 的"我的"页面和个人中心在结构上和运动健康 App 类似——用户卡片、统计数字、菜单列表。但有一个关键区别:旅行 App 的个人中心引入了一个 UserDataService 数据服务层,负责管理用户在其他页面产生的状态数据(保存的城市、打卡的景点、收藏的美食)。这让个人中心从"纯展示"升级成了"数据聚合"。
完整效果
在这里插入图片描述

页面结构

从截图看,页面从上到下依次是:

  1. 导航栏:返回按钮 + "我的"标题
  2. 用户卡片:头像 + 昵称 + 副标题(已探索城市数)
  3. 三栏统计:探索城市、打卡景点、收藏美食
  4. 已保存的城市:用户在城市详情页收藏的城市列表
  5. 菜单列表:我的行程、美食收藏、旅行工具、设置

前三个区域是固定布局,第四区域是动态的(取决于用户收藏了哪些城市),第五区域是固定入口。

UserDataService:用户状态的管理

import { getSavedCities, getVisitedCount, getFoodSaved } from '../services/UserDataService'

这个服务暴露了三个方法:

  • getSavedCities():返回用户收藏的城市列表
  • getVisitedCount():返回已探索的城市数量
  • getFoodSaved():返回收藏的美食数量

这三个方法被个人中心页面调用,用来填充统计数字和城市列表。从调用方式看,UserDataService 应该是一个全局单例——各页面通过调用它的方法来读写用户状态,而不需要自己管理状态存储。

这种设计的好处是数据源单一——所有页面的"用户状态"都从同一个服务获取,不会出现不同页面数据不一致的问题。城市详情页收藏了一个城市后,getSavedCities() 会立即返回更新后的列表。

onPageShow 的实时刷新

onPageShow(): void { this.refresh() }
aboutToAppear(): void { this.refresh() }

private refresh(): void {
  this.saved = getSavedCities()
}

在这里插入图片描述

refresh() 在两个生命周期里被调用:

  • aboutToAppear:页面首次创建时调用
  • onPageShow:每次页面从后台回到前台时调用

onPageShow 是关键——它保证了用户从城市详情页返回时,"已保存的城市"列表会自动更新。如果只在 aboutToAppear 里加载数据,用户收藏了一个城市后返回个人中心,列表不会刷新,因为页面没有被销毁重建。

这种"每次显示时刷新"的模式在需要跨页面同步数据的场景里很常见。代价是每次显示都会重新调用 getSavedCities(),如果数据量很大或查询逻辑很复杂,可能需要优化。但对于城市列表这种小数据量的场景,完全没问题。

用户卡片

Row() {
  Stack() {
    Column() {}.width(60).height(60).borderRadius(30)
      .linearGradient({ angle: 135, colors: [[A, 0], ['#FF9F43', 1]] })
    Text('🧳').fontSize(28)
  }
  Column() {
    Text('旅行者').fontSize(18).fontWeight(FontWeight.Bold).fontColor(T1)
    Text('已探索 ' + getVisitedCount() + ' 个城市 · 旅途愉快!')
      .fontSize(11).fontColor(T3).margin({ top: 2 })
  }.alignItems(HorizontalAlign.Start).margin({ left: 14 }).layoutWeight(1)
}

在这里插入图片描述

头像区用 Stack 实现——底层是橙色渐变圆,上层是旅行箱 emoji。和运动健康 App 的用户卡片结构完全一样,只是 emoji 从跑步换成了旅行箱。

副标题是动态的:'已探索 ' + getVisitedCount() + ' 个城市 · 旅途愉快!'getVisitedCount() 每次调用都返回最新值,所以用户收藏新城市后,这里的数字会自动更新。

三栏统计

Row({ space: 8 }) {
  this.Stat(getVisitedCount().toString(), '探索城市')
  this.Stat(this.landmarkCount().toString(), '打卡景点')
  this.Stat(getFoodSaved().toString(), '收藏美食')
}

三个统计卡片共享同一个 Stat Builder,传入不同的数值和标签。三个数字都来自服务层:

  • 探索城市getVisitedCount() 直接返回
  • 打卡景点this.landmarkCount() 需要额外计算
  • 收藏美食getFoodSaved() 直接返回

landmarkCount 的计算逻辑

private landmarkCount(): number {
  let c: number = 0
  for (let i: number = 0; i < this.saved.length; i++) {
    for (let j: number = 0; j < CITIES.length; j++) {
      if (CITIES[j].id === this.saved[i].cityId) {
        c += CITIES[j].landmarks.length
        break
      }
    }
  }
  return c
}

景点数量不是服务层直接提供的,而是个人中心页面自己算的。逻辑是:遍历用户保存的城市,根据 cityIdCITIES 数组里找到对应城市,累加它的 landmarks.length

这种"跨数据源聚合"的做法在原型阶段是合理的——UserDataService 管用户状态,TravelData 管城市数据,个人中心负责把两者关联起来。如果以后数据量增大,可以把这个计算移到服务层,减少页面的计算负担。

条件渲染:已保存的城市

if (this.saved.length > 0) {
  Column() {
    Text('已保存的城市').fontSize(16).fontWeight(FontWeight.Bold).fontColor(T1)
      .width('100%').margin({ bottom: 10 })
    Column({ space: 8 }) {
      ForEach(this.saved, (item: SavedItem) => {
        Row() {
          Text('🏙️').fontSize(20).margin({ right: 10 })
          Text(item.name).fontSize(14).fontWeight(FontWeight.Medium)
            .fontColor(T1).layoutWeight(1)
          SymbolGlyph($r('sys.symbol.chevron_right')).fontSize(14).fontColor(['#DDD'])
        }.width('100%').padding(12).backgroundColor('#F8F7FC').borderRadius(12)
        .onClick(() => {
          router.pushUrl({
            url: 'pages/CityDetail',
            params: { 'cityId': item.cityId } as Record<string, Object>
          })
        })
      })
    }
  }
}

if (this.saved.length > 0) 是一个条件渲染——只有当用户保存了至少一个城市时,才显示这个区域。如果用户没有任何收藏,整个区域不渲染,不会出现空白的标题栏。

这个条件渲染的 if 在 ArkTS 里是声明式的——当 this.saved.length 从 0 变成 1 时,整个 Column 会自动出现在 UI 上;从 1 变回 0 时,自动消失。不需要手动控制 Visibilitydisplay 属性。

SavedItem 的点击跳转

每张保存的城市卡片点击后跳转到城市详情页,传递 cityId。和首页城市网格的跳转逻辑一样——传 ID,详情页根据 ID 查询数据。

SavedItem 接口只有两个字段:

interface SavedItem { cityId: number; name: string }

cityId 用于跳转时传参,name 用于列表展示。不需要存整个 City 对象——详情页已经有完整的城市数据了,传 ID 就够。

菜单列表

Column({ space: 0 }) {
  this.Menu('我的行程', '🧳', () => { router.pushUrl({ url: 'pages/TripPlanner' }) })
  this.Div()
  this.Menu('美食收藏', '🍜', () => { router.pushUrl({ url: 'pages/CuisinePage' }) })
  this.Div()
  this.Menu('旅行工具', '🔧', () => { router.pushUrl({ url: 'pages/ToolsPage' }) })
  this.Div()
  this.Menu('设置', '⚙️', () => { })
}

四个菜单项,其中三个有跳转逻辑,"设置"是空函数——目前没有实现设置页面。和运动健康 App 的个人中心一样,先把入口放好,功能后面再补。

Menu Builder 和 Div Builder 的实现和运动健康 App 完全一样——白色背景卡片、emoji 图标 + 文字 + 右箭头、分割线从左侧 52px 开始(跳过图标区域)。

和运动健康 App 个人中心的差异

维度运动健康 App旅行探索 App
数据来源全部硬编码部分来自服务层
用户卡片静态"运动达人"动态"已探索 X 个城市"
统计数字硬编码 156/48920/342动态计算
条件渲染无(菜单始终显示)有(无收藏时不显示城市列表)
跨页面同步有(onPageShow 刷新)

旅行 App 的个人中心比运动健康 App 多了一个关键能力——跨页面数据同步。用户在城市详情页收藏了一个城市,返回个人中心后列表会自动更新。这得益于 UserDataService 的全局状态管理和 onPageShow 的刷新机制。

运动健康 App 的个人中心目前没有这个能力——所有数据都是写死的,不会因为用户在其他页面的操作而变化。如果以后要给运动健康 App 加上类似的功能(比如累计运动天数的实时统计),也需要引入一个类似的用户状态服务。

数据流的闭环

个人中心的数据流形成了一个闭环:

CityDetail(收藏城市)→ UserDataService(存储状态)
                         ↓
ProfilePage(读取状态)→ getSavedCities() / getVisitedCount()
                         ↓
UI 更新(城市列表、统计数字)

用户在详情页的操作(收藏/取消收藏)改变了服务层的状态,个人中心通过 onPageShow 感知到页面重新显示,调用 refresh() 读取最新状态,UI 自动更新。

这个闭环的关键节点是 onPageShow——它是"页面从后台回到前台"的信号。没有这个信号,个人中心就不知道什么时候该刷新数据。这也是为什么 onPageShowaboutToAppear 更重要——后者只在页面首次创建时调用,前者在每次显示时都调用。

Logo

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

更多推荐