在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

查快递、管快递、看物流——一个看似需要后端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. 随机生成1-5天的物流时长
  2. 根据天数生成对应数量的物流记录
  3. 从城市池(北京/上海/广州/深圳/杭州/成都/武汉/南京/西安/长沙)中循环选取
  4. 从动作池(到达/发出/揽收/处理/发往)中循环选取
  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);  // ✅ 安全
    });
  }
}

关键点

  1. buildDetailView() 做空值检查
  2. 将非空对象作为参数传给 buildDetailContent(item: PackageItem)
  3. 子Builder的参数类型是非可空的 PackageItem,而不是 PackageItem | null
  4. 所有闭包中访问 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} ...`)
}

如果需要复用复杂的表达式,有两个选择:

  1. 内联:直接将表达式写在组件属性中
  2. 提取到普通方法:将逻辑提取到普通方法中,在 @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: "✅ 添加到我的包裹"

查询流程

  1. 用户选择快递公司(8选1)
  2. 输入单号(必填)
  3. 输入备注名称(选填,默认为"XX快递包裹")
  4. 点击"查询物流"
  5. 显示600ms加载动画
  6. 展示模拟查询结果(含最新物流记录预览)
  7. 用户确认后点击"添加到我的包裹"

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 关键技术要点回顾

  1. 分层Builder模式:解决ArkTS @Builder 闭包中不可空类型无法收窄的问题——外层做空值检查,内层通过非空参数接收。

  2. 模拟数据策略:首次启动用5条预设数据填充,查询时动态生成物流记录,兼顾"开箱即用"和"交互演示"。

  3. 8家快递公司支持:顺丰/中通/圆通/韵达/申通/京东/EMS/极兔,覆盖主流快递品牌。

  4. 三种物流状态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 可扩展方向

  1. 扫码录入:通过 @kit.CameraKit 扫描快递单条形码/二维码自动识别单号。

  2. 派送提醒:通过 @kit.NotificationKit 当物流状态变为"派送中"时发送通知。

  3. 多设备同步:通过 @kit.DistributedKVStore 实现手机和平板间的数据同步。

  4. 物流地图:在地图上标注所有物流节点位置,可视化展示运输路线。


附录:完整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) 加粗最新记录
Logo

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

更多推荐