HarmonyOS旅行探索——架构总览与通用模式提炼
旅行探索 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 能在不同场景执行不同操作。
这六个模式不是"最佳实践",而是在当前项目规模下自然演化出来的约定。它们够用、好理解、容易复现。当项目规模增长到需要更好的可维护性时,再考虑引入更系统的架构方案也不迟。
更多推荐



所有评论(0)