1. 毕设选题怎么锁定Android餐饮点餐系统:我的真实出发点
先交代一下背景。我当年做这个选题的时候,手里的技术栈其实并不算扎实,Java基础只能说勉强够用,Android开发也就跟着网上的教程做过几个小Demo。为什么偏偏选了"基于Android的餐饮点餐系统"?因为那时候我摸了一圈往届毕设题目,发现这个方向有几个先天优势,对毕业生来说性价比极高。
第一,业务场景足够熟悉。不需要你去调研什么工业级的复杂流程,餐饮点餐这个事人人都经历过:进店、看菜单、下单、结账。理解成本几乎为零,需求分析不用靠编。第二,技术栈足够收敛。前台客户端用Android开发,后台要么用Java Web,要么用轻量级数据库方案,中间的网络通信走HTTP协议就行,整个链条上没有那种需要啃几个月的硬骨头。第三,演示效果非常直观。Android端跑起来,界面上能看到菜单列表、购物车角标、订单状态的变化,评委一眼就能看出系统的功能闭环,不用费口舌解释你做了什么。
当然,选这个题也有讲究。如果只做一个孤零零的AndroidApp,数据全写死在本地,那是一眼假的功能演示,评委几句话说下来就很难圆。所以比较稳妥的路线是:Android客户端加一个服务端,再加上数据库,组成一个最小但完整的C/S架构。从毕设验收的角度来说,这个架构能讲的东西就多了,从前端交互到后端接口,再到数据表设计,每一层都有话可说。
我在动手之前还做了一个关键动作:查了一圈学校历年的毕设选题库,确认这个题目没有被同届同学选太多。因为一旦热门,答辩的时候老师会拿不同组的系统做对比,要求会不自觉抬高。选题虽小,避开同质化也是个值得花心思的地方。最终我确定的技术路线是这样的:客户端用Android原生开发,语言用Java,服务端用Spring Boot提供RESTful接口,数据库选用MySQL,客户端和服务端之间用JSON格式交互。这套组合到今天来看仍然是比较主流的毕设方案,网上资料多,遇到问题能查到的解决方案也全。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 系统功能拆解:哪些功能必须做,哪些功能是加分项
很多同学拿到这个题目第一反应是"做几个页面就行了",比如登录页、菜单页、购物车页、订单页。如果真按这个思路走,你会发现做完之后根本写不出两万字的设计文档,答辩的时候PPT也撑不过十分钟。我当时的做法是先把功能清单列全,再按"核心功能"和"扩展功能"两个层级来规划开发节奏。
2.1 六大核心功能模块,缺一不可
- 用户登录与注册:这个模块不只是做一个登录页那么简单。我需要区分普通用户和商家管理员两种角色,登录之后接口返回的角色标识会直接影响客户端跳转到哪个主页。密码不能明文入库,我用的是MD5加盐的方式处理,这个东西写到论文里也算一个小亮点。
- 菜单浏览与分类展示:菜单数据从服务端动态获取,客户端通过RecyclerView分别展示菜品分类和菜品列表。图片加载我用了Glide,菜品图片的URL由服务端返回,不做本地图片写死。
- 购物车管理:加入菜品、修改数量、删除单项、清空购物车。这一块看着简单,但有很多细节点,比如同一个菜品重复添加要合并数量而不是新增一条数据,这些逻辑在SQLite本地缓存和内存对象之间要保持一致。
- 订单提交与确认:用户从购物车进入订单确认页,填写桌号、备注,提交后生成订单。服务端收到订单请求后要生成订单号、计算总价、写入订单表和订单明细表,两张表的数据必须保持事务一致。
- 订单查询:用户能查自己历史下单记录,也能查看当前订单的处理状态。这个功能的实现里有大量状态机逻辑,比如"已提交、制作中、已完成、已取消"几种状态之间的流转关系,写清楚了对毕设文档很有帮助。
- 商家端订单管理:商家登录后能看到所有用户的订单列表,可以修改订单状态。这个模块是体现系统完整性的关键,如果没有后台角色,整个系统就只是"一个人的手机点菜工具",撑不起"系统"两个字。
2.2 加分功能,让答辩更有底气
做完六大核心模块之后,我又加了几个不复杂但很显功底的扩展功能:
- 菜品搜索:客户端搜索框输入关键字,请求服务端接口,返回匹配的菜品列表。这个功能技术含量不高,但演示效果很好。
- 公告轮播:在首页顶部加一个简单的Banner轮播,展示餐厅优惠信息。图片资源来自网络,用ViewPager2实现,这部分代码量不大。
- 用户个人信息管理:修改昵称、头像,其实就是调用一个更新用户信息的接口。很多人会忽略这个小模块,但写到论文里能体现你考虑到了用户体验的完整性。
我把功能优先级排成表格贴在了自己的开发笔记里,每天按优先级推进,不会出现做着做着突然不知道自己接下来该写什么的情况。
| 优先级 | 功能模块 | 涉及角色 | 核心价值 |
|---|---|---|---|
| P0 | 登录注册 | 用户、商家 | 系统入口,必做 |
| P0 | 菜单展示 | 用户 | 核心业务,必做 |
| P0 | 购物车 | 用户 | 核心业务,必做 |
| P0 | 订单提交 | 用户 | 核心业务,必做 |
| P0 | 订单列表 | 用户、商家 | 核心业务,必做 |
| P1 | 菜品搜索 | 用户 | 体验优化,加分 |
| P1 | 公告轮播 | 用户 | 体验优化,加分 |
| P2 | 个人信息 | 用户 | 完整性补全 |
2.3 角色权限的设计思路
系统里分用户和商家两种角色,这个设计听起来简单,但实现上有一个很容易踩的坑:如果你把角色信息只存在客户端本地,用户直接改一下本地标记就能以商家身份登录。我的做法是登录接口返回一个用户对象,里面包含role字段,每次请求需要身份验证的接口时,客户端把token带在请求头里,服务端解析token之后再做权限判断。虽然毕设项目不需要做到Spring Security那种细粒度的权限框架级别,但你至少要表现出"我知道权限控制的重要性"。
token这块我选了JWT,原因很简单——不需要在服务端存session,客户端拿着token请求接口,服务端验签通过就放行。JWT在Android端的处理也很顺手,拦截器里统一加请求头就行,不用每个接口单独处理。
3. 技术选型与开发环境搭建:每一个选择都值得写进论文
技术选型这一块,很多同学容易犯一个错误:看到网上教程用什么就跟着用什么,完全不理解每个组件在系统里承担的角色。结果答辩的时候老师问一句"为什么这里要用RecyclerView而不是ListView",直接就答不上来。我把自己最终确定的选型方案和理由整理在下面,建议大家做技术选型的时候也养成这种"每选一项都要能说出为什么"的习惯。
3.1 Android客户端:原生Java为何是最稳的选择
客户端用原生还是用跨平台框架?我在定方案的时候纠结过这个问题。用Flutter或者React Native的话,界面写起来确实爽,但问题在于:如果你的核心目标是把毕业设计顺利通过,而不是积累跨平台开发经验,那么原生方案在资料查找、代码调试、答辩讲解三个维度上都有明显优势。原生Android项目结构清晰,Activity、Fragment、Adapter这些概念面试官和答辩老师都熟,讲起来不用额外解释框架层面的东西。
语言层面的选择也很重要。我选了Java而不是Kotlin。不是说Kotlin不好,而是我当时的实际情况是Java基础更扎实,而且旧版教程和资料大部分基于Java,遇到报错能搜到的解决方案更多。在毕设这个场景下,选你最有把握的技术栈永远比选最时髦的技术栈更明智。
网络请求我选了OkHttp加Gson的组合。OkHttp负责HTTP通信,Gson负责JSON解析。这两个库加起来依赖也就十几个jar包,不会引入特别复杂的问题。图片加载用Glide,已经在前面提过,这里重点说一下为什么不用原生BitmapFactory:因为Glide帮你处理了图片缓存、缩放、内存回收,毕设项目里网络图片加载用Glide几乎可以无脑选。
3.2 服务端:Spring Boot是符合直觉的选择
服务端如果自己从零搭一个Servlet项目,处理HTTP请求的高并发问题、请求参数解析、响应封装,每一样都得自己写,开发周期太长。我选Spring Boot的理由非常实际:内嵌Tomcat,一键启动,自动配置,而且一切都以Maven依赖的方式管理,省去大量重复配置。
具体版本我用了Spring Boot 2.x系列。为什么不用3.x?因为3.x要求JDK 17以上,而我本机环境是JDK 8,换环境成本高,而且很多老的教程都是基于2.x的,按2.x写出来的代码查资料最方便。这里也给大家一个经验:毕设项目不要盲目追求最新版本,稳定、顺手、能查到的资料多才是第一优先。
服务端的三层架构我严格按照Controller、Service、Mapper来分。Controller只负责参数接收和响应返回,Service写业务逻辑,Mapper用MyBatis操作数据库。这种分层的意义不只是为了论文里画架构图好看,更实在的好处是出bug的时候定位很快。比如订单总价算错了,你不会去Controller里找问题,直接去Service查就行。
3.3 数据库与建表策略:别急着写代码,先花两天设计表
数据库我选了MySQL 5.7,这个版本在Windows环境下安装配置非常成熟,而且跟Spring Boot的兼容性好。建表之前我花了整整两天画E-R图,把用户表、菜品分类表、菜品表、购物车表、订单表、订单明细表这六张表的字段关系理清楚才开始动手。
表结构的设计里有一个特别关键的细节,就是订单表和订单明细表的拆分。为什么不能把菜品直接塞在订单表里?因为一个订单会包含多个菜品,如果直接用逗号分隔的字符串存入订单表的某个字段,后面查订单详情、统计销量会非常痛苦。订单表存的是订单本身的属性(订单号、用户ID、桌号、总价、状态、创建时间),订单明细表存的是每个订单里具体的菜品ID、数量、单价。两张表通过订单号关联,用事务保证同时写入成功。
菜品表字段也值得多花点心思。除了常规的菜名、价格、描述、图片URL之外,我加了一个status字段用来表示菜品是否上架。商家下架某个菜品之后,客户端菜单列表就不会再展示。这个字段看起来不起眼,但做出来之后就能体现你考虑了实际业务场景。
用户表里我把role字段设计成tinyint类型,0代表普通用户,1代表商家。字段类型不要用varchar去存"admin"这种字符串,从数据库设计的规范角度讲不过去。后面加盐加密的字段长度记得要留够,MD5加盐之后是32位十六进制,但盐值本身也要存,所以密码字段我设计成了varchar(64)。
4. 从零到一搭建Android端:页面、适配器、网络层与状态管理
Android客户端的开发是整个项目里代码量最大、最容易让人崩溃的部分。我按模块来讲一下实现过程中最核心的几个环节,同时把那些坑一并交代清楚。
4.1 项目目录结构与依赖配置
新创建一个Android项目之后,第一件事不是写代码,而是先把依赖配齐。我用的关键依赖整理如下:
groovy复制implementation 'com.squareup.okhttp3:okhttp:4.9.3'
implementation 'com.google.code.gson:gson:2.9.0'
implementation 'com.github.bumptech.glide:glide:4.14.2'
annotationProcessor 'com.github.bumptech.glide:compiler:4.14.2'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.viewpager2:viewpager2:1.0.0'
依赖配置上有一个经验:Gson和OkHttp的版本不要乱升。我看到不少同学为了追求新版本把Gson升级到2.10以上,结果跟项目中某些依赖出现冲突,报一些特别绕的错误,排查半天发现只是版本问题。在毕设项目里,锁定一组经过验证的版本组合,是最省心的做法。
4.2 页面结构:多Activity还是单Activity多Fragment
项目里页面不少,一开始有两种选择:每个页面单独建Activity,或者用一个Activity配合多个Fragment做切换。我最终选的是多Activity方案。原因很简单,Fragment在生命周期管理和传参上容易出问题,对初学者不友好。多Activity虽然切换时会有短暂的重建过程,但胜在逻辑直白。
实际的项目结构大概是这样的:
- LoginActivity:登录页
- RegisterActivity:注册页
- MainActivity:主界面,内部通过底部导航栏切换菜单页、购物车页、订单页
- CartActivity:购物车详情页
- OrderConfirmActivity:订单确认页
- OrderListActivity:订单列表页
- OrderDetailActivity:订单详情页
- MerchantOrderActivity:商家端订单管理页
底部导航栏这一块,我用了自定义的Fragment切换。MainActivity里放三个Fragment的容器,通过底部导航栏的选中事件切换Fragment。菜单展示Fragment负责从服务端拉菜品列表,购物车Fragment显示当前购物车里的菜品和总价,订单Fragment展示历史订单。
有一点要提醒大家:Fragment的切换不要用add加hide的组合,不然Fragment实例会越堆越多,占内存不说,还会出现数据不同步的怪问题。推荐的标准做法是replace加commit,这样每次切换都会重建当前Fragment,数据从服务端重新拉,虽然多了一点网络请求,但胜在状态一致性有保障。
4.3 网络请求层封装:Retrofit式和OkHttp手写式,我选了后者
网络请求层是整个客户端代码里最需要花心思设计的地方。我用的OkHttp,思路是封装一个HttpUtil工具类,提供get和post两个静态方法,统一在方法内部处理请求头、超时、错误码。这样做的好处是所有接口调用都走同一个入口,后面要统一加token也只需要改这一个类。
这里贴一下核心的封装代码:
java复制public class HttpUtil {
private static final int TIMEOUT_SECONDS = 10;
private static final OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build();
public static String post(String url, JSONObject params) throws IOException {
RequestBody body = RequestBody.create(
MediaType.parse("application/json; charset=utf-8"),
params.toString()
);
Request request = new Request.Builder()
.url(url)
.post(body)
.addHeader("token", LoginManager.getInstance().getToken() == null
? "" : LoginManager.getInstance().getToken())
.build();
try (Response response = client.newCall(request).execute()) {
if (response.isSuccessful() && response.body() != null) {
return response.body().string();
}
throw new IOException("请求失败,错误码:" + response.code());
}
}
}
这段代码看起来简单,但里面藏着一个容易出问题的细节:超时时间不能设太短。我刚开始设了5秒,结果真机调试时经常因为网络延迟导致请求直接抛超时异常,后来改到10秒才稳定。另外一个细节是addHeader里token为null的情况要处理,否则会直接抛NPE。
接口回调我用了线程的方式处理。OkHttp的execute是同步请求,不能直接在主线程调用。我的做法是在调用处开启子线程,然后在主线程里通过runOnUiThread更新UI。虽然代码写起来多了一层嵌套,但逻辑很清晰,也好跟答辩老师解释Android的主线程模型。
4.4 登录状态管理:单例模式保存用户信息
客户端很多页面都需要获取当前登录用户的信息,比如订单确认页要拿到用户ID,购物车页要判断用户是否登录。如果每次都用Intent把整个用户对象传来传去,不仅麻烦,还容易在页面间跳转过程中丢数据。我的方案是写一个LoginManager单例,在登录成功后把用户信息缓存到内存里,同时用SharedPreferences做持久化,这样App进程被杀掉之后重新进入,还能从本地恢复登录状态。
java复制public class LoginManager {
private static volatile LoginManager instance;
private User currentUser;
private String token;
private LoginManager() {}
public static LoginManager getInstance() {
if (instance == null) {
synchronized (LoginManager.class) {
if (instance == null) {
instance = new LoginManager();
}
}
}
return instance;
}
public void saveLoginInfo(User user, String token) {
this.currentUser = user;
this.token = token;
// 持久化到SharedPreferences
}
public boolean isLoggedIn() {
return currentUser != null;
}
public void logout() {
currentUser = null;
token = null;
}
}
单例模式在Android里要特别注意内存泄漏的问题。LoginManager持有的是Application级别的上下文还是Activity的上下文?如果持有Activity的话,Activity销毁之后这个引用还留在内存里,就会造成泄漏。我的LoginManager里不持有任何Context,SharedPreferences的获取通过传入Context参数或者用一个自定义Application来管理,避免了这个问题。
4.5 购物车实现:内存对象与界面同步的技巧
购物车模块是Android端代码量比较大的一块。我的购物车实体设计成一个CartItem类,包含菜品ID、菜名、价格、数量、图片URL。购物车数据存在一个全局的内存列表里,这样各个页面都能访问到。
购物车Fragment的展示用的是RecyclerView加自定义Adapter。这里有一个比较经典的坑:用户点击"加号"增加数量后,如果直接notifyDataSetChanged,会导致整个列表闪烁,而且滑动位置会丢失。正确的做法是基于position做局部刷新:notifyItemChanged(position)。另外一个需要注意的点是购物车总价的计算,不要在Adapter里算,应该在数据变更事件里统一算好再通过接口回调通知界面刷新。
我在购物车设计上做了一个本地缓存的兜底方案:内存列表每次变更后都同步写入SharedPreferences,App关闭再打开购物车数据不丢。这个功能在答辩演示时很加分,因为你打开App购物车还留着上次的数据,比那种一杀进程就全没了的Demo感好很多。
4.6 菜单列表的下拉刷新与加载更多
菜单页的列表刷新是安卓开发里的高频考点。我用了SwipeRefreshLayout包住RecyclerView的方式。SwipeRefreshLayout是Android官方提供的下拉刷新组件,把它套在RecyclerView外面,监听刷新事件后重新从服务端拉取菜单数据即可。
加载更多我用了RecyclerView的滑动监听,当滑到底部时自动请求下一页数据。服务端分页查询用了MyBatis的PageHelper插件,客户端请求参数带上pageNum和pageSize,响应里返回总条数和当前页数据。这一整套做了之后,自己在答辩演示时讲起来就有底气,因为"列表分页加载"是面试和答辩都乐于听到的实战细节。
5. 服务端接口设计与数据库交互:从一张订单看事务边界
服务端的核心工作就是提供接口给Android端调用,同时把数据正确地落到MySQL里。这一块最怕的就是接口设计混乱,路径命名没有规范,参数定义随心所欲。我提前把所有接口列成了一张表,贴在自己的笔记里,开发时严格按照表来。
5.1 接口清单与统一返回格式
我设计的接口返回格式统一如下。
json复制{
"code": 200,
"message": "success",
"data": {}
}
code为200表示成功,其他为失败。客户端拿到响应后先判断code,再解析data。这个统一格式的好处是客户端解析逻辑只需要写一遍,所有接口通用。如果你每个接口返回的格式都不一样,客户端每个回调都要单独处理,工作量翻倍且容易出bug。
接口列表整理如下:
| 功能 | 请求方法 | 路径 | 请求参数 | 返回数据 |
|---|---|---|---|---|
| 用户登录 | POST | /user/login | username, password | token, 用户信息 |
| 用户注册 | POST | /user/register | username, password, nickname | 用户信息 |
| 获取菜单分类 | GET | /category/list | 无 | 分类列表 |
| 获取菜品列表 | GET | /dish/list | pageNum, pageSize, categoryId, keyword | 分页菜品列表 |
| 提交订单 | POST | /order/submit | userId, tableId, remark, cartItems | 订单详情 |
| 查询用户订单 | GET | /order/user | userId | 订单列表 |
| 查询所有订单 | GET | /order/list | 无 | 订单列表 |
| 修改订单状态 | PUT | /order/status | orderId, status | 更新结果 |
接口路径命名我遵循了RESTful风格,资源用名词复数,操作通过HTTP方法区分。虽然毕设答辩不会严格考核RESTful规范,但规范的命名方式会让代码看起来专业很多。
5.2 下单接口的核心流程:为什么必须用事务
下单是整个系统最核心的业务逻辑。用户点击下单按钮,服务端要做的事情包括:生成订单号、计算订单总价、写入订单表、逐条写入订单明细表、扣减菜品库存、将购物车数据置空。这些操作如果分多次数据库操作而不用事务包裹,一旦中间某一步失败,就会出现订单表有记录但明细表没有数据的问题,数据完整性被破坏。
我在OrderService的submitOrder方法上加上了@Transactional注解,这是Spring声明式事务最直观的用法。要注意的是,这个注解只有在方法抛出RuntimeException时才会触发回滚。如果你在方法里自己catch掉了异常并且返回了一个成功响应,事务并不会回滚。这是我实际踩过的一个坑,后来总结的经验是:Service层做业务逻辑时,不要自己吞异常,让异常抛出到Controller层统一处理。
java复制@Transactional(rollbackFor = Exception.class)
public Order submitOrder(OrderSubmitDTO dto) {
// 1. 生成订单号
String orderNo = generateOrderNo();
// 2. 封装订单实体并写入订单表
Order order = new Order();
order.setOrderNo(orderNo);
order.setUserId(dto.getUserId());
order.setTableId(dto.getTableId());
order.setRemark(dto.getRemark());
order.setStatus(0);
List<CartItemDTO> items = dto.getCartItems();
BigDecimal totalAmount = BigDecimal.ZERO;
for (CartItemDTO item : items) {
// 3. 查询菜品当前价格,不信任客户端传的价格
Dish dish = dishMapper.selectById(item.getDishId());
if (dish == null || dish.getStatus() != 1) {
throw new RuntimeException("菜品不存在或已下架:" + item.getDishId());
}
totalAmount = totalAmount.add(
dish.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))
);
}
order.setTotalAmount(totalAmount);
orderMapper.insert(order);
// 4. 逐条写入订单明细表
for (CartItemDTO item : items) {
OrderDetail detail = new OrderDetail();
detail.setOrderId(order.getId());
detail.setDishName(item.getDishName());
detail.setPrice(item.getPrice());
detail.setQuantity(item.getQuantity());
orderDetailMapper.insert(detail);
}
return order;
}
这里有一个业务细节值得重点说明:菜品单价必须以服务端数据库里的价格为准,不能直接使用客户端传来的价格。因为客户端的请求数据是不可信的,如果用户通过抓包工具修改了请求里的菜品单价,系统价格就会失真。虽然毕设项目没有真正面对恶意用户的风险,但这种"不信任客户端输入"的设计思路,是体现一个开发者是否具备基本安全意识的分水岭。
5.3 MyBatis动态SQL在分类查询中的应用
菜单列表页需要支持按分类查看、按关键字搜索、分页查询。如果每个条件都单独写一个查询方法,代码量会冗余,而且参数组合一多就难以维护。MyBatis的<where>和<if>标签正好解决这个问题。
xml复制<select id="selectDishPage" resultType="com.example.entity.Dish">
SELECT * FROM dish
<where>
<if test="categoryId != null">
AND category_id = #{categoryId}
</if>
<if test="keyword != null and keyword != ''">
AND dish_name LIKE CONCAT('%', #{keyword}, '%')
</if>
AND status = 1
</where>
ORDER BY sort_order ASC
</select>
动态SQL配合PageHelper,服务端的一个接口就能同时满足分类筛选、关键字搜索、分页三个需求。写到这里我想提醒一点,PageHelper的版本和Spring Boot版本有兼容性要求,我用的是PageHelper 1.4.1,配Spring Boot 2.6.x没有出过问题。如果你用最新的PageHelper版本配Spring Boot 3.x,要注意检查官方文档的兼容性说明。
5.4 订单状态机:别让订单状态乱跑
订单状态我设计了0到3四个值:0已提交、1制作中、2已完成、3已取消。状态流转不是随便跳的,比如已取消的订单不能直接跳到已完成。虽然我可以写一个状态校验的方法来限制流转,但在毕设项目中,我在Service层加了一个简单的判断,只允许从当前状态流转到合法的下一个状态。
这个逻辑如果展开写可以做得非常细,甚至引入状态机框架。但毕设项目里不需要过度工程化,我当时的做法就是在修改状态的方法里加上switch判断,非法流转直接抛异常。这一点在文档里写清楚之后,答辩老师会认为你具备了基本的业务抽象能力。
6. 从SVN到Git:版本管理在毕业设计里的真实价值
毕设项目虽然是一个人开发,但版本管理绝对值得做。很多同学习惯性地只在本地目录里堆代码,写了三个月之后回看自己之前的版本,早已面目全非,万一改坏了某个功能还不知道从哪回溯。
我用的是Git加Gitee私有仓库。每天写完代码至少提交一次,commit信息简单写一下今天做了什么事情。这个习惯在后期写论文和拍演示视频的时候帮了大忙:我可以随时切回任何一个历史版本查看当时的功能状态,写功能测试小节的时候直接引用当时的提交记录作为开发进度证据。
另外,Git的分支管理也很有用。我分了master和dev两个分支,dev分支是日常开发用的,每天晚上确定能编译通过后合并到master。整个过程虽然只有我一个人在操作,但用上了这些工程化手段之后,代码质量和开发节奏确实稳了很多。如果出现问题,一个git log就能清楚看到最近改动,定位bug会快很多。
7. 联调与真机调试:那些文档里不会写的坑
客户端和服务端分开开发的时候各跑各的还好,一旦到了联调阶段,问题就层出不穷。我这里把最典型的几个坑以及排查思路分享出来,这些都是踩了之后才得到的经验。
7.1 手机连电脑调试:Android手机连不上或者看不到设备
Android开发用模拟器起步没问题,但到了真机调试环节,经常卡在第一步:手机插上电脑之后adb devices看不到设备。这个问题我排查了很久,最终定位到原因,是多方面的,常见的有三种:
- USB调试选项没有开启。不同手机品牌的路径不一样,通常是在"设置-开发者选项-USB调试"里打开。如果找不到开发者选项,需要连续点击"版本号"七次才能解锁。
- 电脑端缺少对应的USB驱动。Windows系统下插上手机后如果设备管理器里显示的是未知设备,需要安装手机品牌官方驱动或者通用ADB驱动。
- 手机系统默认使用"仅充电"模式,需要手动切换为"文件传输"模式,部分手机在USB连接后会弹窗询问,一定要选"允许USB调试"。
解决了设备识别问题后,还要确认手机和电脑在同一个Wi-Fi网络下。我这里用的是USB线直连,网络请求走的是手机的移动网络或Wi-Fi,而不是电脑的局域网,所以无论手机有没有跟电脑连同一个Wi-Fi都能访问到服务端。
但如果你的服务端跑在电脑上,手机通过电脑的局域网IP访问,就必须保证手机和电脑连同一个路由器。这里有一个非常常见的细节:电脑的IP地址不是固定的,可能因为路由器分配或者重启而变化,所以建议在电脑端设置一个静态IP,或者每次联调前用ipconfig查看当前IP再填进Android代码的BASE_URL里。
我一开始把服务端的地址写成了127.0.0.1,结果真机上怎么都连不上。后来才反应过来,Android模拟器里的10.0.2.2才映射到宿主机,真机上必须填电脑在局域网里的实际IP。用真机调试IP不要写127.0.0.1。当然,如果服务端部署在云服务器上,那就要用服务器的公网IP或者域名。
7.2 网络请求失败:HTTP明文流量为何被拦
Android 9(API 28)开始,系统默认禁止应用使用明文HTTP流量。如果你的服务端用的是http://192.168.x.x:8080这种不走HTTPS的地址,那么请求会直接报错,提示网络可能被阻塞。这个问题的修复方式是在AndroidManifest.xml的application标签里加上android:usesCleartextTraffic="true"。
xml复制<application
android:usesCleartextTraffic="true"
... >
</application>
这个开关在开发阶段加没问题,但真正上线的话,明文流量会有安全风险。毕设项目里加上这个属性属于正常操作,答辩时如果老师问起来,你可以解释清楚它是出于开发调试的考虑,生产环境应该使用HTTPS加密通信。这样反而能展示你对网络安全的基础认知。
7.3 数据库连接失败:MySQL连接地址的localhost坑
服务端连接MySQL的配置里,我用的是jdbc:mysql://localhost:3306/ordering?characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai。这个配置在Windows本机跑没问题,但如果把服务端打成jar包部署到Linux服务器,localhost就会指向那台服务器本身,如果MySQL不在那台机器上,就会连接失败。我这里因为是毕设,服务端最终跑在Windows电脑上,所以没有踩到部署环境的坑,但如果你有云服务器部署的打算,连接地址一定要改成MySQL所在机器的实际IP。
7.4 数据库时区问题:为什么订单时间差了8小时
第一次联调时我发现自己创建的订单时间戳比当前时间晚了8小时,研究之后发现是MySQL的时区设置问题。MySQL连接URL里加上serverTimezone=Asia/Shanghai,并且在MySQL启动参数里设置默认时区为东八区,就能解决。
这种问题在本地开发时因为电脑和数据库默认时区一致所以不容易暴露,但一旦部署到云上,云服务器默认时区可能是UTC,就会出现8小时的偏移。时间相关的坑是最难排查的那一类,因为报错信息不会直接告诉你"时区不对",而是数据看起来差了8小时,需要自己去对比数据库里的时间戳和本机时间。这里建议所有涉及时间的表字段,统一用datetime类型,后端通过Java的LocalDateTime来操作,避免用Date类型时跟数据库时区转换纠缠不清。
7.5 Glide加载图片不显示
菜单图片加载不出来是联调阶段的高频问题。我排查下来发现,大部分情况是图片URL写的是http://127.0.0.1:8080/upload/xxx.jpg,这在服务端所在电脑上能访问,但在手机上访问127.0.0.1指向的是手机自己,自然加载不出来。正确的图片URL应该写电脑在局域网中的IP,和网络请求的BASE_URL保持一致。
另一个原因是Glide默认只从主线程加载,如果你在子线程里用Glide加载图片,它其实会自动帮你切换到主线程,所以这个没问题。真正容易被忽略的是服务端返回的图片URL如果是相对路径,客户端需要自己拼接完整地址,不能直接交给Glide去加载相对路径。
8. 安卓端常见崩溃与异常:从报错堆栈到问题根因
联调跑到后期,App稳定性问题开始集中暴露。每天打开Logcat,看到的都是各种异常堆栈,有些一眼能看懂,有些则需要花很长时间才能定位。我把自己遇到的几个典型崩溃场景记录下来,供大家参考。
8.1 MainActivity的空白页问题
有一次打开App,首页整个白屏,Logcat里也没有明显的报错。我找了很久才发现问题出在Fragment的懒加载逻辑上。我为了性能在Fragment的setUserVisibleHint里加了数据加载逻辑,但这个方法的调用时机在不同版本上有差异,导致首页Fragment第一次显示时数据没触发加载。后来我调整了方案,直接在onResume里做数据加载,配合一个isFirstLoad的布尔变量控制,问题解决。
这个Bug的教训是:Fragment的懒加载功能看起来高级,但如果搞不清楚它的生命周期,很容易踩坑。毕设项目不需要追求极致的性能优化,老老实实在onResume里加载数据,反而最稳定。
8.2 RecyclerView复用导致的图片错位
RecyclerView的高性能来自于item复用,但这也会带来一个经典问题:item滑出屏幕之后被复用给新的数据时,旧的图片可能还没加载完,显示成了上一个item的图。我用了Glide加载图片,Glide本身在绑定时做了处理,但如果你直接给ImageView设置背景或者用非Glide的方式加载图片,就需要在Adapter的onBindViewHolder里先清空ImageView,再加载新图。
java复制@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
Dish dish = dishList.get(position);
holder.tvName.setText(dish.getName());
holder.tvPrice.setText("¥" + dish.getPrice());
holder.ivImage.setImageResource(0);
Glide.with(holder.itemView.getContext())
.load(dish.getImageUrl())
.placeholder(R.drawable.placeholder)
.into(holder.ivImage);
}
上面的setImageResource(0)就是用来清空旧图的关键一行。缺少这一行时,使用异步加载框架时错位问题可能不严重,但如果某个item的图片加载失败,Glide会保留旧图,就会看到错位。
8.3 SQLite数据库升级导致的数据丢失
购物车本地缓存我一开始用SQLite存,数据库版本号从1升到2的时候,因为没有写onUpgrade的逻辑,直接删了旧表重新建新表,用户购物车数据全没了。这个问题毕设阶段可能影响不大,因为没多少用户拿你的App持续用。但如果你要在论文里展示SQLite的使用,就一定要把数据库版本升级的逻辑写清楚,否则答辩老师顺着问一句"数据库升级怎么办",你就露馅了。
在最终版本里我弃用了SQLite,改用SharedPreferences存购物车,因为购物车数据量小,用SharedPreferences序列化一个JSON数组就够了,而且不用考虑版本升级的复杂性。做这种技术替换时,我反复权衡过:SQLite看起来更"正式",但SharedPreferences在这个场景下更合适。
8.4 子线程更新UI导致崩溃
Android不允许在子线程更新UI。如果你在OkHttp的execute回调里直接给TextView设置文字,大概率会抛CalledFromWrongThreadException。我封装HttpUtil时已经在主线程做了回调,但有些地方忘了用runOnUiThread包裹,导致偶发崩溃。
经验是在网络回调里,凡是涉及UI操作的,统一用runOnUiThread或者View.post,不要有侥幸心理。有些崩溃不是每次都出现,而是竞争条件导致,这种Bug最磨人,因为复现概率低,排查方向不明确。
9. 测试与验收准备:怎么把项目做成"说得清、讲得明"的毕业设计
功能写完不等于毕设可以交了。还有一大块工作,虽然不那么兴奋,但直接决定最终分数:测试、文档、演示。这块工作我压到答辩前半个月开始做,结果发现时间非常紧,如果重来一次,我会把测试过程分散在开发周期里,而不是最后突击。
9.1 功能测试用例清单
我按照需求分析里的功能模块,整理了一份简单的测试用例表,每个功能对应几个典型的操作场景。手动执行一遍,在表格里打勾。表格式样大概长这样:
| 模块 | 测试场景 | 预期结果 | 实际结果 |
|---|---|---|---|
| 登录 | 输入正确的用户名密码 | 登录成功,跳转主页 | 通过 |
| 登录 | 输入错误密码 | 提示密码错误 | 通过 |
| 注册 | 输入已存在的用户名 | 提示用户名已被占用 | 通过 |
| 菜单浏览 | 按分类切换菜品 | 菜品列表正确刷新 | 通过 |
| 购物车 | 重复添加同一菜品 | 数量合并,总价更新 | 通过 |
| 提交订单 | 购物车为空点击下单 | 提示购物车为空 | 通过 |
| 提交订单 | 正常提交订单 | 生成订单,购物车清空 | 通过 |
| 商家管理 | 修改订单状态 | 用户端订单状态同步变化 | 通过 |
| 非法操作 | 未登录访问个人信息 | 提示先登录 | 通过 |
这份表格贴到论文里,能很直观地展示你做了系统性的测试工作。表里每一个"通过"背后,都对应着你实际跑过的场景,答辩时随机挑一个问你都能答出来。
9.2 打包APK的注意事项
开发调试时用的是debug包,但答辩演示最好准备一个release包。Android Studio里Build -> Generate Signed Bundle / APK,然后创建一个签名文件。签名文件的密码和别名一定要记下来,不然以后想更新这个APK都做不到。
Release包默认开启混淆,如果你的代码里用了反射或者第三方库的特定功能,混淆之后可能出问题。我的做法是直接在release的build.gradle里把minifyEnabled设置为false,不开启混淆。虽然APK体积略大一点,但不影响功能,也省去配置混淆规则的麻烦。这个选择在答辩时也可以作为话术:考虑到毕设项目的维护性和稳定性,暂不开启代码混淆,生产环境再按需开启。
9.3 演示录像:别等到答辩前才录
正式答辩前一定要先自己录一遍演示视频。这样做的目的是发现流程不顺畅的地方并提前调整。我录第一遍时发现,从菜单页到购物车再到下单,整个交互过程太长了,现场演示时容易因为紧张而卡壳。后来我精简了演示脚本:先展示登录,再展示菜单和搜索,接着演示添加购物车和下单流程,最后切到商家端修改订单状态。录了三遍才满意,花了整整一个下午。
录好的视频也要放在电脑桌面和多处备份。答辩当天总会有意外状况,比如投影仪分辨率不对、接口连不上、手机突然弹窗,提前准备好视频兜底,能让你从容很多。
9.4 答辩前的系统自检清单
我最后一天按这个清单逐项过了一遍:
- 检查所有界面是否能正常打开,没有崩溃和白屏。
- 检查登录后的token是否有效,App杀进程重新进入是否还能保持登录。
- 检查网络请求是否稳定,服务端是否已经改成开机自启或一键启动脚本。
- 检查数据库中是否有演示用的基础数据(菜品、分类、商家账号)。
- 检查APK是否已导出到手机,服务端jar包和数据库脚本是否在电脑上齐全。
- 检查演示用的网络环境,手机和电脑是否连同一个局域网,IP是否已更新到代码里。
- 检查所有页面无中文乱码,无测试用的临时文案。
这些检查项做完,心里就踏实了。哪怕答辩当天出现一些小状况,因为有准备,也能很快应对。
10. 写在最后:这套系统还能怎么演进
最后分享一点我个人的延伸思路,供做完基础功能后有余力的同学参考。
- 安全层面:可以把JWT换成更细粒度的权限控制方案,给接口加上注解式权限校验,防止普通用户调用管理员接口。
- 高并发层面:下单接口可以引入Redis缓存菜品库存,用Redis的原子性操作避免超卖问题。这个方向如果展开,论文的高并发设计章节会非常充实。
- 业务扩展层面:可以增加评价模块、会员积分体系、优惠券功能。这些业务模块虽然复杂,但每加一个都能让系统完整度上一个台阶。
- 客户端架构层面:如果把现有代码重构为MVVM架构,引入ViewModel和LiveData,代码的可维护性会显著提升。这个方向也是面试官非常喜欢追问的点。
按我个人的体会,毕设项目最关键的不是功能多么炫酷,而是每一行代码、每一个设计决策你都能讲明白"为什么这么做"。带着这个思路去做,你就已经在用从业者的标准要求自己了。这套从零到一跑通的经历,无论是对答辩,还是对后续找工作面试,都是一份实打实的底气。
