简介:这份资源是面向Android初学者与移动应用开发练习者的同城物流派送系统项目源码,围绕城市内快速配送场景,完整呈现从订单发起到司机抢单、再到货物送达的业务闭环,可用于学习SQLite数据存储、账号注册登录、自定义适配器列表展示、抢单与派送状态流转以及GPS定位与路线规划等典型模块的实现思路。压缩包共275个文件,约53.42MB,以94个png界面素材、86个class编译文件、39个xml布局与配置、31个java源码为主,另含9个jar依赖、5个so库及apk安装包等,兼顾源码阅读与直接运行体验。目前已有323人学习下载。读者可从中获得一套结构完整的项目参考,对照数据库增删改查、用户认证与适配器数据绑定等环节理解Android应用的组织方式,适合作为课程设计或毕业设计的实践蓝本。
1. 同城物流派送系统拆解:一个 SQLite 单文件撑起整套调度逻辑
同城物流派送这类项目,最容易被做复杂:订单表、骑手表、路线表、状态机、结算流水,一上来就上 MySQL 加 Redis,本地跑个 Demo 还得先装服务、配账号、开端口。我这次拆的这份资源走的是另一条路——整套派送调度逻辑压在一个 SQLite 单文件里,订单落库、骑手状态流转、派单匹配、轨迹点记录全在一个.db文件内完成,拷走就能跑,换台机器不用改一行连接配置。它解决的是中小规模同城配送场景下「数据量不大但事务一致性要求不低」的问题,适合想快速验证派单算法、做课程设计、或者给门店做轻量调度工具的从业者。SQLite 在这里不是妥协,是选型。
2. 数据模型与派单状态机:先把表设计对,后面少改三次代码
拿到这类资源,我一般不看界面先看表结构。同城派送的复杂度八成压在数据模型上:订单从创建到签收要经过哪些状态、骑手接单后订单和骑手怎么绑定、派单失败怎么回滚,这些想不清楚,后面每加一个功能都要动表。这份资源的表设计不算多,但把关键关系收住了,值得先讲透再动手。
2.1 四张核心表与它们的外键关系
资源里的核心表大致是四张:订单表orders、骑手表couriers、派单记录表assignments、轨迹点表track_points。订单表存收货地址经纬度、期望时段、当前状态;骑手表存当前位置、载单上限、在线状态;派单记录表是订单和骑手的多对多桥接,带派单时间和结果;轨迹点表按时间戳追加骑手位置。
关键设计点在assignments这张桥接表:它没有把courier_id直接塞进orders,而是单独记录每一次派单尝试,包括失败的那次。这个选择很关键——同城派送里「派了但骑手拒单」是高频事件,如果只在订单表上挂一个骑手字段,拒单后历史就丢了,事后排查「这单为什么绕了三圈才派出去」没有依据。桥接表让每次尝试都留痕,代价是多一次 join。
-- 订单表:状态用整数枚举,避免字符串比较 CREATE TABLE orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, pickup_lat REAL NOT NULL, pickup_lng REAL NOT NULL, drop_lat REAL NOT NULL, drop_lng REAL NOT NULL, status INTEGER NOT NULL DEFAULT 0, -- 0待派 1已派 2取货中 3配送中 4已签收 5已取消 created_at INTEGER NOT NULL, -- Unix 秒,统一时区 expect_start INTEGER, -- 期望送达时段起点 expect_end INTEGER ); -- 派单记录:每次尝试一行,失败也留痕 CREATE TABLE assignments ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL REFERENCES orders(id), courier_id INTEGER NOT NULL REFERENCES couriers(id), result INTEGER NOT NULL, -- 0待响应 1接受 2拒单 3超时 assigned_at INTEGER NOT NULL, responded_at INTEGER ); CREATE INDEX idx_assign_order ON assignments(order_id); CREATE INDEX idx_assign_courier ON assignments(courier_id, result);上面这段建表语句里有两个参数值得说。status用整数而不是文本,是因为 SQLite 没有原生枚举,文本状态在批量更新时容易拼错且索引效率低,整数配合应用层常量最稳。created_at和expect_start统一存 Unix 秒而不是文本时间,是为了后面算「距离期望时段还有多久」时能直接做减法,不用在 SQL 里调日期函数——SQLite 的日期函数跨时区处理是玄学,能避开就避开。两个索引分别服务「查某订单的派单历史」和「查某骑手当前有效单」,这两个查询在派单循环里会被高频调用。
2.2 状态流转与事务边界
状态机是这类系统最容易翻车的地方。订单状态从 0 到 4 是一条单向链,但取消(5)可以从任意前置状态进入,骑手拒单则要把订单从 1 退回 0。这份资源把状态变更全部收在一个函数里,不允许业务代码直接UPDATE orders SET status,这个约束我认可。
import sqlite3 # 合法状态迁移:当前状态 -> 允许的下一状态集合 TRANSITIONS = { 0: {1, 5}, # 待派 -> 已派 / 取消 1: {2, 0, 5}, # 已派 -> 取货中 / 退回待派(拒单) / 取消 2: {3, 5}, # 取货中 -> 配送中 / 取消 3: {4, 5}, # 配送中 -> 已签收 / 取消 4: set(), # 终态 5: set(), } def change_status(conn, order_id, new_status): cur = conn.cursor() cur.execute("SELECT status FROM orders WHERE id=?", (order_id,)) row = cur.fetchone() if row is None: raise ValueError(f"order {order_id} not found") old = row[0] if new_status not in TRANSITIONS.get(old, set()): raise ValueError(f"illegal transition {old} -> {new_status}") cur.execute( "UPDATE orders SET status=? WHERE id=? AND status=?", (new_status, order_id, old), # 带旧状态做乐观锁 ) if cur.rowcount != 1: raise RuntimeError("concurrent status change detected") conn.commit()这段代码的逻辑说明:先查当前状态,用TRANSITIONS字典校验迁移合法性,非法迁移直接抛错而不是静默忽略——静默忽略是血泪经验,线上出问题时你根本不知道哪一步被吞了。UPDATE语句里带AND status=?是乐观锁,防止两个并发请求同时把订单从 1 改到 2 和 0,rowcount不等于 1 就说明有人抢先改了,抛错让上层重试。参数上,conn由调用方传入而不是函数内新建,是为了让多个状态变更能包在同一个事务里;如果你要一次改多张表,把commit提到外层。
提示:SQLite 默认的
isolation_level是隐式事务,Python 的sqlite3模块在 DML 前会自动开事务。上面代码里显式commit是必要的,但如果你在外层已经开了事务,这里的commit会提前提交,需要改成由外层统一控制。
2.3 派单匹配的 SQL 写法
派单的核心查询是「找一批在线、未满载、距离取货点最近的骑手」。这份资源没有上空间索引,用的是经纬度平方差近似排序,在同城几公里范围内误差可以接受。
-- 候选骑手:在线、载单未满、按到取货点距离平方升序 SELECT c.id, (c.lat - :pickup_lat) * (c.lat - :pickup_lat) + (c.lng - :pickup_lng) * (c.lng - :pickup_lng) AS dist2 FROM couriers c WHERE c.online = 1 AND c.load_count < c.max_load AND c.id NOT IN ( SELECT courier_id FROM assignments WHERE order_id = :order_id AND result IN (1, 0) -- 已接受或待响应 ) ORDER BY dist2 ASC LIMIT 5;逻辑说明:dist2是距离平方,省掉开方,排序结果和真实距离一致但快一点。NOT IN子查询排除掉已经对这笔订单接受或待响应的骑手,避免重复派给同一个人。LIMIT 5是候选池大小,实际派单时按顺序逐个发请求,前一个拒单再试下一个。参数:pickup_lat这类命名占位符比?可读性好,Python 里用字典传参即可。注意load_count是冗余字段,每次接单要同步加一,这个一致性靠应用层保证,SQLite 没有触发器约束的话容易漏——这是后面避坑章要展开的点。
3. 从零跑通派单闭环:建库、灌数据、模拟一轮调度
表设计讲完,接下来是让它动起来。这一章按「建库 → 灌测试数据 → 跑一轮派单 → 看结果」的顺序走,每一步都能直接抄。我一般会先造一批假数据把闭环跑通,再谈优化,不然连状态流转对不对都验证不了。
3.1 初始化数据库与 PRAGMA 调优
SQLite 默认配置偏保守,同城派送这种写多读也多的小系统,开库时设几个 PRAGMA 能明显减少锁等待。
import sqlite3 def open_db(path="dispatch.db"): conn = sqlite3.connect(path, timeout=5.0) cur = conn.cursor() cur.execute("PRAGMA journal_mode=WAL;") # 读写不互斥 cur.execute("PRAGMA synchronous=NORMAL;") # 兼顾安全与速度 cur.execute("PRAGMA foreign_keys=ON;") # 外键约束默认是关的 cur.execute("PRAGMA busy_timeout=5000;") # 锁等待 5 秒 conn.commit() return conn参数说明:journal_mode=WAL让读操作不被写操作阻塞,派单循环里一边写派单记录一边查骑手列表,不开 WAL 会频繁database is locked。synchronous=NORMAL在 WAL 模式下已经足够安全,比默认FULL快不少,代价是极端断电可能丢最后一个事务——同城派送场景可以接受。foreign_keys=ON必须显式开,SQLite 默认不校验外键,不开的话assignments里能插进不存在的order_id,脏数据就是这么来的。busy_timeout是等锁的超时,设 5 秒比默认立即报错友好。
3.2 灌入模拟订单与骑手
测试数据要覆盖边界:有骑手满载、有骑手离线、有订单在期望时段边缘。下面这段造 20 个骑手、50 个订单,坐标在一个虚构城区的矩形范围内随机。
import random, time def seed(conn, n_couriers=20, n_orders=50): cur = conn.cursor() now = int(time.time()) for i in range(n_couriers): cur.execute( "INSERT INTO couriers(lat,lng,online,load_count,max_load) VALUES(?,?,?,?,?)", (30.0 + random.random(), 120.0 + random.random(), 1 if random.random() > 0.2 else 0, # 两成离线 0, random.choice([2, 3, 4])), ) for i in range(n_orders): cur.execute( "INSERT INTO orders(pickup_lat,pickup_lng,drop_lat,drop_lng,status,created_at,expect_start,expect_end)" " VALUES(?,?,?,?,0,?,?,?)", (30.0 + random.random(), 120.0 + random.random(), 30.0 + random.random(), 120.0 + random.random(), now, now + 1800, now + 3600), ) conn.commit()逻辑说明:online用random.random() > 0.2让约两成骑手离线,是为了验证派单查询的WHERE online=1真的生效。max_load在 2 到 4 之间随机,制造满载边界。订单的expect_start设在 30 分钟后、expect_end在 1 小时后,方便后面测「临近超时优先派」的逻辑。坐标范围是虚构的,实际用时换成你所在城区的经纬度矩形即可。
3.3 跑一轮派单并核对结果
派单主循环的逻辑是:取待派订单,按期望时段紧迫度排序,对每单查候选骑手,逐个尝试,成功则更新订单和骑手。
def dispatch_round(conn): cur = conn.cursor() cur.execute("SELECT id,pickup_lat,pickup_lng FROM orders WHERE status=0 ORDER BY expect_end ASC") orders = cur.fetchall() for oid, plat, plng in orders: cur.execute(""" SELECT c.id FROM couriers c WHERE c.online=1 AND c.load_count < c.max_load AND c.id NOT IN (SELECT courier_id FROM assignments WHERE order_id=? AND result IN (0,1)) ORDER BY (c.lat-?)*(c.lat-?)+(c.lng-?)*(c.lng-?) ASC LIMIT 5 """, (oid, plat, plat, plng, plng)) candidates = [r[0] for r in cur.fetchall()] for cid in candidates: try: cur.execute("BEGIN") cur.execute("INSERT INTO assignments(order_id,courier_id,result,assigned_at) VALUES(?,?,0,?)", (oid, cid, int(time.time()))) cur.execute("UPDATE orders SET status=1 WHERE id=? AND status=0", (oid,)) if cur.rowcount != 1: raise RuntimeError("order taken") cur.execute("UPDATE couriers SET load_count=load_count+1 WHERE id=? AND load_count<max_load", (cid,)) if cur.rowcount != 1: raise RuntimeError("courier full") conn.commit() break except Exception: conn.rollback() continue逻辑说明:外层按expect_end升序,越临近超时的订单越先派。内层对每个候选骑手开一个事务,插入派单记录、改订单状态、加骑手载单数三步要么全成要么全回滚。UPDATE orders ... AND status=0和UPDATE couriers ... AND load_count<max_load都带条件,rowcount校验是并发下的后悔药——两个派单线程同时处理同一单或同一骑手时,第二个会失败并回滚。参数上LIMIT 5控制候选池,太大浪费查询,太小可能派不出去,5 是个经验值。跑完一轮后查SELECT status, COUNT(*) FROM orders GROUP BY status就能看到派出去多少、还剩多少待派。
4. 避坑与排查:SQLite 派送系统最容易翻车的五个点
这套东西跑通不难,难的是跑稳。下面五条是我在类似项目里真实踩过或见别人踩过的,按「现象 → 原因 → 解决」写,遇到对应症状直接对号入座。
4.1 现象:派单循环跑几百单后报database is locked
原因:没开 WAL,或者开了 WAL 但多个连接同时写。SQLite 同一时刻只允许一个写者,派单循环里如果每个订单都新开连接,写锁竞争会非常激烈。另一个常见原因是事务没及时提交,长事务占着写锁。
解决:开库时统一走open_db()设journal_mode=WAL和busy_timeout;整个派单循环复用同一个连接,不要每单新建;事务粒度控制在「一单一次」,别把整轮派单包成一个大事务。如果确实要多线程,写操作串行化,读可以并发。
4.2 现象:骑手load_count和实际有效单数对不上
原因:load_count是冗余字段,接单时加一、签收或取消时减一,漏了任何一处就会漂移。最常见的是订单取消时忘了减,或者拒单回滚时减了两次。
解决:不要靠人工记增减点,写一个对账函数定期重算:UPDATE couriers SET load_count=(SELECT COUNT(*) FROM assignments a JOIN orders o ON a.order_id=o.id WHERE a.courier_id=couriers.id AND a.result=1 AND o.status IN (1,2,3))。更稳的做法是干脆不存load_count,每次派单时用子查询实时算,数据量小的时候性能完全够,省掉一致性维护。
4.3 现象:同一订单被派给两个骑手,两人都来取货
原因:派单时先查候选再插入,查和插之间没有锁,两个调度线程同时处理同一单,都查到空候选然后各自插入。这是典型的检查后使用竞态。
解决:插入派单记录前先UPDATE orders SET status=1 WHERE id=? AND status=0,用rowcount判断是否抢到,抢到再插记录;或者给assignments加唯一约束UNIQUE(order_id, courier_id, result)配合事务。核心是让「占单」这个动作原子化,别把判断和写入分开。
4.4 现象:按距离排序结果和地图上看到的顺序不一致
原因:用经纬度平方差排序,在纬度跨度大或者靠近极地时误差明显;另外如果经纬度存的是文本或者混了不同坐标系(比如有的点存了偏移后的坐标),排序直接失效。
解决:同城几公里范围内平方差够用,但前提是所有坐标同一坐标系、同一单位。入库前统一转成 WGS84 的十进制度,别混。跨度超过十几公里时改用 Haversine 公式或者上 R-Tree 索引,SQLite 自带rtree模块,建虚拟表按矩形范围先筛再精排。
4.5 现象:程序重启后部分订单状态卡在「已派」但骑手说没收到
原因:派单记录插了、订单状态改了,但通知骑手的动作在事务外,通知失败后事务已提交,状态和实际不一致。或者反过来,通知发了但事务回滚了。
解决:把「通知」做成可重试的独立步骤,订单状态里加一个「已派待确认」,骑手确认后才转「取货中」。通知失败时靠定时任务扫「已派待确认」超时的单重新派。别指望一次通知必达,同城派送的常态就是重试。
5. 进阶:用触发器和对账任务把一致性焊死
前面几章把闭环跑通了,但冗余字段和状态一致性还是靠应用层自觉。真要在生产边缘用,我一般会加两层保险:一层是 SQLite 触发器兜底,一层是定时对账任务。触发器负责「该减的必须减」,对账负责「已经漂的拉回来」,两层配合基本不用再担心数据对不上。
先看触发器。订单状态从「已派/取货中/配送中」进入终态(签收或取消)时,对应骑手的load_count必须减一。用AFTER UPDATE触发器实现:
CREATE TRIGGER trg_order_release_load AFTER UPDATE OF status ON orders WHEN OLD.status IN (1,2,3) AND NEW.status IN (4,5) BEGIN UPDATE couriers SET load_count = MAX(load_count - 1, 0) WHERE id = ( SELECT courier_id FROM assignments WHERE order_id = NEW.id AND result = 1 ORDER BY assigned_at DESC LIMIT 1 ); END;逻辑说明:AFTER UPDATE OF status只在状态列被改时触发,避免无关更新误触。WHEN子句限定「从进行中进入终态」,重复触发不会重复减。MAX(load_count - 1, 0)防止减成负数,这是兜底中的兜底。子查询取最近一次接受的派单记录,因为一单可能被派过多次,只有最后接受的那个骑手该减。参数上没有可调的,但要注意触发器在事务内执行,如果外层事务回滚,触发器效果也回滚,这点和普通语句一致。
再看对账任务。触发器能覆盖大部分路径,但历史脏数据、手工改库、触发器创建前的数据都管不到,所以需要一个定时跑的校正脚本:
def reconcile(conn): cur = conn.cursor() cur.execute(""" UPDATE couriers SET load_count = ( SELECT COUNT(*) FROM assignments a JOIN orders o ON a.order_id = o.id WHERE a.courier_id = couriers.id AND a.result = 1 AND o.status IN (1,2,3) ) """) conn.commit() return cur.rowcount这个脚本把每个骑手的load_count重算成「当前有效单数」,rowcount返回被修正的行数,可以打到日志里监控漂移频率。我一般设成每 10 分钟跑一次,或者每次派单批次结束后跑一次。如果rowcount长期大于 0,说明触发器有漏网路径,得回去查。
验证方法也简单:造一批订单,随机走完签收和取消,然后跑一次reconcile,看rowcount是否为 0。不为 0 就说明有路径没被触发器覆盖。我习惯在每次改状态机代码后都跑一遍这个验证,比肉眼看代码靠谱。
注意:触发器会增加写操作开销,派单高峰期如果发现写入变慢,先查是不是触发器里的子查询没走索引。
assignments(order_id)和assignments(courier_id, result)这两个索引就是为它准备的,别删。
从那以后我每次给这类单文件数据库加冗余字段,都强制先写触发器再写业务代码,最后补一个对账脚本兜底——顺序反了,后面补一致性就是无底洞。希望帮到你。
本文还有配套的精品资源,点击获取