Android Studio校园快递代拿App实战:订单状态机与并发控制
简介面向Android开发学习者与本科毕业生的校园快递代拿跑腿App源码案例基于Android Studio开发覆盖MVP/MVVM分层架构、网络请求封装、MySQL数据库设计与安全认证等核心模块便于系统梳理移动端项目从设计到实现的全过程。压缩包共52个文件以Vue/JS前端代码、SQL数据库脚本、CSS样式文件为主辅以JSON配置、Markdown说明和图片预览整体约540KB目录清晰方便按功能模块查找。目前已有116人学习下载。通过该案例可获得完整前后端代码、数据库建表语句及界面预览图登录注册、下单、订单状态跟踪等业务模块一应俱全既能支撑毕业设计和课程答辩也可作为校园服务类App开发的实践参考。1. 从 AndroidStudio 毕业源码到校园代拿工具订单状态机比跑腿功能更值钱下午三点快递员把包裹放进菜鸟驿站你还在教学楼上课。这不是懒是时间冲突。校园快递代拿跑腿 App 解决的就是这种场景发单人出几块钱悬赏骑手顺路取件送到楼下。也正因为场景具体、实体关系明确这个题目成了 AndroidStudio 项目实例里的热门选择既不像电商那样物流复杂也不像记账本那样功能单薄刚好覆盖登录、列表、订单状态流转、通知更新这一整条业务线。我打开过不少“基于AndroidStudio校园快递代拿跑腿app设计毕业源码案例设计.zip”这类压缩包也帮学弟调过里面跑不起来的代码。这类项目最常见的形态是用户端和骑手端共用一个客户端靠一个 role 字段区分身份订单只有五个状态靠一条 update 语句解决抢单并发。技术栈基本是 Java SQLite RecyclerView个别案例加了高德地图核心其实还是 CRUD。把套路拆开你会发现这套东西足够支撑一次完整的毕业答辩。这篇文章不废话 UI 素材只讲这个标题背后的设计取舍和落地路径。你要复现这套源码先建数据表再写状态变更然后处理缓存、联调、打包。适合没做过完整项目的 Java 课程设计案例源码读者也适合想短期出作品的 Android 初学者。2. 先建模再写界面角色、订单和状态机把业务锁死写代码之前先把实体关系画清楚。这个案例之所以好讲答辩是因为两张表就能讲清所有业务谁发的单、谁接的单、单子走到哪一步。很多同学拿到 zip 直接点开 MainActivity 看代码结果越看越乱就是因为没有先看数据库设计。先建模再写界面顺序反了后面全在返工。2.1 功能拆解用户、骑手、管理端放一个 App 的取舍常见的快递代拿产品会拆成用户端、骑手端两个独立 App但作为毕业设计我不建议拆。原因有三单个工程单签名演示的时候切换角色只需重新登录省去装两个 APK 的麻烦代拿流程本身短一个 App 里用整数 role 控制入口就够代码量和答辩讲稿都更好控制。这个案例里的功能边界通常是这么划分的。用户端能发布订单填写取件码、快递重量、收货地址、悬赏金额还能取消订单、确认送达、评价。骑手端能看到待接单列表点“接单”后订单状态变成已接单然后标记取件、标记送达。管理端最简单只做一个订单列表甚至不做也行。共用部分只有登录注册和个人中心而“附近订单”这个功能在大多数源码里不是真定位而是靠“校区字段”过滤避免接入地图 SDK 带来的隐私适配问题。把这些功能落到代码上每个功能基本对应一个 Activity 或 Fragment。订单列表用 RecyclerView表单页用 ScrollView 包 LinearLayout数据库操作集中在 Dao 类。下一步就是把订单表建好因为所有界面交互最终都会落到 status 字段的变化上。2.2 SQLite 建表订单表决定你能演示到什么程度这套源码最核心的不是 UI而是订单状态机。我建议不用 Room 这类 ORM直接用 SQLiteOpenHelper 写裸 SQL一方面方便你逐行解释答辩另一方面在复制到其他环境时也少踩版本坑。下面这两张表是我常用的结构。CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, phone TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, nickname TEXT, role INTEGER DEFAULT 0, -- 0普通用户 1骑手 2管理员 campus_area TEXT DEFAULT 本部, create_time TEXT DEFAULT (datetime(now,localtime)) ); CREATE TABLE orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT UNIQUE NOT NULL, publisher_id INTEGER NOT NULL, taker_id INTEGER DEFAULT -1, -- 未接单时为 -1 pickup_code TEXT NOT NULL, package_size INTEGER DEFAULT 1, -- 1小 2中 3大 pickup_address TEXT NOT NULL, delivery_address TEXT NOT NULL, reward REAL DEFAULT 2.0, status INTEGER DEFAULT 0, -- 0待接单 1已接单 2已取件 3已送达 4已取消 create_time TEXT DEFAULT (datetime(now,localtime)), accept_time TEXT, finish_time TEXT ); CREATE INDEX idx_orders_status ON orders(status); CREATE INDEX idx_orders_publisher ON orders(publisher_id);字段里的细节都是踩过坑换来的。order_no 单独存一份而不是直接把自增 id 暴露给用户因为后面接后端接口时订单号要打印在取件页面和短信里自增 id 会让别人知道你平台一天产生多少单。taker_id 默认值用 -1 而不是 NULL这样查询未接单订单的 SQL 可以写taker_id -1走普通索引而且 Java 那边拿到的不会因为封装类型是 Integer 而判空崩溃。package_size 用 int 存展示时再映射文字别在图里存“小中大”。reward 用 REAL演示完全够真实交易场景会最后转成分存 int。status 字段必须用 int因为状态判断在 Java 里就是一个 switch比字符串拼 SQL 干净得多。status 的流转规则也要提前约定否则写完 Activity 回头改表结构就麻烦了。常用规则是status含义能走下一步的人0待接单发单人可取消骑手可接单1已接单骑手可标记取件2已取件骑手可标记送达3已送达发单人确认并评价4已取消双方只读状态必须按顺序走从 0 不能直接跳到 3否则就出现“还没取件就送达”的笑话。代码里我会用专门的状态推进方法而不是在 Activity 里随便写cv.put(status, 3)。2.3 抢单的并发控制一条 update 语句顶过五把锁骑手点“接单”是这个 App 里最刺激的瞬间但也是最容易写错的地方。新手典型写法是先查订单状态看到 status 是 0判断自己可以接再发起 update。问题在于两个骑手可能在同一个毫秒都查到了 status0然后各自更新成功一单被接了两回。正确做法是把“查”和“改”合并成一条语句。我用 Java 原生写给你看public int acceptOrder(long orderId, long takerId) { SQLiteDatabase db helper.getWritableDatabase(); ContentValues values new ContentValues(); values.put(taker_id, takerId); values.put(status, 1); values.put(accept_time, DateUtils.now()); // 重点where 条件里带 status0这就是乐观锁 int rows db.update(orders, values, id? AND status0, new String[]{String.valueOf(orderId)}); return rows; // rows1 表示抢单成功rows0 表示被别人抢先 }db.update 返回的是受影响行数它天然能告诉我们“到底有没有改成功”。两个请求同时进来数据库引擎会串行执行后执行的那条 update 已经匹配不到status0于是返回 0。用这个返回值去决定 UI 是跳转订单详情还是弹 Toast“手慢了订单已被接走”逻辑特别干净。有些人会纠结要不要加synchronized锁。我的看法是单进程内 synchronized 勉强可用但一旦以后接真实后端服务端是多进程部署锁就失效了。数据库层面的乐观锁能同时覆盖本地多线程和未来远程接口所以这套源码剖析与架构实战里我坚持用它。当然如果接单成功后还要给骑手累计接单次数、给发单人发一条通知就需要把多步写操作包进beginTransaction()保证原子性。3. Android Studio 工程搭建与核心代码实现模型设计好之后剩下的工作就是往 Android Studio 里填充代码。这个环节最容易让刚接触工程的人劝退因为步骤多且顺序不能乱。我按照自己搭过几套 androidstudio 项目实例的经验把从导入到跑通的核心点拆成下面几步。3.1 导入压缩包后的第一件事统一 Gradle、SDK、JDK 版本从 zip 解压出来的工程第一件事不是打开 MainActivity而是检查三个文件gradle/wrapper/gradle-wrapper.properties、项目根目录的build.gradle、app/build.gradle。这三处分别定义 Gradle 版本、Android Gradle Plugin 版本和编译 SDK 版本只要有一个和本机 Android Studio 不一致就会出现“Event Log 里全是红色Build 按钮永远转圈”的局面。我一般在终端里先看一眼 wrapper 配置grep distributionUrl gradle/wrapper/gradle-wrapper.properties如果输出的是 Gradle 4.x 或 5.x而本机 Android Studio 是 2023 之后的版本不要急着降级 AS直接把distributionUrl改成和当前 AS 匹配的版本比如 8.2。同时把 Project 的build.gradle里classpath com.android.tools.build:gradle:xxx改成对应版本。记住一条经验与其让旧工程去适配新 AS不如让配置对齐 AS 的默认模板这样省半小时。app/build.gradle里要注意compileSdkVersion不能高于本机已安装的 SDK否则会报 “SDK component missing”。另外targetSdkVersion别一上来就设 33因为 Android 13 的通知权限、Android 12 的精确闹钟权限都可能让你半夜调试到怀疑人生。做毕业设计targetSdkVersion设 30 或 31 最舒服。3.2 本地登录会话SharedPreferences 保存 userId 和 role这个项目没有真实的 Token 体系所以登录状态用一个轻量方案存就行。SharedPreferences 足够别用数据库表去存杀鸡用牛刀。我在 LoginHelper 里做了两件事登录成功后写入当前用户 id 和角色App 启动时检查有没有这个标记。// LoginHelper.java public class LoginHelper { private static final String FILE session; public static void save(Context ctx, long userId, int role) { ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE) .edit() .putLong(user_id, userId) .putInt(role, role) .commit(); // 登录跳转前用 commit确保写盘完成 } public static boolean check(Context ctx) { return ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE) .getLong(user_id, -1) ! -1; } public static int getRole(Context ctx) { return ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE) .getInt(role, 0); } }这段代码里MODE_PRIVATE保证文件只被本应用读不做跨应用暴露。commit()是同步写盘适合登录这种马上要跳转的场景如果是在主线程之外的定时任务里可以用apply()异步落盘。MainActivity 的onCreate先if (!LoginHelper.check(this))是就跳登录页否则根据 role 走对应首页。这个模式在所有同类型 App 里都通用也是答辩时能一句话讲清的模块。3.3 发布订单表单校验与数据写入发布订单页的字段很多取件码、包裹大小、取件地址、送达地址、悬赏金额。我建议先在页面层做一轮基础校验比如取件码不能为空、金额必须在 0.5 到 99 之间然后再写数据库。这样避免把脏数据插进表里后面列表页查出来又是一堆空值。public long insertOrder(OrderBean o) { SQLiteDatabase db helper.getWritableDatabase(); ContentValues cv new ContentValues(); cv.put(order_no, generateOrderNo(o.getPublisherPhone())); cv.put(publisher_id, o.getPublisherId()); cv.put(pickup_code, o.getPickupCode()); cv.put(package_size, o.getPackageSize()); cv.put(pickup_address, o.getPickupAddress()); cv.put(delivery_address, o.getDeliveryAddress()); cv.put(reward, o.getReward()); cv.put(status, 0); // 注意不设 taker_id数据库默认 -1 return db.insert(orders, null, cv); } private String generateOrderNo(String phone) { return OD System.currentTimeMillis() phone.substring(phone.length() - 4); }订单号的生成规则我刻意避开了自增 id用“OD 时间戳 手机尾号”拼出来的串可读性更好骑手在取件时能口头核对尾号。插入方法返回db.insert的自增 id可以直接返回给调用方让 Activity 跳转到订单详情页。这里还有一个隐藏规范pickup_code在发单人心里是隐私列表页显示时最好打码比如只显示后四位直到骑手接单后才显示完整取件码。这个细节做上答辩老师会高看你一眼。3.4 骑手取件/送达状态推进的通用模板骑手端两个动作一个是“标记取件”一个是“标记送达”。这两个动作本质都是状态迁移唯一区别是各自要记录的时间不同。我把它们抽成一个方法用 fromStatus 和 toStatus 双参数保证迁移路径可控。public boolean updateStatus(long orderId, int fromStatus, int toStatus) { SQLiteDatabase db helper.getWritableDatabase(); ContentValues cv new ContentValues(); cv.put(status, toStatus); if (toStatus 2) cv.put(accept_time, DateUtils.now()); // 已取件时间 if (toStatus 3) cv.put(finish_time, DateUtils.now()); int rows db.update(orders, cv, id? AND status?, new String[]{String.valueOf(orderId), String.valueOf(fromStatus)}); return rows 1; }与抢单一样这里也靠status?做条件限制。如果订单已经被发单人取消或者已经被其他骑手提前标记送达rows就是 0。Activity 里拿到 false 后弹一个友好的提示然后调用列表刷新。这个模板把状态校验收拢在一个方法里全局搜updateStatus就能看到所有状态推进点调 bug 时不用到处找ContentValues。4. 数据接口与联网本地模拟和真实后端平滑切换很多毕业设计做到这一步就停了数据库写在本地Activity 直接查演示结束万事大吉。但如果你以后想把这套东西接到一个真实后端或者答辩时被问“你的数据能同步到另一台手机吗”前面那种写法会让你很难堪。所以我在工程里习惯加一层仓库抽象让本地 SQLite 和未来远程接口共用一套调用门面。4.1 为什么不能把 SQLite 查询直接丢在 ActivityActivity 直接持有 DBHelper 的问题不是不行而是试错成本太高。今天你在模拟器上跑通明天想加一个 PHP 后端就发现所有 Activity 都要改。更合理的做法是先定义接口比如 OrderRepositoryActivity 面向接口编程。public interface OrderRepository { ListOrderBean getOrders(int status); boolean acceptOrder(long orderId, long userId); boolean updateOrderStatus(long orderId, int fromStatus, int toStatus); }这套接口怎么设计其实对应了 App 端最重要的三个操作拉列表、抢单、改状态。本地实现自然是调我们前面写的 OrderDao远程实现则用 OkHttp 请求后端 API。调用方不关心数据从哪来这在源码剖析与架构实战里是最基础的一层抽象但很多课程作业不教。4.2 本地模拟实现故意慢 300 毫秒的演示智慧没有后端时本地仓库实现非常简单但我会在里面加一点“网络延迟”public class LocalOrderRepository implements OrderRepository { Override public ListOrderBean getOrders(int status) { // sleep 300ms 模拟网络延迟让加载动画肉眼可见 try { Thread.sleep(300); } catch (InterruptedException ignored) {} return orderDao.queryByStatus(status); } }这一行 sleep 是很多同学看不明白的“玄学”。如果界面秒开你演示“加载中”就走得太快故意慢 300 毫秒ProgressDialog 或 SwipeRefreshLayout 就有存在感也贴合真实体验。但要注意这行代码必须放在子线程里如果放在 Main 线程300 毫秒只是轻微卡顿放在骑手点击接单的回调里就容易让人误判成“点击没反应”。我会配合 AsyncTask 或线程池使用而不是无脑调用。4.3 对接真实后端OkHttp 与 JSON 的典型写法一旦接真实后端替换掉 LocalOrderRepository 即可上层 Activity 代码一行不用动。远程实现的核心就是用 OkHttp 发请求解析 JSON。我贴一段 getOrders 的骨架Request request new Request.Builder() .url(http://your.server/api/order/list?status status) .header(Authorization, Bearer token) .get() .build(); new OkHttpClient().newCall(request).enqueue(new Callback() { Override public void onFailure(Call call, IOException e) { // 回调运行在子线程要回到主线程提示错误 } Override public void onResponse(Call call, Response response) throws IOException { String json response.body().string(); // Gson: ListOrderBean list gson.fromJson(json, new TypeToken...(){}.getType()); // 然后用 runOnUiThread 把 list 刷新进 RecyclerView } });这里最容易被忽略的是 URL 里挂在 query 上的status。很多刚学 Android 的同学会把状态放在POSTbody 里也能通但不符合 RESTful 风格答辩老师可能会追问。另外一定不要在主线程直接调用 okhttp 的 execute必须 enqueue 或自己开线程否则会在 Android 4.0 以上直接抛NetworkOnMainThreadException。4.4 接口字段对齐让后端返回 order_no 而不是 id本地数据库设计时我们保留了 order_no就是为了在真实接口中不暴露自增主键。给你参考一份最简单的接口字段约定字段类型示例order_nostringOD17001234561234statusint0pickup_codestring4A-3124rewardfloat2.5create_timestring2025-02-18 15:20:00OrderBean 里的字段要和这张表一致特别是类型。status在后端经常是字符串比如 “0”Gson 解析成 int 时会报错所以我会在 Bean 里用int然后让后端保证返回纯数字或者自己接字符串再强转。前端字段名和后端不一致是最常见的联调 bug没有之一。5. 避坑与排查5 个让毕业设计当场翻车的老问题写代码是一个环节跑起来是另一个环节。这个项目我至少见过四五个学弟在以下问题上卡住每条我都能背出报错信息了。按“现象 → 原因 → 解决”给你写下省得你再去群里问。5.1 Gradle 版本错乱导致构建卡死现象导入 zip 后 Android Studio 一直在 “Building”或者 Event Log 报 “Could not resolve com.android.tools.build:gradle:4.1.2”。 原因源码压缩包用的 Gradle 版本和本机 AS 不匹配也可能是国内网络下载 Gradle 失败。 解决打开gradle/wrapper/gradle-wrapper.properties将distributionUrl改成你当前 AS 版本支持的地址再把 Project 的build.gradle里classpath的 AGP 版本对齐。如果还是卡先关闭 AS删除.gradle缓存文件夹再重新打开多半能解决。5.2 手机号登录查不到已注册用户现象注册成功后退出登录再用同一手机号登录提示“用户不存在”。 原因注册和登录用了两个不同的DBHelper实例SQLite 会分别创建或打开同一个文件但如果你在onUpgrade里写了DROP TABLE IF EXISTS users版本号每次升级都会清库那注册数据就没了。 解决在整个 App 里只保留一个DBHelper对象可以用一个单例或 Application 持有同时检查数据库版本号确认onUpgrade里没有裸的 DROP。我在调试时还会在登录方法里Log.d打印查询条件看看 phone 前面是不是多了空格或换行。5.3 发布订单后列表刷新不出来现象发单人提交订单提示成功切到骑手端列表仍是空白。 原因骑手端查询条件用了status 0但发布端插入时忘了给 status 赋值数据库默认值可能没生效导致实际存了一个空字符串或 null。 解决在insertOrder里显式cv.put(status, 0)不要依赖数据库默认值。另外查一下骑手端getOrders(0)的 SQL 条件是不是真的传入 0有时参数写成String.valueOf(status)和字段类型不匹配会查出一个空集合。5.4 Android 9 以后明文 HTTP 请求被拦截现象对接真实后端时OkHttp 回调直接走 onFailure控制台出现 “CLEARTEXT communication to x.x.x.x not permitted by network security policy”。 原因Android 9 默认禁止应用发起明文 HTTP 请求只允许 HTTPS。很多毕设后端是本机局域网 IP 加 HTTP必然被拦。 解决开发阶段在 AndroidManifest.xml 的application标签里加一行android:usesCleartextTraffictrue。如果你想让代码更体面可以用network_security_config.xml只允许指定域名走明文其余一律 HTTPS。但答辩时间紧直接加这个标签最稳上线前删掉就行。5.5 RecyclerView 滑动后显示数据错乱现象骑手端待接单列表下滑再上滑发现之前“小件”的 item 变成了“大件”或者选中的状态跑到了别的行。 原因RecyclerView 的 ViewHolder 回收复用如果你只在条件成立时给控件赋值不成立时没管它复用的 item 会带着上一个数据的状态。 解决在 onBindViewHolder 里对每个需要显示的字段都无条件赋值尤其注意图片和 status tag。比如holder.statusTv.setText(order.getStatus() 0 ? 待接单 : 已接单)而不是if (order.getStatus() 0) holder.statusTv.setText(待接单)。前者每次覆盖后者会残留。6. 从演示到答辩三个让源码脱离脚本的高级玩法如果你的代码已经跑通下面这一步能让你的答辩比同组同学多一点技术分量。6.1 用 WorkManager 替代 Activity 里的轮询骑手端要看到新订单很多源码用 Handler 每 5 秒刷新一次。演示时没问题但锁屏后系统会挂起回来发现订单列表还是旧的。升级做法是用 WorkManager 注册周期任务让 App 退到后台也能定期拉一次数据PeriodicWorkRequest refresh new PeriodicWorkRequest.Builder( OrderRefreshWorker.class, 15, TimeUnit.MINUTES).build(); WorkManager.getInstance(context).enqueue(refresh);注意 WorkManager 的周期任务最小间隔是 15 分钟所以它不是做秒级轮询的而是做数据同步的。答辩时你可以说“我用周期任务替代了前台轮询降低功耗”这句话足够回应老师对后台刷新机制的提问。6.2 状态变更通知本地广播 Notification当骑手接了单发单人那边还在刷朋友圈等回来看到订单还挂着肯定会问。更出彩的做法是状态更新成功后发一条本地广播Intent intent new Intent(ORDER_STATUS_CHANGED); intent.putExtra(orderId, id); LocalBroadcastManager.getInstance(context).sendBroadcast(intent);在 MainActivity 注册对应 BroadcastReceiver收到广播后弹一条 Notification提示“你的订单有人接单”。Android 13 上要记得申请POST_NOTIFICATIONS运行时权限否则通知栏里什么都不显示。这个功能代码量不大但演示效果很强。6.3 答辩前必做三件小事我吃过一个亏答辩现场从 AS 直接 Run 到模拟器结果模拟器卡顿项目白屏。后来我的习惯是提前打包成正式签名 APK装到自己手机演示数据库里预置 5 个待接单订单和 2 个骑手账号把 Logcat 里的调试日志全部清掉。这些小动作不改变代码但能让评分老师看到完整流程。如果你要自己搭一个 AndroidStudio 项目实例建议先把第 2 章的状态机和第 5 章的网络明文配置确认好再写 UI否则后面全是返工。希望帮到你。本文还有配套的精品资源点击获取