HarmonyOS旅行探索——旅行工具四个页面实现详解
首页的旅行工具区域放了四个快捷入口:行程规划、美食指南、汇率换算、旅行短语。这四个页面的复杂度差异很大——行程规划有状态管理和动态添加功能,美食指南是纯粹的数据展示,汇率换算涉及实时计算和自定义选择器,旅行短语用的是语言切换 + 数据过滤。放在一起看,恰好覆盖了 ArkTS 页面开发中几种常见的交互模式。
完整效果



TripPlanner:行程规划的状态管理
行程规划页面是四个工具里交互最复杂的——它需要处理待办事项的勾选、新行程的添加、进度条的动态计算。
数据结构
interface PlanItem { day: number; title: string; desc: string; done: boolean }
四个字段:天数序号、标题、描述、完成状态。done 是唯一会变化的状态字段。
勾选逻辑:引用变更触发刷新
private toggleDay(idx: number): void {
this.days[idx].done = !this.days[idx].done
const copy: PlanItem[] = []
for (let i: number = 0; i < this.days.length; i++) { copy.push(this.days[i]) }
this.days = copy
}

这段代码有一个关键的步骤容易被忽略——this.days = copy。如果只写 this.days[idx].done = !this.days[idx].done,UI 不会刷新。原因是 ArkTS 的 @State 只追踪引用变更,不追踪对象内部属性的修改。修改 done 属性不会改变 days 数组的引用,所以 @State 认为数据没有变化,不会触发重新渲染。
解决办法是创建一个新数组,把旧数据复制进去,然后用 this.days = copy 替换整个引用。这样 @State 检测到引用变了,就会重新渲染 UI。
for 循环复制而不是用 slice() 或展开运算符,是因为 ArkTS 里数组方法的类型推断有时不够精确,手动循环是最稳妥的方式。
添加行程
@State newTitle: string = ''
private addDay(): void {
if (this.newTitle.trim().length === 0) return
this.days.push({
day: this.days.length + 1,
title: this.newTitle,
desc: '自定义行程',
done: false
})
this.newTitle = ''
const copy: PlanItem[] = []
for (let i: number = 0; i < this.days.length; i++) { copy.push(this.days[i]) }
this.days = copy
}
添加行程的流程:先检查输入是否为空,不为空就 push 一个新对象到数组里,清空输入框,然后复制数组触发刷新。新行程的 day 序号用 this.days.length + 1 计算——假设原来有 5 天,新添加的就是 Day 6。
有一个问题:如果用户删除了中间某一天,后续的 day 序号会不连续。目前这个页面没有删除功能,所以不会出现这个问题。但如果以后加上删除,需要在 addDay 里重新计算所有 day 值。
进度条
Row() {
Text('已完成 ' + this.doneCount() + '/' + this.days.length + ' 天')
.fontSize(13).fontColor(T2)
Blank()
Row() {}
.width((this.doneCount() / this.days.length) * 100)
.height(6).borderRadius(3).backgroundColor(A)
}
进度条的宽度用 (doneCount / total) * 100 计算,单位是 vp。如果 5 天里完成了 3 天,宽度就是 3/5 * 100 = 60vp。
这里有个隐患:如果 days 为空(用户删掉了所有行程),doneCount / days.length 会除以零。目前 days 有默认值所以不会出现,但加上防御性检查会更稳健。
每日卡片的勾选 UI
Stack({ alignContent: Alignment.Center }) {
Row() {}.width(22).height(22).borderRadius(11)
.backgroundColor(item.done ? A : 'transparent')
.border({ width: 1.5, color: item.done ? A : '#D0CED8' })
if (item.done) {
Text('✓').fontSize(12).fontWeight(FontWeight.Bold).fontColor(Color.White)
}
}
勾选框用 Stack 实现——底层是一个圆形 Row(选中时橙色填充,未选中时透明 + 灰色边框),上层是一个条件渲染的勾号。Stack 让勾号自动居中显示在圆形上方。
左侧的竖线也根据 done 状态改变颜色:
Column() {}.width(2).height(20).backgroundColor(item.done ? A + '40' : '#F0EEF4')
已完成的竖线用主色的 25% 透明度(A + '40'),未完成的用浅灰色。这和勾选框的颜色保持了视觉一致。
CuisinePage:数据聚合与扁平化
美食指南页面把所有城市的美食数据聚合到一个列表里,按城市分组显示。
数据准备
@State allCuisines: FoodItem[] = []
aboutToAppear(): void {
const list: FoodItem[] = []
for (let i: number = 0; i < CITIES.length; i++) {
for (let j: number = 0; j < CITIES[i].cuisines.length; j++) {
list.push({
city: CITIES[i].name,
emoji: CITIES[i].cuisines[j].emoji,
name: CITIES[i].cuisines[j].name,
desc: CITIES[i].cuisines[j].desc,
price: CITIES[i].cuisines[j].price
})
}
}
this.allCuisines = list
}
双重 for 循环遍历所有城市的所有美食,把它们扁平化成一个一维数组。每个 FoodItem 额外加了一个 city 字段,记录这道美食属于哪个城市。
为什么不直接用 CITIES[i].cuisines[j]?因为 Cuisine 接口里没有 city 字段——它只有 name、emoji、desc、price。但美食列表页面需要显示"这道菜来自哪个城市",所以必须在遍历时手动补上。
这是数据聚合的一个常见模式:原始数据的结构不一定满足展示需求,需要在中间层做一次转换。aboutToAppear 就是做这个转换的地方——在页面渲染之前,把数据处理成 UI 需要的格式。
城市标签
Row() {
Text(f.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(T1)
Text(f.city).fontSize(10).fontColor(A)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.backgroundColor('#FFF5F0').borderRadius(3).margin({ left: 6 })
}
每道美食名称后面跟了一个橙色的小标签,显示它所属的城市。标签用极小的字号(10px)、浅橙色背景(#FFF5F0)和紧凑的内边距,保证不抢主信息的视觉焦点,但用户需要时又能快速定位来源。
CurrencyPage:实时计算与自定义选择器
汇率换算是四个工具里逻辑最密集的页面——它需要处理用户输入、货币选择、实时汇率计算。
汇率数据
private currencies: string[] = [
'CNY 人民币','USD 美元','EUR 欧元','JPY 日元',
'KRW 韩元','THB 泰铢','GBP 英镑','AUD 澳元'
]
private rateMap: Record<string, number> = {
'CNY':1, 'USD':0.138, 'EUR':0.127, 'JPY':20.0,
'KRW':183.0, 'THB':4.95, 'GBP':0.109, 'AUD':0.21
}

汇率用 Record<string, number> 存储,key 是货币代码(CNY、USD 等),value 是相对于人民币的汇率。比如 1 CNY = 20 JPY,所以 JPY 的值是 20.0。
这些汇率是硬编码的 mock 数据。真正的 App 会从汇率 API 实时获取,但在原型阶段,写死数据能快速验证 UI 和计算逻辑。
转换计算
private convert(): void {
const fromCode: string = this.from.substring(0, 3)
const toCode: string = this.to.substring(0, 3)
const amt: number = parseFloat(this.amount) || 0
const resultVal: number = amt * (this.rateMap[toCode] / this.rateMap[fromCode])
this.result = resultVal.toFixed(2)
this.rates = '1 ' + fromCode + ' = ' +
(this.rateMap[toCode] / this.rateMap[fromCode]).toFixed(4) + ' ' + toCode
}
计算逻辑分三步:
- 提取货币代码:
this.from.substring(0, 3)从"CNY 人民币"里取前三个字符得到"CNY" - 查汇率做除法:
rateMap[toCode] / rateMap[fromCode]算出源货币到目标货币的汇率 - 格式化结果:
toFixed(2)保留两位小数显示金额,toFixed(4)保留四位小数显示汇率
parseFloat(this.amount) || 0 是一个防御性处理——如果用户输入了非数字内容,parseFloat 返回 NaN,|| 0 会把 NaN 替换成 0,避免计算结果出现 NaN。
这个转换在三个地方被调用:输入金额变化时、切换源货币时、切换目标货币时。任何可能导致结果变化的操作都会触发重新计算。
自定义货币选择器
@Builder CurrencyPicker(selected: string, onSelect: (c: string) => void) {
Scroll() {
Column({ space: 4 }) {
ForEach(this.currencies, (c: string) => {
Text(c).fontSize(14)
.fontWeight(c === selected ? FontWeight.Bold : FontWeight.Regular)
.fontColor(c === selected ? '#FF6B35' : T2)
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor(c === selected ? '#FFF5F0' : 'transparent')
.borderRadius(10).width('100%')
.onClick(() => { onSelect(c) })
})
}
}.height(120).scrollBar(BarState.Off)
}
没有用 HarmonyOS 内置的 Select 组件,而是自己用 Scroll + Column + ForEach 拼了一个选择器。高度固定 120vp,只显示大约 4 个选项,超出部分可滚动。
选中状态通过 selected 参数判断——和当前选中货币相同的那一项加粗、变橙色、加浅橙背景。点击后调用 onSelect 回调,父组件更新 from 或 to 的值,然后触发 convert() 重新计算。
这个选择器被复用了两次:
this.CurrencyPicker(this.from, (c: string) => { this.from = c; this.convert() })
this.CurrencyPicker(this.to, (c: string) => { this.to = c; this.convert() })
同一个 Builder,不同的参数和回调——这就是 Builder 模式的核心优势。
PhraseBook:语言切换与数据过滤
旅行短语页面的交互模式是"选语言 → 显示对应短语"。
语言切换
@State lang: string = '日语'
Row({ space: 6 }) {
ForEach(['日语','法语','英语'], (l: string) => {
Text(l).fontSize(13)
.fontWeight(this.lang === l ? FontWeight.Bold : FontWeight.Regular)
.fontColor(this.lang === l ? '#FFFFFF' : T2)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(16)
.backgroundColor(this.lang === l ? '#FF6B35' : '#F5F5F7')
.onClick(() => { this.lang = l })
})
}
三个语言标签用 Row 横向排列,选中态用橙色背景 + 白色文字,未选中态用灰色背景 + 灰色文字。切换语言时只需要修改 @State lang 的值,UI 自动更新。
这种"Tab 切换"模式在 ArkTS 里很常见——不需要用复杂的 Tab 组件,一个 Row + ForEach + onClick 就能实现。关键在于 this.lang 的值决定了每个 Tab 的样式,点击时修改这个值就行。
数据过滤
private getPhrases(): Phrase[] {
const p: Phrase[] | undefined = PHRASES[this.lang]
if (p) return p
return []
}
PHRASES 是一个 Record<string, Phrase[]>,key 是语言名称,value 是该语言的短语数组。getPhrases() 根据当前选中的语言从 PHRASES 里取出对应的短语列表。
如果 this.lang 对应的语言在 PHRASES 里不存在(undefined),返回空数组。这保证了 ForEach 不会因为数据为空而崩溃。
这个"根据状态过滤数据"的模式和 CuisinePage 的"聚合数据"正好相反——CuisinePage 是把多个数据源合并成一个列表,PhraseBook 是从一个大字典里按 key 取子集。
短语卡片
Column() {
Row() {
Text(p.chinese).fontSize(16).fontWeight(FontWeight.Bold).fontColor(T1)
Blank()
Text('🔊').fontSize(16)
}.width('100%').margin({ bottom: 8 })
Text(p.foreign).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FF6B35')
.margin({ bottom: 4 })
Text(p.pronunciation).fontSize(13).fontColor(T3)
}
每张短语卡片有三层信息:中文(黑色粗体)、外文(橙色大号粗体)、发音(灰色小字)。右上角有一个喇叭 emoji,暗示"点击播放语音"——目前没有绑定实际的 TTS 功能,只是一个视觉占位。
外文用橙色 #FF6B35 和大号字体突出显示,因为它是用户最需要看到的信息——“这句话用外语怎么说”。中文作为对照放在上方,发音辅助放在下方。
四个页面的 Builder 复用统计
| 页面 | 使用的 Builder | 参数数量 | 有回调参数 |
|---|---|---|---|
| TripPlanner | 无(ForEach 内联) | - | - |
| CuisinePage | 无(ForEach 内联) | - | - |
| CurrencyPage | CurrencyPicker | 2 | 是(onSelect) |
| PhraseBook | 无(ForEach 内联) | - | - |
四个页面里只有 CurrencyPage 抽了 Builder。原因很简单——货币选择器在页面里出现了两次(源货币和目标货币),抽成 Builder 可以避免重复代码。其他页面的列表项只出现一次,抽 Builder 的收益不大。
这个规律可以总结为:重复出现的 UI 模式值得抽 Builder,只出现一次的内联写就行。不要为了"架构好看"而强行抽象——过度抽象比代码重复更难维护。
数据流向的差异
四个页面的数据流向各不相同:
- TripPlanner:数据完全本地化,所有状态在
@State里,不依赖外部数据源。添加和勾选操作都在本地完成。 - CuisinePage:数据来自
TravelData.ets的CITIES数组,在aboutToAppear里做一次聚合转换,之后所有操作基于转换后的allCuisines。 - CurrencyPage:数据来自本地
rateMap,但计算逻辑是动态的——每次用户输入或选择变化都会触发重新计算。 - PhraseBook:数据来自
TravelData.ets的PHRASES字典,根据@State lang动态过滤,切换语言时实时更新列表。
这四种模式——本地状态管理、数据聚合、实时计算、条件过滤——基本覆盖了移动 App 里最常见的数据处理场景。理解了这四种模式,大多数页面的数据层设计都能套用。
更多推荐



所有评论(0)