这一篇专门讲路线模块。旅行路线不是普通列表,它要表达“第几天去哪、住哪里、吃什么、亮点是什么”。疆域纪行用一套轻量时间轴把多日行程做成了可读结构。

项目落点:

  • 路线模型:RouteItem
  • 单日模型:RouteDayDetail
  • 路线页:RoutePage()
  • 路线详情:RouteDetail()
  • 时间轴:RouteTimeline()RouteTimelineDay()
  • 行程状态:itineraryIds

读完这一篇,你可以复用一套路线展示方案:列表展示路线决策信息,详情页用时间轴展示每日安排,本地行程只保存稳定 id。

本文解决什么

  • 把多日路线拆成 RouteItemRouteDayDetail,避免详情页硬编码行程。
  • 用时间轴表达第几天、住哪里、吃什么和当天亮点,让路线可扫读。
  • 把路线和景点都加入本地行程清单,先跑通 MVP 的个人规划闭环。

路线模块为什么单独成篇

旅行攻略 App 里,路线不是普通列表。它承载的是“把多个目的地按时间组织起来”的能力。

《疆域纪行》的路线模块包含三层体验:

  • 路线列表:展示多条预设路线。
  • 路线详情:查看每日安排。
  • 行程清单:把感兴趣的路线或景点加入本地计划。

第一版没有做复杂拖拽编辑,但已经把路线规划 MVP 的核心打通了。

能力 MVP 做法 暂不做的原因
预设路线 内置 RouteItem 内容可控,首版最快闭环
每日安排 RouteDayDetail 时间轴 比纯文本更容易扫读
本地行程 保存 id 列表 先验证收藏和规划意图
拖拽编辑 暂不实现 需要更复杂的数据结构和交互验证

这个取舍很适合第一版:先让用户看懂路线、收藏路线、保存路线,再谈复杂编辑。

路线数据模型回顾

路线模型是 RouteItem

export interface RouteItem {
  id: string;
  title: string;
  days: string;
  region: string;
  summary: string;
  tags: string[];
  color: string;
  image: Resource;
  bestSeason: string;
  transport: string;
  dailyPlans: string[];
  dayDetails: RouteDayDetail[];
  tips: string[];
}

export interface RouteDayDetail {
  title: string;
  meal: string;
  stay: string;
  highlight: string;
}

dailyPlans 适合列表概览,dayDetails 适合详情时间轴。一个字段用于轻量浏览,一个字段用于深度阅读,这是路线类内容很实用的设计。

路线页结构

路线页很简洁:

@Builder
RoutePage() {
  Column({ space: 16 }) {
    this.PageHeader('路线规划', 'Route Planner')
    this.RoutePlannerCard()
    ForEach(this.routes, (item: RouteItem) => {
      this.RouteCard(item)
    }, (item: RouteItem) => item.id)
  }
}

顶部的 RoutePlannerCard 负责展示当前本地行程概览;下面的 RouteCard 展示官方预设路线。

这套结构符合旅行规划流程:先看到自己的计划,再浏览可加入的路线。

路线卡片:列表页只展示决策信息

路线卡片需要回答几个问题:

  • 这条路线叫什么?
  • 玩几天?
  • 覆盖哪个区域?
  • 适合什么主题?
  • 是否值得点进去?

代码上它通常包含:

  • 封面图
  • 标题
  • 天数
  • 区域
  • 摘要
  • 标签
  • 收藏入口

这里仍然使用稳定 id 作为 ForEach key:

ForEach(this.routes, (item: RouteItem) => {
  this.RouteCard(item)
}, (item: RouteItem) => item.id)

在 ArkUI 列表中,key 很重要。使用稳定 id 可以减少列表重建时的状态错乱,也方便后续插入、删除、排序。

路线详情页结构

路线详情页与景点详情类似,但中间主体换成时间轴:

@Builder
RouteDetail(item: RouteItem) {
  Stack({ alignContent: Alignment.TopStart }) {
    Column() {
      this.DetailTopBar()
      Scroll() {
        Column({ space: 0 }) {
          this.DetailHero(item.color, item.image)
          Column({ space: 16 }) {
            this.RouteDetailHeader(item)
            Text(item.summary)
              .fontSize(14)
              .fontColor('#34424B')
              .lineHeight(23)
            this.SectionTitle('行程概览', 'DAILY PLAN')
            this.RouteTimeline(item)
            this.SectionTitle('出行提示', 'TRAVEL NOTES')
            ForEach(item.tips, (tip: string) => {
              this.NoteRow(tip)
            }, (tip: string) => tip)
          }
          .padding({ left: 18, right: 18, top: 18, bottom: 188 })
        }
      }
    }
  }
}

路线详情页的重点不是图文排版,而是如何把“多天安排”做得清楚。

时间轴组件

时间轴入口:

@Builder
RouteTimeline(item: RouteItem) {
  Column({ space: 0 }) {
    ForEach(item.dayDetails, (day: RouteDayDetail, index: number) => {
      this.RouteTimelineDay(item, day, index)
    }, (day: RouteDayDetail) => day.title)
  }
  .padding({ left: 0, right: 0, top: 4, bottom: 4 })
  .width('100%')
  .backgroundColor(Color.White)
  .borderRadius(18)
}

这里把每一天拆成 RouteTimelineDay,让时间轴主体保持简单。

如果后续要加入“每日景点列表”“车程”“导航跳转”,也可以只扩展单日组件,不影响整体结构。

单日节点设计

单日时间轴节点代码:

@Builder
RouteTimelineDay(item: RouteItem, day: RouteDayDetail, index: number) {
  Row({ space: 10 }) {
    Column() {
      Text(`D${index + 1}`)
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(Color.White)
        .width(42)
        .height(42)
        .textAlign(TextAlign.Center)
        .backgroundColor('#000000')
        .borderRadius(21)
      if (index < item.dayDetails.length - 1) {
        Column()
          .width(1)
          .height(118)
          .backgroundColor('#1B1B1B')
      }
    }
    .width(46)
    .alignItems(HorizontalAlign.Center)

    Column({ space: 10 }) {
      Text(day.title)
        .fontSize(18)
        .fontColor('#111111')
        .fontWeight(FontWeight.Bold)
      Row({ space: 12 }) {
        this.RouteMealMeta(day.meal)
        this.RouteStayMeta(day.stay)
      }
      Text(day.highlight)
        .fontSize(13)
        .fontColor('#111111')
        .fontWeight(FontWeight.Medium)
        .lineHeight(20)
        .backgroundColor('#FFE347')
        .borderRadius(7)
    }
    .layoutWeight(1)
    .constraintSize({ minWidth: 0 })
  }
}

这个时间轴有几个细节:

  • 左侧用 D1/D2/D3 形成强识别。
  • 非最后一天继续画竖线,形成连续感。
  • 右侧内容使用 layoutWeight(1),避免长标题挤压布局。
  • 当日亮点用高亮背景强调,便于快速扫读。

时间轴不需要复杂组件,Row + Column + 条线 + 圆点 就能完成。

行程清单:路线和景点共用 id 列表

行程清单状态:

@State itineraryIds: string[] = [];

加入或移除:

private toggleItinerary(id: string): void {
  if (this.isInItinerary(id)) {
    this.itineraryIds = this.itineraryIds.filter((item: string) => item !== id);
  } else {
    this.itineraryIds = [...this.itineraryIds, id];
  }
  this.persistItinerary();
}

景点详情页可以加入行程,路线详情页也可以加入行程。因为保存的是 id,所以需要分别从景点库和路线库里解析:

private getItinerarySpots(): AtlasItem[] {
  return this.itineraryIds
    .map((id: string) => this.spots.find((item: AtlasItem) => item.id === id))
    .filter((item: AtlasItem | undefined) => !!item) as AtlasItem[];
}

private getItineraryRoutes(): RouteItem[] {
  return this.itineraryIds
    .map((id: string) => this.routes.find((item: RouteItem) => item.id === id))
    .filter((item: RouteItem | undefined) => !!item) as RouteItem[];
}

这种写法简单直接。未来如果行程类型更多,可以把 id 改成带类型的结构:

interface ItineraryRef {
  kind: 'spot' | 'route';
  id: string;
}

但当前项目里景点 id 和路线 id 前缀不同,使用字符串数组已经足够。

行程摘要

项目用一个方法生成本地行程摘要:

private getItinerarySummary(): string {
  return `${this.getItineraryRoutes().length}条路线 / ${this.getItinerarySpots().length}个景点|本地保存`;
}

这里没有单独保存 count,而是每次从 itineraryIds 推导。对于这种小数据,派生计算比手动维护计数更安全。

手动维护计数常见问题是:添加成功了但计数没更新,删除失败了但计数先减了。派生值能避免这类不一致。

路线模块后续可以怎么升级

当前路线规划是 MVP,已经实现预设路线和本地加入。后续可以逐步增强:

  • 支持按天添加景点。
  • 支持拖拽调整每日顺序。
  • 支持自定义路线命名。
  • 支持路线导出为文本或图片。
  • 支持按季节推荐路线。
  • 支持景点之间参考车程。

但这些功能都应该建立在当前模型之上,而不是推翻重来。第一版先把 id、路线详情、时间轴和本地持久化跑通,是正确的节奏。

升级时建议优先保留 RouteItemRouteDayDetail 两层结构,只给每日详情增加 spotIdsdriveTimenote 这类字段。这样列表页、详情页和收藏逻辑都能复用,真正变化的是行程编辑页,而不是整个路线系统。

验证清单

检查项 操作方式 通过标准
每条路线都有 dayDetails 对照项目文件或真机/模拟器操作 结果符合文章描述
时间轴最后一天不继续画竖线 对照项目文件或真机/模拟器操作 结果符合文章描述
长住宿名称不会挤出屏幕 对照项目文件或真机/模拟器操作 结果符合文章描述
路线加入行程后摘要数量更新 对照项目文件或真机/模拟器操作 结果符合文章描述
重启后行程清单仍存在 对照项目文件或真机/模拟器操作 结果符合文章描述

常见问题和处理

问题现象 优先排查 处理方式
时间轴高度错乱 检查单日节点高度和竖线高度 长文案加 maxLines 和省略
路线和景点混在一起解析失败 检查 id 前缀或类型判断 必要时升级为 { kind, id } 结构
摘要数量不准 不要手动维护计数 从 id 列表重新派生

本篇小结

路线模块的实战要点:

  • 路线模型要区分概览字段和详情字段。
  • 列表页展示决策信息,详情页展示每日安排。
  • 时间轴可以用基础 ArkUI 组件组合实现。
  • 行程清单保存稳定 id,不复制完整对象。
  • 摘要从原始状态派生,避免手动计数不同步。

下一篇我们看资源和启动体验:如何管理本地图片、启动页、图标和离线素材,让 App 看起来不像 Demo。

Logo

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

更多推荐