ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android大学食堂点餐App开发复盘:从技术选型到库存防超卖

Android大学食堂点餐App开发复盘:从技术选型到库存防超卖

这套系统名字里带着项目代号“yx06s4r4”,一看就是课程设计或者毕业设计里常见的编号格式,但它解决的问题一点都不“课设”——凡是中午十二点站过食堂窗口前排过长队的人,都能立刻明白这套基于Android的大学食堂点餐App到底在干什么。简单说,就是让学生提前在手机上选好饭、下好单,到点直接凭取餐码端走,不用再挤在窗口前盯着打菜师傅犹豫三分钟。整套系统分成学生端App、商家窗口端和后台管理三块,我这次重点把Android学生端的实现链路完整复盘一遍,从技术选型到数据库设计,再到那些不跑几遍根本发现不了的坑,一并整理出来,给正在做同类项目的朋友作为参考。

1. 这套食堂点餐系统解决的绝不只是“排队”

很多第一次接触这类项目的人会下意识觉得,食堂点餐App不就是个“菜单展示加下单”的小工具吗,有什么可设计的?真上手需求分析就会发现,大学食堂场景比一般的外卖平台复杂得多,它有几个非常特殊的约束条件,直接决定了系统该怎么设计。

第一,用餐时间高度集中。大学食堂的客流峰值集中在午餐和晚餐各一到两个小时,热门窗口在这段时间内是持续超饱和状态。学生最关心的不是“这家店评分多少”,而是“我现在下单,到了能不能立刻拿到,会不会已经卖完了”。所以系统必须有库存概念,热门菜品需要设置每日限量,下单时实时校验剩余份数,否则就会出现“下单成功、到窗口却被告知已售罄”的纠纷。

第二,取餐方式与外卖完全不同。外卖的核心链路是把餐送到用户手上,但食堂场景是用户走到窗口自取。订单状态因此不是简单的“待支付—已支付—配送中—已完成”,而是多了一个“待取餐”状态:支付完成后厨房开始制作,制作完成或预计可取时在窗口端的屏幕上叫号,学生看到状态变更后凭订单号和取餐码去窗口取餐。这个状态机的设计是整套系统的灵魂,很多人做的时候图省事只用了“已支付”和“已完成”两个状态,实际演示时就会暴露问题。

第三,使用者角色多且权限分明。学生端只管浏览、下单、查看订单历史;食堂窗口端要能维护菜品、修改库存、更新制作状态;系统管理员则负责账号审核、整体统计和菜品上下架。如果是课设预算中的系统,不一定要做复杂的权限框架,但在用户表里留一个role字段、在不同端区分登录入口,是必须的,否则后续功能完全没法扩展。

第四,并发量虽然不算大,但存在局部热点冲突。“红烧肉还剩最后两份”这个信息一旦被多个学生同时看到,就可能出现两个人同时下单、同时扣库存的竞争条件。虽然课设规模用不到分布式事务,但在数据库设计和下单接口里做好事务处理、在App端做好“提交中”防重复点击,是必须提前考虑的。

再回到最开始的需求边界上。这套系统的核心用户就是学生和食堂窗口经营者,管理员作为第三角色存在。如果只做学生端App而不做窗口端,那“食堂点餐系统”这个名字是名不副实的,因为订单状态根本流转不起来。我这次复盘的方案是:Android学生端 + 一个基于Web的简易窗口端/管理后台 + 一份Java后端接口服务,三者共用同一个数据库。技术不算前沿,但胜在结构完整,演示时逻辑闭环,相关文档页数也能写够。

2. 技术栈选型与App整体架构设计

先交代我这套方案里每个层面的选型,再解释为什么这么选。Android端用的是Java语言配合Android Studio开发,没有采用Kotlin,不是不知道Kotlin更现代,而是考虑到这套系统的定位是校内项目,绝大多数教学环境和参考资料还是以Java为主,团队成员上手成本低,遇到问题能搜到的案例也多。

本地存储选SQLite,但这里有个细节值得多说一句。很多初学者在Android里操作SQLite时习惯直接用SQLiteOpenHelper写SQL语句,这套方案在体量小、表结构稳定的前提下完全可行,我也确实是这么做的。但它有个明显的短板:所有数据库操作都要手写,切换线程时容易出错。如果你的项目周期宽裕,或者预期后续要加字段、加表,建议直接上Room。Room在编译期做校验,写起来像操作普通对象,性能上也没有额外损耗。我用SQLiteOpenHelper是因为这套系统的表结构在动手之前就已经完全固定,不会频繁变更,手写SQL反而更直观,每条语句都能精确控制。

网络层我用了OkHttp加Gson的组合,没有上Retrofit。原因很直接:系统的后端接口数量大致在十五到二十个左右,属于偏中小规模,Retrofit的注解和接口代理带来的便利在这个体量下体现得不够明显;而OkHttp加Gson的方式在写请求工具类的时候虽然繁琐一点,但是每一个请求的细节都握在自己手里,出问题时排查路径短。不过对于接口数量超过三十个的项目,我还是建议直接用Retrofit,接口定义清晰,团队协作时维护成本低很多。

图片加载用的是Glide。菜品图片在真实场景里是后台上传的,分辨率不可控,Glide的采样压缩和磁盘缓存能比较省心地解决OOM问题,不必自己写Bitmap压缩。UI层面以RecyclerView为核心,配合CardView来做菜品卡片列表,布局文件用LinearLayout和RelativeLayout做嵌套,这套组合在课设体量下写起来轻快,调试也直观。

整个App的架构属于轻量MVC:Activity承担控制器和视图的职责,数据层单独封装了一组Manager类,比如LoginManager、CartManager、OrderManager,它们负责与后端接口交互、本地缓存和状态维护,布局XML只做纯粹的用户界面。这样划分不等于严格意义上的高内聚低耦合,但对于单人开发、一个月的周期来说,它比MVP和MVVM都要务实,调试的时候从Activity直接点进Manager方法找问题,跳转路径最短。

还有几个容易在选择时被忽略的点。一个是时间显示,后端返回的时间一般是时间戳或者标准字符串,App端在多个页面都要显示不同格式,我建议统一封装一个TimeUtils工具类,别在每个Activity各写一遍SimpleDateFormat,否则格式稍微调整一次就能让人改到崩溃。另一个是全局网络状态,App里至少要有两个需要感知网络变化的页面(登录和下单),用广播监听网络变化通知用户“当前网络不可用,请检查连接”,这个体验细节虽然不起眼,但在答辩演示时被问到“你们的系统有考虑弱网吗”,至少能给出一个明确回应。

3. 从账号登录到订单生成:核心代码链路复盘

这一部分我按用户实际操作App的视角逐步拆解,顺带穿插关键代码片段,你拿到之后可以直接对照着写。

3.1 登录模块:会话保持怎么做才安全

学生打开App首先进入登录页,输入学号和密码,点击登录后客户端会把账号密码通过POST请求提交给后端登录接口。这里涉及两个初学者容易处理不好的问题。第一个是密码传输,直接用明文在HTTP请求里传输肯定是不可取的,哪怕只是校内演示系统,也至少应该做一次MD5加盐哈希再传,后端同样用加盐方式校验。第二个是登录成功后的状态保持,我用的是服务端返回token加本地SharedPreferences存储的结合方案。登录成功后后端返回一个token字符串和一个完整用户对象,App拿到后把token和用户信息写入SharedPreferences,后续所有接口请求都带着token头,后端过滤校验。这种做法的好处是退出登录时只需清除本地数据,改密或封禁用户时后端让token失效即可,扩展空间比单纯存一个userId宽敞不少。

关键代码片段,登录请求的封装大概是这样的:

public static void login(String account, String password, final Callback<UserInfo> callback) { String hashed = Md5Utils.hashWithSalt(password, SALT); RequestBody body = new FormBody.Builder() .add("username", account) .add("password", hashed) .build(); Request request = new Request.Builder() .url(API_BASE + "/user/login") .post(body) .build(); mClient.newCall(request).enqueue(new Callback() { @Override public void onResponse(Call call, Response response) throws IOException { String json = response.body().string(); // 用Gson解析统一返回体,成功则回调携带token和用户信息 callback.onSuccess(parsed); } @Override public void onFailure(Call call, IOException e) { callback.onError(e.getMessage()); } }); }

提示:网络请求里永远不要在主线程执行。OkHttp虽然强制要求调用enqueue而不是execute来避免ANR,但如果写在自定义Manager里,务必保证回调里没有直接更新UI,要通过runOnUiThread或者让Activity拿到结果再更新视图。

登录之后App会进入主界面,底部Tab分三个页签:首页展示菜品分类和推荐菜,订单页展示历史订单和当前进行中的订单,我的页面显示个人信息和退出按钮。这三个页面实际上对应的是三套完全不同的数据加载逻辑,但它们的共同点是都需要在onResume阶段重新拉取一次数据,保证用户从别的页面回来时看到的不是脏数据。

3.2 菜品浏览:分类加载、库存显示和轮询更新的平衡

首页顶部的分类栏数据来自dish_category表,默认加载全部菜品并按照窗口和分类排序。菜品的卡片上除了名字、图片、价格,还应该明确展示“剩余份数”或者“已售罄”标签。这里有个实现细节:库存数字到底多久刷一次?如果每隔两秒就全量刷新菜品列表,服务器压力大,手机流量也扛不住;如果完全靠用户下拉刷新,又很容易出现“学生看到有货、提交订单时被告知售罄”的情况。

我的处理是折中策略:进入首页时全量拉取一次,之后每三十秒自动静默刷新一次,用户下拉也可手动刷新;下单接口在后端做二次库存校验。这个方案在课设演示环境里完全够用,而且能在答辩时讲出“我们考虑到了数据一致性问题”这个加分点。RecyclerView的适配器实现是常规写法,菜品item布局里库存文字需要根据剩余数量切换颜色,这些细节不要放在Adapter里写if逻辑,而是建议封装成DishItemViewModel,把“库存状态”的渲染逻辑抽成一个方法,后面如果要增加“即将售罄”这类新的状态提示,只改一个地方就行。

假如你不幸用了ListView而不是RecyclerView做列表,建议立刻重构。原因不只是性能,RecyclerView的ViewHolder复用机制在图片加载和item局部刷新方面都要顺手得多,GridView做横向分类菜单也天然兼容。

3.3 购物车:放本地还是放服务端

购物车是这个系统里最值得拿出来讲的一个设计决策。我知道很多同类项目是把购物车数据直接放在App的内存或者SharedPreferences里,只在提交订单时才发给后端,这种方案实现简单,但是有一个致命问题:用户换设备登录后购物车就没了,这在食堂场景下虽然不算严重,可一旦要扩展“多端同步”功能就寸步难行。我最终采用的是服务端购物车方案:App端每次加菜、减菜、改数量都调用对应的购物车接口,由后端UPDATE cart表,同时App端本地只维护一个购物车数量角标,用于界面上的即时反馈。

这个方案上课设演示的好处特别明显:当答辩老师问到“为什么不用本地购物车”,我可以说“本地购物车的所有操作在服务端都是黑盒,无法统计用户的加购行为,也无法跨端同步;而放在服务端,一份在食堂窗口电脑上登录过的账号记录也能同步过来”。当然,代价是每次加购都多一次网络往返,操作响应略微变慢,实际上在校内局域网环境几乎没有感知。

购物车页面里的每一项展示菜品名称、单价、数量、小计和删除按钮。这里要注意的坑是数量增减按钮的点击事件里必须处理边界值:数量最小为1,最大不能超过该菜品当前库存。如果只做了加法不做上限拦截,就会出现购物车能加到一百份、提交订单时后端直接拒绝的尴尬局面,体验上非常不专业。库存上限在服务端也要做一次校验,不能只靠客户端拦截。

3.4 提交订单:这一个请求里必须包含什么

下单是整个App中最核心的动作,也是一条最容易出bug的链路。用户点击“去结算”,购物车页面会弹出一个确认页,展示购物车里的菜品明细、总金额、取餐窗口、备注信息,确认后调用了提交订单接口。这个接口的请求参数里,我建议除了userId和list数组,还应当携带一个由客户端生成的requestId(每次下单生成的唯一随机串)。为什么?因为网络请求在校园Wifi下超时重传是家常便饭,如果用户在点击下单按钮的瞬间网络卡了,他多点了一次,两个相同的订单就可能被先后提交,后端如果没有幂等校验,系统里就会凭空多出一笔重复订单。requestId的作用就是让后端判断“这个请求我是否已经处理过”,如果处理过就直接返回上一次的订单结果,保证业务不被重复执行。

订单提交成功后,后端返回订单号和一个取餐码,App跳转到“订单详情页”,同时启动一个轻量轮询任务,每五秒请求一次订单状态,直到订单状态变成“待取餐”,即在界面上弹出“可以去取餐啦”的提示,并且让手机震动一下做提醒。实现方式是这样:在Activity里用一个Handler.postDelayed定时请求,订单状态一旦变成终态(已完成或已取消),立即取消轮询。

这里还应该补充一个模拟支付状态的设计。真实支付需要接入第三方支付SDK,课设里通常做的是模拟支付:订单表里有一个pay_type字段,表示是余额支付还是模拟支付;下单后订单状态为“待支付”,用户点击“模拟支付”按钮后,前端把订单号传给后端,后端把这个订单的status置为“待制作”或“已支付”。虽然只是一个模拟动作,但状态流转的过程完整,逻辑闭环。

4. 数据库表与接口设计:先把地基打好

数据库是整个系统里最不应该将就的部分,我见过太多项目因为表结构设计不合理,写到后半段被迫删表重建。这个项目的核心表一共是六张:用户表、菜品分类表、菜品表、购物车表、订单主表、订单明细表。下面两张表的字段设计是重点。

4.1 订单主表和订单明细表为什么必须拆开

这是数据库设计中最基础但也最关键的一个决策。如果图省事,把订单里的每道菜都存成一个JSON字符串塞进订单表的一个字段,那这张表在查询统计的时候会非常痛苦。比如管理员想统计“这周卖得最好的菜是哪几样”,要解析所有订单的JSON才能算出来,完全没有利用数据库的聚合能力。拆分成order和order_item两张表之后,统计菜品的销量只需要对order_item表按dish_id做GROUP BY再排序,一条SQL就搞定。

order表核心字段包括:order_id主键、user_id外键、order_no(业务订单号)、total_amount、status、create_time、pay_time、pickup_time和取餐码字段。order_item表核心字段包括:item_id主键、order_id外键、dish_id、dish_name、dish_price、quantity。这里把dish_name和dish_price冗余在明细表里,是故意为之:菜品名称和价格未来可能调整,但历史订单上的名称和价格必须保持下单那一刻的原样,不能跟着菜品表变。这就是所谓的“快照”思想,很多人在做订单系统时容易漏掉这一层。

4.2 库存字段的两种处理方案

菜品表dish里有个stock字段表示当天剩余份数。但“每天重置库存”这个需求怎么处理,值得提前设计好。如果只在dish表里维护一个简单的stock字段,系统一天运行结束不会自动归位,第二天学生看到的库存还是前一天的残值。我采用的做法是在stock字段基础上增加一个sale_date字段,表示这个库存数值是哪一天的数据。后端在下单接口里判断当前日期是否等于sale_date,如果不等于,就先把库存重置成配置的每日总量再执行扣减。这个逻辑用一条UPDATE加一条SELECT就能实现,却能让系统看起来像个真正的生产系统。

4.3 接口返回格式的统一约定

后端接口统一返回JSON结构:code(状态码)、message(描述信息)、data(具体数据体)。成功时code为200,失败时根据场景返回不同的业务码,例如1001表示账号或密码错误,1002表示库存不足,1003表示订单不存在。这样App端写网络回调时就能做一个通用分发:如果code是200,走成功逻辑;否则弹Toast提示message内容,不用每个接口都写一遍错误处理。接口文档里还需要统一时间格式为“yyyy-MM-dd HH:mm:ss”,避免前后端在时间解析上面扯皮。

接口清单大致如下,写后端时需要直接对应上:

  • POST /user/login 登录,返回token和用户信息
  • GET /category/list 获取分类列表
  • GET /dish/list?categoryId=xxx 获取分类下的菜品列表,携带库存状态
  • GET /cart/list?userId=xxx 获取购物车
  • POST /cart/add 加菜到购物车
  • POST /cart/update 修改购物车数量
  • POST /cart/remove 删除购物车单项
  • POST /order/create 提交订单
  • GET /order/list?userId=xxx 查看历史订单
  • GET /order/detail?orderId=xxx 查看订单详情,包括明细和状态
  • POST /order/pay 模拟支付
  • POST /order/cancel 取消订单
  • GET /order/status?orderId=xxx 轮询用,只返回状态字段

业务接口数量不算多,但每一条对应的SQL操作、数据校验和异常返回都要写清楚。我做完这版接口文档后的体会是:接口定得越细,写后端的时候越不需要动脑子,App端联调也越顺利。

5. 真实开发中踩过的坑和处理手段

这部分我尽量挑那些不跑几遍绝对发现不了、一旦踩到又非常耗时的问题。

5.1 重复点击下单按钮,数据库里多出两笔一模一样的订单

这是我在联调阶段遇到的最典型问题。Android端的按钮如果不在点击后立即置灰或进入loading状态,用户手一抖连续点两下,后端就会收到两个完全相同的请求。我在前面提到的requestId幂等校验就是针对这个问题做的。另一种也很有效的辅助手段是下单按钮点击后立即变成“提交中”状态并禁用,等接口返回后再恢复。两手都做,前端防重复点击,后端防重复提交,双保险才稳。

5.2 中文乱码:一个编码问题排查了一下午

只要涉及Android客户端、Java后端和MySQL三端的请求与存储,中文乱码就是大概率事件。我的排查路径是:先看后端接收请求时有没有设置UTF-8编码过滤器,再看数据库连接串上有没有characterEncoding=utf8,最后才排查Android端有没有在RequestBody里显式声明编码。实际上这三处往往是连环的,任何一个环节漏了都会乱码。强烈建议项目一启动就把后端全局过滤器里的编码设置写好,别等测出问题再补。

5.3 Glide加载高清大图导致列表滑动卡顿

后台上传的菜品照片动辄两三千像素宽,如果直接用原图加载到小卡片里,列表滑动时掉帧严重。解决方式是在Glide加载时显式加上override宽高和CenterCrop裁剪,让Glide在解码阶段就按需要的尺寸生成Bitmap,而不是把原图塞进内存再缩放。对于图片上传端,后端保存图片时也最好顺手生成一份压缩缩略图,前端优先加载缩略图。这个思路在真实项目里同样适用,属于图片加载优化的基操。

5.4 订单状态的流转顺序“走不回去”

我在做窗口端叫号功能时,最初设计的订单状态是“待支付→已支付/待制作→制作完成/待取餐→已完成”,没有考虑“用户付完钱不想等了”和“窗口端打烊前未取走的订单”这类情况。后来只能补一个“已取消”状态和对应接口,并且规定取消只能发生在“待支付”或“待取餐”两个状态,弥补后状态机才闭合。这里想提醒的是,设计状态机时一定把异常分支一起画出来,别只画阳光大道。

5.5 下拉刷新与列表自动刷新的冲突

首页既然做了三十秒自动刷新,就必然和用户手动下拉刷新有冲突的可能。最常见的现象是:用户正在浏览某一道菜品的详情,自动刷新触发列表整体重排,RecyclerView回到顶部,用户被强行打断。解决方案比较粗暴但有效:自动刷新时不要直接notifyDataSetChanged整个列表,而是先比较新旧数据中是否有状态变化的菜品(库存变化、售罄下架),有变化才局部刷新对应item;没有变化则静默结束。代码上就是在Adapter里维护一个diff方法,比对后再决定刷新范围。

5.6 订餐高峰期的库存超卖

最后的库存校验必须放在数据库那一层做,而且是加锁扣减。我用的方案是下单接口里面执行:UPDATE dish SET stock = stock - 1 WHERE dish_id = ? AND stock > 0,然后判断受影响行数,为0则说明库存已经被抢光,直接返回库存不足。这种写法天然具备原子性,不会出现两人同时扣减同一份库存的情况。这是我从电商库存方案降级后用到这个课设里的,成本几乎为零,效果却立竿见影。

6. 项目测试、交付与实际部署建议

一套系统做到功能闭环只是第一步,测试和交付的质量往往决定了项目最终能拿到的评价。我的测试清单按功能模块拆成四块:账号注册登录、菜品浏览加载、购物车操作、下单支付与状态流转。但这里有个经常被忽略的维度——多状态下的界面表现,比如网络超时时要给重试按钮,列表为空时要有空态图,加载中要有转圈动画而不是白屏。这些处理好了,演示时给人的专业感完全不一样。

测试时还建议覆盖三个常见场景:第一,弱网环境(校园里偶尔信号差很常见);第二,从后台切回App时界面的刷新恢复;第三,同一账号在另一台设备登录后,本机旧会话的处理。第三点我做得比较简略,因为会话管理本身可以单独拿一个半小时来优化,我这边是发现token失效后自动跳回登录页,这个结果至少比闪退专业。

UAT测试建议找几个不参与开发的同学实际使用,不要自己一个人闭门造车。他们大概率会在“购物车没清空”“清空购物车按钮在哪”“取消订单之后状态为什么没有立刻变”“取餐码能不能放大显示”这类细节上提出很多需求,这些建议反而是答辩时最拿得出手的改进来源。

关于部署,演示时不一定非要买云服务器,用一台实验室电脑装Tomcat加MySQL,手机和电脑连同一个局域网,App里的API地址改成电脑的局域网IP,就能完成整个联调演示。这套配置的坑是,Android模拟器访问电脑要用10.0.2.2代替localhost,真机访问要用电脑的IP,很多人在环境配置这里折腾半天。建议在项目里单独写一个常量类管理API地址,发布或者演示前只需改一行。

最后说一个从这次项目里沉淀下来的小建议,也是我每次做这类系统都会遵守的原则:功能宁可少做两个,但做出来的每一个都必须能抗住追问。比如购物车同步、库存防超卖、订单幂等,这些点虽然不起眼,但每个都能在答辩时讲出完整的设计理由和实现细节,比堆砌一堆花架子功能有用得多。大学食堂点餐系统这类题目,做完的那一刻感觉普通,但你在上面花过心思的判断和取舍,会一直留下来。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进