简介:一份基于SQLite的同城物流派送系统完整Android项目,覆盖订单发起、司机抢单、配送状态跟踪等核心业务,适合Android开发学习者作为课设、毕设或就业项目参考。项目基于SQLite构建数据存储层,包含用户注册登录、自定义适配器列表展示、订单流程控制、GPS定位与路线规划等模块,代码结构清晰。其中注册登录模块涉及密码加密保存与登录校验,订单模块包含抢单和状态同步逻辑,适配器用于高效展示订单与司机信息。资源共275个文件,压缩包约53.42MB,以Java源码、XML布局、class编译文件为主,另有APK安装包、GIF演示图和界面截图,方便直接运行和对照调试。已有323人学习,适合作为课程设计参考项目。通过源码可掌握SQLite数据库增删改查与封装、自定义Adapter绑定数据、订单并发状态更新、定位与路线规划接入等实用技术,同时理解Android项目多模块协作方式。
1. 同城物流派送系统:一张 SQLite 文件扛下订单、骑手与派单
同城配送的订单没有固定路由,派给谁、从哪取、送到哪全靠动态决策,这类系统的难点从来不在界面,而在派单和数据一致性。
这套同城物流派送系统把订单、骑手、派单记录和状态日志全部收进一张 SQLite 文件里,单机直接跑,不需要安装数据库服务,拷走文件夹就能交付。适合用来改课设、做毕设起点,或者小团队内部快速验证派单思路。
关键词 sqlite 是核心:所有业务读写都落在同一个 .db 文件,后面讲的表结构、派单 SQL 和避坑路径也全部围绕它展开。
2. 数据层先立住:五张表、索引和连接管理的两种姿势
2.1 为什么这个场景选 SQLite 而不是 MySQL
同城派单一天的订单量就是几百到一两千行,骑手几十人,这种规模上 MySQL 属于给课设找麻烦。身边翻车最多的不是业务逻辑,而是 MySQL 没启动、账号密码记不住、端口被占。SQLite 是嵌入式数据库,整库就是一个 .db 文件,Python 自带 sqlite3 模块直接读写,没有服务进程、没有账号权限这一层,拷走文件夹就是完整交付。
ACID 事务它一样支持,写操作串行化,读操作可以并行,对单机派单系统绰绰有余。我跑通这套资源后最直观的感受是:做演示前不用先启动任何服务,双击运行脚本就能看到订单列表。
边界也划清楚:如果以后要多门店、多终端同时高频写,每秒几十次下单这种,SQLite 的写锁会成为瓶颈,那时候再迁 MySQL。表结构几乎不用大改,迁移成本主要在连接层。选型不是越重越好,量级决定工具。
2.2 建库脚本:骑手、订单、日志、参数、派单记录
资源包里的建库脚本是这么设计的,我在原版基础上加了字段注释:
-- 骑手表:运力主体,status 决定能不能接单,active_orders 参与派单评分 CREATE TABLE riders ( rider_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, phone TEXT UNIQUE NOT NULL, status TEXT DEFAULT '空闲', -- 空闲 / 忙碌 / 休息 current_x REAL DEFAULT 0, -- 当前经度(地图坐标系近似) current_y REAL DEFAULT 0, -- 当前纬度 max_orders INTEGER DEFAULT 5, -- 最大并行配送单数 active_orders INTEGER DEFAULT 0 -- 正在配送的单数 ); -- 订单表:一单两址,前端展示和派单评分都用它 CREATE TABLE orders ( order_id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT UNIQUE NOT NULL, customer_name TEXT NOT NULL, customer_phone TEXT NOT NULL, pickup_addr TEXT NOT NULL, delivery_addr TEXT NOT NULL, pickup_x REAL NOT NULL, pickup_y REAL NOT NULL, delivery_x REAL NOT NULL, delivery_y REAL NOT NULL, status TEXT DEFAULT '待分配', -- 待分配/已分配/配送中/已完成/已取消 rider_id INTEGER, create_time TEXT DEFAULT (datetime('now','localtime')), assign_time TEXT, finish_time TEXT, pay_amount REAL DEFAULT 0, FOREIGN KEY (rider_id) REFERENCES riders(rider_id) ); -- 状态日志表:每次流转留痕,核验阶段全靠它 CREATE TABLE order_status_log ( log_id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, from_status TEXT, to_status TEXT NOT NULL, change_time TEXT DEFAULT (datetime('now','localtime')), remark TEXT ); -- 派单记录表:系统派单和人工改派都写一条,方便追溯 CREATE TABLE dispatch_log ( dispatch_id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, rider_id INTEGER NOT NULL, dispatch_by TEXT DEFAULT 'system', -- system / manual score REAL, dispatch_time TEXT DEFAULT (datetime('now','localtime')) ); -- 参数表:权重和半径放配置,调参不碰代码 CREATE TABLE config ( key TEXT PRIMARY KEY, value REAL ); INSERT INTO config(key, value) VALUES ('dist_weight', 0.8); INSERT INTO config(key, value) VALUES ('load_weight', 0.2); INSERT INTO config(key, value) VALUES ('search_radius_km', 10); -- 高频查询索引 CREATE INDEX idx_orders_status ON orders(status); CREATE INDEX idx_orders_rider ON orders(rider_id); CREATE INDEX idx_orders_time ON orders(create_time);字段设计上有几个容易忽略的点。骑手表的 current_x / current_y 是派单评分的输入,演示环境里用定时任务模拟骑手移动,真实环境换成骑手 App 上报即可。订单表把取货点和送货点的坐标都存成 REAL,评分时不用再调接口。status 用中文 TEXT 而不是数字枚举,代价是多占几个字节,换来的排查体验是界面直接显示、不靠查表,课设场景值得。
状态日志表是数据核验的底牌。原版资源没有这张表,我跑通后补上的——没有日志,订单状态出问题时只能瞎猜。三个索引里,idx_orders_status 支撑"待分配队列"和状态统计,idx_orders_rider 支撑骑手负载统计,idx_orders_time 支撑时间排序和批量派单取前 N 单。不要在 status + rider_id 上建联合索引,状态变化频繁,索引维护成本大于收益,单列已经够用。
2.3 连接管理:短连接 + 统一入口
同城派单这类系统最常见的隐患是连接被多个窗口共用。资源包的做法是统一走一个入口函数,我在原版基础上加了 WAL 和超时配置:
# db.py:所有模块都从这里拿连接 import sqlite3 from contextlib import contextmanager DB_PATH = "same_city.db" # 与建库脚本保持同一路径 @contextmanager def get_db(path=DB_PATH): conn = sqlite3.connect(path, timeout=5.0) # 等锁最多 5 秒 conn.row_factory = sqlite3.Row # 列名访问结果 conn.execute("PRAGMA journal_mode=WAL") # 读写并行 conn.execute("PRAGMA busy_timeout=3000") # 锁等待 3 秒 conn.execute("PRAGMA foreign_keys=ON") # 外键约束生效 try: yield conn conn.commit() # 正常结束提交事务 except Exception: conn.rollback() # 异常时全部回滚 raise finally: conn.close() # 用完即关,不给长连接留机会每个模块用with get_db() as conn:拿连接,函数退出即提交并关闭,天然避开两件事:事务没提交导致锁悬挂、连接被另一个线程误用。PRAGMA foreign_keys=ON 需要每条连接都执行,所以放在入口里而不是建库脚本里——这一点原版资源容易漏,漏掉之后删除骑手会静默失败,界面上看起来像 bug。
另一种姿势是长连接 + threading.local,适合界面常驻、频繁下拉刷新的场景,在 get_db 里按线程缓存连接、线程结束时关闭。课设阶段短连接足够,别两头都上,越简单越不容易翻车。
3. 派单核心逻辑:贪心评分、状态机与事务边界
3.1 为什么要写成贪心而不是全局最优
订单和骑手是多对多的指派问题,教科书上是匈牙利算法找全局最优。但同城派单是动态的,订单随机进来,骑手位置一直在变,全局最优算完,现场条件已经变了。资源包选的是贪心:每进来一单,从空闲且没满载的骑手里挑评分最低的。单量在两三百单以内,贪心和全局最优的差距通常只有几个百分点,但实现和维护成本低一个量级。
评分公式是核心,资源包里这样定义:
score = 距离 × 0.8 + 负载 × 0.2两个细节决定评分是否靠谱。一是负载必须归一化再乘权重:active_orders / max_orders 得到一个 0 到 1 的小数,再乘 0.2;如果直接把 active_orders 乘 0.2,负载权重就淹没在距离的公里级数值里,等于没起作用。二是距离用欧氏距离近似:
def calc_distance(x1, y1, x2, y2): """城市尺度下的欧氏距离近似,单位为公里量级""" return ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5城市内几公里的范围,经纬度欧氏距离和真实球面距离的误差可以忽略,省去 haversine 的开销。注意第三地图服务商返回的经纬度坐标系不一致,评分前先统一坐标系,别混着用。
3.2 单笔派单模块:一个事务包住三步写操作
def dispatch_order(conn, order_id): """为单笔订单指派骑手,返回 (骑手, 提示)""" row = conn.execute( "SELECT * FROM orders WHERE order_id=? AND status='待分配'", (order_id,) ).fetchone() if not row: return None, "订单不存在或已被分配" riders = conn.execute( "SELECT * FROM riders " "WHERE status='空闲' AND active_orders < max_orders " "ORDER BY current_x, current_y" ).fetchall() if not riders: return None, "当前没有可用骑手" # 取配置权重,参数表里的值优先于代码默认值 cfg = {k: v for k, v in conn.execute("SELECT key, value FROM config")} dist_w = cfg.get("dist_weight", 0.8) load_w = cfg.get("load_weight", 0.2) best, best_score = None, None for r in riders: d = calc_distance(row["pickup_x"], row["pickup_y"], r["current_x"], r["current_y"]) load = r["active_orders"] / max(r["max_orders"], 1) # 归一化 score = d * dist_w + load * load_w if best_score is None or score < best_score: best, best_score = r, score conn.execute( "UPDATE orders SET rider_id=?, status='已分配', " "assign_time=datetime('now','localtime') WHERE order_id=?", (best["rider_id"], order_id) ) conn.execute( "UPDATE riders SET active_orders=active_orders+1 " "WHERE rider_id=?", (best["rider_id"],) ) conn.execute( "INSERT INTO order_status_log(order_id, from_status, to_status, remark) " "VALUES (?, ?, ?, ?)", (order_id, "待分配", "已分配", "系统派单") ) return best, "OK"这段代码有三个边界要点。一是查询可用骑手时就过滤 status 和 active_orders,不让评分函数处理无效候选。二是三条 UPDATE/INSERT 必须在同一个事务里提交,保证订单状态、骑手负载、日志一起生效;函数外面用 get_db 包一层,异常时全部回滚,不会出现订单已分配但骑手负载没加的半截状态。三是 dispatch_log 可以在同一事务里追加一条,记录骑手和评分,事后追溯"这单是谁派出去的、按什么分数派的",人工改派功能里也应该写它。
3.3 状态机把流转钉死,防止脏数据
界面按钮一多,订单状态最容易被写乱。资源包的做法是集中管流转:
ALLOWED_TRANSITIONS = { "待分配": ["已分配", "已取消"], "已分配": ["配送中", "已取消"], "配送中": ["已完成", "已取消"], "已完成": [], "已取消": [], } def change_status(conn, order_id, to_status, remark=""): cur = conn.execute( "SELECT status FROM orders WHERE order_id=?", (order_id,)) row = cur.fetchone() if not row: raise ValueError("订单不存在") if to_status not in ALLOWED_TRANSITIONS[row["status"]]: raise ValueError(f"非法流转: {row['status']} → {to_status}") extra_sql = "" params = [to_status, order_id] if to_status == "已完成": extra_sql = ", finish_time=datetime('now','localtime')" conn.execute( "UPDATE orders SET status=?" + extra_sql + " WHERE order_id=?", params ) conn.execute( "INSERT INTO order_status_log(order_id, from_status, to_status, remark) " "VALUES (?, ?, ?, ?)", (order_id, row["status"], to_status, remark) )所有界面按钮都只调 change_status,不直接写 UPDATE。非法流转直接抛异常,演示时最怕的就是"已取消订单又进入配送中"这种尴尬。注意"已完成"分支里顺带写 finish_time,别留在别处补,补的那次往往就忘了。
4. 运行期避坑记录:锁库、跨线程和中文乱码的排查路径
4.1 "database is locked" 频繁出现,单机也能看到并发
现象:批量派单按钮一点,界面抛 sqlite3.OperationalError: database is locked,有时候重试一下又好了,有时候一直卡死。
原因:默认 journal 模式下读写不能并行;某个连接开了事务没提交,别的连接写就排队超时。最常见的是长连接挂着不关,事务悬着不放。
解决:三件事一起做。连接里执行 PRAGMA journal_mode=WAL 和 PRAGMA busy_timeout=3000,合并到 get_db 入口;所有写操作走短连接、用完即关;批量派单不要在一个事务里循环几百个订单,拆成每 50 单一批。
提示:WAL 模式下仍然只有一个写者,busy_timeout 只是让写操作排队等待,不是并发写。真到了高频并发写,现在就该考虑迁移 MySQL。
4.2 跨线程共享连接,直接报线程错误
现象:界面里起线程刷新订单列表,把主线程建立的 conn 传进子线程,抛 ProgrammingError: SQLite objects created in a thread can only be used in that thread。
原因:sqlite3 连接默认绑定创建它的线程,跨线程使用属于未定义行为,库直接拦截,不给你出错的机会。
解决:每个线程独立开连接。子线程里重新调用 get_db(),用完关闭;数据量小,短连接开销可忽略。不要偷懒共享连接,省下的代码量会在排错时加倍还回去。
4.3 导出的 CSV 在表格软件里全是乱码
现象:开发工具打开 CSV 正常,发给别人用表格软件打开,中文全变成乱码,第一行尤其明显。
原因:Python open 默认 utf-8 无 BOM 写文件,某些表格软件按本地编码解码,中文自然全翻。
解决:写文件时加 encoding='utf-8-sig',带上 BOM 头表格软件就能正确识别。这不只是派单系统的问题,所有导出功能都建议这么写:
with open("orders.csv", "w", encoding="utf-8-sig", newline="") as f: writer = csv.writer(f) writer.writerow(["订单号", "状态", "骑手"]) # 按查询结果逐行写入4.4 打包后报 no such table
现象:源码跑通,用 PyInstaller 打包成 exe 后启动报 no such table: orders,同目录的 .db 文件明明就在。
原因:打包时把仓库里的 .db 也收进去了,程序运行时解包到临时目录,每次启动路径都会变;数据库被当成静态模板,写操作写到了另一个地方。
解决:把数据库路径改成程序根目录,用 sys.executable 的所在目录判断,启动时检查文件不存在就从模板复制一份。千万别用file判断打包后的路径,临时目录靠不住。
4.5 按时间排序的订单顺序乱了
现象:订单列表按 create_time 排序,9:50 的单排在 10:05 的单后面,看着像排序 bug。
原因:时间存成了 '2025/6/9 9:50' 这类没补零的字符串,字典序和自然时间序不一致,字符串排序结果就错位。
解决:统一存储 'YYYY-MM-DD HH:MM:SS',插入时用 datetime('now','localtime') 保证格式。存量脏数据写一条 UPDATE 重新格式化,别在 SELECT 里做字符串替换,那是治标不治本。
5. 派单调优与数据核验:批量派单和三组校验 SQL
5.1 批量派单:一个事务包住一批
def batch_dispatch(conn, limit=50): pending = conn.execute( "SELECT order_id FROM orders WHERE status='待分配' " "ORDER BY create_time LIMIT ?", (limit,) ).fetchall() for p in pending: dispatch_order(conn, p["order_id"])批次由外层 with get_db() 包同一个事务,中途出错全部回滚,避免"派了一半"的死状态。limit 参数控制批次大小,配合前面锁的问题,50 单一批比较稳。ORDER BY create_time 保证先来的订单先被派,配合订单表上的时间索引,取前 N 单不会全表扫。
5.2 三组校验 SQL,把数据查穿
-- 1. 状态与骑手不一致的脏单 SELECT order_id, status, rider_id FROM orders WHERE status != '待分配' AND rider_id IS NULL; -- 2. 骑手负载分布,找出过载骑手 SELECT rider_id, COUNT(*) AS cnt FROM orders WHERE status IN ('已分配', '配送中') GROUP BY rider_id ORDER BY cnt DESC; -- 3. 状态日志缺失的订单,说明有人绕过状态机直接改库 SELECT o.order_id, o.status, COUNT(l.log_id) AS log_cnt FROM orders o LEFT JOIN order_status_log l ON o.order_id = l.order_id WHERE o.status NOT IN ('已完成', '已取消') GROUP BY o.order_id HAVING log_cnt = 0;第一组查脏单,状态非待分配却没有骑手,基本是改派逻辑没写干净。第二组看负载,骑手手里单数长期顶满,评分权重需要调。第三组最关键,日志缺失意味着某处代码直接 UPDATE 了 orders 表,绕过了状态机,这类问题不查出来,后面所有统计都是黑匣子。
5.3 接手任何源码包,先核验再改代码
我最初拿到这套资源时,第一件事就是跑上面三组 SQL,结果发现自带数据里真有两条状态与骑手不一致的脏单——不是程序运行出来的,是建库脚本里某个状态流转漏写了日志。从那以后我每次接手别人的课设源码包,都强制先跑一遍校验 SQL,确认状态机和数据对得上,才开始动界面代码,省下的排错时间比什么都值。
这套包里的建库脚本、派单模块和校验 SQL 是一体的,表名和字段按自己的演示数据改一改就能用;评分权重放进 config 表后,调参连代码都不用碰。希望帮到你。
本文还有配套的精品资源,点击获取