简介:一款基于Kotlin编写的Android外卖应用完整工程,包含客户端、服务端与数据库三部分,定位清晰,是面向移动开发课程设计的综合实战项目,适合本科或专科学生参考学习。项目覆盖界面交互、逻辑处理与数据存储等移动应用开发核心环节,源码中也能体现Kotlin与Java互操作的常见写法。资源包共270个文件,约150MB,主要包含52个kt源码、42个xml布局配置、35个so库、23个java文件,并附带sql脚本、gradle构建文件、课程报告docx以及可安装的apk成品,目录结构便于按功能模块查找。项目内附“饥了么外卖”报告文档与演示gif,运行后可直观理解整体业务流程,对撰写课程设计说明和答辩汇报也有参考价值。目前已有148人学习下载,适合想完整走通Android前后端联调、补全课设项目细节的学习者。
1. 拿到 Kotlin 外卖课程设计压缩包后,先别急着打开 App 工程
大三课程设计周,最怕的不是题难,而是拿到一份写着“Kotlin 编写 Android 外卖应用,含数据库+客户端+服务端”的压缩包之后,不知道从哪下手。很多人第一反应是先解压、打开 App 工程运行,最后卡在服务端连不上、数据库没建表、首页一直在转圈。这篇文章把反直觉的结论放在前面:一套课程设计外卖系统的价值顺序,是数据库表关系大于服务端接口约定,再大于 Android 客户端界面。表设计错了后面全乱,接口约定变了客户端全改,界面反而是最后最好补的一块。内容会从压缩包如何快速跑通讲起,依次拆数据库建表、服务端给接口、Kotlin 客户端接接口,再单列最容易翻车的五条路。适合正在做外卖或电商类课程设计、手里只有代码包但还没跑通的同学。
2. 从压缩包到三层架构:外卖系统怎么分工,为什么客户端用 Kotlin
2.1 一次点餐背后的三层数据流
课程设计的外卖项目,规模上不需要微服务,但三层职责必须分清楚。Android 客户端只负责两件事:把用户操作变成 HTTP 请求,把服务端返回的 JSON 渲染成界面。服务端负责校验参数、拼 SQL、调用数据库、返回统一格式的 JSON。数据库负责把用户、商家、菜品、订单这些数据真正落盘。三层之间唯一的沟通语言是 HTTP 加 JSON,客户端永远不该直接写 SQL。
以“用户下单”这个动作为例,完整链路是:用户在购物车点结算,客户端带着 token、菜品 id 列表和数量发起 POST 请求到服务端;服务端先查菜品 id 是否存在、库存是否够,再开事务写入订单表和订单明细表;写成功后返回订单 id;客户端拿到订单 id 跳转到订单详情页。如果某层越界,比如客户端为了省事直接连数据库,改起来会非常痛苦,答辩时也容易被导师追问。
常见做法里,数据通过 DTO 在层与层之间传递。我一般会让每个接口的返回都包一层固定结构:code、message、data,后续接分页、加错误处理都不用动接口调用方。三个角色各自的范围可以这样划分:
- 客户端(Kotlin):界面渲染、输入校验、HTTP 调用、登录态保存
- 服务端:路由分发、参数校验、业务逻辑、事务控制、SQL 执行
- 数据库(MySQL):表结构、外键约束、唯一索引、数据落盘
这个分工看起来简单,但很多课程设计翻车就翻在没把接口约定当合同。客户端写死了字段名,服务端返回的是另一个字段名,这一类的联调问题能占整个项目调试时间的一半。
2.2 为什么客户端非用 Kotlin 不可
选 Kotlin 不选 Java,不是赶时髦,而是 Kotlin 在课程设计场景下确实能少踩两个坑。第一个是空安全。外卖 App 最容易出现空指针的地方是:从接口返回的 JSON 里取用户头像、数据库查出 null 的菜品描述、点击事件里 context 被回收。Kotlin 的类型系统在编译期就把这些风险标出来,比如下面的写法:
// 服务端返回的菜品详情,description 可能是空字段 var description: String? = jsonObj.optString("description").takeIf { it.isNotBlank() } // 空安全调用,空值返回默认文本 binding.tvDesc.text = description ?: "商家未填写描述"代码里String?表示 description 是可空类型,?:是 Elvis 操作符,空值就显示默认文案。换到 Java,通常要写if (description != null)再赋值,漏一个判断就空指针。第二个好处是协程,课程设计里网络请求是高频操作,用协程可以把回调地狱写成顺序代码,比 Java 的new Thread加handler.post好维护得多。
不过选 Kotlin 也有代价:Gradle 同步慢、Kotlin 编译比 Java 慢。课程设计时间紧的话,改一次界面布局等两分钟编译是常态,心里要有预期。这不是劝退,而是让你别在最后一天才改代码,给编译留出缓冲时间。
2.3 拿到压缩包后最快的跑通路径:先数据库,再服务端,最后 App
不要一上来就打开 App 工程。跑通整套系统的顺序应该是:先把数据库建好,再把服务端启动,最后才运行 App。因为 App 启动后第一件事就是请求服务端,服务端连不上数据库,App 首页一定会转圈或者白屏。
假设压缩包解压后目录结构是 client、server、sql 三个目录,一套可靠的最小启动流程如下:
# 1. 进入项目根目录 cd course-project # 2. 启动本机 MySQL,并确认 root 密码 sudo systemctl start mysql # 3. 建库并导入建表脚本 mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS takeout DEFAULT CHARACTER SET utf8mb4;" mysql -u root -p takeout < sql/schema.sql # 4. 启动服务端(假设是 Spring Boot 打好的 jar 包) java -jar server/takeout-server.jar --server.port=8080 # 5. 另开终端,进入 Android 工程目录构建 Debug 包 cd client ./gradlew assembleDebugCREATE DATABASE后面的DEFAULT CHARACTER SET utf8mb4很关键,它保证中文字段能正常存取,避免后期出现菜品名称乱码。第 4 步的--server.port=8080是启动参数,端口必须和 Android 端网络请求里配置的端口一致,否则联调时所有请求都会连接失败。第 5 步assembleDebug会生成 debug 包,直接装到模拟器或真机。
如果压缩包里服务端不是 jar 包而是源码工程,第 4 步换成在服务端源码目录执行对应构建命令,比如 Maven 工程就是mvn spring-boot:run。Windows 用户第 2 步直接在服务管理器里启动 MySQL 服务即可,不需要走 systemd。判断服务端是否启动成功的标志只有一个:浏览器访问http://127.0.0.1:8080/api/health,能看到 JSON 返回就算成功。
3. 数据库设计与建表:外卖系统该建几张表,关系怎么定
3.1 六张核心表和它们之间的关系
外卖系统小型课程设计,表不是越多越好。把用户、商家、菜品、购物车、订单、订单明细这几条主干建清楚,功能就能闭环。刻意加一张复杂的权限表反而不讨好,答辩时导师问“这张表为什么存在”,答不上来会被扣分。
对照常见做法,整理一张表清单:
| 表名 | 用途 | 关键字段 | 关系 |
|---|---|---|---|
| user | 用户登录与地址 | id, username, password | 被 orders 引用 |
| merchant | 商家列表 | id, name, min_price | 被 dish 引用 |
| dish | 菜品列表 | id, merchant_id, name, price | 多对一商家 |
| cart | 购物车 | id, user_id, dish_id, count | 关联用户和菜品 |
| orders | 订单主表 | id, user_id, merchant_id, amount, status | 一对多明细 |
| order_item | 订单明细 | id, order_id, dish_id, price | 多对一订单 |
关系上只需要两处关键设计。第一处是 dish 表通过merchant_id关联商家,一个商家对应多个菜品,典型一对多。第二处是订单和明细拆成两张表:orders 只存订单总金额和状态,order_item 存每个菜品的单价和数量。这样后续要做菜品销量统计,直接聚合 order_item 就行,不用解析 JSON。不少课程设计会把菜品快照拼成一个字段塞进 orders 表,演示时能跑,但导师会追问“订单历史里的菜品价格变了怎么办”。order_item 把 price 单独存一份,就是在回答:这是下单时刻的价格快照,不随菜品表变化。
购物车表需要加唯一索引uk_user_dish,防止同一道菜被重复加进购物车。这是应用层兜底,不加的话并发点击加购会出现两行同样的记录。
3.2 MySQL 建表脚本:utf8mb4、默认时间、订单明细快照
下面的 SQL 是 MySQL 8.0 写法,把六张表一次性建出来。字段命名全部小写下划线,避免在 Linux 环境下出现大小写敏感问题。
CREATE DATABASE IF NOT EXISTS takeout DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE takeout; CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, phone VARCHAR(20), address VARCHAR(255) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE merchant ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, min_price DECIMAL(10,2) DEFAULT 0, delivery_fee DECIMAL(10,2) DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE dish ( id BIGINT PRIMARY KEY AUTO_INCREMENT, merchant_id BIGINT NOT NULL, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, description VARCHAR(255), INDEX idx_dish_merchant (merchant_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE cart ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, dish_id BIGINT NOT NULL, count INT NOT NULL DEFAULT 1, UNIQUE KEY uk_user_dish (user_id, dish_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, merchant_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, dish_id BIGINT NOT NULL, dish_name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, count INT NOT NULL DEFAULT 1 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个容易被忽略的细节。第一,所有表显式加ENGINE=InnoDB,外键约束和事务都依赖 InnoDB,别用默认的 MyISAM。第二,orders 的created_at直接写DEFAULT CURRENT_TIMESTAMP,插入数据时不用手动填时间,省去在代码里拼日期。第三,order_item 里存了dish_name和price两个冗余字段,这是刻意留下的快照:订单明细要记录下单那一刻的菜名和价格,不是当前菜品表的值。
有人会用外键约束把 order_item 的 dish_id 指向 dish 表,建议不加。加了外键后,删除菜品会被订单明细挡住,演示“下架菜品”功能时反而把自己卡死。表关系可以在 ER 图里画出来,但实际建表时,课程设计项目完全可以用代码保证引用关系,不建物理外键,演示更灵活。
3.3 连接串里的固定参数:时区与字符编码
服务端连接数据库的 JDBC 连接串,最常见的坑就是少了时区参数。MySQL 8 默认时区和本地时间不一致,不声明serverTimezone,服务端插入created_at可能报错,或者查出来的时间差八个小时。
jdbc:mysql://127.0.0.1:3306/takeout?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8&allowPublicKeyRetrieval=trueuseSSL=false是关掉 SSL 加密握手,本机开发没必要开,开了反而慢。characterEncoding=utf8保证中文字符在 JDBC 层以 utf8 编码传输,和建表时的 utf8mb4 对应。allowPublicKeyRetrieval=true是 MySQL 8 配合新版驱动常用的参数,不加会看到Public Key Retrieval is not allowed的报错。
需要说明的是,如果服务端用的是 mysql-connector-j 5.x 驱动,serverTimezone可以省略;课程设计一般用 8.x 驱动,这两个参数最好都写上。数据库连接池(比如 HikariCP)的配置里还有个connectionTimeout参数,本地调试默认 30 秒足够,不用刻意调小,否则启动时偶发网络波动会导致连接失败。
4. 服务端接口与 Kotlin 客户端:一个 JSON 约定,两边同步实现
4.1 先定接口返回结构,再写两边的代码
联调成本高,大部分原因是接口返回结构没有提前固定。我一般会先定一个公共返回格式,再让服务端和客户端照着实现:
{ "code": 0, "message": "ok", "data": { "token": "abc123", "username": "test_user" } }code用 0 表示成功,非 0 表示业务错误;message是对人可读的错误描述;data是具体业务数据。这个结构的好处是客户端只需要处理一次code != 0的分支,不用为每个接口单独解析错误格式。课程设计的外卖系统,核心接口五六个就够,先列成合同:
| 接口 | 方法 | 入参 | 出参 data |
|---|---|---|---|
| /api/register | POST | username, password | 无 |
| /api/login | POST | username, password | token, username |
| /api/merchant/list | GET | 无 | 商家数组 |
| /api/dish/list | GET | merchantId | 菜品数组 |
| /api/cart/add | POST | dishId, count | 购物车数量 |
| /api/order/create | POST | dishIds, counts | orderId |
这张表相当于两份代码的合同。服务端照表实现路由,客户端照表拼请求,两边同时开工,不互相等。字段名的命名风格也要提前统一,比如菜品价格用price还是dishPrice,定下来就不要再改。
4.2 服务端怎么把数据查出来并返回 JSON
服务端如果使用 Spring Boot,写接口的套路非常固定。以菜品列表为例,进来一个merchantId,用 MyBatis 或 JPA 查 dish 表,组装成统一返回结构。下面是一段常见的写法:
@RestController @RequestMapping("/api") public class DishController { @GetMapping("/dish/list") public Result list(@RequestParam("merchantId") Long merchantId) { List<Dish> dishes = dishMapper.findByMerchantId(merchantId); return Result.ok(dishes); } }@GetMapping把 URL 和方法绑定,merchantId从查询参数取。Result.ok()是一个静态方法,内部把code置为 0,data放进dishes。这里能看出接口约定的价值:客户端需要的数据结构完全由服务端控制,数据库表结构即使变了,只要Result结构不变,客户端就不用改。
写服务端接口时建议养成一个习惯:入参先校验再查库。merchantId是空值或小于 0,直接返回code=40001,不要等查完数据库再报错。联调时看 code 立刻能定位:是客户端传参不对,还是服务端内部错误。
4.3 Kotlin 客户端:用 OkHttp 请求接口并解析 JSON
Android 端网络层,课程设计最合适的是 OkHttp 轻量封装,不引入全家桶。下面代码是一个完整的获取菜品列表请求,放在 ViewModel 里,用协程切线程:
class DishViewModel(private val scope: CoroutineScope) { fun loadDishList(merchantId: Long, onResult: (List<Dish>) -> Unit) { scope.launch { withContext(Dispatchers.IO) { // 构建 GET 请求 val request = Request.Builder() .url("http://10.0.2.2:8080/api/dish/list?merchantId=$merchantId") .build() val response = OkHttpClient().newCall(request).execute() val body = response.body?.string() ?: "" // 解析统一返回结构 val json = JSONObject(body) if (json.getInt("code") == 0) { val dishes = json.getJSONArray("data").map { item -> Dish( id = (item as JSONObject).getLong("id"), name = item.getString("name"), price = item.getDouble("price") ) } withContext(Dispatchers.Main) { onResult(dishes) } } } } } }这段代码核心有两点。第一,Dispatchers.IO保证网络请求不在主线程执行,避免界面卡死;结果切回Dispatchers.Main再回调,保证 UI 操作安全。第二,10.0.2.2是 Android 模拟器访问宿主机的固定地址,如果使用真机调试,要改成电脑的局域网 IP,比如http://192.168.x.x:8080。这是联调最容易踩的坑,下文避坑章节会展开。
merchantId是数值类型,不存在 URL 转义问题。如果入参是中文关键词,比如“搜索川菜”,拼 URL 前要先用URLEncoder.encode("川菜", "UTF-8"),否则服务端收到的是乱码。另外 Kotlin 解析 JSON 用org.json包即可,课程设计不需要引入 Gson 或 Moshi,减少依赖复杂度。
4.4 联调顺序:先测通一个 GET,再写界面
联调不要从登录下单开始,要从最简单的 GET 接口开始。第一步在浏览器访问http://127.0.0.1:8080/api/dish/list?merchantId=1,确认返回 JSON;第二步在 App 登录页加一个“测试连接”按钮,点击后请求同一个接口,能显示菜品数就算通;第三步再做登录态和下单。
这个顺序的逻辑是:第一步不通,问题大概率在服务端或数据库;第一步通但第二步不通,问题大概率在 IP、端口或明文 HTTP 权限;前两步都通,才进入业务联调。切成三段后,排查范围一下子缩小很多,不至于对着一个空白页面猜问题在哪个环节。
5. 避坑:Kotlin 外卖课程设计最常见的 5 条踩坑记录
5.1 Gradle 依赖拉取失败,构建一直卡在下载
现象:Android 工程首次同步,Gradle 提示某个依赖下载失败,构建卡了十几分钟甚至直接失败。
原因:默认依赖仓库源在部分网络环境下超时率高,大文件下载到一半就断开。
解决:在项目根目录的 settings.gradle 里,把依赖仓库都配置成可用的镜像仓库后再同步。课程设计场景下,依赖源只是拉包通道,换镜像不影响功能,编译时间也能明显缩短。配完后如果还有个别依赖失败,去 Gradle 缓存目录里删掉对应文件再重新同步,比 AS 菜单里的 Try Again 更有效。
5.2 模拟器请求不到本机服务端,一直转圈
现象:App 在模拟器里能打开,但所有列表页都加载不出来,Logcat 报ConnectException或timeout。
原因:Android 模拟器内部的127.0.0.1指向模拟器自身,不是电脑。代码写http://127.0.0.1:8080,请求根本到不了电脑上的服务端。
解决:把地址改成http://10.0.2.2:8080。真机调试必须改成电脑的局域网地址,并保证手机和电脑在同一网段。Windows 上还要在防火墙里放行 8080 端口,否则真机同样连不上。建议直接在代码里写一个常量BASE_URL,换环境只改一处,别每个请求单独写地址。
5.3 中文乱码:服务端查出来是好的,App 显示却是问号
现象:服务端在命令行查数据库字段显示正常,但 App 界面上菜品名和订单地址是???。
原因:链路三层都要对才能正常显示:建表字符集、JDBC 连接串字符编码、HTTP 响应头。任一层不是 utf8,显示就乱。
解决:建表时统一utf8mb4,JDBC 连接串加characterEncoding=utf8,服务端返回 JSON 时显式声明Content-Type: application/json;charset=UTF-8。按这个顺序排查,乱码问题基本能消掉。如果 App 端是从旧工程里复制过来的代码,还要检查 TextView 是不是被硬编码了其他编码。
5.4 订单时间差八个小时,数据库时间全对不上
现象:插入订单后查库,created_at和本地时间不一致,App 显示的时间比实际早了或晚了 8 小时。
原因:MySQL 驱动和 JVM 默认取系统时区,服务端和数据库所在机器时区不一致时,驱动在转换 DATETIME 时会出现偏移。
解决:JDBC 连接串加serverTimezone=Asia/Shanghai,服务端启动参数也指定时区,Spring Boot 加-Duser.timezone=Asia/Shanghai。两个配置统一后时间就正常了。模拟器和真机上也要把系统时区设为东八区,否则 App 显示端还会再偏一次。
5.5 Android 9 之后访问明文 HTTP 直接闪退
现象:接口地址已经写对,模拟器也能 ping 通,但 App 一请求就崩,Logcat 报CLEARTEXT communication not permitted。
原因:Android 9 开始默认禁止应用使用明文 HTTP 流量。课程设计开发阶段没有 HTTPS 证书,所以需要显式放开 http 协议。
解决:在 AndroidManifest.xml 的 application 节点上配置:
<application android:usesCleartextTraffic="true" ... >usesCleartextTraffic表示允许明文流量,仅建议在开发调试阶段使用,正式上线要撤掉走 HTTPS。课程设计本地联调时,这个开关可以省出一晚上的折腾。如果团队里有人改了 Manifest 没同步,这个错还会反复出现,建议提交代码前全局搜一遍usesCleartextTraffic。
6. 答辩演示技巧:把外卖 Demo 的亮点讲出技术深度
课程设计答辩时,导师不会要求系统真的能扛住高并发,但会问“这里为什么这么设计”。与其背八股文,不如在演示过程中埋三个小而实的亮点。第一个亮点是数据库快照字段。演示时打开一个菜品,改一下价格,再打开历史订单,指着明细里的价格说:这笔价格是下单时的快照,不会随菜品改价而变化。这句话直接把订单明细表的设计动机讲清楚了。
第二个亮点是 Kotlin 的空安全和协程。被问到“用了哪些 Kotlin 特性”时,不要只说“用了协程”,要讲具体场景:接口返回的菜品描述可能是 null,用?:给了默认值,所以列表不会因为空数据崩溃;再补一句“网络请求放在 Dispatchers.IO,回主线程更新 UI”,就是一个完整的技术回答。
第三个重点是演示脚本。正式答辩前预置两三个演示账号和订单数据,不要现场注册,不要现场下单等配送。把演示流程写在手机便签里,按顺序点:登录、商家列表、菜品列表、加购物车、下单、订单列表。这个顺序两分钟走完,覆盖全部核心功能。我当年就是把“现场下单”作为演示主路径,结果演示环境里商家数据没配好,首页一直加载不出来,场面很尴尬。后来养成的习惯是:任何系统演示前,先清空后端日志、灌好预置数据、App 冷启动跑一遍完整流程,确认每一步都有数据再上台。
如果时间充裕,可以在服务端加一个最简的前端管理页,只做“上架/下架菜品”一个功能。答辩时现场下架一道菜,App 刷新后菜品消失,这个前后端联动效果比任何截图都有说服力。希望帮到你。
本文还有配套的精品资源,点击获取