一、前言

1.1 物流追踪——移动端最高频的场景之一

"你的包裹已揽收""正在运输途中""快递员正在派送"——这些话我们几乎每天都在不同的 App 里看到。物流追踪已经成为移动应用中最常见的功能模块之一,从电商到外卖,从生鲜到快递柜,几乎每个涉及实物交付的场景都需要它。

但就是这个看似简单的"进度条",要做好并不容易。它涉及到:

  • 状态机设计 —— 物流有固定的流转顺序,不能跳过、不能回退(至少用户侧不能随意回退)
  • 视觉表达 —— 步骤间的关系(已完成、进行中、未完成)需要一目了然
  • 边界处理 —— 第一步不能再回退,最后一步不能再前进
  • 数据映射 —— 后端返回的状态码需要映射到前端展示文案

本文将以一个完整的 ArkTS 物流追踪组件为例,从代码拆解开始,逐步深入到状态机设计、可视化方案、动画增强、组件化重构等话题。

1.2 最终效果预览

+----------------------------------+
|        快递物流进度模拟            |
|                                    |
|  ●━━━●━━━●━━━○━━━○━━━○           |
|  已  商家  运输  到达  派送  已    |
|  下  揽收  途中  网点  中   签收   |
|  单                                   |
|                                    |
|  ┌──────────────────────┐          |
|  │  当前物流状态        │          |
|  │  运输途中            │          |
|  └──────────────────────┘          |
|                                    |
|  [回退状态]    [更新物流]          |
+----------------------------------+

阶梯式的进度线配 6 个步骤点,已完成步骤绿色、未完成灰色。两个按钮控制前进和回退,边界时弹窗提示。


二、完整代码

@Entry
@Component
struct ExpressTrack {
  @State currentStep: number = 2
  private stepList: string[] = ["已下单", "商家揽收", "运输途中", "到达网点", "派送中", "已签收"]

  // 上一步
  prevStep() {
    if(this.currentStep > 0){
      this.currentStep--
    }else{
      AlertDialog.show({message:"已是初始状态"})
    }
  }

  // 下一步
  nextStep() {
    if(this.currentStep < this.stepList.length - 1){
      this.currentStep++
    }else{
      AlertDialog.show({message:"物流已完成签收"})
    }
  }

  build() {
    Column({ space: 30 }) {
      Text("快递物流进度模拟")
        .fontSize(26)
        .fontWeight(FontWeight.Bold)

      Row() {
        ForEach(this.stepList, (item: string, idx: number) => {
          Column({ space: 8 }) {
            Circle()
              .width(24)
              .height(24)
              .fill(idx <= this.currentStep ? 0xFF27AE60 : 0xFFDCDCDC)
            Text(item)
              .fontSize(12)
              .textAlign(TextAlign.Center)
          }
          .layoutWeight(1)
        })
      }
      .width("90%")
      .margin({bottom:15})

      // 连接线
      Row() {
        ForEach(this.stepList, (_: string, idx: number) => {
          if(idx < this.stepList.length - 1){
            Line()
              .startPoint([0, 0.5])
              .endPoint([1, 0.5])
              .strokeWidth(4)
              .stroke(idx < this.currentStep ? 0xFF27AE60 : 0xFFDCDCDC)
              .layoutWeight(1)
          }
        })
      }
      .width("90%")
      .position({y: -42})

      Column() {
        Text("当前物流状态")
          .fontSize(19)
          .fontWeight(FontWeight.Medium)
        Text(this.stepList[this.currentStep])
          .fontSize(22)
          .fontColor(0xFF27AE60)
          .margin({top:10})
      }
      .width("90%")
      .padding(20)
      .backgroundColor("#fff")
      .borderRadius(10)

      Row({ space: 25 }) {
        Button("回退状态")
          .width(130)
          .height(45)
          .onClick(() => this.prevStep())

        Button("更新物流")
          .width(130)
          .height(45)
          .backgroundColor(0xFF27AE60)
          .fontColor(Color.White)
          .onClick(() => this.nextStep())
      }
    }
    .width("100%")
    .height("100%")
    .justifyContent(FlexAlign.Center)
    .padding(20)
    .backgroundColor("#f5f7fa")
  }
}

95 行代码,包含了一个物流追踪组件的全部核心逻辑。


三、状态机设计

3.1 物流流转的本质是一个有限状态机

物流追踪在计算机科学视角下,本质上是一个有限状态机(FSM, Finite State Machine)

状态集合:S = {已下单, 商家揽收, 运输途中, 到达网点, 派送中, 已签收}
初始状态:s0 = 已下单
终止状态:s5 = 已签收
转移函数:δ(si) = si+1 (只能单向递增,不能跳步)

FSM 的特性在这个组件中体现得淋漓尽致:

  • 状态有限 —— 6 个状态,明确且固定
  • 单向流转 —— 只能从前往后,不能逆序
  • 有终止态 —— "已签收"后不再转移
  • 确定性 —— 每个状态的下一个状态是唯一的

在代码中,状态机由两个元素共同实现:

// 状态集合
private stepList: string[] = ["已下单", "商家揽收", "运输途中", "到达网点", "派送中", "已签收"]

// 当前状态(索引)
@State currentStep: number = 2

currentStep 是状态机的"当前状态",stepList 是状态名称到索引的映射表。currentStep++ 就是状态转移。

3.2 为什么用数字索引而不是字符串

一个新手常见的问题是:"为什么不直接用 '运输途中' 这样的字符串来表示状态?"

原因有三:

1. 比较效率

// 索引比较:O(1) 整数比较
if (this.currentStep >= 2) { ... }

// 字符串比较:O(n) 逐字符比较
if (this.currentStatus === '运输途中') { ... }

虽然对于 6 个状态差异不大,但索引的整数比较在任何场景下都比字符串比较快。

2. 边界判断简洁

// 索引方式
if (this.currentStep > 0)                     // 是否可以回退
if (this.currentStep < this.stepList.length - 1)  // 是否可以前进

// 字符串方式
const index = this.stepList.indexOf(this.currentStatus)
if (index > 0) ...
if (index < this.stepList.length - 1) ...

索引方式不需要 indexOf 查找,更简洁、更安全(不会出现 -1 的场景)。

3. 条件渲染友好

// 索引直接用于条件判断
Circle().fill(idx <= this.currentStep ? 绿色 : 灰色)

// 字符串需要转换为索引或额外维护状态码

3.3 边界状态处理

状态机的核心设计原则是:永远不要让系统进入未定义状态

本组件通过两个边界检查来保证这一点:

// 前进边界
if(this.currentStep < this.stepList.length - 1) {
  this.currentStep++
} else {
  AlertDialog.show({message:"物流已完成签收"})
}

// 回退边界
if(this.currentStep > 0) {
  this.currentStep--
} else {
  AlertDialog.show({message:"已是初始状态"})
}

这两个检查确保了 currentStep 永远在 [0, 5] 的闭区间内。任何超出边界的操作都被拦截,并给出用户反馈。

这里有一个值得思考的问题:"已是初始状态"和"物流已完成签收"是弹窗提示好,还是直接禁用按钮好?

两种方案各有优劣:

方案 优点 缺点
弹窗提示 用户明确知道为什么不能操作 打断操作流,弹窗需要手动关闭
禁用按钮 无需弹窗,视觉上直观 用户可能不理解为什么按钮不可用

当前代码选择了弹窗方案——因为在这个"模拟器"场景中,用户可能需要测试边界行为,弹窗提供了明确的解释,而不是静默地什么都不发生。

但如果是生产环境中的真实物流页面,"回退状态"按钮根本不应该出现——用户不应该能回退物流状态。按钮禁用 + Tooltip 提示会更合适:

Button("回退状态")
  .width(130)
  .height(45)
  .enabled(this.currentStep > 0)     // 第一步时禁用
  .alpha(this.currentStep > 0 ? 1.0 : 0.5)  // 禁用时半透明
  .onClick(() => this.prevStep())

四、步骤可视化:Circle + Line 的进度条方案

4.1 节点的渲染

Row() {
  ForEach(this.stepList, (item: string, idx: number) => {
    Column({ space: 8 }) {
      Circle()
        .width(24)
        .height(24)
        .fill(idx <= this.currentStep ? 0xFF27AE60 : 0xFFDCDCDC)
      Text(item)
        .fontSize(12)
        .textAlign(TextAlign.Center)
    }
    .layoutWeight(1)
  })
}

每个步骤节点由 Circle + Text 组成,垂直排列在一个 Column 中,间距 8。

颜色判断逻辑:idx <= this.currentStep

这是整个进度显示的核心规则:

步骤索引 currentStep=2 时 颜色
0 (已下单) 0 <= 2 ✅ 绿色
1 (商家揽收) 1 <= 2 ✅ 绿色
2 (运输途中) 2 <= 2 ✅ 绿色
3 (到达网点) 3 <= 2 ❌ 灰色
4 (派送中) 4 <= 2 ❌ 灰色
5 (已签收) 5 <= 2 ❌ 灰色

注意这里用的是 <= 而不是 <。这意味着当前步骤本身也是绿色。这是合理的——当前步骤代表"已经到达的位置",应当标记为已完成。

但有些设计规范中会把"当前步骤"单独标记为另一种颜色(比如橙色/蓝色),以示"进行中"和"已完成"的区别。这取决于设计需求,当前代码选择了最简单的"已过 = 绿,未到 = 灰"方案。

4.2 连接线的渲染

连接线的渲染和节点类似,但多了一个条件:

Row() {
  ForEach(this.stepList, (_: string, idx: number) => {
    if(idx < this.stepList.length - 1) {
      Line()
        .startPoint([0, 0.5])
        .endPoint([1, 0.5])
        .strokeWidth(4)
        .stroke(idx < this.currentStep ? 0xFF27AE60 : 0xFFDCDCDC)
        .layoutWeight(1)
    }
  })
}

为什么需要 if(idx < this.stepList.length - 1)

因为 6 个步骤之间只有 5 条连接线。如果不加这个条件,最后一个步骤后面会多出一条"废弃"的线。

换个角度想:ForEach 遍历 6 次,每次生成一条线,第 6 次时 idx = 5,而 stepList.length - 1 = 5,条件 5 < 5 为假,跳过。所以正好生成 5 条线。

连接线的颜色逻辑:idx < this.currentStep

注意这里是 <(小于)而不是 <=(小于等于):

线段索引 经过的步骤 currentStep=2 时
0 已下单 → 商家揽收 0 < 2 ✅ 绿色
1 商家揽收 → 运输途中 1 < 2 ✅ 绿色
2 运输途中 → 到达网点 2 < 2 ❌ 灰色
3 到达网点 → 派送中 3 < 2 ❌ 灰色
4 派送中 → 已签收 4 < 2 ❌ 灰色

关键区别:线段在"到达当前节点之前"变绿,而节点在"到达当前节点(含)"变绿。

举例:当 currentStep = 2(运输途中)时:

  • 节点 0、1、2 都是绿色(idx <= 2
  • 但线段 0、1 是绿色,线段 2 是灰色(idx < 2

这意味着线段 2(运输途中 → 到达网点)是灰色的——虽然运输途中这个节点已经亮了,但它到下一个节点的路还没走完。这个设计更符合直觉:"我到了运输中这个站,但我还在这个站,还没往下走。"

4.3 定位的"魔法数字"

.position({y: -42})

42 这个数字怎么来的?我们来算一下:

步骤 Row 中的每个 Column 包含了 Circle(24px 高)+ 间距(8px)+ Text(约 16px 行高)≈ 48px。

两个 Row 之间的间距是 space: 30。但连接线 Row 需要上移以对齐到节点的中心位置。

节点 Row 中,每个 Column 的内容:

  • 上半部分:Circle 半径 12 + 间距 8 + Text 约 8px(上半部分)≈ 28px
  • Circle 中心到 Column 顶部的距离 ≈ 12px

实际上 42 这个数值是手动调试出来的,目的是让 Line 行的中心线与 Circle 的中心对齐。

这里有一个更好(且不需要魔法数字)的方案:使用 Stack 层叠布局替代两个独立的 Row

Stack() {
  // 底层:连接线
  Row() {
    ForEach(this.stepList, (_: string, idx: number) => {
      if(idx < this.stepList.length - 1) {
        Line()
          .startPoint([0, 0.5])
          .endPoint([1, 0.5])
          .strokeWidth(4)
          .stroke(idx < this.currentStep ? 0xFF27AE60 : 0xFFDCDCDC)
          .layoutWeight(1)
      }
    })
  }
  .width("90%")

  // 上层:节点
  Row() {
    ForEach(this.stepList, (item: string, idx: number) => {
      Column({ space: 8 }) {
        Circle()
          .width(24).height(24)
          .fill(idx <= this.currentStep ? 0xFF27AE60 : 0xFFDCDCDC)
        Text(item).fontSize(12)
          .textAlign(TextAlign.Center)
      }
      .layoutWeight(1)
    })
  }
  .width("90%")
}

Stack 中,子组件默认居中重叠,连接线自动对齐到 Circle 的中心,不需要任何偏移。position({y: -42}) 这个魔法数字也就没有存在的必要了。

4.4 步骤文字过长的问题

当前步骤文字是 4-6 个中文字符:

已下单        → 3 字符
商家揽收      → 4 字符
运输途中      → 4 字符
到达网点      → 4 字符
派送中        → 3 字符
已签收        → 3 字符

在 5 等分的 Row 中(每份 18% 宽度),每份约 60-70px(以 360px 屏幕宽度计算)。字号 12 的情况下,4 个中文字约 48px,可以放下。

但如果步骤文字变长,比如:

["订单已提交", "仓库处理中", "配送员接单", "正在派送", "已签收"]

"仓库处理中" 5 个字 × 12px ≈ 60px,在 70px 的空间内就会很挤。

解决方案:

Text(item)
  .fontSize(12)
  .textAlign(TextAlign.Center)
  .maxLines(2)                    // 允许换行
  .textOverflow({ overflow: TextOverflow.Ellipsis })  // 超出省略

或者动态计算字号:

Text(item)
  .fontSize(item.length > 4 ? 10 : 12)

五、UI 布局解析

5.1 整体布局树

Column (space: 30, 100%×100%, centered, bg:#f5f7fa)
├── Text("快递物流进度模拟")      // 标题
├── Row (90% width)              // 步骤节点行
│   ├── Column(layoutWeight:1)   // 步骤 0
│   │   ├── Circle(24×24)
│   │   └── Text("已下单")
│   ├── Column(layoutWeight:1)   // 步骤 1
│   │   ├── Circle(24×24)
│   │   └── Text("商家揽收")
│   └── ... (步骤 2-5)
├── Row (90% width, y:-42)       // 连接线行
│   ├── Line (线段 0)
│   ├── Line (线段 1)
│   └── ... (线段 2-4)
├── Column (90% width, white card) // 状态展示卡片
│   ├── Text("当前物流状态")
│   └── Text("运输途中")
└── Row (space: 25)              // 按钮行
    ├── Button("回退状态")
    └── Button("更新物流")

一共 4 个主要内容区块,通过 Column({ space: 30 }) 纵向排列。每个区块占"屏幕宽度 90%"(除了按钮行),左右留有 5% 的边距。

5.2 背景色选择

.backgroundColor("#f5f7fa")

#f5f7fa 是一种极浅的灰蓝色,在移动端 UI 设计中非常常见。它有几个特点:

  1. 视觉减负 —— 纯白背景长时间看会累,浅灰色更有"纸张质感"
  2. 层次区分 —— 卡片的白色(#fff)和背景的浅灰(#f5f7fa)形成自然的层次感
  3. 通用性 —— 几乎适合任何主题色,绿色、蓝色、橙色都能搭

5.3 状态卡片

Column() {
  Text("当前物流状态")
    .fontSize(19)
    .fontWeight(FontWeight.Medium)
  Text(this.stepList[this.currentStep])
    .fontSize(22)
    .fontColor(0xFF27AE60)
    .margin({top:10})
}
.width("90%")
.padding(20)
.backgroundColor("#fff")
.borderRadius(10)

卡片是移动端 UI 中最经典的信息容器形式。这里用了 10px 的圆角——比直角柔和,但没有胶囊那么夸张。白色底 + 阴影(其实这里没加 shadow,加了会更好看)让卡片从灰色背景上"浮起来"。

当前状态用 22 号绿色字体突出显示,和步骤节点中的绿色保持一致。

补充阴影会让卡片更有层次感:

Column() {
  // ...
}
.width("90%")
.padding(20)
.backgroundColor(Color.White)
.borderRadius(10)
.shadow({
  radius: 8,
  color: 0x1A000000,
  offsetX: 0,
  offsetY: 4
})

5.4 按钮设计

两个按钮形成了对比:

属性 回退状态 更新物流
背景色 默认(无特殊设置) #27AE60 绿色
文字色 默认 白色
含义 辅助操作、可逆 主要操作、前进

"更新物流"用绿色填充+白色文字,视觉权重更高,符合用户预期——主要操作是"前进"而不是"后退"。

"回退状态"没有设置背景色,显示为系统默认的按钮样式。这种主次分明的按钮设计让用户自然地将注意力集中在"更新物流"上。


六、ForEach 渲染深度解析

6.1 ForEach 的语法

ForEach(
  arr: any[],           // 数据源数组
  (item: any, index?: number) => void,  // UI 生成函数
  key?: (item: any, index?: number) => string  // 可选,key 生成器
)

在当前代码中:

ForEach(this.stepList, (item: string, idx: number) => {
  // 生成 Column(Circle + Text)
})

ForEach(this.stepList, (_: string, idx: number) => {
  // 生成 Line
})

第一个 ForEach 遍历 stepList 生成 6 个节点组件。 第二个 ForEach 遍历 stepList 生成 5 条连接线(通过 if 跳过最后一个)。

6.2 key 的重要性

ForEach 的第三个参数——key 生成器——是一个重要的性能优化点。

// 不传 key:使用默认索引作为 key
ForEach(this.stepList, (item, idx) => { ... })

// 传 key:使用唯一标识
ForEach(this.stepList, (item, idx) => { ... }, (item, idx) => item)

当列表不变时,两者没区别。但当列表动态变化(增、删、排序)时:

不传 key 的问题:

ArkTS 使用索引作为默认 key。当列表变化时,比如删除了第一项,原来的第二项变成了新的第一项,它的 key 从 1 变成了 0。框架会认为"原来的第 0 项被删除了,第 1 项变成了第 0 项,后面依次前移"。

这会导致两个问题:

  1. 不必要的组件复用 —— 组件实例被保留但内容变了,动画状态等可能异常
  2. 不必要的重新渲染 —— 所有组件的索引都变了,可能触发全量更新

传 key 的好处:

ForEach(this.stepList, (item, idx) => { ... }, (item) => item)

使用 item(即步骤名称字符串)作为 key。因为每个步骤名称是唯一的("已下单"只有一个),框架能精确追踪每个组件。

在当前的物流场景中,stepList 是固定不变的,传不传 key 没有实际性能差异。但作为习惯,始终为 ForEach 提供 key 参数是一个好习惯——你不知道哪天列表就变成动态的了。

6.3 ForEach 与 LazyForEach

对于固定且数量很少的列表(6 项),ForEach 完全够用。但如果步骤数量达到几十个,或者步骤内容非常复杂,就应该考虑 LazyForEach。

// 懒加载渲染:只渲染可见区域
LazyForEach(this.stepDataSource, (item: StepItem, idx: number) => {
  this.stepItemBuilder(item, idx)
}, (item: StepItem) => item.id)

LazyForEach 要求数据源实现 IDataSource 接口:

class StepDataSource implements IDataSource {
  private data: StepItem[] = []

  totalCount(): number {
    return this.data.length
  }

  getData(index: number): StepItem {
    return this.data[index]
  }

  // ... 其他接口方法
}

对于 6 个步骤的物流追踪,ForEach 即可。但了解 LazyForEach 的存在,有助于你在面对更复杂场景时做出正确选择。


七、响应式状态与数据流

7.1 @State 在 ExpressTrack 中的角色

@State currentStep: number = 2

这是组件中唯一个 @State 变量stepListprivate 的普通成员变量,不是 @State。

这意味着:

  • currentStep 变化 → UI 重新渲染(节点颜色、连接线颜色、卡片文本)
  • stepList 不变 → 永远不需要触发渲染(它是一个常量数据源)

这种"一个可变状态驱动多个 UI 节点"的模式,是本组件状态管理的核心。

7.2 数据流

用户点击"更新物流"
        ↓
nextStep() 被调用
        ↓
条件判断:currentStep < 5 ?
        ↓
是 → currentStep++
        ↓
UI 自动更新:
  ├── 节点颜色变化(第 3 个节点从灰变绿)
  ├── 连接线颜色变化(第 2 条线从灰变绿)
  └── 卡片文本从"运输途中"变为"到达网点"

整个数据流是单向的:用户事件 → 状态变化 → UI 更新。这是声明式 UI 的核心模式。

7.3 状态一致性的保障

物流追踪有一个重要的特性:状态是累积的。这意味着"已完成的上一步"永远包含"上一步的所有信息"。

比如,当 currentStep = 3(到达网点)时,它隐含了:

  • 已下单 ✅
  • 商家揽收 ✅
  • 运输途中 ✅
  • 到达网点 ✅
  • 派送中 ❌
  • 已签收 ❌

这种"历史状态由当前状态隐式表达"的设计,大大简化了数据模型。你不需要一个数组来记录"哪些步骤已完成",只需要一个 currentStep 索引即可。

这也是为什么条件 idx <= this.currentStep 就能正确标记所有已完成步骤——<= 天然表达了"当前及之前所有步骤"。


八、边界情况与错误处理

8.1 边界测试用例

操作序列 预期结果
初始状态 (currentStep=2) 节点 0,1,2 绿色;3,4,5 灰色
点击"更新物流"×1 currentStep=3,"到达网点"
点击"更新物流"×2 currentStep=4,"派送中"
点击"更新物流"×3 currentStep=5,"已签收"
点击"更新物流"×4 弹窗"物流已完成签收"
点击"回退状态"×1 currentStep=4,"派送中"
点击"回退状态"×3 currentStep=1,"商家揽收"
点击"回退状态"×2 currentStep=-1❌ 这就是 bug

等等——当我们从 currentStep=0 再回退时:

if(this.currentStep > 0) {
  this.currentStep--
} else {
  AlertDialog.show({message:"已是初始状态"})
}

currentStep=0 时,条件 0 > 0 为假,走 else 分支弹窗。所以永远不会进入 currentStep=0 再回退的情况。

✅ 代码正确覆盖了所有边界。

8.2 弹窗的用户体验

AlertDialog.show 在鸿蒙中是一个模态弹窗,会阻止用户的其他操作直到关闭。

在当前场景中,弹窗的出现意味着用户尝试了越界操作(第一步回退或最后一步前进)。弹窗是必要的——如果静默忽略,用户会困惑"为什么点了按钮没反应"。

弹窗的文案也值得推敲:

  • "已是初始状态" —— 明确告知"已经在最前面了"
  • "物流已完成签收" —— 明确告知"已经到最后了"

两者都给出了原因,而不仅仅是"操作失败"。


九、扩展与优化

9.1 动画增强

当前的进度切换是瞬变的。加入动画后效果会好很多:

// 步骤节点动画
Circle()
  .width(24).height(24)
  .fill(idx <= this.currentStep ? 0xFF27AE60 : 0xFFDCDCDC)
  .animation({
    duration: 300,
    curve: Curve.EaseInOut
  })

// 连接线动画(长度渐变)
Line()
  .width(idx < this.currentStep ? "100%" : "0%")
  .animation({
    duration: 500,
    curve: Curve.EaseInOut
  })

让节点颜色渐变更流畅,让连接线从左到右"生长"出来,用户视觉上能感受到"物流在前进"的动态。

9.2 时间线增强

在实际的物流追踪页面中,每个步骤通常还包含时间戳

interface StepInfo {
  name: string
  time: string
  location?: string
  remark?: string
}

@State steps: StepInfo[] = [
  { name: "已下单", time: "2025-01-15 10:30", location: "上海市" },
  { name: "商家揽收", time: "2025-01-15 14:20", location: "上海市徐汇区" },
  { name: "运输途中", time: "2025-01-16 08:00" },
  { name: "到达网点", time: "2025-01-17 09:15", location: "北京市朝阳区" },
  { name: "派送中", time: "2025-01-17 11:30", location: "北京市朝阳区望京" },
  { name: "已签收", time: "", location: "" }
]

然后在每个步骤的 Column 中显示时间:

Column({ space: 8 }) {
  Circle().width(24).height(24).fill(...)
  Text(item.name).fontSize(12)
  if (item.time) {
    Text(item.time).fontSize(10).fontColor("#999")
  }
}

9.3 模拟真实 API 数据

开发阶段我们手动控制 currentStep,但真实场景中数据来自后端 API:

@State currentStep: number = 0
@State isLoading: boolean = false

async fetchTrackingInfo(orderId: string) {
  this.isLoading = true
  try {
    const response = await fetch(`https://api.logistics.com/track/${orderId}`)
    const data = await response.json()
    // 假设后端返回 { status: "transporting", step: 2 }
    const statusMap: Record<string, number> = {
      pending: 0,
      picked_up: 1,
      transporting: 2,
      arrived: 3,
      delivering: 4,
      delivered: 5
    }
    this.currentStep = statusMap[data.status] ?? 0
  } catch (e) {
    // 降级处理
    AlertDialog.show({ message: "获取物流信息失败" })
  } finally {
    this.isLoading = false
  }
}

这样就和后端数据打通了。

9.4 支持物流异常状态

真实的物流过程并不总是顺利的。可能遇到:

  • 运输延迟
  • 包裹异常
  • 退回
  • 重新派送

这些"异常状态"需要特殊处理:

// 扩展状态定义
type StepStatus = 'completed' | 'current' | 'pending' | 'error' | 'warning'

interface StepData {
  name: string
  time?: string
  status: StepStatus
}

@State steps: StepData[] = [
  { name: "已下单", time: "01-15 10:30", status: 'completed' },
  { name: "商家揽收", time: "01-15 14:20", status: 'completed' },
  { name: "运输途中", time: "01-16 08:00", status: 'current' },
  { name: "到达网点", time: "", status: 'pending' },
  // 异常状态
  { name: "派送失败", time: "01-17 11:30", status: 'error' },
  { name: "重新派送", time: "01-18 09:00", status: 'warning' },
  { name: "已签收", time: "", status: 'pending' },
]

不同状态的视觉区分:

getCircleColor(status: StepStatus): ResourceColor {
  switch (status) {
    case 'completed': return 0xFF27AE60   // 绿色
    case 'current':   return 0xFF3498DB   // 蓝色
    case 'pending':   return 0xFFDCDCDC   // 灰色
    case 'error':     return 0xFFE74C3C   // 红色
    case 'warning':   return 0xFFF39C12   // 橙色
  }
}

9.5 组件化拆分

当逻辑变得复杂,应该拆分为子组件:

// StepNode.ets — 单个步骤节点
@Component
struct StepNode {
  @Prop name: string
  @Prop isCompleted: boolean
  @Prop isCurrent: boolean

  build() {
    Column({ space: 8 }) {
      Circle()
        .width(24).height(24)
        .fill(this.isCompleted ? 0xFF27AE60 : 0xFFDCDCDC)
        .stroke(this.isCurrent ? 0xFF27AE60 : Color.Transparent)
        .strokeWidth(this.isCurrent ? 4 : 0)
      Text(this.name).fontSize(12).textAlign(TextAlign.Center)
    }
  }
}

// StepLine.ets — 连接线
@Component
struct StepLine {
  @Prop isCompleted: boolean

  build() {
    Line()
      .startPoint([0, 0.5])
      .endPoint([1, 0.5])
      .strokeWidth(4)
      .stroke(this.isCompleted ? 0xFF27AE60 : 0xFFDCDCDC)
  }
}

// StatusCard.ets — 状态展示卡
@Component
struct StatusCard {
  @Prop currentStepName: string

  build() {
    Column() {
      Text("当前物流状态").fontSize(19).fontWeight(FontWeight.Medium)
      Text(this.currentStepName).fontSize(22).fontColor(0xFF27AE60).margin({ top: 10 })
    }
    .width("90%").padding(20).backgroundColor(Color.White).borderRadius(10)
  }
}

主组件变得简洁:

build() {
  Column({ space: 30 }) {
    Text("快递物流进度模拟").fontSize(26).fontWeight(FontWeight.Bold)

    Row() {
      ForEach(this.stepList, (item: string, idx: number) => {
        StepNode({
          name: item,
          isCompleted: idx <= this.currentStep,
          isCurrent: idx === this.currentStep
        })
        .layoutWeight(1)
      }, (item) => item)
    }
    .width("90%")

    Row() {
      ForEach(this.stepList, (_: string, idx: number) => {
        if (idx < this.stepList.length - 1) {
          StepLine({ isCompleted: idx < this.currentStep })
            .layoutWeight(1)
        }
      }, (_, idx) => idx.toString())
    }
    .width("90%")

    StatusCard({ currentStepName: this.stepList[this.currentStep] })

    Row({ space: 25 }) {
      Button("回退状态").width(130).height(45).onClick(() => this.prevStep())
      Button("更新物流").width(130).height(45)
        .backgroundColor(0xFF27AE60).fontColor(Color.White)
        .onClick(() => this.nextStep())
    }
  }
  .width("100%").height("100%").justifyContent(FlexAlign.Center)
  .padding(20).backgroundColor("#f5f7fa")
}

每个子组件职责单一,可测试、可复用的程度大幅提高。


十、与其他框架进度条方案对比

10.1 ArkTS vs Flutter

Flutter 中的步骤指示器通常用 Stepper widget:

Stepper(
  currentStep: _currentStep,
  steps: [
    Step(title: Text('已下单'), ...),
    Step(title: Text('商家揽收'), ...),
    // ...
  ],
  onStepContinue: () => setState(() => _currentStep++),
  onStepCancel: () => setState(() => _currentStep--),
)

Flutter 有内置的 Stepper,ArkTS 暂无官方 Steps 组件,需要自己用 Circle + Line + Column 组合。

ArkTS 的优势:自定义程度更高,样式完全可控。 Flutter 的优势:开箱即用,内置动画和交互。

10.2 ArkTS vs SwiftUI

SwiftUI 没有直接的 Steps 组件,通常用 HStack + Circle + Capsule 组合,与 ArkTS 方案几乎一致。

SwiftUI 和 ArkTS 在声明式 UI 上的语法差异很小,核心概念完全一致。

10.3 ArkTS vs Jetpack Compose

Compose 中也没有内置的 Stepper,同样需要手动组合:

Row(
  modifier = Modifier.fillMaxWidth(0.9f),
  horizontalArrangement = Arrangement.SpaceEvenly
) {
  steps.forEachIndexed { index, name ->
    Column(
      horizontalAlignment = Alignment.CenterHorizontally,
      modifier = Modifier.weight(1f)
    ) {
      Circle(
        modifier = Modifier.size(24.dp),
        color = if (index <= currentStep) Color.Green else Color.Gray
      )
      Text(name, fontSize = 12.sp)
    }
  }
}

Compose 的 weight(1f) 对应 ArkTS 的 layoutWeight(1)。Compose 的 Color.Green 对应 ArkTS 的 0xFF27AE60。语法不同,思想一致。


十一、性能分析

11.1 渲染性能

当前组件的 UI 树:

1 个 Column
1 个 Text(标题)
1 个 Row(节点) → 6 个 Column → 6 × (1 Circle + 1 Text) = 18 个叶子节点
1 个 Row(连线) → 5 个 Line
1 个 Column(卡片) → 2 个 Text
1 个 Row(按钮) → 2 个 Button

总节点数约 30 个。对于 ArkTS 渲染引擎来说,这是一个极小规模的 UI 树,渲染时间可以忽略不计。

11.2 更新性能

每次 currentStep 变化:

  • 节点颜色:6 次颜色判断 → 6 个 Circle 的 fill 属性更新
  • 连接线颜色:5 次颜色判断 → 5 个 Line 的 stroke 属性更新
  • 卡片文本:1 次文本更新

总共约 12 个属性更新。ArkTS 的细粒度响应式确保只有这些属性被更新,不会触发整棵树重建。

11.3 优化点

对于当前代码,其实没有实质性的性能瓶颈。唯一值得一提的优化是:

将 stepList 声明为组件外常量,避免每次 build 重新创建数组:

// 组件外部常量
const STEP_LIST: string[] = ["已下单", "商家揽收", "运输途中", "到达网点", "派送中", "已签收"]

@Component
struct ExpressTrack {
  @State currentStep: number = 2
  // 不需要再声明 stepList,直接引用 STEP_LIST
}

这样 stepList 只初始化一次,不随组件实例化而重复创建。


十二、最佳实践总结

12.1 状态机设计模式

物流追踪是一个典型的 FSM(有限状态机)应用场景。设计时遵循:

  1. 状态穷举 —— 列出所有可能的状态,确保没有遗漏
  2. 转移矩阵 —— 明确哪些转移是允许的(物流只能前进,部分场景支持回退)
  3. 终止态 —— 定义流程的终点(签收后不再变化)
  4. 边界约束 —— 在状态转移函数内部进行边界检查

12.2 UI 与数据分离

  • 数据层:stepList(常量数据)、currentStep(可变状态)
  • 逻辑层:prevStep()nextStep()(状态转移方法)
  • 表现层:build()(UI 描述)

三者职责清晰,各自独立。这是 ArkTS 组件设计的基本套路。

12.3 条件样式的原则

// 节点:当前及之前 = 绿,之后 = 灰
Circle().fill(idx <= this.currentStep ? 绿 : 灰)

// 连线:当前之前 = 绿,当前及之后 = 灰
Line().stroke(idx < this.currentStep ? 绿 : 灰)

"≤ 包含当前节点,< 不包含当前线段"——这个细微的区别是进度条视觉效果是否"对"的关键。画进度条时,始终问自己:已完成的边界是否包含当前项?

12.4 弹窗的克制使用

AlertDialog 在边界时用作提示,但不应作为正常交互流程的一部分。在真实应用中,多于三次的弹窗提示会让人厌烦。更好的替代方案:

  • 边界时禁用按钮 + 降低不透明度
  • 使用 Snackbar / Toast 轻提示替代弹窗
  • 工具提示(Tooltip)在悬停时显示

十三、写在最后

13.1 核心要点回顾

  • 状态机设计 —— 物流追踪本质是一个 FSM,用索引表示当前状态
  • 条件渲染 —— idx <= currentStep 控制节点颜色,idx < currentStep 控制线段颜色
  • 边界处理 —— 通过 if...else 拦截越界操作,用弹窗给出明确反馈
  • 组件化 —— 随着逻辑变复杂,拆分为 StepNode、StepLine、StatusCard 等子组件
  • 定位技巧 —— 用 Stack 替代 position 偏移,消除魔法数字
  • 动画增强 —— 颜色渐变、线段生长动画提升体验

13.2 完整的代码回顾

95 行代码,是一个完整的、可直接运行的物流追踪组件。它包含了声明式 UI 开发的核心范式:数据驱动 UI,状态决定样式,边界兜底异常

@Entry
@Component
struct ExpressTrack {
  @State currentStep: number = 2
  private stepList: string[] = ["已下单", "商家揽收", "运输途中", "到达网点", "派送中", "已签收"]

  prevStep() {
    if(this.currentStep > 0) this.currentStep--
    else AlertDialog.show({ message: "已是初始状态" })
  }

  nextStep() {
    if(this.currentStep < this.stepList.length - 1) this.currentStep++
    else AlertDialog.show({ message: "物流已完成签收" })
  }

  build() {
    Column({ space: 30 }) {
      Text("快递物流进度模拟").fontSize(26).fontWeight(FontWeight.Bold)

      Row() {
        ForEach(this.stepList, (item: string, idx: number) => {
          Column({ space: 8 }) {
            Circle().width(24).height(24)
              .fill(idx <= this.currentStep ? 0xFF27AE60 : 0xFFDCDCDC)
            Text(item).fontSize(12).textAlign(TextAlign.Center)
          }
          .layoutWeight(1)
        })
      }
      .width("90%").margin({ bottom: 15 })

      Row() {
        ForEach(this.stepList, (_: string, idx: number) => {
          if (idx < this.stepList.length - 1) {
            Line()
              .startPoint([0, 0.5]).endPoint([1, 0.5])
              .strokeWidth(4)
              .stroke(idx < this.currentStep ? 0xFF27AE60 : 0xFFDCDCDC)
              .layoutWeight(1)
          }
        })
      }
      .width("90%").position({ y: -42 })

      Column() {
        Text("当前物流状态").fontSize(19).fontWeight(FontWeight.Medium)
        Text(this.stepList[this.currentStep]).fontSize(22)
          .fontColor(0xFF27AE60).margin({ top: 10 })
      }
      .width("90%").padding(20).backgroundColor("#fff").borderRadius(10)

      Row({ space: 25 }) {
        Button("回退状态").width(130).height(45)
          .onClick(() => this.prevStep())
        Button("更新物流").width(130).height(45)
          .backgroundColor(0xFF27AE60).fontColor(Color.White)
          .onClick(() => this.nextStep())
      }
    }
    .width("100%").height("100%").justifyContent(FlexAlign.Center)
    .padding(20).backgroundColor("#f5f7fa")
  }
}

13.3 下一步可以做什么

如果看完了这篇文章并且理解了其中的设计思想,下一步可以尝试:

  1. 接入真实 API —— 把模拟的前进/后退替换为真实的后端数据
  2. 添加地图组件 —— 在运输途中和派送中显示快递员的实时位置
  3. 推送通知 —— 物流状态变更时发送系统通知
  4. 多包裹追踪 —— 在一个页面展示多个物流进度
  5. 历史轨迹回放 —— 展示包裹从发货到签收的完整路径

物流追踪是移动应用中看似简单实则考验功力的模块。它需要你同时理解状态机设计、UI 可视化、边界处理和数据流——本文所讨论的每一点,都可以应用到你自己的项目中。

Logo

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

更多推荐