旅行探索 App 做到现在已经覆盖了 10 个页面和 2 个服务层文件。回过头看整个项目,会发现很多在单篇文章里讲不清楚的规律——哪些 Builder 被复用了多少次,哪些布局模式反复出现,数据流在页面之间是怎么流转的。这篇把所有页面摊开,做一次全局性的梳理。
完整效果
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

项目全貌

pages/
  Index.ets           探索首页
  CityDetail.ets      城市详情(景点+美食)
  TripPlanner.ets     行程规划
  CuisinePage.ets     美食指南
  FoodDetail.ets      美食详情
  CurrencyPage.ets    汇率换算
  PhraseBook.ets      旅行短语
  ToolsPage.ets       旅行工具箱
  WeatherPage.ets     旅行天气
  ProfilePage.ets     个人中心

services/
  TravelData.ets      城市/景点/美食/短语数据
  UserDataService.ets 用户状态(收藏/打卡)

10 个页面,2 个服务层。页面之间通过 router.pushUrl 跳转,数据在服务层集中管理。

页面间的数据流

整个 App 的数据流向可以画成一张图:

TravelData (城市/景点/美食/短语 mock 数据)
  │
  ├→ Index (读取 CITIES,展示城市网格)
  │    ├→ CityDetail (传 cityId,查找城市详情)
  │    │    └→ FoodDetail (传全字段,美食详情)
  │    ├→ TripPlanner (本地状态)
  │    ├→ CuisinePage (聚合所有城市的美食)
  │    │    └→ FoodDetail (传全字段)
  │    ├→ CurrencyPage (本地汇率数据)
  │    ├→ PhraseBook (读取 PHRASES 字典)
  │    ├→ WeatherPage (读取 CITIES + 本地天气 mock)
  │    └→ ToolsPage (聚合入口 + 本地状态)
  │
  └→ ProfilePage
       ├→ UserDataService (读取用户状态)
       └→ 各功能页面 (跳转)

三个关键节点:

  • Index 是分发中心——从 TravelData 读取城市列表,根据用户操作分发到各个子页面
  • CityDetail 是数据中转站——接收 ID,查找完整数据,展示嵌套内容,再把部分字段传给 FoodDetail
  • UserDataService 是状态仓库——各页面的用户操作(收藏、打卡)汇总到这里,ProfilePage 从这里读取聚合数据

路由跳转的两种模式

整个 App 的路由跳转只有两种模式:

模式一:ID 查询

// 跳转方
router.pushUrl({ url: 'pages/CityDetail', params: { 'cityId': city.id } as Record<string,Object> })

// 接收方
const id: number = p['cityId'] as number
for (let i = number = 0; i < CITIES.length; i++) { if (CITIES[i].id === id) { ... } }

使用场景:CityDetail、ExerciseDetail(运动健康 App)。适用于数据量大、需要从统一数据源查找的场景。

模式二:全字段传递

// 跳转方
router.pushUrl({ url: 'pages/FoodDetail', params: { 'name': f.name, 'emoji': f.emoji, ... } as Record<string,Object> })

// 接收方
if (p['name']) this.name = p['name'] as string

使用场景:FoodDetail。适用于数据量小、只在当前场景使用的场景。

两种模式的选择标准:数据量 > 5 个字段用 ID 查询,≤ 5 个字段用全字段传递。

Builder 复用统计

统计整个项目里所有 Builder 的使用情况:

Builder 所在页面 被调用次数 是否跨页面复用
NavBtn Index 1
ToolBtn Index 1
ToolCard ToolsPage 1
TimeCard ToolsPage 1
WDetail WeatherPage 1
ForecastRow WeatherPage 1
Chip CityDetail 1
Stat ProfilePage 1
Menu ProfilePage 1 否(但结构和运动健康 App 一致)
Div ProfilePage 1 否(但结构和运动健康 App 一致)
DataCard BodyDataPage 1
GoalRow GoalPage 1
CurrencyPicker CurrencyPage 2 页面内复用
ForecastRow WeatherPage 1

整个项目没有跨页面复用的 Builder——每个 Builder 都只在自己的页面里使用。唯一的页面内复用是 CurrencyPage 的 CurrencyPicker(被源货币和目标货币两个地方调用)。

这和运动健康 App 的情况一样。Builder 在 ArkTS 里的定位是"页面内的轻量复用",不是"跨页面的通用组件"。跨页面的通用 UI 需要用 @Component 实现,Builder 适合处理"同一页面里出现两次以上的相似结构"。

颜色常量的跨项目一致性

常量 用途 运动健康 App 旅行探索 App
A 主色 强调、按钮、标签 #FF4757(红) #FF6B35(橙)
T1 #1E1B2E 标题文字 相同 相同
T2 #888888 副文字 相同 相同
T3 #BBBBBB 辅助灰 相同 相同
BG #F8F7FC 页面背景 相同 相同(部分页面用 #FFF8F5)
CW #FFFFFF 卡片白 相同 相同

T1/T2/T3 和 BG/CW 在两个 App 里完全一致——它们是"中性色",不承载品牌调性,只负责文字层次和背景。A(主色)根据 App 主题变化:运动用红色,旅行用橙色。

这种"中性色固定、主题色可变"的模式是一个可靠的设计系统基础。新增 App 时只需要定义自己的主色 A,其他颜色直接复用。

四个 @Builder 的三种用途

整个项目(含运动健康 App)里的 @Builder 可以归纳为三种用途:

用途一:条件分支

// 用于 ForEach 内部的条件渲染
if (item.done) { Text('✓')... }

Builder 不直接参与条件分支,但 if/else 经常出现在 Builder 内部(如 AchievementPage 的 earned 检查)。

用途二:列表模板

// 用于 ForEach 的每个元素
@Builder ForecastRow(day: string, icon: string, high: string, low: string) { ... }
ForEach(this.forecast, (f) => { this.ForecastRow(...) })

这是 Builder 最常见的用途。当 ForEach 的每个元素结构复杂(多层嵌套、多个属性)时,抽成 Builder 让 build() 方法保持整洁。

用途三:可交互的回调容器

// 接收 () => void 回调,绑定到 onClick
@Builder Menu(label: string, icon: string, action: () => void) {
  Row() { ... }.onClick(action)
}

当同一个 UI 结构需要在不同地方执行不同操作时,用回调参数让 Builder 保持通用性。

数据层的设计取舍

旅行 App 的数据层有一个明显的特征——大部分数据是只读的 mock

  • TravelData.ets:城市、景点、美食、短语,全部写死
  • UserDataService.ets:用户状态(收藏/打卡),运行时维护
  • 各页面的本地 @State:行程规划的 days、小费计算器的 amount、天气页面的 selectedCity

只有 TripPlanner、CurrencyPage、ToolsPage 的出行清单有用户可修改的数据,而且这些数据目前都在 @State 里,没有持久化存储。刷新页面或退出 App 后,数据会重置。

这是原型阶段的合理取舍。真正的 App 需要接入 @ohos.data.preferences(本地持久化)或服务器 API(云端同步),但目前的重点是验证 UI 和交互逻辑,数据持久化是后续的工作。

通用模式提炼

回顾整个项目,可以提炼出六个在所有页面里反复出现的模式:

模式一:Stack + Scroll + Column 骨架
几乎所有页面都用这个结构——Stack 叠加固定元素(导航栏/底部导航)和可滚动内容区。

模式二:导航栏的固定结构
34×34 圆形返回按钮 + 标题 + 右侧操作区。这个结构在 10 个页面里出现了 9 次(Index 用底部导航栏代替)。

模式三:白色卡片 + 浅灰背景
backgroundColor('#FFFFFF').borderRadius(14-18) 的白色卡片放在 #F8F7FC 的浅灰背景上。这是整个 App 的视觉基础。

模式四:@State 数组的引用变更刷新
修改数组元素后必须复制新数组并替换引用,否则 UI 不刷新。这个模式在 TripPlanner、ToolsPage 出行清单里反复出现。

模式五:条件渲染控制区块显隐
if (this.saved.length > 0) 控制"已保存的城市"区域,if (this.city) 控制详情页内容区。空数据时不显示对应 UI,而不是显示空白占位。

模式六:回调参数实现 Builder 的通用化
Menu、ToolBtn、ToolCard 等 Builder 都接收 () => void 回调,让同一个 Builder 能在不同场景执行不同操作。

这六个模式不是"最佳实践",而是在当前项目规模下自然演化出来的约定。它们够用、好理解、容易复现。当项目规模增长到需要更好的可维护性时,再考虑引入更系统的架构方案也不迟。

Logo

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

更多推荐