首页的旅行工具区域放了四个快捷入口:行程规划、美食指南、汇率换算、旅行短语。这四个页面的复杂度差异很大——行程规划有状态管理和动态添加功能,美食指南是纯粹的数据展示,汇率换算涉及实时计算和自定义选择器,旅行短语用的是语言切换 + 数据过滤。放在一起看,恰好覆盖了 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
}

计算逻辑分三步:

  1. 提取货币代码this.from.substring(0, 3)"CNY 人民币" 里取前三个字符得到 "CNY"
  2. 查汇率做除法rateMap[toCode] / rateMap[fromCode] 算出源货币到目标货币的汇率
  3. 格式化结果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 回调,父组件更新 fromto 的值,然后触发 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 内联)--
CurrencyPageCurrencyPicker2是(onSelect)
PhraseBook无(ForEach 内联)--

四个页面里只有 CurrencyPage 抽了 Builder。原因很简单——货币选择器在页面里出现了两次(源货币和目标货币),抽成 Builder 可以避免重复代码。其他页面的列表项只出现一次,抽 Builder 的收益不大。

这个规律可以总结为:重复出现的 UI 模式值得抽 Builder,只出现一次的内联写就行。不要为了"架构好看"而强行抽象——过度抽象比代码重复更难维护。

数据流向的差异

四个页面的数据流向各不相同:

  • TripPlanner:数据完全本地化,所有状态在 @State 里,不依赖外部数据源。添加和勾选操作都在本地完成。
  • CuisinePage:数据来自 TravelData.etsCITIES 数组,在 aboutToAppear 里做一次聚合转换,之后所有操作基于转换后的 allCuisines
  • CurrencyPage:数据来自本地 rateMap,但计算逻辑是动态的——每次用户输入或选择变化都会触发重新计算。
  • PhraseBook:数据来自 TravelData.etsPHRASES 字典,根据 @State lang 动态过滤,切换语言时实时更新列表。

这四种模式——本地状态管理、数据聚合、实时计算、条件过滤——基本覆盖了移动 App 里最常见的数据处理场景。理解了这四种模式,大多数页面的数据层设计都能套用。

Logo

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

更多推荐