HarmonyOS旅行探索——首页交互功能与路由跳转实现
旅行探索首页的 UI 布局确定之后,接下来要解决的是交互问题——用户点击城市卡片怎么跳到详情页,点击底部导航栏怎么切换页面,点击工具按钮怎么打开对应功能。这些交互的核心是 router.pushUrl 路由跳转和 onClick 事件绑定,实现起来不复杂,但有几个容易踩坑的地方。
完整效果

交互点梳理
首页上可点击的元素一共有三类:
- 城市卡片:点击后跳转到城市详情页,需要传递城市 ID
- 工具按钮:四个快捷入口,分别跳转到行程规划、美食指南、汇率换算、旅行短语
- 底部导航栏:五个 Tab,非当前页跳转,当前页无操作
三类交互的实现方式都是 onClick + router.pushUrl,但各自的参数传递和边界处理有所不同。
城市卡片的参数化跳转
城市网格的每张卡片都绑定了点击事件:
.onClick(() => {
router.pushUrl({
url: 'pages/CityDetail',
params: { 'cityId': city.id } as Record<string, Object>
})
})

这里有两个细节值得说一下。
参数类型断言:{ 'cityId': city.id } as Record<string, Object> 里的 as Record<string, Object> 是必须的。router.pushUrl 的 params 参数类型是 Record<string, Object>,而直接写 { 'cityId': city.id } 会被 ArkTS 推断为更具体的类型(比如 { cityId: number }),类型检查会报错。加 as Record<string, Object> 显式声明类型,编译才能通过。
这个写法在 ArkTS 路由里非常常见,几乎每次传参都会用到。如果忘了加,IDE 会报类似"类型不兼容"的错误,新手容易卡在这里。
只传 ID,不传对象:city 对象本身包含了名称、国家、描述、景点、美食等大量数据。如果把整个对象传过去,代码会变成 params: { 'city': city } as Record<string, Object>,但这样做的问题是——ArkTS 的路由参数只支持基本类型(string、number、boolean)和它们的数组,不支持自定义对象。传对象会导致运行时数据丢失或序列化错误。
所以正确的做法是只传 ID,详情页根据 ID 从数据源重新查询。这是一种"参数最小化"原则——路由参数越简单,出问题的概率越低。
详情页的参数接收
CityDetail 页面在 aboutToAppear 里接收参数:
aboutToAppear(): void {
const p = router.getParams() as Record<string, Object>
if (p && p['cityId']) {
const id: number = p['cityId'] as number
for (let i: number = 0; i < CITIES.length; i++) {
if (CITIES[i].id === id) { this.city = CITIES[i]; break }
}
}
}
router.getParams() 返回的是 object | undefined,需要先断言为 Record<string, Object> 才能用键名访问。然后逐个字段取出并做类型断言——p['cityId'] as number。
这个查找逻辑用的是 for 循环而不是 find(),原因和之前一样——ArkTS 里数组方法的类型推断不够精确,find() 返回的类型有时会被推断为 T | undefined,后续使用时 IDE 会报"可能为空"的警告。手动循环 + break 虽然啰嗦,但类型安全。
找到匹配的城市后赋值给 @State city,UI 自动更新。如果参数里没有 cityId 或者找不到对应城市,city 保持 undefined,页面会显示空白——这是一个需要处理的边界情况。
工具按钮的直接跳转
四个工具按钮的跳转逻辑比城市卡片简单,因为不需要传参数:
Row({ space: 10 }) {
this.ToolBtn('🧳', '行程规划', '#5DADE2', () => {
router.pushUrl({ url: 'pages/TripPlanner' })
})
this.ToolBtn('🍜', '美食指南', '#E8734A', () => {
router.pushUrl({ url: 'pages/CuisinePage' })
})
this.ToolBtn('💱', '汇率换算', '#00B894', () => {
router.pushUrl({ url: 'pages/CurrencyPage' })
})
this.ToolBtn('🗣️', '旅行短语', '#8A5DFA', () => {
router.pushUrl({ url: 'pages/PhraseBook' })
})
}

每个 ToolBtn 接收一个 action 回调,点击时执行对应的路由跳转。这种"Builder 只负责 UI,回调函数负责逻辑"的模式,让 Builder 保持了通用性——如果以后要加第五个工具按钮,只需要在 Row 里多加一行 this.ToolBtn(...) 就行,不需要改 Builder 的代码。
ToolBtn Builder 的回调参数
@Builder ToolBtn(icon: string, label: string, color: string, action: () => void) {
Column() {
Text(icon).fontSize(28).margin({ bottom: 6 })
Text(label).fontSize(11).fontColor(T1)
}.layoutWeight(1).padding({ top: 14, bottom: 14 })
.backgroundColor(color + '10').borderRadius(14)
.onClick(action)
}
action 参数的类型是 () => void——一个不接收参数、没有返回值的函数。.onClick(action) 把这个回调绑定到点击事件上。Builder 本身不知道点击后会发生什么,它只负责"用户点了之后调用这个函数"。
这种设计让 ToolBtn 可以复用于任何需要"图标 + 文字 + 点击"的场景。运动记录的快捷按钮、社区的入口按钮,只要换一下图标、文字和回调就行。
底部导航栏的条件路由
底部导航栏的跳转逻辑和工具按钮类似,但多了一个条件判断——当前 Tab 不跳转:
@Builder NavBtn(idx: number, label: string, active: boolean) {
Column({ space: 2 }) {
Text(label === '探索' ? '🌍' : (label === '行程' ? '🧳' :
(label === '美食' ? '🍜' : (label === '工具' ? '🔧' : '👤'))))
.fontSize(20)
Text(label).fontSize(9).fontColor(active ? A : '#C0BFC6')
}.onClick(() => {
if (idx === 1) router.pushUrl({ url: 'pages/TripPlanner' })
else if (idx === 2) router.pushUrl({ url: 'pages/CuisinePage' })
else if (idx === 3) router.pushUrl({ url: 'pages/CurrencyPage' })
else if (idx === 4) router.pushUrl({ url: 'pages/ProfilePage' })
})
}

注意 idx === 0(探索 Tab)的情况——onClick 里没有对应的 if 分支。当用户点击"探索"时,什么都不会发生。这是合理的:用户已经在首页了,再点"探索"没有意义。
但如果要做得更完善,应该在 idx === 0 时让页面滚动到顶部:
if (idx === 0) {
// 滚动到顶部的逻辑
}
目前首页的 Scroll 没有绑定 ScrollController,所以暂时无法用代码控制滚动位置。如果以后需要这个功能,可以给 Scroll 绑一个 controller,然后在 onClick 里调用 controller.scrollToTop()。
图标的三元表达式嵌套
Text(label === '探索' ? '🌍' : (label === '行程' ? '🧳' :
(label === '美食' ? '🍜' : (label === '工具' ? '🔧' : '👤'))))
这段代码用嵌套的三元表达式根据 label 选择对应的 emoji 图标。写法虽然紧凑,但可读性不太好——嵌套了四层,读起来需要从外往里逐层解析。
更清晰的做法是用映射表:
private getIcon(label: string): string {
const icons: Record<string, string> = {
'探索': '🌍', '行程': '🧳', '美食': '🍜', '工具': '🔧', '我的': '👤'
}
return icons[label] || '🏠'
}
但在当前规模下(只有 5 个 Tab),嵌套三元表达式也能接受。代码量少的时候,简洁比可读性更重要;代码量多了再重构也不迟。
颜色透明度的计算
ToolBtn 和 NavBtn 的背景色都用了一个 + '10' 的后缀:
.backgroundColor(color + '10')
这里的 '10' 是十六进制的透明度值。在 ARGB 颜色模型里,#FF6B3510 表示 #FF6B35 这个颜色加上 0x10(十进制 16)的透明度。0x10 / 0xFF = 6.3%,也就是说背景色是主题色的 6% 不透明度——几乎看不见,但能让按钮区域和周围有一点微妙的区分。
这个透明度计算方式在整个项目里统一使用。如果以后要调整按钮的背景深浅,只需要改这个后缀值就行——'20' 是 12%,'30' 是 18%,以此类推。
路由跳转的错误处理
目前所有的 router.pushUrl 调用都没有错误处理。如果目标页面不存在(比如 module.json5 里忘了注册),运行时会崩溃而不是优雅降级。
一个更健壮的做法是加上回调:
router.pushUrl({ url: 'pages/CityDetail', params: { 'cityId': city.id } as Record<string, Object> },
(err) => {
if (err) { console.error('路由跳转失败: ' + JSON.stringify(err)) }
})
pushUrl 的第二个参数是一个回调函数,接收错误信息。在原型阶段可以不加这个回调(方便快速调试),但上线前应该补上。
同样的问题也存在于城市详情页的参数接收——如果 cityId 无效或 CITIES 数组为空,this.city 会保持 undefined,页面会显示空白。应该加一个兜底逻辑:
aboutToAppear(): void {
const p = router.getParams() as Record<string, Object>
if (p && p['cityId']) {
const id: number = p['cityId'] as number
for (let i: number = 0; i < CITIES.length; i++) {
if (CITIES[i].id === id) { this.city = CITIES[i]; break }
}
}
if (!this.city) {
// 兜底:显示默认城市或返回上一页
router.back()
}
}
页面跳转的数据流
整个首页的跳转数据流可以用一张图概括:
首页 (Index)
├── 点击城市卡片 → CityDetail (params: cityId)
├── 点击行程规划 → TripPlanner
├── 点击美食指南 → CuisinePage
├── 点击汇率换算 → CurrencyPage
├── 点击旅行短语 → PhraseBook
└── 点击底部"我的" → ProfilePage
六个跳转目标,只有一个需要传参(CityDetail),其他五个都是直接跳转。这种"大多数简单、少数复杂"的分布是典型的 App 首页模式——首页是分发中心,把用户引导到各个功能页面,不需要在首页做太多复杂交互。
数据流向是单向的:首页只负责"把用户送出去",不负责"把数据收回来"。如果以后需要从详情页返回首页时刷新数据(比如用户在详情页收藏了一个城市,返回首页后要更新收藏状态),需要用 router.back 的 result 参数或者全局状态管理来实现。但在当前版本里,这个需求还不存在。
交互设计的一致性
三类交互虽然场景不同,但在实现上保持了一致:
- 都用
onClick绑定事件 - 都用
router.pushUrl做页面跳转 - 跳转参数都用
as Record<string, Object>类型断言 - Builder 都只负责 UI,业务逻辑通过回调传入
这种一致性不是偶然的——它来自于对 ArkTS 路由机制的理解和对项目代码风格的统一。当所有跳转都用同一种写法时,排查问题变得简单:路由跳不通?先检查 module.json5 有没有注册页面;参数没收到?先检查 as Record<string, Object> 有没有加。这些"约定"比"技术"更重要。
更多推荐



所有评论(0)