鸿蒙物流追踪组件:打造高效移动端体验
一、前言
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 设计中非常常见。它有几个特点:
- 视觉减负 —— 纯白背景长时间看会累,浅灰色更有"纸张质感"
- 层次区分 —— 卡片的白色(
#fff)和背景的浅灰(#f5f7fa)形成自然的层次感 - 通用性 —— 几乎适合任何主题色,绿色、蓝色、橙色都能搭
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 项,后面依次前移"。
这会导致两个问题:
- 不必要的组件复用 —— 组件实例被保留但内容变了,动画状态等可能异常
- 不必要的重新渲染 —— 所有组件的索引都变了,可能触发全量更新
传 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 变量。stepList 是 private 的普通成员变量,不是 @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(有限状态机)应用场景。设计时遵循:
- 状态穷举 —— 列出所有可能的状态,确保没有遗漏
- 转移矩阵 —— 明确哪些转移是允许的(物流只能前进,部分场景支持回退)
- 终止态 —— 定义流程的终点(签收后不再变化)
- 边界约束 —— 在状态转移函数内部进行边界检查
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 下一步可以做什么
如果看完了这篇文章并且理解了其中的设计思想,下一步可以尝试:
- 接入真实 API —— 把模拟的前进/后退替换为真实的后端数据
- 添加地图组件 —— 在运输途中和派送中显示快递员的实时位置
- 推送通知 —— 物流状态变更时发送系统通知
- 多包裹追踪 —— 在一个页面展示多个物流进度
- 历史轨迹回放 —— 展示包裹从发货到签收的完整路径
物流追踪是移动应用中看似简单实则考验功力的模块。它需要你同时理解状态机设计、UI 可视化、边界处理和数据流——本文所讨论的每一点,都可以应用到你自己的项目中。


更多推荐



所有评论(0)