鸿蒙实战:快递包裹管理——完整源码与状态机通用模板(下)



实例:快递包裹管理(Parcel Manager)|技术:全量代码解读、状态机模式抽象、三种业务复用
一、为什么单独写"完整代码"一篇
前两篇分别讲了数据层和 UI 层,这一篇做三件事:
- 全量源码逐段解读——把 ParcelDao 和 ParcelPage 从头到尾过一遍,标注每段代码解决什么问题、与文章前两篇的对应关系;
- 运行效果演示——从首页入口到完整操作链的"操作剧本";
- 模式抽象——提炼出"主表快照 + 从表事件"的通用模板,并给出三个可直接改写的业务场景。
理解一篇博客的价值,不在于"看过代码",而在于"能复述为什么这样写"。这一篇就是"为什么"的总结篇。
二、完整源码:ParcelDao.ets
/**
* 快递包裹管理数据访问对象(DAO)
* 对应文章:26_快递包裹管理/26-1 建表与数据层设计、26-2 页面UI与操作实现、26-3 完整代码与运行效果
* 核心能力:双表关联(包裹主表 + 状态时间线从表)、状态机流转、按公司分组统计
*/
import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
/** 包裹状态枚举(状态机) */
export const PARCEL_STATUS: string[] = ['待揽收', '运输中', '派送中', '已签收', '异常'];
/** 包裹主记录,对应 parcel 表结构 */
export interface Parcel {
id: number;
trackingNo: string; // 快递单号
company: string; // 物流公司
goods: string; // 物品描述
sender: string; // 寄件人
receiver: string; // 收件人
status: number; // 0 待揽收 / 1 运输中 / 2 派送中 / 3 已签收 / 4 异常
remark: string; // 备注
createdTime: number; // 创建时间戳(毫秒)
signedTime: number; // 签收时间戳(0 表示未签收)
}
/** 状态时间线条目,对应 parcel_trace 表结构 */
export interface ParcelTrace {
id: number;
parcelId: number;
status: number; // 与主表同语义的状态值
note: string; // 该节点的说明文字
traceTime: number; // 该节点发生时间戳(毫秒)
}
/** 按公司分组的统计条目 */
export interface CompanyStat {
company: string;
count: number;
signedCount: number; // 已签收数量
}
const DOMAIN = 0x0001;
const TAG = 'ParcelDao';
export class ParcelDao {
private static readonly TABLE = 'parcel';
private static readonly TRACE_TABLE = 'parcel_trace';
private static store?: relationalStore.RdbStore;
/** 获取(或创建)数据库实例,单例复用;同时建主表、时间线表与索引 */
static async getStore(context: common.Context): Promise<relationalStore.RdbStore> {
if (ParcelDao.store) {
return ParcelDao.store;
}
const config: relationalStore.StoreConfig = {
name: 'parcel.db',
securityLevel: relationalStore.SecurityLevel.S1,
};
ParcelDao.store = await relationalStore.getRdbStore(context, config);
// 主表
await ParcelDao.store.executeSql(
`CREATE TABLE IF NOT EXISTS ${ParcelDao.TABLE} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tracking_no TEXT NOT NULL,
company TEXT NOT NULL,
goods TEXT DEFAULT '',
sender TEXT DEFAULT '',
receiver TEXT DEFAULT '',
status INTEGER NOT NULL DEFAULT 0,
remark TEXT DEFAULT '',
created_time INTEGER NOT NULL,
signed_time INTEGER DEFAULT 0
)`
);
// 状态时间线从表:parcel_id 指向主表 id,逻辑外键
await ParcelDao.store.executeSql(
`CREATE TABLE IF NOT EXISTS ${ParcelDao.TRACE_TABLE} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parcel_id INTEGER NOT NULL,
status INTEGER NOT NULL,
note TEXT DEFAULT '',
trace_time INTEGER NOT NULL
)`
);
await ParcelDao.store.executeSql(
`CREATE INDEX IF NOT EXISTS idx_parcel_status ON ${ParcelDao.TABLE} (status)`
);
await ParcelDao.store.executeSql(
`CREATE INDEX IF NOT EXISTS idx_parcel_created ON ${ParcelDao.TABLE} (created_time)`
);
await ParcelDao.store.executeSql(
`CREATE INDEX IF NOT EXISTS idx_trace_parcel ON ${ParcelDao.TRACE_TABLE} (parcel_id)`
);
hilog.info(DOMAIN, TAG, '包裹表与时间线表初始化成功');
return ParcelDao.store;
}
// ...(行映射、增删改查、统计、种子数据见前两篇,此处省略重复段)
}
逐段解读
| 代码段 | 职责 | 设计意图 |
|---|---|---|
| 常量与接口 | PARCEL_STATUS / Parcel / ParcelTrace / CompanyStat | 状态机唯一事实来源 + 三实体类型,页面与 DAO 共享 |
| getStore | 打开库 + 建 2 表 + 建 3 索引 | 幂等初始化,单例复用,一次性就绪 |
| rowToParcel / collectParcel | 结果集 → 实体数组 | 字段名映射集中地,页面不接触列名 |
| insert + addTrace | 新增包裹 + 初始时间线节点 | 事件溯源:创建即产生首条事件 |
| updateStatus | 事务:更新主表 + 追加时间线 | 强一致性,签收时间联动维护 |
| delete | 先删从表再删主表 | 杜绝孤儿数据 |
| queryAll / queryByStatus / queryByTrackingNo / queryTrace | 四个查询入口 | 覆盖列表页全部查询需求 |
| statusSummary | 一条 SQL 五状态计数 | 条件聚合,一次扫描 |
| companyStats | GROUP BY 公司 + 签收数 | 维度统计 |
| initSeedData | 12 件跨状态种子 | 首启演示数据,幂等 |
三、完整源码:ParcelPage.ets
/**
* 快递包裹管理页面
* 对应文章:26_快递包裹管理/26-1 ~ 26-3
* 功能:状态筛选 Tab + 包裹卡片列表 + 新增包裹弹窗 + 详情抽屉(含状态时间线 + 状态流转)
*/
import { common } from '@kit.AbilityKit';
import { promptAction } from '@kit.ArkUI';
import { ParcelDao, Parcel, ParcelTrace, PARCEL_STATUS } from '../../database/ParcelDao';
@Entry
@Component
struct ParcelPage {
@State parcels: Parcel[] = [];
@State filter: number = -1; // -1 全部 / 0~4 对应状态
@State statusCounts: number[] = [0, 0, 0, 0, 0];
@State companyStats: string = '';
@State formVisible: boolean = false;
@State fNo: string = '';
@State fCompany: string = '';
@State fGoods: string = '';
@State fSender: string = '';
@State fReceiver: string = '';
@State detailVisible: boolean = false;
@State current: Parcel | null = null;
@State traces: ParcelTrace[] = [];
private context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
aboutToAppear(): void {
this.refresh();
}
// ...(refresh/loadList/onSave/openDetail/changeStatus/onDelete/statusColor/fmtTime 见中篇)
}
页面代码的组织纪律
- 数据在前、交互在中、UI 在 build:状态量、生命周期、业务方法、工具方法依次排列,build 放最后——这是 ArkTS 页面的标准组织结构,前 20 个实例均遵循;
- UI 只调方法、不写 SQL:页面零 SQL、零 ResultSet,所有数据操作都收敛到 DAO。页面代码量因此大幅下降,可读性与可测试性提升;
- @State 与 @Entry 配合:@Entry 标记页面入口,路由注册在 main_pages.json;@State 驱动渲染。
四、接入工程三步
新页面接入现有工程只需三步(与 Canvas_2d 系列 README 一致):
Step 1:放文件
ParcelDao.ets→entry/src/main/ets/database/ParcelPage.ets→entry/src/main/ets/pages/samples/
Step 2:注册路由(main_pages.json)
{
"src": [
...
"pages/samples/ParcelPage",
...
]
}
Step 3:首页入口(Index.ets)
Button('📦 26 快递包裹管理')
.fontSize(14)
.width('80%')
.onClick(() => {
this.getUIContext().getRouter().pushUrl({ url: 'pages/samples/ParcelPage' });
})
三步之后即可运行。整个实例零第三方依赖,仅使用 @kit.ArkData(relationalStore)、@kit.ArkUI(promptAction)、@kit.AbilityKit(context)、@kit.PerformanceAnalysisKit(hilog)四个系统 Kit。
五、运行效果演示(操作剧本)
以下按真实使用顺序演示,读者可对照源码验证:
场景一:首次打开(种子数据)
| 步骤 | 界面表现 | 数据来源 |
|---|---|---|
| 1 | 标题栏显示"📦 快递包裹管理",副标题公司概览:“顺丰速运 2件/签收2 · 圆通速递 2件/签收1 · 中通快递 2件/签收0…” | companyStats |
| 2 | 状态 Tab:全部 / 待揽收(3) / 运输中(3) / 派送中(2) / 已签收(3) / 异常(1) | statusSummary |
| 3 | 列表按创建时间倒序展示 12 张卡片,每张有公司、单号、彩色状态徽标、物品、寄收件人、创建时间 | queryAll |
场景二:切换状态筛选
点"派送中"Tab → 列表只显示 2 件派送中包裹(圆通、申通),徽标橙色。再点"全部"恢复 12 件。
场景三:新增包裹
- 点右下角 + → 弹窗出现;
- 输入单号
YD1234567890123、公司韵达快递、物品保温杯、寄件人小米、收件人小王; - 点保存 → 弹窗关闭,toast"✅ 包裹已添加",列表顶部出现新卡片(状态徽标灰色"待揽收"),待揽收 Tab 角标 +1。
场景四:状态流转与时间线
- 点新卡片 → 详情抽屉打开,时间线只有 1 条节点(实心圆点"待揽收");
- 点"运输中"按钮 → 确认框 → 确定 → toast"✅ 状态已更新";
- 抽屉内时间线变为 2 条:○待揽收 → ●运输中(最新实心);
- 再点"已签收" → 时间线变为 3 条:○待揽收 → ○运输中 → ●已签收;
- 回到列表,卡片徽标变绿色"已签收",已签收 Tab 角标 +1,公司概览里"韵达快递"签收数更新。
场景五:删除包裹
- 打开任意包裹详情 → 点红色"🗑 删除此包裹";
- 确认框提示"确定删除 XXX 及其全部物流记录吗?" → 点删除;
- 抽屉关闭,toast"🗑 已删除",列表少一件,状态角标与公司概览同步更新。
场景六:数据持久性验证
杀掉应用重新打开 → 数据仍在(SQLite 落盘)。删除应用 → 沙箱数据库随应用删除而清除(默认行为)。
六、模式抽象:状态机 + 事件时间线通用模板
本实例最大的复用价值是下面这个模式。快递包裹只是它的一个实例,任何"对象有生命周期、状态会变化、变化需要留痕"的业务都适用。
6.1 三件套结构
┌─────────────────────────────────────────────────┐
│ 主表(当前状态快照) │
│ id / 业务字段... / status / 冗余结论字段 │
│ ── 列表查询、统计、筛选的主战场 │
└────────────────────────┬────────────────────────┘
│ 1:N(逻辑外键)
┌────────────────────────┴────────────────────────┐
│ 从表(事件时间线) │
│ id / 主表_id / status(快照) / note / event_time │
│ ── 只追加、不修改、不删除(历史不可变) │
└─────────────────────────────────────────────────┘
| 角色 | 表 | 特点 | 典型操作 |
|---|---|---|---|
| 快照 | 主表 | status 是"现在是什么状态" | UPDATE(状态变更)、SELECT(列表/统计) |
| 事件 | 从表 | 每行是"曾经发生了什么" | INSERT(追加)、SELECT(时间线,只读) |
6.2 六个约定
- 状态用数字枚举 + 常量数组:
STATUS_NAMES[idx]取中文,加状态只改数组; - 新增即写首条事件:insert 主表后立刻 addTrace,轨迹从"出生"开始;
- 状态变更 = 事务双写:UPDATE 主表 + INSERT 事件,同生共死;
- 事件不可变:从表只有 INSERT 与 SELECT,没有 UPDATE/DELETE——历史就是历史;
- 结论冗余到主表:高频查询的"结论"(如签收时间)放主表,别每次都查事件表;
- 删除先子后父:先清事件,再删快照,不留孤儿。
6.3 三个直接改写场景
场景 A:工单跟踪系统
| 快递实例 | 工单改写 |
|---|---|
| parcel | ticket(工单:编号/标题/负责人/优先级) |
| parcel_trace | ticket_log(流转记录:处理人/操作/时间) |
| status 0-4 | 新建/处理中/已解决/已关闭/驳回 |
| signed_time | closed_time(关闭时间) |
DAO 方法基本照搬:insertTicket / updateTicketStatus(事务:改状态 + 记日志)/ queryTicketLog / statusSummary。
场景 B:订单状态机(电商)
| 快递实例 | 订单改写 |
|---|---|
| parcel | order(订单:单号/金额/收货地址) |
| parcel_trace | order_log(状态日志:支付/发货/签收/退款) |
| status | 待支付/已支付/已发货/已签收/已取消 |
场景 C:任务审批流
| 快递实例 | 审批改写 |
|---|---|
| parcel | task(任务:标题/申请人/截止时间) |
| parcel_trace | approval_record(审批记录:审批人/意见/时间) |
| status | 待审批/审批中/已通过/已驳回 |
三个场景的共同点:主表描述对象现状,事件表描述对象经历。理解这一点,就掌握了本实例的全部精华。
七、性能与优化前瞻
7.1 当前数据量下的表现
12 件包裹、每件 1~5 条时间线,全表不过几十行,SQLite 毫秒级响应,无需任何优化。先写对,再优化是本系列的一贯立场。
7.2 数据量增长后的三板斧
| 增长场景 | 优化手段 |
|---|---|
| 包裹上千 | 列表分页:queryAll 加 limitAs + offset 或游标分页,复用 08 商品分页 |
| 时间线万级 | 按 parcel_id 索引已就位;必要时按 created_time 分区表 |
| 统计变慢 | statusSummary/companyStats 已是单表扫描 + 索引命中,可考虑物化统计表定时刷新 |
7.3 安全与合规
- 单号、收件人姓名、地址属个人信息,存储建议加密(relationalStore 支持 securityLevel 提升与数据库加密);
- 导出/分享前脱敏(如
SF123****8901),参考 10 数据备份导出的经验。
八、文章技术要点总对照表(三篇汇总)
| 篇章 | 核心知识点 | 一句话总结 |
|---|---|---|
| 26-1 建表与数据层设计 | 双表设计、状态机、事务、条件聚合、分组统计 | 快照表存现状,事件表存历史,状态变更事务双写 |
| 26-2 页面UI与操作实现 | 状态 Tab、卡片列表、FAB、详情抽屉、时间线 UI | UI 数据驱动,时间线用实心圆点标记最新节点 |
| 26-3 完整代码与运行效果 | 全量源码、操作剧本、模式抽象、三种复用 | 主表+事件表模板可改写任意状态机业务 |
九、FAQ(三篇汇总补遗)
Q1:这个实例和 09 订单明细的区别到底是什么?
A:09 订单明细是静态主从——订单与商品明细同时写入、不再变化,查询靠 JOIN 一次性取出。本实例是动态主从——主表状态会变,从表随时间追加,核心操作是"状态变更"而非"关联查询"。前者是数据建模,后者是事件记录。
Q2:为什么不把 status 和时间线合并成一张表?
A:单表设计会导致"当前状态"需要子查询(SELECT ... ORDER BY trace_time DESC LIMIT 1)才能拿到,每次列表刷新都做子查询,且状态统计(statusSummary)无法一条 SQL 完成。分表后主表查询 O(1),从表专职历史,各司其职。
Q3:事务里能嵌套 addTrace 吗?addTrace 内部又调 getStore,会不会拿到不同实例?
A:不会。addTrace 内部 await ParcelDao.getStore(context) 返回的是同一个单例 store(static 缓存),且 getStore 在事务已开启的情况下不会重新初始化(store 已存在直接 return)。所以 updateStatus 事务里的两次写操作走的是同一个连接、同一个事务。单例模式 + 幂等 getStore 是这套设计能成立的前提。
Q4:如果我想让时间线支持"删除某条错误节点"怎么办?
A:违背"事件不可变"约定。正确做法是追加一条纠正事件(如 status 回到上一个值,note 写"修正:误操作"),而不是物理删除。这样历史完整可审计。删除节点只应在开发调试期,生产环境一律追加纠正。
Q5:种子数据为什么是 12 件而不是 3 件?
A:12 件保证:① 五种状态都有代表(各 1~3 件);② 六家公司有分布;③ 列表滚动有视觉层次。种子数据的价值是"打开即有演示效果",数量太少(3 件)状态覆盖不全,太多(50 件)影响阅读。12 是覆盖与简洁的平衡点。
Q6:什么时候用 querySql、什么时候用 predicates?
A:简单条件(等值、模糊、排序)用 predicates,链式 API 类型安全、天然防注入;复杂聚合(CASE WHEN、GROUP BY 组合)用 querySql,SQL 表达更直接。本实例两者都用到了:查询用 predicates,统计用 querySql——这是 ArkTS 数据库开发的黄金分工。
十、结语
三篇文章完整走完了快递包裹管理的"设计 → 实现 → 复用"闭环:
- -1 教会你双表建模:快照与事件分离,事务保证一致;
- -2 教会你界面落地:数据驱动 Tab、时间线可视化、状态流转闭环;
- -3 教会你模式抽象:六条约定 + 三个改写场景,让一套代码服务三类业务。
更多推荐




所有评论(0)