Kotlin客户端+Java服务端外卖App实战:从课程设计到可运行项目

📅 发布时间:2026/10/9 19:17:37
Kotlin客户端+Java服务端外卖App实战:从课程设计到可运行项目
简介这是一套基于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、服务端用 Java2.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 对应表。别一上来就微服务课程设计用单体足够报告里还能写「分层架构」显得有设计感。数据库连接池用 HikariCPSpring 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies逻辑说明客户端依赖里 Retrofit Gson 负责网络Glide 负责图片协程负责异步服务端依赖里 starter-web 提供内嵌 Tomcatmybatis 负责 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 菜品表 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 下架 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单明细表 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明orders表名别用order那是 SQL 关键字查询要加反引号容易忘。金额字段用DECIMAL(10,2)用FLOAT会出现 0.10.2 不等于 0.3 的经典问题订单金额对不上就是事故。order_item里存price快照因为菜品价格会变订单要保留当时的价格。参数说明utf8mb4支持 emoji昵称里带表情不会乱码索引加在user_id和order_id上查订单列表快。3.2 接口约定统一返回体和字段命名客户端和服务端最容易吵架的地方就是字段名。服务端 Java 习惯驼峰totalAmount数据库是下划线total_amount客户端 Kotlin 也是驼峰。我一般会定一个统一返回体// 服务端统一返回体 Data public class ResultT { private int code; // 200 成功其他失败 private String msg; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 200; r.msg success; r.data data; return r; } public static T ResultT fail(String msg) { ResultT r new Result(); r.code 500; r.msg msg; return r; } }逻辑说明所有接口返回Result客户端只判断code 200data用泛型接。这样客户端解析逻辑统一不用每个接口写一套。参数说明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): ResultLoginResp GET(dish/list) suspend fun dishList(Query(category) category: String?): ResultListDish POST(order/create) suspend fun createOrder(Body body: OrderReq): ResultOrderResp } 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发 JSONQuery拼 URL 参数。参数说明ResultLoginResp里的泛型要和返回体对齐Gson 才能正确解析。注意suspend函数必须在协程作用域里调常见错误是在主线程直接调导致NetworkOnMainThreadException用viewModelScope.launch包起来。4. 核心链路实现登录、菜品列表、下单4.1 登录密码哈希与 token 发放服务端登录逻辑// UserServiceImpl.java public ResultLoginResp 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 ResultListDish list(RequestParam(required false) String category, RequestParam(defaultValue 1) int page, RequestParam(defaultValue 10) int size) { int offset (page - 1) * size; ListDish list dishMapper.selectByPage(category, offset, size); return Result.ok(list); }对应 MyBatisselect idselectByPage resultTypecom.demo.entity.Dish SELECT * FROM dish WHERE status 1 if testcategory ! null and category ! AND category #{category} /if ORDER BY id DESC LIMIT #{offset}, #{size} /select逻辑说明if做动态 SQLcategory 为空就不加条件。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 ResultOrderResp createOrder(int userId, ListOrderItemReq 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让受检异常也回滚默认只回滚运行时异常容易漏。useGeneratedKeystrue keyPropertyid在 insert 上配才能拿到自增主键。5. 避坑与排查联调时最容易翻车的 5 个点5.1 客户端请求报 CLEARTEXT communication not permitted现象Android 9 以上真机请求http://接口直接失败日志报明文流量不允许。原因Android 9 默认禁止明文 HTTP。解决在AndroidManifest.xml的application标签加android:usesCleartextTraffictrue或者配network_security_config只放行你的测试域名。课程设计用前者快但报告里最好写后者显得规范。5.2 服务端返回中文乱码现象客户端收到msg是问号或乱码。原因服务端响应头Content-Type没带charsetUTF-8。解决Spring Boot 在application.yml配server.servlet.encoding.charset: UTF-8和force: true数据库连接 URL 加useUnicodetruecharacterEncodingutf8。两头都要配只配一头还会乱。5.3 时间字段差 8 小时现象订单创建时间比实际早 8 小时。原因MySQL 时区和 JVM 时区不一致或者 JDBC 连接没指定时区。解决连接 URL 加serverTimezoneAsia/Shanghai实体时间字段用LocalDateTime而不是java.util.Date。如果已经存了错数据改时区后旧数据不会自动修正得手动 update这是血泪经验。5.4 图片加载不出来但 URL 能打开现象浏览器能打开图片 URLApp 里 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。这些坑踩过一次就记住了希望帮到你。本文还有配套的精品资源点击获取