简介:这是一套基于Kotlin开发的Android外卖应用完整项目,包含客户端与服务端,服务端采用Java搭配MySQL实现,适合作为Android课程设计、毕业设计、大作业或工程实训的参考方案,也适合希望从客户端到后端完整走通一遍外卖业务逻辑的进阶学习者。资源包共273个文件,约149.98MB,其中52个kt文件承载客户端核心业务代码,42个xml负责界面布局,23个java文件对应服务端逻辑,另有1个sql脚本用于数据库建表,并附带docx课程设计报告、apk安装包及png、jpg等界面素材,结构完整、层次清晰。目前已有141人学习下载。读者可从中获得一套可直接运行的外卖项目源码、数据库脚本与配套报告,便于理解Kotlin与Java混合开发、客户端与服务端接口对接及MySQL数据存储的完整实现思路,也可在此基础上进行二次开发或功能扩展。
1. 从一份课程设计说起:Kotlin 客户端 + Java 服务端的外卖 App 到底怎么落地
很多人第一次接触「Android 外卖应用」这个词,是在课程设计选题表上。标题写得很唬人:Kotlin 写客户端,Java 写服务端,MySQL 存数据,还要交一份课程设计报告加源码。真动手才发现,难点根本不在「外卖」这个业务本身,而在于两端怎么对齐:客户端用 Kotlin 的协程发请求,服务端用 Java 的 Servlet 或 Spring Boot 接,字段名对不上、时间格式对不上、图片路径对不上,一个下单接口能调一整天。这篇笔记就按我实际带过的一个模拟项目 X 的路径,把「Kotlin 客户端 + Java 服务端 + MySQL」这套组合从选型、建表、接口、联调到避坑讲透。适合正在做课程设计、想拿一套能跑通的骨架去改的同学,也适合想从纯 Android 单机 Demo 过渡到「真有一个后端」的开发者。读完你至少能拿到一条可复现的最小链路:登录、浏览菜品、加购物车、下单、查订单。
2. 技术选型与工程骨架:为什么客户端用 Kotlin、服务端用 Java
2.1 客户端为什么选 Kotlin 而不是 Java
课程设计里最常见的翻车是:客户端用 Java 写,写着写着发现空指针、回调地狱、异步请求嵌套三层,代码量爆炸。Kotlin 的空安全、协程、扩展函数正好治这几个病。外卖 App 的典型场景是「列表页拉数据 → 点进详情 → 加购物车 → 下单」,全是异步 IO,用协程写就是顺序代码,可读性比回调高一个量级。我一般会这样定客户端技术栈:Kotlin + AndroidX + Retrofit + OkHttp + Gson + Glide + ViewModel + LiveData(或 Flow)。Retrofit 负责声明式接口,OkHttp 管连接和拦截器,Gson 做 JSON 解析,Glide 加载菜品图,ViewModel 扛住屏幕旋转不丢数据。这套组合在课程设计里足够,也不至于引入 Compose 那种学习曲线陡的东西——当然你想用 Compose 也行,但报告里要写清楚理由。
2.2 服务端为什么用 Java + MySQL 而不是别的
服务端选 Java,一是课程要求常见,二是生态成熟:Spring Boot 起步快,MyBatis 或 JPA 操作 MySQL 直观,Tomcat 内置,打成 jar 就能跑。MySQL 选 5.7 或 8.0 都行,课程设计数据量小,重点是表结构设计要合理。我一般会这样分层:Controller 接请求、Service 写业务、Mapper/DAO 碰数据库、Entity 对应表。别一上来就微服务,课程设计用单体足够,报告里还能写「分层架构」显得有设计感。数据库连接池用 HikariCP(Spring Boot 默认),别自己写 JDBC 工具类,容易漏关连接。
2.3 最小工程骨架与依赖清单
客户端build.gradle关键依赖:
// 客户端 build.gradle(模块级) dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.0" implementation "androidx.core:core-ktx:1.12.0" implementation "androidx.appcompat:appcompat:1.6.1" implementation "androidx.recyclerview:recyclerview:1.3.2" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0" implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.7.0" implementation "com.squareup.retrofit2:retrofit:2.9.0" implementation "com.squareup.retrofit2:converter-gson:2.9.0" implementation "com.squareup.okhttp3:logging-interceptor:4.12.0" implementation "com.github.bumptech.glide:glide:4.16.0" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" }服务端pom.xml关键依赖:
<!-- 服务端 pom.xml 关键片段 --> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>逻辑说明:客户端依赖里 Retrofit + Gson 负责网络,Glide 负责图片,协程负责异步;服务端依赖里 starter-web 提供内嵌 Tomcat,mybatis 负责 SQL 映射,mysql-connector 是驱动。参数说明:版本号不是越新越好,课程设计环境里 JDK 8/11/17 都常见,Spring Boot 3.x 要求 JDK 17,如果你机器上还是 JDK 8,就降到 Spring Boot 2.7.x,否则启动直接报Unsupported class file major version。这是第一个容易踩的坑。
3. 数据库设计与接口约定:先把表建对,再写代码
3.1 外卖业务的核心表结构
外卖业务看着复杂,核心就几张表:用户、商家、菜品、购物车、订单、订单明细。课程设计不用做骑手调度,把这几张表理清就够。我一般会这样建:
-- 用户表 CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `phone` VARCHAR(20) NOT NULL UNIQUE COMMENT '手机号,登录用', `password` VARCHAR(64) NOT NULL COMMENT '存 MD5 或 BCrypt 哈希,别存明文', `nickname` VARCHAR(32) DEFAULT '新用户', `avatar` VARCHAR(255) DEFAULT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 菜品表 CREATE TABLE `dish` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(64) NOT NULL, `price` DECIMAL(10,2) NOT NULL COMMENT '金额用 DECIMAL,别用 FLOAT', `image_url` VARCHAR(255), `category` VARCHAR(32) COMMENT '分类:主食/饮品/小吃', `stock` INT DEFAULT 0, `status` TINYINT DEFAULT 1 COMMENT '1 上架 0 下架' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表 CREATE TABLE `orders` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `user_id` INT NOT NULL, `total_amount` DECIMAL(10,2) NOT NULL, `status` TINYINT DEFAULT 0 COMMENT '0 待支付 1 已支付 2 已完成 3 已取消', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单明细表 CREATE TABLE `order_item` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `order_id` INT NOT NULL, `dish_id` INT NOT NULL, `quantity` INT NOT NULL, `price` DECIMAL(10,2) NOT NULL COMMENT '下单时价格快照', INDEX idx_order (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:orders表名别用order,那是 SQL 关键字,查询要加反引号,容易忘。金额字段用DECIMAL(10,2),用FLOAT会出现 0.1+0.2 不等于 0.3 的经典问题,订单金额对不上就是事故。order_item里存price快照,因为菜品价格会变,订单要保留当时的价格。参数说明:utf8mb4支持 emoji,昵称里带表情不会乱码;索引加在user_id和order_id上,查订单列表快。
3.2 接口约定:统一返回体和字段命名
客户端和服务端最容易吵架的地方就是字段名。服务端 Java 习惯驼峰totalAmount,数据库是下划线total_amount,客户端 Kotlin 也是驼峰。我一般会定一个统一返回体:
// 服务端统一返回体 @Data public class Result<T> { private int code; // 200 成功,其他失败 private String msg; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 200; r.msg = "success"; r.data = data; return r; } public static <T> Result<T> fail(String msg) { Result<T> r = new Result<>(); r.code = 500; r.msg = msg; return r; } }逻辑说明:所有接口返回Result,客户端只判断code == 200,data用泛型接。这样客户端解析逻辑统一,不用每个接口写一套。参数说明:code用 200/500 是简化,真实项目会用 401 未登录、403 无权限,课程设计够用。字段命名约定:数据库下划线,Java 实体驼峰,MyBatis 开map-underscore-to-camel-case: true自动映射,别手写resultMap每个字段,累且易错。
3.3 客户端 Retrofit 接口声明
// 客户端 ApiService.kt interface ApiService { @POST("user/login") suspend fun login(@Body body: LoginReq): Result<LoginResp> @GET("dish/list") suspend fun dishList(@Query("category") category: String?): Result<List<Dish>> @POST("order/create") suspend fun createOrder(@Body body: OrderReq): Result<OrderResp> } data class LoginReq(val phone: String, val password: String) data class LoginResp(val token: String, val userId: Int, val nickname: String)逻辑说明:suspend关键字让 Retrofit 支持协程,调用处直接apiService.login(...)不用回调。@Body发 JSON,@Query拼 URL 参数。参数说明:Result<LoginResp>里的泛型要和返回体对齐,Gson 才能正确解析。注意suspend函数必须在协程作用域里调,常见错误是在主线程直接调导致NetworkOnMainThreadException,用viewModelScope.launch包起来。
4. 核心链路实现:登录、菜品列表、下单
4.1 登录:密码哈希与 token 发放
服务端登录逻辑:
// UserServiceImpl.java public Result<LoginResp> login(String phone, String password) { User user = userMapper.findByPhone(phone); if (user == null) return Result.fail("用户不存在"); // 数据库存的是 BCrypt 哈希,用 matches 比对 if (!BCrypt.checkpw(password, user.getPassword())) { return Result.fail("密码错误"); } String token = JwtUtil.generate(user.getId()); LoginResp resp = new LoginResp(token, user.getId(), user.getNickname()); return Result.ok(resp); }逻辑说明:密码绝不能明文存,注册时用BCrypt.hashpw存哈希,登录用checkpw比对。token 用 JWT 生成,里面塞 userId,客户端后续请求放 header。参数说明:BCrypt 每次哈希结果不同(带盐),所以不能用equals比,必须用checkpw。JWT 密钥别硬编码在代码里,放配置文件,课程设计也养成习惯。
4.2 菜品列表:分页与图片加载
服务端分页查询:
// DishController.java @GetMapping("/list") public Result<List<Dish>> list(@RequestParam(required = false) String category, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size) { int offset = (page - 1) * size; List<Dish> list = dishMapper.selectByPage(category, offset, size); return Result.ok(list); }对应 MyBatis:
<select id="selectByPage" resultType="com.demo.entity.Dish"> SELECT * FROM dish WHERE status = 1 <if test="category != null and category != ''"> AND category = #{category} </if> ORDER BY id DESC LIMIT #{offset}, #{size} </select>逻辑说明:<if>做动态 SQL,category 为空就不加条件。LIMIT offset, size是 MySQL 分页写法。参数说明:offset从 0 开始,page从 1 开始,转换别搞反,否则第一页数据会跳过。客户端 Glide 加载图片:
// DishAdapter.kt 里绑定数据 Glide.with(holder.itemView.context) .load(dish.imageUrl) .placeholder(R.drawable.ic_placeholder) .error(R.drawable.ic_error) .into(holder.ivDish)逻辑说明:placeholder是加载中占位图,error是失败图,不加的话图片没加载出来是空白,用户体验差。参数说明:imageUrl要是完整 URL,服务端返回相对路径的话客户端要拼 baseUrl,这个拼接点最容易出 bug,建议服务端直接返回完整 URL。
4.3 下单:事务与库存扣减
下单是核心,涉及订单表和明细表两张表写入,必须用事务:
// OrderServiceImpl.java @Transactional(rollbackFor = Exception.class) public Result<OrderResp> createOrder(int userId, List<OrderItemReq> items) { BigDecimal total = BigDecimal.ZERO; for (OrderItemReq item : items) { Dish dish = dishMapper.selectById(item.getDishId()); if (dish == null || dish.getStock() < item.getQuantity()) { throw new RuntimeException("菜品库存不足:" + item.getDishId()); } total = total.add(dish.getPrice().multiply(new BigDecimal(item.getQuantity()))); } Orders order = new Orders(); order.setUserId(userId); order.setTotalAmount(total); order.setStatus(0); orderMapper.insert(order); // 插入后 order.getId() 有值(useGeneratedKeys) for (OrderItemReq item : items) { OrderItem oi = new OrderItem(); oi.setOrderId(order.getId()); oi.setDishId(item.getDishId()); oi.setQuantity(item.getQuantity()); oi.setPrice(dishMapper.selectById(item.getDishId()).getPrice()); orderItemMapper.insert(oi); dishMapper.reduceStock(item.getDishId(), item.getQuantity()); } return Result.ok(new OrderResp(order.getId(), total)); }逻辑说明:@Transactional保证订单和明细要么都成功要么都回滚。先校验库存再扣减,扣减用UPDATE dish SET stock = stock - #{qty} WHERE id = #{id} AND stock >= #{qty},靠数据库行锁防超卖。参数说明:rollbackFor = Exception.class让受检异常也回滚,默认只回滚运行时异常,容易漏。useGeneratedKeys="true" keyProperty="id"在 insert 上配,才能拿到自增主键。
5. 避坑与排查:联调时最容易翻车的 5 个点
5.1 客户端请求报 CLEARTEXT communication not permitted
现象:Android 9 以上真机请求http://接口直接失败,日志报明文流量不允许。原因:Android 9 默认禁止明文 HTTP。解决:在AndroidManifest.xml的application标签加android:usesCleartextTraffic="true",或者配network_security_config只放行你的测试域名。课程设计用前者快,但报告里最好写后者,显得规范。
5.2 服务端返回中文乱码
现象:客户端收到msg是问号或乱码。原因:服务端响应头Content-Type没带charset=UTF-8。解决:Spring Boot 在application.yml配server.servlet.encoding.charset: UTF-8和force: true;数据库连接 URL 加useUnicode=true&characterEncoding=utf8。两头都要配,只配一头还会乱。
5.3 时间字段差 8 小时
现象:订单创建时间比实际早 8 小时。原因:MySQL 时区和 JVM 时区不一致,或者 JDBC 连接没指定时区。解决:连接 URL 加serverTimezone=Asia/Shanghai,实体时间字段用LocalDateTime而不是java.util.Date。如果已经存了错数据,改时区后旧数据不会自动修正,得手动 update,这是血泪经验。
5.4 图片加载不出来但 URL 能打开
现象:浏览器能打开图片 URL,App 里 Glide 加载失败。原因:多数是 URL 里带中文或空格没编码,或者服务端返回的是相对路径。解决:服务端返回前用URLEncoder.encode处理文件名,或者干脆用 UUID 重命名图片文件,避免中文。客户端加error占位图,至少能看出是加载失败而不是布局错。
5.5 下单接口重复提交
现象:用户快速点两次下单,生成两笔订单。原因:客户端没防抖,服务端没幂等。解决:客户端按钮点击后置灰,服务端用「用户 ID + 时间戳」或前端传的 requestId 做唯一索引,重复插入直接报唯一键冲突返回失败。课程设计至少做客户端防抖,服务端幂等是加分项,报告里能写。
6. 进阶技巧:把课程设计做成能写进简历的项目
课程设计和真实项目的差距,往往不在功能多少,而在「有没有工程化痕迹」。我一般会建议在基础链路跑通后,加三个东西。第一是接口文档,用 Swagger 或 Knife4j 自动生成,服务端加依赖和注解就行,报告里截图接口列表,比手写文档有说服力。第二是统一异常处理,用@RestControllerAdvice捕获全局异常,返回统一Result,别让 500 堆栈直接吐给客户端,这是后端基本功。第三是客户端加一个简单的本地缓存,用 Room 存菜品列表,断网时能看缓存数据,虽然课程设计不要求,但面试时能讲「离线可用」是加分项。
验证方法上,我习惯用 Postman 先把服务端所有接口跑一遍,确认返回体正确,再联调客户端。这样出问题时能快速定位是服务端还是客户端。联调阶段用 OkHttp 的logging-interceptor打印请求和响应日志,BODY级别能看到完整 JSON,比猜快得多。最后打包时,服务端mvn package出 jar,客户端assembleRelease出 apk,报告里附上部署命令和截图。
说个我自己的教训:第一次做这类项目时,我把所有接口写在一个 Controller 里,两百多行,改一个接口怕影响别的,后来拆成 UserController、DishController、OrderController,每个不到五十行,改起来心里有底。还有一次数据库字段用FLOAT存金额,测试时 19.9 加 0.1 变成 20.000001,被验收老师一眼看出来,从此金额一律DECIMAL。这些坑踩过一次就记住了,希望帮到你。
本文还有配套的精品资源,点击获取