简介:面向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。问题在于,两个骑手可能在同一个毫秒都查到了 status=0,然后各自更新成功,一单被接了两回。
正确做法是把“查”和“改”合并成一条语句。我用 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 条件里带 status=0,这就是乐观锁 int rows = db.update("orders", values, "id=? AND status=0", new String[]{String.valueOf(orderId)}); return rows; // rows=1 表示抢单成功,rows=0 表示被别人抢先 }db.update 返回的是受影响行数,它天然能告诉我们“到底有没有改成功”。两个请求同时进来,数据库引擎会串行执行,后执行的那条 update 已经匹配不到status=0,于是返回 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 查询直接丢在 Activity
Activity 直接持有 DBHelper 的问题不是不行,而是试错成本太高。今天你在模拟器上跑通,明天想加一个 PHP 后端,就发现所有 Activity 都要改。更合理的做法是先定义接口,比如 OrderRepository,Activity 面向接口编程。
public interface OrderRepository { List<OrderBean> 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 List<OrderBean> 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: List<OrderBean> 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_no | string | OD17001234561234 |
| status | int | 0 |
| pickup_code | string | 4A-3124 |
| reward | float | 2.5 |
| create_time | string | 2025-02-18 15:20:00 |
OrderBean 里的字段要和这张表一致,特别是类型。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:usesCleartextTraffic="true"。如果你想让代码更体面,可以用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,否则后面全是返工。希望帮到你。
本文还有配套的精品资源,点击获取