
简介这是一款基于Kotlin编写的Android外卖应用程序课程设计项目面向移动开发学习者与大三学生完整覆盖客户端、服务端与数据库三层架构。项目可直接导入Android Studio运行并附5个可安装APK搭配67张UI设计图与52个Kotlin源文件能直观展示界面交互、业务逻辑与数据持久化的协作方式。压缩包共270个文件约150MB除Kotlin源码外还包含42个XML布局、35个SO底层库、1个SQL数据库脚本及2份docx课程设计报告构建脚本与静态资源齐全目录清晰便于按模块学习。目前已有148人学习下载适合正在完成Android课程设计或准备Kotlin项目实战的读者。通过这套资料可掌握Android端页面搭建、服务端接口设计与数据库表结构规划并借助附带的APK与报告文档对照源码理解外卖订单、支付等核心流程的完整实现是集代码、文档、构建于一体的教学样例。1. 从标题看透这个外卖课程设计Kotlin客户端、服务端与数据库的三层博弈标题已经把项目的全貌交代得很清楚一个用 Kotlin 写的 Android 外卖客户端配套一个服务端和一个数据库典型的大三课程设计规模。这种三层结构几乎是安卓课设里最常出现的题目因为外卖场景足够生活化——用户看菜单、加购物车、下单、查订单状态业务闭环完整数据流清晰又不会复杂到一个人做不完。这篇笔记想解决三件事帮你拆清楚客户端、服务端、数据库各自要写什么层与层之间怎么连以及联调时最容易在哪翻车。无论你是拿到别人的课程设计包想改明白还是准备从零自己搭一套读完都能对着目录直接动手。2. 拆解技术栈Kotlin客户端、服务端和数据库为什么这样分工2.1 客户端与服务端的职责边界从标题能确认一个关键信息这是一套前后端分离的课设项目而不是把数据库直接嵌在 App 里的 Demo。Kotlin 写的是 Android 客户端服务端独立运行数据库单独保存数据三者通过 HTTP 接口通信。这个结构对应的正是真实软件项目的分工方式——客户端管界面和交互服务端管业务规则和校验数据库只负责持久化。职责边界是这门课设最重要的隐含考点。很多项目翻车就是因为业务逻辑写错了位置。比如计算订单总价逻辑上必须由服务端根据数据库里的菜品价格重新计算而不是客户端把金额传过来服务端照单全收。客户端传过来的价格可以被篡改这在答辩时一问一个准。我一般遵守一条原则客户端只传菜品 id 和购买数量服务端负责查价格、算总价、扣库存。订单状态的变更同理。客户端点击“确认收货”本质是请求服务端把订单状态从“配送中”改成“已完成”而不是客户端自己改一个本地变量再提交一个新状态。这个边界如果守住整个项目的数据一致性就是清楚的如果没守住演示时多按几次返回键再进页面状态就错乱了。2.2 数据库在课程设计里的角色数据库不是简单存几张表而是充当整个项目的“状态真相”。外卖场景的核心是订单一个订单从创建到完成状态要经过待支付、已支付、制作中、配送中、已完成这几个阶段。这些状态必须能随时从数据库查出来客户端可以本地缓存但一旦下拉刷新就要以服务端返回的数据库状态为准。另一个容易被忽略的点是时间字段。订单创建时间、支付时间、完成时间这些在答辩演示和写报告时非常有用。如果只存一个字符串时间戳后面做排序和统计就非常别扭。建议订单表里用 DATETIME 类型存时间服务端写入时用统一格式客户端展示时再转成“2025-06-01 12:30”这种可读形式。数据库选型上课程设计最常见的是 MySQL其次是 SQLite。这里要特别提醒SQLite 适合纯本地应用但如果你确实按标题说的拆了客户端和服务端服务端就不要用 SQLite应该用 MySQL 或 PostgreSQL 这类独立数据库服务。原因是服务端程序要并发处理多个客户端的请求SQLite 的并发写入能力很弱一旦两个模拟器同时下单你就会看到 database is locked 错误。2.3 为什么客户端用 Kotlin 而不是 JavaKotlin 在课程设计里的第一个价值是减少样板代码。数据类一行定义Java 要写 getter/setter 的一堆东西在 Kotlin 里都用 data class 解决空安全机制让网络接口返回 null 时不会直接 NPE 崩溃而是能在编译期就提示处理。第二个价值是协程用协程发网络请求可以避免传统回调嵌套代码读起来和顺序执行一样答辩时讲逻辑也顺。服务端要不要也用 Kotlin我的看法是客户端 Kotlin 已经是加分项服务端用你最有把握的技术栈。常见的服务端方案有两种一是用 Java Servlet JDBC 手写接口二是用 Spring Boot。第一种轻量、没有复杂依赖、适合快速跑通第二种更接近工业实践但依赖管理和配置对第一次做全栈的人来说有一定门槛。如果你拿到的课程设计包服务端是纯 Servlet别嫌它“不够现代”先保证能跑再考虑优雅。3. 数据库设计与服务端连接外卖应用的核心数据链路3.1 用户、菜品、订单、订单明细、配送地址五张表的字段设计外卖系统最核心的六张表用户表、菜品表、订单表、订单明细表、配送地址表外加一个可选的购物车表。购物车我建议留在客户端内存里不落库因为课设里用户没有“跨设备同步购物车”的需求落库反而增加复杂度。核心表设计如下表名关键字段类型与约束说明userid, username, password, phone, created_atid 自增主键username 唯一存登录账号密码不要存明文dishid, name, price, category, image_url, statusprice 用 DECIMAL(10,2)status 默认 1status 控制上架/下架ordersid, user_id, total_price, status, create_time, pay_time, finish_timeuser_id 外键status 用 TINYINTstatus 用数字表示状态机order_itemid, order_id, dish_id, dish_name, quantity, pricedish_name 冗余快照菜品改名不影响历史订单addressid, user_id, receiver, phone, detail, is_defaultuser_id 外键一个用户可多地址这里有两个设计点是答辩加分项。第一是 order_item 里冗余了 dish_name 和 price目的在于记录下单那一刻的快照防止菜品后来改名或调价导致历史订单显示错误。第二是 orders 表的 status 用数字存不用字符串因为状态机用数字判断更稳定前端再映射成“待支付”“已支付”等文案。3.2 SQL 建表脚本与关键约束下面是完整的 MySQL 建表脚本包含外键、索引和默认值。注意建表顺序先建无外键的表再建有外键的表。-- 用户表 CREATE TABLE user ( id INT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL COMMENT 建议存 SHA-256 摘要, phone VARCHAR(20) DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 菜品表 CREATE TABLE dish ( id INT NOT NULL AUTO_INCREMENT, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, category VARCHAR(50) DEFAULT 默认, image_url VARCHAR(255) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表 CREATE TABLE orders ( id INT NOT NULL AUTO_INCREMENT, user_id INT NOT NULL, total_price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2制作中 3配送中 4已完成 5已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME DEFAULT NULL, finish_time DATETIME DEFAULT NULL, PRIMARY KEY (id), KEY idx_user_id (user_id), CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES user (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单明细表 CREATE TABLE order_item ( id INT NOT NULL AUTO_INCREMENT, order_id INT NOT NULL, dish_id INT NOT NULL, dish_name VARCHAR(100) NOT NULL, quantity INT NOT NULL DEFAULT 1, price DECIMAL(10,2) NOT NULL COMMENT 下单时单价, PRIMARY KEY (id), KEY idx_order_id (order_id), CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES orders (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 配送地址表 CREATE TABLE address ( id INT NOT NULL AUTO_INCREMENT, user_id INT NOT NULL, receiver VARCHAR(50) NOT NULL, phone VARCHAR(20) NOT NULL, detail VARCHAR(255) NOT NULL, is_default TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_addr_user (user_id), CONSTRAINT fk_addr_user FOREIGN KEY (user_id) REFERENCES user (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;花点时间理解三处设计。第一password字段我写的是 64 位长度对应 SHA-256 摘要如果你用 MD5 就改成 32 位但答辩时老师会问你“密码明文存没存”提前用摘要能少一个问题。第二order_item外键指向orders这意味着下单时必须先插入订单主表拿到 order_id再批量插入明细。第三所有表都用 utf8mb4是因为要存 Emoji 和中文生僻字这个细节能规避掉一半的乱码问题。3.3 服务端连接数据库JDBC 连接池配置课程设计服务端最常见的数据库访问方式是 JDBC。这里有一个关键选择每次请求都新建 Connection还是用连接池。在课设规模下前者也能跑但并发一高就会出现 Too many connections 错误而且新建连接的开销在答辩演示时会变成肉眼可见的卡顿。我建议用 HikariCP 连接池配置非常简单。// DatabaseManager.java - 基于 HikariCP 的单例连接池 import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; public class DatabaseManager { private static HikariDataSource dataSource; static { HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/food_app?useSSLfalseserverTimezoneAsia/Shanghai); config.setUsername(root); config.setPassword(your_password); config.setMaximumPoolSize(10); config.setMinimumIdle(2); config.setConnectionTimeout(3000); dataSource new HikariDataSource(config); } public static Connection getConnection() throws Exception { return dataSource.getConnection(); } }参数说明maximumPoolSize设成 10 对课设足够设太大会浪费数据库连接资源connectionTimeout是获取连接的超时时间3000 毫秒比较合理太短会误报失败太长会导致接口假死。serverTimezoneAsia/Shanghai必须加否则 MySQL 8.x 会报时区错误。useSSLfalse是让本地连接不走 SSL减少握手开销。这里还要强调一个习惯拿到 Connection 后一定要在 finally 块里关闭或者用 try-with-resources。连接池的 Connection 关闭不是真的断开而是归还给池子如果你不关池子里的连接很快会被耗尽后面的请求全部排队。这个问题在课设里非常常见。3.4 服务端 API 怎么把数据库查询结果转成 JSON客户端要的数据格式是 JSON数据库返回的是 ResultSet服务端的工作就是把 ResultSet 转成 JSON。下面是一个查询菜品列表的 Servlet 示例。// DishServlet.java - 处理 GET /api/dishes WebServlet(/api/dishes) public class DishServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType(application/json;charsetUTF-8); PrintWriter out resp.getWriter(); ListDish dishes new ArrayList(); String sql SELECT id, name, price, category, image_url FROM dish WHERE status 1; try (Connection conn DatabaseManager.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { Dish dish new Dish(); dish.setId(rs.getInt(id)); dish.setName(rs.getString(name)); dish.setPrice(rs.getBigDecimal(price)); dish.setCategory(rs.getString(category)); dish.setImageUrl(rs.getString(image_url)); dishes.add(dish); } // 用 Gson 或 Jackson 序列化 out.write(new Gson().toJson(dishes)); } catch (Exception e) { e.printStackTrace(); resp.setStatus(500); out.write({\code\:500,\msg\:\server error\}); } } }这段代码暴露了一个常见问题Dish类的字段名必须和 JSON 期望的字段名严格一致。Gson 默认是把 Java 字段名直接输出为 JSON key所以如果你在客户端用imageUrl接收服务端就必须用imageUrl这个名字。前后端字段对不上是联调阶段的头号问题最直接的办法是统一用一个字段名规范数据库列名用下划线Java 属性用驼峰JSON 用驼峰。代码里rs.getInt(id)、rs.getString(name)这些操作可以封装成一个RowMapper但课设规模没必要直接写更直观。4. 客户端 Kotlin 实现从接口调用到界面刷新的完整链路4.1 用 Retrofit OkHttp 封装网络请求层服务端接口写好后客户端第一步是封装网络层。Android 课设里最稳的方案是 Retrofit OkHttp Gson 解析配合 Kotlin 协程代码非常简洁。先定义数据类和接口。// Dish.kt - 与 JSON 字段对应的数据类 data class Dish( val id: Int, val name: String, val price: Double, val category: String, val imageUrl: String? ) // ApiService.kt - 定义接口 interface ApiService { GET(api/dishes) suspend fun getDishes(): ListDish POST(api/orders) suspend fun createOrder(Body orderRequest: OrderRequest): OrderResponse }关键点imageUrl声明为可空类型String?因为数据库里这个字段可能为 null。接口方法用suspend修饰这是配合协程调用网络请求的标准写法。Body注解的OrderRequest是一个包含 user_id、商品列表、地址 id 的数据类Retrofit 会自动把它序列化成 JSON 发送给服务端。接下来是构建 Retrofit 实例这里有一个必须设置的参数baseUrl必须以/结尾否则会抛异常。我在课设里见过太多同学栽在这里。// NetworkClient.kt object NetworkClient { private val client OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .build() val api: ApiService Retrofit.Builder() .baseUrl(http://10.0.2.2:8080/food_app/) .client(client) .addConverterFactory(GsonConverterFactory.create()) .build() .create(ApiService::class.java) }10.0.2.2是模拟器访问宿主机的固定地址这是 Android 模拟器的一个特性——模拟器里的localhost指向模拟器自己而不是你的电脑。如果你在真机上调试要改成电脑在局域网里的 IP。这个坑后面避坑章节还会展开。4.2 RecyclerView 展示菜单适配器与数据绑定拿回菜品列表后下一步是展示到界面上。Android 展示列表用 RecyclerView需要写一个 Adapter。// DishAdapter.kt class DishAdapter( private val itemClick: (Dish) - Unit ) : RecyclerView.AdapterDishAdapter.ViewHolder() { private val dishes mutableListOfDish() fun submitList(newList: ListDish) { dishes.clear() dishes.addAll(newList) notifyDataSetChanged() } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val view LayoutInflater.from(parent.context) .inflate(R.layout.item_dish, parent, false) return ViewHolder(view) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.bind(dishes[position]) } override fun getItemCount() dishes.size inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { fun bind(dish: Dish) { itemView.findViewByIdTextView(R.id.tvName).text dish.name itemView.findViewByIdTextView(R.id.tvPrice).text ${dish.price} itemView.setOnClickListener { itemClick(dish) } } } }这里有几个写得比较讲究的地方。submitList方法负责整体刷新数据而不是在 Activity 里直接操作 adapter 的内部列表这样职责更清晰。notifyDataSetChanged()是整体刷新数据量小的时候没问题如果你做的是搜索筛选功能可以用notifyItemRangeChanged做增量更新但那需要 DiffUtil课设阶段用不到。Adapter 构造参数里传一个(Dish) - Unit类型的点击回调这是 Kotlin 的高阶函数用法Activity 里可以这样调用adapter DishAdapter { dish - showDetail(dish) }。这样点击逻辑可以放在 Activity 中而不是绑死在 Adapter 里。4.3 购物车状态与下单流程Activity/Fragment 的状态管理购物车是外卖 App 里最需要状态管理的部分。最简单的实现方式是用一个HashMapInt, Int维护“菜品 id - 数量”放在 Activity 的成员变量里。// OrderActivity.kt 中的核心逻辑 private val cartMap mutableMapOfInt, Int() // key: dishId, value: quantity private val dishMap mutableMapOfInt, Dish() // 用于快速根据 id 取菜品 fun addToCart(dish: Dish) { cartMap[dish.id] (cartMap[dish.id] ?: 0) 1 dishMap[dish.id] dish updateCartBadge() // 更新购物车角标 } fun submitOrder() { val orderRequest OrderRequest( userId currentUserId, items cartMap.map { (dishId, quantity) - OrderItemRequest(dishId dishId, quantity quantity) }, addressId selectedAddressId ) lifecycleScope.launch { try { val response NetworkClient.api.createOrder(orderRequest) Toast.makeText(thisOrderActivity, 下单成功, Toast.LENGTH_SHORT).show() cartMap.clear() refreshOrderList() } catch (e: Exception) { Toast.makeText(thisOrderActivity, 下单失败: ${e.message}, Toast.LENGTH_SHORT).show() } } }lifecycleScope.launch是 Kotlin 协程与 Android 生命周期绑定的标准写法规避。用它的好处是如果 Activity 销毁了协程会自动取消不会出现网络请求返回后去更新一个已经不存在的界面也就规避了大部分内存泄漏问题。这段代码里有一个重要细节OrderRequest里的userId是客户端传的。在上面章节讨论过价格不能由客户端决定但用户身份必须由客户端标识。更严谨的做法是用 token 来标识用户但课设里很多同学直接传 user_id答辩时老师大概率会问“如果有人把 user_id 改成别人的怎么办”你可以回答“真实系统里用户身份从登录态中取不会信任客户端传入的 user_id”然后说明你的同学设计是课设简化版本。4.4 订单状态轮询与进度展示下单成功之后客户端需要能展示订单状态的变化。外卖的订单状态不是客户端点击一次就能跳变的而是由商家端或服务端在后台推进。课设里最简单的方案是轮询每隔几秒请求一次订单详情接口刷新状态。// OrderDetailActivity.kt 中的轮询实现 private var job: Job? null private fun startPolling(orderId: Int) { job lifecycleScope.launch { while (isActive) { try { val order NetworkClient.api.getOrderDetail(orderId) updateStatusUI(order.status) if (order.status 4) { // 已完成停止轮询 break } } catch (e: Exception) { // 单次轮询失败不崩溃下次继续 Log.w(OrderDetail, poll failed: ${e.message}) } delay(3000) // 3 秒轮询一次 } } } override fun onDestroy() { super.onDestroy() job?.cancel() }delay(3000)配合while (isActive)是最典型的轮询写法。isActive是协程作用域是否有活动状态的标记当 Activity 销毁或协程被取消时isActive会变成 false循环自动退出。这里没有用 Thread.sleep因为那样会阻塞线程也没有用 Handler.postDelayed 做递归因为协程的 delay 是挂起非阻塞的代码更直白。轮询间隔 3 秒是一个折中值。太短会频繁请求服务端数据库压力大太长则用户觉得状态更新不跟手。如果你想做得更漂亮可以用网络推送替代轮询但那需要一个长连接方案课设阶段不建议投入这个成本答辩时说明你知道两者差异就足够了。5. 避坑排查手册课程设计联调中最常见的五个坑5.1 Android 9 以上禁止明文 HTTP 请求导致接口全挂现象客户端请求服务端接口报错CLEARTEXT communication to 10.0.2.2 not permitted by network security policy但服务端日志显示请求根本没到。原因Android 9API 28开始系统默认禁止应用发送明文 HTTP 请求。课程设计的服务端接口一般跑在 Tomcat 上走的是 http 协议而非 https因此被安全策略拦截。解决在AndroidManifest.xml的 application 节点加一行android:usesCleartextTraffictrue。这样做在课设阶段没问题因为它允许所有域名走明文但答辩时你要能说出这只是开发和测试阶段的做法正式发布必须用 https。application android:usesCleartextTraffictrue android:allowBackuptrue android:labelstring/app_name android:themestyle/Theme.App /application5.2 模拟器访问宿主机服务端localhost、127.0.0.1 与 10.0.2.2 的差别现象客户端请求http://localhost:8080/api/dishes一直超高延迟或报连接超时。服务端明明已经启动但请求就是过不去。原因Android 模拟器是一个独立的虚拟机它内部的localhost和127.0.0.1都指向模拟器自己而不是你的电脑。你的服务端跑在电脑上所以要在模拟器里访问电脑必须用10.0.2.2。解决把客户端里所有接口地址的 host 部分全部换成10.0.2.2。如果你用的是真机调试那么要把 host 换成电脑的局域网 IP比如192.168.x.x并且保证手机和电脑连接的是同一个 Wi-Fi。提示换完 IP 之后如果仍然连不上重点检查电脑防火墙是否放行了 Tomcat 的 8080 端口这一步在 Windows 上尤其容易漏。5.3 数据库连接失败驱动、时区、端口三座山现象服务端启动时报ClassNotFoundException: com.mysql.cj.jdbc.Driver或The server time zone value Öйú±ê׼ʱ¼ä is unrecognized或Communications link failure一长串堆栈根本读不完。原因这三个报错分别对应三个问题。驱动类找不到是因为 MySQL 驱动 jar 没有放进服务端的 lib 目录。时区报错是因为 MySQL 8.x 默认时区跟 JDBC 客户端不一致。Communications link failure大概率是端口写错了或者 MySQL 服务没有启动。解决驱动找不到就把mysql-connector-java的 jar 文件放到 WEB-INF/lib 下注意 Maven 项目还要检查依赖是否真的打包进去了。时区问题在 JDBC URL 后面加serverTimezoneAsia/Shanghai这一步已经在 3.3 节的配置里写过。端口问题先确认 MySQL 是不是真的在 3306 端口监听Windows 上可以用netstat -ano | findstr 3306查Linux/macOS 用lsof -i:3306查。5.4 主线程网络操作导致 ANR现象App 一启动就卡死点击按钮没有反应过几秒弹出“APP isnt responding”对话框。Logcat 里看到NetworkOnMainThreadException。原因Android 不允许在主线程UI 线程做网络请求。这是一个硬性限制目的是防止网络阻塞卡死界面。你在 Activity 的onCreate里直接调用NetworkClient.api.getDishes()并且用了同步等待就会触发这个异常。解决所有网络请求都放进协程或者子线程里执行。前面 4.3 节展示的lifecycleScope.launch写法已经规避了主线程问题因为在标准实现中协程的 IO 操作会自动切到后台线程回来后切回主线程更新 UI。关键的一条纪律是只有suspend函数能发起网络请求不要在回调里写阻塞式代码。5.5 打包 APK 后接口全部失效现象在 Android Studio 里直接运行 App一切正常但打包成 APK 安装到手机后所有接口全部请求失败。Logcat 报错要么是连接超时要么是证书校验失败。原因两个叠加因素。第一你在 Studio 里运行时用的调试版签名网络配置可能没问题但打包成 release 版时如果开了混淆代码被混淆后没用到的反射类或 Gson 解析的模型类会被移除导致 JSON 解析失败。第二release 版的网络安全配置没有正确继承或者你打包时改了 IP 地址。解决在proguard-rules.pro里保留 Gson 和你的数据类。网络配置用usesCleartextTraffictrue之后在 release 包中仍然生效不要额外加骨架网络安全配置覆盖它。打包前把接口 IP 改成真机可访问的局域网地址然后确认手机和设备在同一个网络下。# proguard-rules.pro -keep class com.example.foodapp.model.** { *; } -keepattributes Signature -keepattributes *Annotation* -dontwarn okhttp3.** -dontwarn retrofit2.**6. 让课程设计从「能运行」到「能答辩」几个值得做的进阶打磨一个课程设计到“能运行”并不等于“能得高分”。差的那一步往往在于你能否在答辩时展示出项目不止是代码堆出来的而是有设计意识的。下面几个方向每个都不难但加起来能明显提升完成度。第一个是给订单状态加上可视化的进度条。用 MaterialButton 或 LinearProgressIndicator 展示“待支付 → 已支付 → 制作中 → 配送中 → 已完成”五个节点客户端根据 status 字段渲染进度。这个功能实现成本极低但演示效果很好——老师能看到你的项目对订单状态流转有完整思考。第二个是做一个简易的就餐者端入口或者一个管理端页面。很多课程设计只有 C 端 App没有商家或管理员角色。你不需要做完整的后台只要在服务端加一个“查询当前所有订单”的接口再用一个简单的列表页面展示就能说明你理解了不同的角色需要对数据有不同视角的查看权限。第三个是对服务端接口做一个简单的统一返回格式。前面 3.4 节的示例里直接返回了 JSON 数组更稳妥的格式是包一层{ code: 200, data: [...] }把业务状态码和 HTTP 状态码分离。这样客户端可以根据 code 判断业务是否成功而不是通过网络状态判断。改动量不大却能让代码更像一个正式接口。第四个是把自己的代码里硬编码的 IP 和端口抽到一个配置类里。Android 侧做一个常量服务端侧做一个 properties 文件两边都从一处读取。这个细节一般不会被老师主动发现但如果你主动提“我把服务端地址做成了可配置方便换环境”会比等老师问起再答加分得多。以我自己的经验来看课设答辩翻车的往往不是代码跑不起来而是业务逻辑讲不清楚。整理一张订单状态流转的草图写清楚每个状态由谁触发、通过哪个接口触发、落库时更新哪些字段这些准备比多写一百行代码更有价值。做完这几点剩下的就是演示时把模拟器返回到主页面再进一次订单列表证明数据真的存到了数据库。希望你也能把这套三层结构的课设做得比大多数同学稳——希望帮到你。本文还有配套的精品资源点击获取