快递追踪器APP开发实战:基于HarmonyOS API 24的数据驱动应用完整案例



查快递、管快递、看物流——一个看似需要后端API支持的应用,如何用纯前端ArkUI实现?本文从数据模型到时间线UI,从模拟数据到真实API对接预留,完整记录开发全过程。
一、项目缘起:为什么做"快递追踪器"
1.1 痛点分析
当代大学生的日常生活几乎离不开快递——网购的衣物、教材、零食、数码产品,每天都有无数的包裹在路上。管理这些包裹的物流状态是一个真实的痛点:
- 多包裹分散:不同平台的订单分布在不同的APP里,需要逐个打开查看
- 状态焦虑:不知道快递到哪了,时不时想查一下
- 信息混乱:单号记不住,快递公司混在一起
一个集中管理所有快递包裹的追踪工具,可以很好地解决这些问题。这就是"快递追踪器"APP的产品定位——做一个轻量、聚焦、好用的快递管理工具。
1.2 纯前端方案的可行性
快递查询通常需要对接物流API(如快递100、菜鸟等)。但本APP采用纯前端方案,原因如下:
| 维度 | 纯前端方案 | 对接API方案 |
|---|---|---|
| 开发成本 | 低(1-2天) | 高(需申请API Key + 后端服务) |
| 数据真实性 | 模拟数据 | 真实物流数据 |
| 离线可用 | ✅ 完全可用 | ❌ 依赖网络 |
| 后续扩展 | 可预留接口替换 | 直接接入即可 |
策略:先用模拟数据实现完整的UI和交互流程,将来接入真实API时只需替换 simulateSearch() 一个方法。
1.3 技术选型
| 技术维度 | 选择 | 理由 |
|---|---|---|
| 开发语言 | ArkTS | 严格类型安全,适合数据密集型应用 |
| UI框架 | ArkUI | List + ForEach 长列表渲染 |
| 数据持久化 | Preferences ( @kit.ArkData ) | 轻量KV存储 |
| 版本 | API 24 (HarmonyOS NEXT) | 最新稳定版 |
| 模拟策略 | 内置5条默认快递 + 动态模拟 | 开箱即用 |
二、需求分析与架构设计
2.1 功能需求
| 需求 | 优先级 | 说明 |
|---|---|---|
| 包裹列表 | P0 | 展示所有已添加的包裹及最新状态 |
| 统计卡片 | P0 | 全部/运输中/已签收计数 |
| 查询物流 | P0 | 选择快递公司 + 输入单号 → 模拟查询 |
| 添加快递 | P0 | 确认查询结果后添加到列表 |
| 物流详情 | P0 | 时间线式物流轨迹展示 |
| 删除包裹 | P1 | 从列表中移除 |
| 数据持久化 | P0 | Preferences存储,重启不丢失 |
| 默认数据 | P0 | 首次启动自动填充5条模拟快递 |
2.2 数据模型
快递公司枚举:
interface CourierCompany {
name: string // 显示名称(如"顺丰速运")
code: string // 编码(如"sf")
emoji: string // 图标(如"⚡")
}
物流记录:
interface TrackRecord {
time: string // 时间(如"12/27 08:15")
location: string // 地点(如"北京")
desc: string // 描述(如"快件到达【北京】朝阳区分拣中心")
}
快递包裹:
interface PackageItem {
id: number // 自增ID
name: string // 自定义名称(如"新买的运动鞋")
courier: string // 快递公司名
courierCode: string // 快递公司编码
trackingNo: string // 快递单号
status: TrackStatus // 当前状态
statusText: string // 状态文字
records: TrackRecord[] // 物流轨迹数组
addTime: number // 添加时间
}
物流状态枚举:
enum TrackStatus {
PENDING = 'pending', // 待揽收 📦
TRANSIT = 'transit', // 运输中 🚚
DELIVERING = 'delivering',// 派送中 🏃
DELIVERED = 'delivered', // 已签收 ✅
FAILED = 'failed' // 异常 ⚠️
}
2.3 三层架构
┌─────────────────────────────────────────────────────────┐
│ 表现层 (UI Layer) │
│ buildListView() / buildSearchView() / buildDetail()│
│ List + ForEach / 时间线 / 统计卡片 / 表单输入 │
├─────────────────────────────────────────────────────────┤
│ 状态层 (State Layer) │
│ @State packages / filteredPackages / searchResult │
│ @State currentView / detailItem / isSearching │
├─────────────────────────────────────────────────────────┤
│ 数据层 (Data Layer) │
│ loadData() / saveData() / refreshStats() │
│ getDefaultPackages() / simulateSearch() │
│ generateMockRecords() │
└─────────────────────────────────────────────────────────┘
2.4 视图导航结构
build()
├── 标题栏 (📦 快递追踪器)
├── 当前视图 (条件渲染)
│ ├── currentView === 'list' → buildListView()
│ │ ├── 统计卡片 (全部/运输中/已签收)
│ │ └── 包裹卡片列表 (List + ForEach)
│ │
│ ├── currentView === 'search' → buildSearchView()
│ │ ├── 快递公司选择 (8家)
│ │ ├── 单号输入
│ │ ├── 名称备注输入
│ │ ├── 查询按钮 (600ms模拟延迟)
│ │ └── 查询结果预览 + 添加按钮
│ │
│ └── currentView === 'detail' → buildDetailView()
│ └── buildDetailContent(item)
│ ├── 包裹信息卡片 (快递+单号+状态)
│ └── 物流时间线 (ForEach)
│
└── 底部导航栏 (📋 包裹 | 🔍 查询)
三、数据层:模拟数据策略
3.1 默认种子数据
首次启动时,APP内置5条模拟快递数据,覆盖三种物流状态:
private getDefaultPackages(): PackageItem[] {
return [
// 派送中 - 顺丰 - 5条物流记录
{ id: 1, name: '新买的运动鞋', courier: '顺丰速运',
status: DELIVERING, statusText: '派送中',
records: [ /* 深圳→广州→北京 链路 */ ] },
// 运输中 - 中通 - 3条物流记录
{ id: 2, name: '双十一买的书', courier: '中通快递',
status: TRANSIT, statusText: '运输中',
records: [ /* 长沙→武汉 链路 */ ] },
// 已签收 - 圆通 - 5条物流记录
{ id: 3, name: '女朋友送的围巾', courier: '圆通速递',
status: DELIVERED, statusText: '已签收',
records: [ /* 杭州→上海 链路,含签收记录 */ ] },
// 运输中 - 京东 - 3条物流记录
{ id: 4, name: '键盘', courier: '京东快递',
status: TRANSIT, statusText: '运输中',
records: [ /* 成都本地流转 */ ] },
// 已签收 - 韵达 - 5条物流记录
{ id: 5, name: '手机壳', courier: '韵达快递',
status: DELIVERED, statusText: '已签收',
records: [ /* 合肥→南京 链路,含签收记录 */ ] },
];
}
每条数据都包含:
- 真实的物流链路:如"深圳揽收→广州分拣→北京派送"
- 合理的时间线:时间倒序排列,最新记录在数组末尾
- 多样化的状态覆盖:确保用户能看到不同类型的包裹
3.2 动态模拟查询
当用户输入单号查询时,simulateSearch() 方法动态生成物流记录:
simulateSearch(trackingNo: string, courierIdx: number, name: string): PackageItem {
const days = Math.floor(Math.random() * 5) + 1;
const records = this.generateMockRecords(courierIdx, days);
// 根据天数决定状态
let status: TrackStatus;
if (days <= 1) { status = DELIVERING; statusText = '派送中'; }
else if (days <= 3) { status = TRANSIT; statusText = '运输中'; }
else { status = DELIVERED; statusText = '已签收'; }
// ...
}
模拟数据的生成逻辑:
- 随机生成1-5天的物流时长
- 根据天数生成对应数量的物流记录
- 从城市池(北京/上海/广州/深圳/杭州/成都/武汉/南京/西安/长沙)中循环选取
- 从动作池(到达/发出/揽收/处理/发往)中循环选取
- 根据总天数确定包裹状态
3.3 Preferences持久化
async loadData(): Promise<void> {
const json = await this.pref.get(STORAGE_KEY, '[]') as string;
const raw: PackageItem[] = JSON.parse(json);
if (raw.length === 0) {
this.packages = this.getDefaultPackages(); // 首次启动
this.nextId = this.packages.length + 1;
await this.saveData();
} else {
this.packages = raw.sort((a, b) => b.addTime - a.addTime);
}
}
ID自增策略:
private nextId: number = 1;
// 加载时更新
for (const p of this.packages) {
if (p.id >= this.nextId) this.nextId = p.id + 1;
}
// 新增时使用
item.id = this.nextId++;
四、@Builder 方法中的类型安全
4.1 问题:可空类型在闭包中无法收窄
在 buildDetailView() 中,我们遇到了一个棘手的ArkTS类型问题:
@Builder
buildDetailView() {
if (this.detailItem != null) { // 类型收窄到非空
// 这里 this.detailItem 是非空的
ForEach(this.detailItem.records, (record, idx) => {
// ❌ 这里 this.detailItem 又变成可空了!
// ArkTS 的类型收窄无法穿透闭包
this.detailItem.status // 编译错误:对象可能为 null
});
}
}
原因:ArkTS的"类型收窄"(Type Narrowing)是基于控制流的。if (x != null) 之后、在同一个作用域内的代码可以安全地使用 x。但闭包(箭头函数、匿名函数)创建了一个新的作用域,ArkTS编译器无法保证闭包执行时 x 仍然是非空的——因为理论上在闭包创建和闭包执行之间,x 可能被其他地方设置为 null。
在标准TypeScript中,同样的代码是可以通过编译的,因为TS的编译器做了更智能的"闭包类型收窄"分析。但ArkTS为了编译期安全和性能考量,采用了更严格的策略——闭包内不继承外层作用域的类型收窄。
4.2 解决方案:分层Builder模式
解决方法是将需要非空参数的内容提取到独立的 @Builder 方法中:
@Builder
buildDetailView() {
if (this.detailItem != null) {
// 类型已收窄,作为参数传递给子Builder
this.buildDetailContent(this.detailItem);
}
}
@Builder
buildDetailContent(item: PackageItem) {
// item 参数类型是 PackageItem(非空!)
Column() {
ForEach(item.records, (record, idx) => {
// item.records 安全访问 ✅
// item.status 安全访问 ✅
});
Button().onClick(() => {
this.deletePackage(item.id); // ✅ 安全
});
}
}
关键点:
buildDetailView()做空值检查- 将非空对象作为参数传给
buildDetailContent(item: PackageItem) - 子Builder的参数类型是非可空的
PackageItem,而不是PackageItem | null - 所有闭包中访问
item.xxx都是类型安全的
这个模式可以推广到所有包含闭包的 @Builder 场景:
@Builder
parentBuilder() {
if (nullableData != null) {
childBuilder(nullableData) // 类型收窄后传参
}
}
@Builder
childBuilder(data: NonNullType) {
// 所有闭包安全访问 data
}
4.3 @Builder 中的变量声明限制
在 @Builder 方法中,不能声明局部变量:
@Builder
buildDetailContent(item: PackageItem) {
// ❌ 不允许:const last = item.records[item.records.length - 1];
// ✅ 正确:内联访问
Text(`${item.records[item.records.length - 1].time} ...`)
}
如果需要复用复杂的表达式,有两个选择:
- 内联:直接将表达式写在组件属性中
- 提取到普通方法:将逻辑提取到普通方法中,在
@Builder中调用
// 方案2示例:提取到普通方法
getLastRecordTime(item: PackageItem): string {
const len = item.records.length;
if (len === 0) return '';
return item.records[len - 1].time;
}
@Builder
buildDetailContent(item: PackageItem) {
Text(this.getLastRecordTime(item)) // ✅ 调用普通方法
}
五、UI实现详解
5.1 包裹列表视图
包裹列表是APP的首页,展示所有已添加的快递:
Column
├── Row: 统计卡片 (📦 全部X | 🚚 运输中X | ✅ 已签收X)
└── List
└── ForEach: packages
└── ListItem
└── 包裹卡片
├── Row
│ ├── 快递公司emoji (30fp)
│ ├── Column
│ │ ├── Row: 名称 + 快递公司标签
│ │ └── Text: 单号
│ └── Column: 状态emoji + 状态文字
└── Text: 最新物流记录 (灰色小字)
包裹卡片设计要点:
- 左侧:快递公司emoji,一眼识别是哪家快递
- 中间:自定义名称 + 快递公司标签 + 单号
- 右侧:状态emoji + 状态文字(颜色随状态变化)
- 底部:最新物流记录预览(单行省略)
5.2 查询视图
查询视图是一个复杂的表单页面,包含多个输入区域:
Scroll
└── Column
├── 快递公司选择
│ └── Column
│ └── ForEach: 8家快递公司
│ └── Row: emoji + 名称 + 选中标记✓
│
├── 单号输入
│ └── Row: 📮 + TextInput
│
├── 备注名称输入
│ └── Row: 🏷️ + TextInput
│
├── Button: "🔍 查询物流"
│
└── 查询结果 (条件渲染)
└── if showSearchResult && searchResult != null
└── Column: 结果卡片
├── Row: 快递图标 + 信息 + 状态
├── Text: 最新物流记录
└── Button: "✅ 添加到我的包裹"
查询流程:
- 用户选择快递公司(8选1)
- 输入单号(必填)
- 输入备注名称(选填,默认为"XX快递包裹")
- 点击"查询物流"
- 显示600ms加载动画
- 展示模拟查询结果(含最新物流记录预览)
- 用户确认后点击"添加到我的包裹"
5.3 物流时间线
物流详情页的核心是一个垂直时间线UI:
Column
├── 包裹信息卡片
│ ├── Row: 快递emoji + 名称·单号
│ └── Row: 状态emoji + 状态文字 (底色标签)
│
└── 物流轨迹 (标题)
└── Column
└── ForEach: item.records (从旧到新)
└── Row (alignItems: Top)
├── Column (宽度30, 居中)
│ ├── 圆点 (12px/8px, 彩色/灰色)
│ └── 竖线 (2px宽, 灰色, 最后一条不显示)
│
└── Column (内容)
├── Text: 物流描述 (14fp)
└── Text: "时间 · 地点" (12fp, 灰色)
时间线的视觉层级:
- 最新记录(数组最后一条,idx=0):彩色圆点(匹配状态色)+ 粗体描述文字
- 历史记录(idx>0):灰色小圆点 + 常规字重描述
- 竖线连接:相邻记录之间用2px灰色竖线连接,营造时间轴感
5.4 底部导航
底部采用两个标签的简洁导航:
@Builder
buildBottomNav() {
Row() {
this.buildNavItem('📋', '包裹', 'list')
this.buildNavItem('🔍', '查询', 'search')
}
}
detail 视图没有自己的导航标签——它通过点击包裹卡片进入,通过顶部的"← 返回"按钮回到列表。
六、踩坑合集
坑1:@Builder闭包中的空值类型
症状:if (this.detailItem != null) 包裹的代码块中,在 ForEach 回调或 onClick 里访问 this.detailItem.xxx 报错。
原因:ArkTS的类型收窄不跨闭包传播。
修复:分层Builder模式——外层做空值检查,内层通过非空参数接收。
坑2:@Builder中不能声明局部变量
症状:
@Builder
buildCard() {
const last = item.records[length - 1]; // ❌ 编译错误
}
原因:ArkUI的 @Builder 设计上不允许局部变量声明,以保持声明式UI的纯粹性。
修复:
- 方法1:内联表达式
- 方法2:提取到普通方法
坑3:ForEach的key生成器
症状:List中的列表项无法正确追踪更新。
原因:ForEach 需要key生成器来唯一标识每个列表项。
修复:
ForEach(this.packages,
(item: PackageItem) => { /* UI */ },
(item: PackageItem) => item.id.toString() // key生成器
)
对于 ForEach(item.records, ...) 中的嵌套列表,同样需要key:
ForEach(item.records,
(record: TrackRecord, idx: number) => { /* UI */ },
(record: TrackRecord, idx: number) => idx.toString()
)
坑4:@State数组删除后的引用刷新
症状:删除包裹后UI没有更新。
原因:虽然 this.packages = this.packages.filter(...) 创建了新数组,但如果 this.detailItem 指向被删除的对象,且没有将其置为null,详情视图仍然显示被删除的内容。
修复:
deletePackage(id: number): void {
this.packages = this.packages.filter(p => p.id !== id);
this.saveData();
this.refreshStats();
// 如果详情页显示的就是被删除的包裹,回到列表
if (this.detailItem != null && this.detailItem.id === id) {
this.detailItem = null;
this.currentView = 'list';
}
}
七、数据流全景
用户添加新包裹
↓
addPackage()
├── simulateSearch() → 生成模拟物流数据
├── item.id = this.nextId++
├── this.packages.unshift(item)
├── saveData() → Preferences写入
├── refreshStats() → 更新统计
└── reset UI + 切回列表
用户点击包裹卡片
↓
buildPackageCard().onClick()
├── this.detailItem = item
└── this.currentView = 'detail'
用户删除包裹
↓
buildDetailContent().onClick('🗑️')
└── deletePackage(id)
├── this.packages = this.packages.filter(...)
├── saveData()
├── refreshStats()
└── if (detailItem.id === id) → 切回列表
八、项目结构与代码统计
8.1 文件结构
Index.ets (~670行)
├── 类型定义 (~40行)
│ ├── enum TrackStatus / interface TrackRecord
│ ├── interface PackageItem / CourierCompany
│ └── type LogType
│
├── 组件定义 (~620行)
│ ├── @State变量及private成员 (~30行)
│ ├── 数据层 (~90行)
│ │ ├── loadData() / saveData() / refreshStats()
│ │ ├── getDefaultPackages() ← 5条默认数据
│ │ └── generateMockRecords() / simulateSearch()
│ ├── CRUD操作 (~40行)
│ │ ├── addPackage() / deletePackage()
│ │ └── previewSearch()
│ ├── 辅助方法 (~30行)
│ │ ├── getStatusColor / getStatusEmoji
│ │ └── getCourierEmoji
│ ├── build() 入口 (~10行)
│ ├── @Builder buildBottomNav (~25行)
│ ├── @Builder buildListView (~80行)
│ ├── @Builder buildSearchView (~140行)
│ ├── @Builder buildDetailView + buildDetailContent (~150行)
│ └── @Builder buildPackageCard (~40行)
8.2 代码量分布
| 模块 | 行数 | 占比 |
|---|---|---|
| 类型定义 | ~40 | 6% |
| 数据层 | ~90 | 13% |
| 默认数据 | ~60 | 9% |
| CRUD+辅助 | ~70 | 10% |
| UI层 | ~410 | 62% |
九、总结与展望
9.1 关键技术要点回顾
-
分层Builder模式:解决ArkTS
@Builder闭包中不可空类型无法收窄的问题——外层做空值检查,内层通过非空参数接收。 -
模拟数据策略:首次启动用5条预设数据填充,查询时动态生成物流记录,兼顾"开箱即用"和"交互演示"。
-
8家快递公司支持:顺丰/中通/圆通/韵达/申通/京东/EMS/极兔,覆盖主流快递品牌。
-
三种物流状态UI:运输中(蓝)、派送中(橙)、已签收(绿),颜色编码让状态一目了然。
9.2 接入真实API的改造方案
当前APP使用模拟数据,接入真实物流API只需替换以下方法:
// 替换这个一个方法即可
async queryRealTracking(trackingNo: string, courierCode: string): Promise<PackageItem> {
// 1. 调用物流API(如快递100、菜鸟)
const response = await fetch(`https://api.kuaidi100.com/query?type=${courierCode}&postid=${trackingNo}`);
const data = await response.json();
// 2. 将API返回的数据映射到 PackageItem 接口
return {
id: 0,
name: '',
courier: this.getCourierName(courierCode),
courierCode: courierCode,
trackingNo: trackingNo,
status: this.mapStatus(data.state),
statusText: this.mapStatusText(data.state),
records: data.data.map((item: any) => ({
time: item.time,
location: item.location,
desc: item.context
})),
addTime: Date.now()
};
}
9.3 可扩展方向
-
扫码录入:通过
@kit.CameraKit扫描快递单条形码/二维码自动识别单号。 -
派送提醒:通过
@kit.NotificationKit当物流状态变为"派送中"时发送通知。 -
多设备同步:通过
@kit.DistributedKVStore实现手机和平板间的数据同步。 -
物流地图:在地图上标注所有物流节点位置,可视化展示运输路线。
附录:完整API清单
@kit.ArkData
| API | 用途 |
|---|---|
preferences.getPreferences(ctx, name) |
获取偏好数据库 |
Preferences.get(key, default) |
读取值 |
Preferences.put(key, value) |
写入值 |
Preferences.flush() |
刷入磁盘 |
ArkUI组件
| 组件 | 用途 |
|---|---|
Column / Row |
布局容器 |
Text / TextInput |
文本显示输入 |
Button |
按钮 |
List / ListItem |
长列表 |
Scroll |
滚动容器 |
ForEach |
循环渲染 |
时间线UI用到的属性
| 属性 | 用途 |
|---|---|
.borderRadius() |
圆点圆形 |
.backgroundColor() |
竖线颜色 |
.width(2).height('100%') |
竖线尺寸 |
.alignItems(VerticalAlign.Top) |
顶部对齐 |
.maxLines(1).textOverflow() |
文本截断 |
.fontWeight(FontWeight.Medium) |
加粗最新记录 |
更多推荐

所有评论(0)