☰
基于Spring Boot与Android的大学食堂点餐系统全栈实战
2026/10/20 8:26:42 网站建设 项目流程

1. 项目整体设计与需求拆解

1.1 核心需求解析

大学食堂点餐App这个项目,我前前后后做了两版,第一版是纯课程设计级别的Demo,第二版才是真正能拿去演示和扩展的工程化项目。先聊聊我拿到这个标题时的第一反应:这不是一个简单的“写个App交作业”需求,它背后牵扯的是传统食堂就餐模式里那些真实的痛点。

学生在食堂吃饭,高峰期窗口排队动辄十几分钟,下课时间集中导致体验很差;食堂管理者想知道哪个窗口卖得好、哪个时段客流大,却只能靠经验拍脑袋;传统校园卡充值要跑人工窗口,排队充值这件事在移动支付年代显得非常违和。所以这个项目的价值不在于“做了一个App”,而在于用一套完整的移动端点餐系统,把用户端选餐、下单、支付、取餐,以及管理端的菜品管理、订单监控、数据统计串起来。

从技术角度拆解,这个项目有三个核心关键词:Spring Boot、Android、大学食堂。Spring Boot负责后端接口与业务逻辑,Android负责移动端展示与交互,食堂场景决定了业务闭环必须包含菜品分类、购物车、订单状态流转、支付对接、取餐通知这些模块。适合谁来参考?刚学完Java Web和Android基础、想要一个完整全栈项目的同学最合适,也可以作为毕业设计的选题框架。

1.2 功能模块全景

把需求梳理成具体功能点,整个系统可以拆成两个端、各自若干个模块。用户端(Android App)包含:注册登录、首页菜品浏览与分类筛选、菜品详情、购物车管理、下单支付、订单列表与状态跟踪、个人中心(地址/历史订单/收藏)。管理端(后台管理,一般用Web页面实现,也可以做成Android管理版)包含:食堂窗口管理、菜品信息维护(上下架、库存、价格)、订单管理(接单、完成、退款处理)、数据看板(日订单量、销售额、热门菜品排行)。

我实际做的时候,还加了一个模拟支付的环节,因为真实接入支付宝或微信支付需要企业资质和签约流程,学生在开发阶段拿不到这些。模拟支付的意思是:用户提交订单后跳转到支付页面,选择“模拟支付成功”,后台直接把这个订单标记为已支付,这样整套业务流程就闭环了,不会因为支付环节卡住导致后面取餐流程没法演示。

这里的核心设计思路是:不要为了追求“真实支付”而卡住整个项目进度。等后期真有条件接入真实支付,只需要替换支付Controller层的实现,把模拟逻辑换成调用第三方支付SDK的代码就行,整个订单流程和回调处理逻辑不用动。

2. 核心技术选型与系统架构

2.1 为什么是Spring Boot + Android

先回答一个很多人会问的问题:为什么后端选Spring Boot而不是其他框架?我做这个项目时对比过几种方案。

如果用SSH(Struts2 + Spring + Hibernate)那套老框架,配置文件的复杂度能劝退一半新手,而且社区里已经很少有人用这套东西做新项目了。如果不用Spring Boot,用Node.js的Express或Python的Flask/Django也能做后端,但问题是:我假设目标读者是计算机专业的学生,他们的课程主线通常是Java体系,Spring Boot是最贴近课程知识结构的选择,学完可以直接用在毕设和后续工作中。更重要的是,Spring Boot的自动配置让开发效率极高,内嵌Tomcat意味着不用单独部署War包,一个java -jar就能启动服务,非常适合快速迭代。

Android端的选择相对简单:原生开发。虽然现在跨平台方案很多(Flutter、React Native、UniApp),但大学课程里教的、绝大多数教材里写的都是Java/Kotlin原生开发。而且原生开发对系统API的调用最直接,比如后面如果要加扫码取餐、蓝牙打印机对接,原生方案都是最顺畅的。

2.2 整体架构与技术栈明细

整个系统采用前后端分离架构,Android端和后端通过RESTful API通信,数据格式统一用JSON。这样做的好处是:Android端和后端可以并行开发,只要提前约定好接口文档,互不阻塞。后端可以先用Postman把接口测试通过,再把接口文档发给写Android的同学,双方各干各的,最后联调时再统一处理边界情况。

技术栈明细如下:

层级技术选型说明
前端(Android)Java原生、XML布局、OkHttp、Gson、RecyclerView、GlideOkHttp做网络请求,Gson解析JSON,RecyclerView展示列表,Glide加载菜品图片
后端(Spring Boot)Spring Boot 2.x、Spring MVC、MyBatis Plus、MySQL、RedisMyBatis Plus简化数据访问层开发,Redis做缓存(商品信息、Token)
数据库MySQL 8.x存储用户、菜品、订单等核心业务数据
接口文档Swagger / Knife4j自动生成API文档,方便前后端对接
构建工具Maven、Gradle后端Maven管理依赖,Android用Gradle构建

这里我特别说一下为什么引入Redis。很多人做这种课程级别的项目会忽略缓存这层,但我实际测试过:当菜品数量达到几百个,用户并发访问首页时,每次请求都查MySQL,数据库连接池很容易被打满。Redis缓存菜品列表和用户Token之后,绝大多数请求直接命中缓存,数据库压力大幅下降。原理很简单:菜品数据是读多写少的类型,非常适合做缓存,并且菜品更新(上下架、改价格)的频率不高,缓存可以设置较长过期时间,或者管理端操作时主动清理相关缓存。

2.3 前后端接口通信方式

Android端通过HTTP协议与后端通信,基础URL一般配置成http://10.0.2.2:8080/api(Android模拟器访问宿主机用10.0.2.2,真机调试则用电脑在局域网内的IP地址)。创建网络请求时,OkHttp + Retrofit是常见组合,不过Retrofit需要额外引入,我为了让代码结构更直观,直接用OkHttp + Gson手动封装了一个轻量的网络请求工具类。具体做法是:

public class HttpUtil { private static final String BASE_URL = "http://10.0.2.2:8080/api"; private static OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .build(); public static void doGet(String url, Callback callback) { Request request = new Request.Builder() .url(BASE_URL + url) .get() .build(); client.newCall(request).enqueue(callback); } public static void doPost(String url, String json, Callback callback) { RequestBody body = RequestBody.create( MediaType.parse("application/json; charset=utf-8"), json); Request request = new Request.Builder() .url(BASE_URL + url) .post(body) .build(); client.newCall(request).enqueue(callback); } }

封装好后,调用方只需要传入相对路径和回调,不用每次写重复的Request构建代码。回调方法在子线程执行,拿到JSON后需要通过runOnUiThread切回主线程更新UI,这个细节很多新手会漏掉,导致直接在回调里改UI控件时程序崩溃。我会在后面常见问题章节详细说这个坑。

3. 数据库设计与核心表结构

3.1 核心业务表规划

数据库设计是整个项目的地基,表结构没设计好,后面写代码会处处别扭。我第一版就吃了亏,当时把订单和订单详情放在一张表里,用户下订单时直接把所有菜品拼成一个字符串存在一个字段里。这样做写起来简单,但后续统计哪个菜品卖得好、订单金额怎么按菜品拆分,全是麻烦事。第二版我重新建模,严格按照范式设计,核心表包括以下几张:

用户表(user)

CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(255) NOT NULL COMMENT '密码(MD5加密存储)', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `avatar` varchar(255) DEFAULT NULL COMMENT '头像URL', `balance` decimal(10,2) DEFAULT '0.00' COMMENT '账户余额', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

窗口表(canteen_window)食堂按窗口维度管理菜品,窗口表记录窗口名称、位置、所属食堂楼层等。窗口和菜品是一对多关系。

菜品表(dish)

CREATE TABLE `dish` ( `id` int(11) NOT NULL AUTO_INCREMENT, `window_id` int(11) NOT NULL COMMENT '所属窗口ID', `name` varchar(100) NOT NULL COMMENT '菜品名称', `price` decimal(10,2) NOT NULL COMMENT '价格', `image` varchar(255) DEFAULT NULL COMMENT '图片URL', `description` varchar(500) DEFAULT NULL COMMENT '菜品描述', `category` varchar(50) DEFAULT NULL COMMENT '分类(荤菜/素菜/汤类/主食)', `status` tinyint(1) DEFAULT '1' COMMENT '1上架 0下架', `sales_count` int(11) DEFAULT '0' COMMENT '销量', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

订单表(orders)

CREATE TABLE `orders` ( `id` int(11) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单编号', `user_id` int(11) NOT NULL COMMENT '用户ID', `window_id` int(11) DEFAULT NULL COMMENT '窗口ID(跨窗口下单时可为空)', `total_amount` decimal(10,2) NOT NULL COMMENT '总金额', `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0待支付 1已支付 2已接单 3已完成 4已取消', `remark` varchar(255) DEFAULT NULL COMMENT '备注', `pickup_code` varchar(6) DEFAULT NULL COMMENT '取餐码', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `pay_time` datetime DEFAULT NULL COMMENT '支付时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

订单明细表(order_item)

CREATE TABLE `order_item` ( `id` int(11) NOT NULL AUTO_INCREMENT, `order_id` int(11) NOT NULL COMMENT '订单ID', `dish_id` int(11) NOT NULL COMMENT '菜品ID', `dish_name` varchar(100) NOT NULL COMMENT '菜品名称(冗余存储,防止菜品改名后影响历史订单)', `price` decimal(10,2) NOT NULL COMMENT '下单时单价', `quantity` int(11) NOT NULL DEFAULT '1' COMMENT '数量', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这几个表包含一个很重要的设计思想:订单明细冗余存储菜品名称和价格。为什么这么设计?因为菜品表里的名称和价格是会变的——餐厅经理把“鱼香肉丝”从10元涨到12元,这是很正常的操作。如果订单明细直接关联菜品表,后期菜品改价后,历史订单的金额就跟着变了,这显然不合理。所以下单那一刻,把菜名和价格快照到订单明细表里,历史订单永远不受影响。

取餐码的设计也值得说一下。6位数字取餐码,生成规则可以是:窗口ID的后两位 + 当天日期的后两位 + 当天该窗口订单序号的后两位。例如窗口3的第15单,生成031501(03表示窗口3,15表示当天日期15日,01表示序号01),这样用户取餐时说取餐码,食堂阿姨不用翻订单列表,直接看码就能定位到单子,非常高效。

3.2 MyBatis Plus的使用细节

持久层框架我选了MyBatis Plus而不是原生MyBatis,核心原因就是开发效率。MyBatis Plus提供内置的BaseMapper接口,单表的增删改查不需要写XML映射文件,直接调用现成方法就行。但要注意,使用MyBatis Plus做多表关联查询时,还是要自己写XML或注解SQL。

我在实际应用中遇到过一个坑:MyBatis Plus的updateById方法做更新时,默认只更新非null字段,也就是说如果你的实体类某个字段是null,该字段不会被更新到数据库。这在某些场景下是特性,但在另一些场景下就是坑——比如用户想清空备注字段,前端传了空字符串,但后端接收后变成null,调用updateById后数据库里的备注仍然是老内容。解决办法是使用UpdateWrapper显式设置要更新的字段,或者给实体类字段加注解@TableField(updateStrategy = FieldStrategy.IGNORED)。

对于订单表和菜品表的查询,我建议把公共查询条件封装成QueryWrapper:

QueryWrapper<Dish> wrapper = new QueryWrapper<>(); wrapper.eq("status", 1); if (StringUtils.hasText(category)) { wrapper.eq("category", category); } wrapper.orderByDesc("sales_count"); List<Dish> dishList = dishMapper.selectList(wrapper);

这段代码的逻辑是:查询上架状态的菜品,如果传入分类参数就按分类过滤,最后按销量倒序排列。用QueryWrapper的好处是条件拼接是动态的、可变的,不会因为某个条件为空就报错。这是MyBatis Plus用得最频繁的API,建议多花点时间熟练掌握。

4. 后端核心接口实现

4.1 整体接口设计规范

后端接口设计我遵循RESTful风格,以资源为中心命名URL。下面这份接口清单是开发时的核心参考,前后端都按照这个约定来做。

功能请求方式接口路径说明
用户注册POST/api/user/register用户名唯一性校验
用户登录POST/api/user/login登录成功返回Token
获取菜品列表GET/api/dish/list支持分类、窗口ID过滤
获取菜品详情GET/api/dish/{id}根据ID查询单条
加入购物车POST/api/cart/add购物车操作由后端管理
查看购物车GET/api/cart/list返回当前用户购物车
创建订单POST/api/order/create从购物车生成订单
订单支付POST/api/order/pay模拟支付接口
获取订单状态GET/api/order/status/{orderNo}订单实时状态
窗口列表GET/api/window/list获取食堂窗口

接口设计有一个值得注意的点:购物车放后端还是放前端?第一版我把购物车做在了Android本地(SharedPreferences存储),结果发现用户换手机登录后购物车就没了,而且管理端想分析“哪些商品被加入购物车但没下单”(购物车放弃率)也完全做不到。第二版我把购物车改成了后端存储,用一张cart_item表记录用户ID、菜品ID、数量。虽然每个操作要多一次网络请求,但带来了跨端同步和分析的价值。电商系统里购物车放后端是标准做法,这个小项目的设计思路也是对齐真实业务场景的。

4.2 登录鉴权与Token机制

登录功能是整个系统的安全基础,我不建议用最朴素的“登录后把用户ID存在Session里,Android端用Cookie维持会话”这种方式。原因有两个:一是移动端应用和后端交互时Cookie的处理不如Web端那么自然,二是后续如果要把接口开放给小程序或Web端,Session的方案扩展性差。

我使用的是简单但有效Token方案:用户登录成功后,后端用UUID生成一个随机Token,以token_userid作为Key存入Redis,有效期设置为7天。之后的每次请求,Android端在请求头Authorization里带上这个Token,后端通过拦截器校验Token并解析出用户ID,存入ThreadLocal方便Controller层获取当前登录用户。

具体实现时,我用Spring MVC拦截器来完成Token校验逻辑:

@Component public class LoginInterceptor implements HandlerInterceptor { @Autowired private StringRedisTemplate redisTemplate; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } String token = request.getHeader("Authorization"); if (StringUtils.hasText(token)) { String userId = redisTemplate.opsForValue().get("token_" + token); if (userId != null) { UserContext.set(Long.parseLong(userId)); return true; } } response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录或Token已过期\"}"); return false; } }

Token存储的关键点在于:Redis Key要有前缀,便于区分不同业务数据;Token值存的是用户ID而不是整个用户对象,避免Redis里存储体积过大;拦截器同时要处理CORS(跨域)的问题,因为Android端请求虽然不是浏览器跨域,但如果是Web管理端访问就需要放开跨域配置。

4.3 下单与库存扣减的并发处理

下单接口是业务逻辑最复杂的地方,也是并发问题最容易暴露的地方。当多个用户同时对同一道菜下单时,库存扣减必须保证数据一致性。我第一版写的代码是:先查库存,判断库存大于0,然后执行库存扣减。这在并发情况下存在严重的超卖问题——两个用户同时查到的库存都是1,都认为可以下单,结果库存变成-1。

正确的做法有两种:第一种是数据库层面的乐观锁,在菜品表加上version字段,更新时校验版本号;第二种是利用SQL的原子操作,直接执行UPDATE dish SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity}。返回受影响行数为1表示扣减成功,为0表示库存不足。

我实际选择的是第二种,代码更简洁且不需要额外字段:

@Transactional public boolean reduceStock(Long dishId, Integer quantity) { int rows = dishMapper.reduceStock(dishId, quantity); return rows > 0; }
<update id="reduceStock"> UPDATE dish SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity} </update>

这套方案的原理是:把“检查库存”和“扣减库存”合并成一条SQL原子操作,数据库层面保证同一时间只有一个事务能成功执行更新,从根源上避免了超卖。配合@Transactional注解保证整个下单流程的事务性——要么全部成功,要么全部失败回滚。这里要注意的是,事务里的所有操作要尽量短平快,不要在事务里做耗时操作(比如调用外部接口),否则会长时间占用数据库连接,影响系统的并发能力。

4.4 支付回调与订单状态流转

模拟支付的核心逻辑比较简单:调用支付接口时传入订单号,后台模拟支付成功,然后更新订单状态为“已支付”,同时生成取餐码。但要注意的是,订单的状态流转必须是有状态机的概念——不是任何状态都可以跳到任意状态的。

我定义的状态流是:待支付(0)→ 已支付(1)→ 已接单(2)→ 已完成(3),其中待支付可以跳转到已取消(4)。每次状态变更用代码校验当前状态是否符合预期,防止非法流转。例如已完成的订单不可能再变成已支付,已取消的订单不能再支付。

状态变更的代码实现:

@Transactional public boolean updateOrderStatus(String orderNo, Integer fromStatus, Integer toStatus) { int rows = orderMapper.updateStatus(orderNo, fromStatus, toStatus); if (rows == 0) { throw new BizException("订单状态更新失败,当前状态不允许该操作"); } return true; }

对应SQL中的条件更新:

UPDATE orders SET status = #{toStatus}, pickup_code = CASE WHEN #{toStatus} = 1 THEN #{pickupCode} ELSE pickup_code END WHERE order_no = #{orderNo} AND status = #{fromStatus}

这种条件更新写法保证了状态流转是线程安全的,同时也避免了应用层先查后改的并发问题。我在测试过程中,用JMeter模拟了200个并发用户同时支付同一个订单,最终只有一条更新成功,其余全部返回“状态更新失败”,说明方案是有效的。

5. Android端界面与核心功能实现

5.1 界面框架选型与页面设计

Android端的UI结构分两层考虑:一是底部导航栏的整体框架,二是各页面的布局细节。

整体框架用的是最常见的底部Tab结构:底部四个Tab——首页、订单、购物车、我的。实现方式我用的是Fragment + ViewPager2 + BottomNavigationView的组合。ViewPager2负责左右滑动切换页面,BottomNavigationView负责点击切换,两者联动。

这里有一个设计细节我要多说一句:很多人做多Fragment切换时会用FragmentTransaction首页的replace方法,每次切换都重新创建Fragment。这种做法在页面简单时看不出问题,但一旦Fragment里有大量图片加载和网络请求,每切换一次就重新加载一次,体验很糟糕,而且容易产生内存泄漏。

我采用的是Fragment的显示隐藏机制:首次切换到某个Fragment时创建实例并add到容器,之后切换只show和hide,这样Fragment的状态(滚动位置、列表加载结果)都能保留。配合ViewPager2的setOffscreenPageLimit方法设置预加载页面数量,滑动体验更顺滑。

首页布局从上到下依次是:搜索框、Banner轮播图、分类快捷入口(按菜品分类图标)、推荐菜品列表。菜品列表用RecyclerView实现,这里要注意Item的布局类型不能全部一样——我实际做的时候,首页列表里混了两种Item类型:横幅类型和普通菜品类型,所以要用RecyclerView的getItemViewType方法做多类型适配。实现起来不复杂,但能明显提升页面层次感。

5.2 网络层封装与数据解析

Android端的网络请求与数据解析是日常开发中频率最高的操作,封装不好代码会非常冗余。我定义了一个统一的响应体基类:

public class ApiResponse<T> { private int code; private String msg; private T data; public boolean isSuccess() { return code == 200; } // getter/setter }

后端所有接口的返回格式统一是{"code":200,"msg":"success","data":{...}}这种结构。Android端拿到JSON后,先用Gson解析成ApiResponse对象,判断code字段,再取出data部分做具体类型转换。注意泛型在Gson中的解析需要借助TypeToken:

Type type = new TypeToken<ApiResponse<List<Dish>>>(){}.getType(); ApiResponse<List<Dish>> response = new Gson().fromJson(jsonString, type);

这个写法很多人会忘记。直接new Gson().fromJson(json, ApiResponse.class)解析出来data字段是LinkedTreeMap而不是List 对象,后面一调用列表方法就强转崩溃。用TypeToken能解决泛型信息在运行时被擦除的问题,是必须要掌握的技巧。

5.3 购物车与下单流程

Android端购物车页面的展示逻辑是:进入页面时请求/api/cart/list接口,拿到购物车明细列表(每个条目包含菜品信息、数量、小计金额),展示在RecyclerView中。每个条目右侧有数量加减按钮,点击时调用/api/cart/update接口更新数量,同时本地更新小计金额和总计金额。

下单流程的核心逻辑是:购物车页面点击“去结算”按钮,跳转到确认订单页面。确认订单页显示商品清单、总金额、备注输入框,点击“提交订单”调用/api/order/create接口,后端创建订单成功后返回订单号和订单总金额,然后跳转到收银台页面模拟支付。

关于下单时购物车数据不一致的问题,有一个很实际的场景:用户加购了菜品A,在确认订单页停留了5分钟,这期间菜品A下架了。后端创建订单时需要校验购物车里每一项菜品是否仍然是上架状态,如果下架则提示用户“部分菜品已下架,请返回购物车修改”。这个校验逻辑放在后端订单创建接口里,而不是Android端,因为前端校验完全可以被绕过,后端才是信任边界。

5.4 订单状态轮询与取餐提醒

订单列表页面需要实时展示订单状态,用户支付完想知道“商家接单没”,取餐时想知道“我的餐好了没”。实现方案有两种:轮询和长连接。

轮询方案简单稳定:用户进入订单列表或订单详情页时,启动一个定时器,每隔3秒请求一次订单状态接口,页面销毁时停止定时器。长连接方案(WebSocket)更实时但复杂度大幅提升,需要后端维护连接状态。

我的建议是:这个项目用轮询就够了,不要为了追求技术的“高级感”而不合理地引入复杂方案。订单状态的变更频率很低(半小时内变化次数不超过5次),3秒轮询完全够用,对服务器压力也小。我用Handler的postDelayed方法实现轮询,注意要在onDestroy时移除所有消息,防止页面销毁后Handler还在执行任务导致内存泄漏。

取餐提醒可以是订单状态变成“已完成”时(代表餐已做好可领取),用户在订单列表看到状态变化,同时App可以发一个系统通知。Android通知的权限(POST_NOTIFICATIONS)在Android 13以上需要动态申请,这是新手容易遗漏的地方——不申请权限直接发通知,代码不报错,但用户看不到任何通知。

6. 项目部署与联调经验

6.1 后端打包与本地部署

完成代码编写后,后端打包部署主要分三步:配置外部化的环境参数、Maven打包、启动服务。

Spring Boot的配置文件我建议把开发测试用的配置和正式部署用的配置分开,用spring.profiles.active切换环境。我用的是application-dev.yml和application-prod.yml区分。开发环境配置本机数据库和Redis地址,生产环境配置服务器地址。启动时指定参数就能切换环境:

mvn clean package -DskipTests java -jar target/order-app-1.0.0.jar --spring.profiles.active=dev

-DskipTests是跳过测试用例打包,这个地方要记得,否则项目里如果有集成测试用例会尝试连接数据库导致打包失败。

部署过程中最容易踩的坑是数据库时区问题。MySQL连接URL必须加上serverTimezone=Asia/Shanghai参数,否则默认使用服务器时区(可能是UTC),时间查询结果会差8小时。这个坑非常隐蔽,查订单列表时看着所有时间都对,但一旦做时间范围统计就发现数据对不上。

6.2 Android真机联调的网络配置

用模拟器调试时访问后端地址是http://10.0.2.2:8080,但换成真机联调时,需要把后端URL改成电脑在局域网内的IP。获取方式是在电脑命令行执行ipconfig(Windows)或ifconfig(Mac/Linux),找到局域网IP地址。我踩过的坑是:手机和电脑连接的不是同一个WiFi,或者校园网的AP隔离策略导致设备间通讯被禁止。前者好解决,后者只能换网络环境或者后端部署到云服务器。

真机调试还有一个坑是Android 9及以上系统默认禁止HTTP明文流量。如果后端接口地址是http://而不是https://,请求会被系统直接拦截,报错信息是CLEARTEXT communication to XX not permitted by network security policy。解决办法是配置网络安全策略,允许特定域名或IP使用明文HTTP。

具体做法是在AndroidManifest.xml中添加android:usesCleartextTraffic="true",或者更精细地配置network_security_config.xml:

<network-security-config> <domain-config cleartextTrafficPermitted="true"> <domain includeSubdomains="true">10.0.2.2</domain> <domain includeSubdomains="true">192.168.1.100</domain> </domain-config> </network-security-config>

这种方法比全局放开明文流量更安全,因为只针对调试环境的后端IP放开,正式环境仍然强制HTTPS。

6.3 接口联调中的异常处理策略

前后端联调阶段一定会遇到各种数据不一致的问题。我一般给Android端定义一个统一的异常处理入口,也就是网络请求回调里先判断后端返回的code。如果code是401(未登录或Token过期),直接跳转到登录页面并清除本地缓存用户信息;如果code是500(服务器内部异常),弹Toast显示“服务器开小差了,请稍后重试”;如果网络请求本身失败(超时、无法连接),则显示“网络连接失败,请检查网络设置”。

联调时最好的工具是抓包工具。Android端推荐用HttpCanary或直接在电脑上对手机设置代理抓包。但我实际用下来,更方便的是在后端开启日志,把每次请求的URL、参数、返回结果全部打印出来。Spring Boot的日志配置里把Mapper层的日志级别设为DEBUG,就能打印出所有执行的SQL语句,排查数据问题特别有用:

logging: level: com.example.order.mapper: debug

这个配置在开发阶段一定要开着。SQL日志能直接告诉我执行了什么语句、传了什么参数、查出了什么结果,很多时候前后端扯皮半天的问题,看一条SQL日志就真相大白了。

7. 常见问题与排查技巧实录

7.1 数据库层面的典型问题

中文乱码问题:写入数据库的中文变成???。排查思路分三层:数据库连接URL是否指定了characterEncoding=utf8,MySQL表本身的字符集是否是utf8mb4,以及请求进入后端时是否设置了正确的响应编码。我在代码里统一加了一个过滤器拦截所有请求和响应,设置字符集UTF-8。表字符集在建表时用DEFAULT CHARSET=utf8mb4解决,utf8mb4比utf8支持更多字符(比如emoji),现代项目直接用utf8mb4没毛病。

时间字段永远差8小时:上面说了是时区问题。数据库连接的serverTimezone参数要设置,另外Java实体类里的Date字段,和前端JSON交互时,默认序列化格式可能不是预期的yyyy-MM-dd HH:mm:ss,需要在字段上加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")注解。

7.2 Android端的崩溃与渲染问题

子线程更新UI导致的崩溃:OkHttp的回调是在子线程执行的,在回调里直接给TextView设置文本,会抛出android.view.ViewRootImpl$CalledFromWrongThreadException。解决方法是使用runOnUiThread或Handler.post切回主线程再更新UI。我封装网络请求工具类时,在回调层就做好了线程切换,调用方不用关心这个细节,但新手如果自己写网络请求代码,这个坑一定会踩。

图片加载导致的OOM:菜品图片如果不做压缩,直接加载原图到内存,很容易在低端手机上触发OutOfMemoryError。我用Glide加载图片,并且配置了缩略图策略:

Glide.with(context) .load(imageUrl) .placeholder(R.drawable.placeholder) .error(R.drawable.error) .override(300, 300) .into(imageView);

override(300, 300)强制把图片压缩到300x300的尺寸再加载,内存占用减小10倍。实际图片源在服务器上可能是2K分辨率的大图,但列表页根本不需要那么大的图,这个压缩策略是必须的。

列表数据刷新后跳动:用RecyclerView加载数据后,如果直接notifyDataSetChanged(),列表会回到顶部,用户体验很差。使用DiffUtil做增量更新可以解决,但配置稍复杂。偷懒的解决方案是记录当前位置,在数据更新完成后重新定位到原位置:

int position = layoutManager.findFirstVisibleItemPosition(); adapter.notifyDataSetChanged(); layoutManager.scrollToPosition(position);

7.3 订单支付与状态异常的排查

订单一直显示待支付:用户反馈说支付成功但订单状态没变。我排查这个问题时发现是支付回调接口中的orderNo参数传递错误。Android端点击支付后,把订单号传给了后端,但后端更新订单表时,order_no字段在数据库里的值和前端传的不一样——因为订单号在创建时有一个“再拼接”逻辑,比如先加一个固定的前缀再存库,更新时却用原始值去匹配,自然匹配不上。解决方法是统一订单号的生成和匹配逻辑,用一个工具类专门处理,前后端都用这个工具类生成和解析。

重复支付问题:模拟支付的接口如果被快速点击多次,同一订单可能被支付多次,产生重复的支付记录和异常状态。解决办法是在支付接口加幂等校验:先查订单状态,状态不是“待支付”则直接返回“订单已支付,请勿重复操作”。这属于接口级别的幂等性设计,在支付场景是必须要做的。

取消订单后库存没恢复:用户取消待支付订单后,菜品库存应该加回去。我第一版没做这个逻辑,结果订单取消了,库存却永久减少了。后来在订单取消接口中增加了库存回补逻辑:根据订单明细表中的菜品和数量,逐一调用UPDATE dish SET stock = stock + #{quantity} WHERE id = #{id}。同样在事务中执行,保证取消订单和库存回补要么都成功,要么都失败。

8. 写在最后的实际操作心得

做完整套系统,我最想分享的心得是:先画清楚状态流转图和数据关系图,再动手写代码。这个项目第一版我跳过了设计阶段直接开写,结果改了三版表结构,前后端接口反复调整,浪费了大量时间。第二版老老实实先花两个小时把E-R图画出来、把订单状态机画出来、把接口清单列成表格,后面编码非常顺畅,几乎没有大改。

另一个心得是:联调阶段一定要有接口文档。搭个Swagger就很好用,后端把注释写好,前端用浏览器就能看到每个接口的请求参数类型和返回结构,不用反复在微信群里@对方问字段名。如果你自己就是唯一的开发者,同样建议写清楚——过两周你自己回头看代码,也需要文档帮你快速回忆当时的设计意图。

最后说一个小技巧:菜品图片的管理可以用一个简单的上传接口实现,后端用本地目录存储图片文件,数据库里存URL路径,这样避免用Base64把图片整个塞进数据库导致表体积暴涨。开发初期可以用一些免费的图床服务存测试图片,但正式演示时还是自己管理图片更可靠——图床容易失效,图片一挂列表页就是一片空白,影响整体效果。

整个项目做完,前后端加起来核心代码大约一万行左右,如果你基础扎实,两到三周可以完成;如果边学边做,预留一个月比较稳妥。真正花时间的不是某个技术点,而是各种细节问题的排查和联调。踩过那些坑之后,你对Spring Boot和Android开发的理解会提升一个层次。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询