简介:一份基于安卓平台的外卖APP开发与设计资源包,采用Java语言实现,面向毕业设计、课程设计以及Android应用开发学习者,提供从项目搭建到功能落地的完整参考。压缩包约17.19MB,内含源码、说明文档与演示视频(平台未列出文件总数),便于对照学习与效果预览,目前已有193人学习。项目知识点覆盖Java面向对象、Android SDK及Android Studio集成环境、ViewModel/LiveData/Repository等架构组件,并延伸至后端SSM框架、MySQL数据库设计、Retrofit/OkHttp网络通信与地图API接入。同时涉及Material Design界面规范、多线程异步处理、运行时权限管理以及JUnit/Espresso测试调试,帮助开发者系统理解Android客户端与后端服务交互的完整链路。通过源码研读、文档解析和演示视频观摩,可高效掌握外卖类App的模块拆分、数据流转和功能实现,为同类课题开发提供扎实的实践蓝本。
1. 一个安卓外卖APP的zip包,为什么值得把它跑起来再看源码
你手上如果有一个标题叫“基于安卓的外卖APP开发与设计”的Java项目压缩包,里面带着源码、说明文档和演示视频,大概率第一反应是“又一个毕设项目”。但我想先说一个反直觉的结论:这种项目真正难住新人的地方,不在安卓界面的布局,而在服务端接口怎么约定、下单的订单状态怎么流转、以及安卓端怎么在真实局域网环境里把数据拉通。一个小外卖App把这三层全部走一遍,比单独刷一百集Android教程都管用。
这套东西适合三类人:拿它做毕业设计的学生、想往安卓开发投简历的初级工程师、以及想快速了解一个Java后端接口是怎么被手机App调用的前端开发者。前提是你别只把它当代码压缩包来解压,而是当成一条完整的技术链路来复现:MySQL建表、Spring Boot起服务、OkHttp发请求、RecyclerView刷列表。下面按我平时接手这类项目的顺序,从服务端一路讲到安卓端,再落到联调排错和进阶验证。
2. 先看服务端:外卖App的Java后端怎么搭才算合理
拿到项目先别急着打开Android Studio,我一般会先把服务端代码翻一遍。原因很简单:App端所有页面都是在等接口返回数据,接口字段叫什么、返回什么结构,决定了你后面写解析时要不要加班。
2.1 选型逻辑:Spring Boot + MyBatis和更轻方案的区别
常见的外卖App毕设项目里,服务端跑的是Spring Boot加MyBatis,配MySQL数据库。这个组合对安卓开发者的Java基础要求不算高:你只要会写Controller、Service、Mapper三层,再把常用注解认熟,剩下的交给框架。它不是性能最优解,但胜在生态成熟,出任何问题都能在网络上搜到现成的答案。
如果你拿到的版本用的是Servlet加JDBC,也别急着嫌弃。Servlet项目启动简单,不依赖Maven拉一堆包,适合把精力放在“接口设计”本身。判断一个服务端代码写得好不好,先看数据库脚本。我见过不少翻车现场是订单表里没有订单状态字段,或者菜品价格用的是double。基础表结构至少要有这么几张:用户表、菜品表、购物车表、订单表、订单明细表。用SQL看更直接:
CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(32) NOT NULL, `password` varchar(64) NOT NULL, `avatar` varchar(255) DEFAULT NULL, `address` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `dish` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(64) NOT NULL, `price` decimal(10,2) NOT NULL, `image` varchar(255) DEFAULT NULL, `category` varchar(32) DEFAULT NULL, `status` tinyint(1) DEFAULT '1', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `orders` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `total_price` decimal(10,2) NOT NULL, `status` tinyint(2) DEFAULT '0' COMMENT '0待支付 1已支付 2配送中 3已完成 4已取消', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `remark` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有两个细节值得记一下。价格字段一定用decimal(10,2)而不是double,这是做支付相关项目的基本常识,double算金额会出现0.01元的误差,后面对账有你哭的。订单状态用tinyint加注释比字符串更省空间,安卓端拿到数字后自己映射成文字展示。我们约定状态码0到4,和后面要讲的接口返回结构是一套配合的逻辑。
2.2 订单接口从Controller到事务提交
外卖App最核心的接口是下单。很多人把它做成简单的insert语句,吃完就出问题——下单要同时写订单表和订单明细表,两步必须在一个事务里。用户下单点一次,结果只生成了订单主记录,明细没写进去,订单列表页打开就是一堆没有菜品的空壳。新手最容易犯的错误恰恰是忘记给Service层加@Transactional。
我习惯把下单接口的代码写成这样,它足够简短但包含了核心校验:
@RestController @RequestMapping("/order") public class OrderController { @Autowired private OrderService orderService; @PostMapping("/create") public Result createOrder(@RequestBody OrderRequest request) { if (request.getUserId() == null || request.getItems().isEmpty()) { return Result.error("用户ID和菜品列表不能为空"); } try { Long orderId = orderService.createOrder(request); return Result.success(orderId); } catch (Exception e) { return Result.error("下单失败:" + e.getMessage()); } } }这段代码注释很少,是因为逻辑都压在Service层,Controller只做参数校验和结果包装。注意@PostMapping搭配@RequestBody,安卓端传JSON用的是POST而不是GET,因为下单请求体里带着菜品列表,GET根本装不下。OrderRequest里面至少要有userId和items字段,items是每个菜品的id和数量。
Service层才是重点。createOrder方法里要干三件事:计算总价、插入订单主表拿回自增id、再循环插入明细表。这个循环插入不要用for循环加单条insert,MyBatis支持批量插入,一次insert list性能好很多。事务失效的经典坑有两个:一个是方法被同类内部调用,@Transactional没生效;另一个是异常被try catch吞了,事务管理器根本不知道出了错。所以我在事务方法里不catch业务异常,直接抛出去,让全局异常处理器兜底。
2.3 接口约定:统一返回体和JSON命名
安卓端写网络解析最怕什么?怕接口每个方法返回的结构都不一样。有的接口直接返对象,有的返回一个字符串,出错时有的给code、有的给message,客户端每接一个接口就要写一套判断逻辑。这个项目如果是多人协作或者从教程里拷来的,很容易看到这种混乱局面。
解决办法是全局统一返回体,所有接口都返回这个结构:
public class Result<T> { private Integer code; // 0成功,非0失败 private String message; // 错误信息,成功时为空 private T data; // 真正的业务数据 public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.code = 0; r.message = "ok"; r.data = data; return r; } public static <T> Result<T> error(String msg) { Result<T> r = new Result<>(); r.code = 1; r.message = msg; return r; } // getter/setter省略 }这个类的价值是给安卓端吃定心丸。客户端永远只认code字段,成功走data分支,失败走message提示,不用在每个回调里再猜返回值的含义。字段命名上要留意一件事:后端习惯用Java小驼峰,比如createTime,而SQL字段是create_time。网上很多教程建议在MyBatis配置文件里开启mapUnderscoreToCamelCase,这样属性映射不用写一堆resultMap,这个开关默认是开启的,但有些老版本项目模板会关掉,导致查出来的createTime永远是null。翻车的时候先检查这个配置。
日期格式化也要在这一层约定好。默认情况下,LocalDateTime序列化出来是一串带T的字符串,安卓端直接显示很难看。我一般会在配置里指定格式,让接口直接返回“2025-06-18 12:30:00”这种字符串,客户端拿过来直接用,省得在手机端再写一遍日期转换代码。
3. 安卓端的实现顺序:先有网络层,再谈页面
很多从零开始学安卓的人有个习惯,先从布局文件开始拖控件。但对于有服务端接口的项目,这是本末倒置。页面是死的,数据是活的,只要接口还没定,界面做得再漂亮也是空壳。正确做法是把网络层放在最前面,先让App能把服务端的数据拉下来,再考虑RecyclerView怎么渲染。
3.1 OkHttp统一请求封装与异步解析
现在绝大多数Java写的安卓外卖项目用的网络库是OkHttp配Gson。选择它不是因为功能最全,而是因为生态稳定、资料多、遇到问题能搜到答案。封装网络层我习惯用一个单例,把baseUrl、超时时间、公共header集中管理。核心代码如下:
public class HttpManager { private static final String BASE_URL = "http://你的服务器IP:8080/"; private static OkHttpClient client; private static Gson gson = new Gson(); static { client = new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .build(); } public static void post(String url, Map<String, Object> params, Callback callback) { String json = gson.toJson(params); 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); } }注意POST请求的body要手动指定ContentType为application/json,配合服务端的@RequestBody使用。如果ContentType是text/plain,Spring Boot默认会报415错误,这是联调时最高频的一个翻车点。
超时时间我设的是10秒。外卖App的列表和下单接口都不应该超过这个时间,万一服务端没启动,10秒内客户端能收到连接失败的回调,给用户一个友好的“网络异常”提示,而不是让界面卡住不动。异步用enqueue而不是execute,因为网络请求不能跑在主线程,Android在3.0以后就已经禁止在主线程做网络访问了,很多新手直接把execute放到Activity里,一运行就崩溃。
3.2 RecyclerView菜品列表和图片问题
菜品列表是整个项目里最像“外卖App”的页面。一张菜品卡片要有图片、名字、价格、加入购物车按钮,滚动起来不能卡。用RecyclerView是安卓的标准答案,三件事:布局管理器、适配器、数据集合。
适配器的稳定写法是ViewHolder模式,onCreateViewHolder里inflate布局,onBindViewHolder里set文本和图片。代码结构如下:
public class DishAdapter extends RecyclerView.Adapter<DishAdapter.ViewHolder> { private List<Dish> list; public DishAdapter(List<Dish> list) { this.list = list; } @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { View view = LayoutInflater.from(parent.getContext()) .inflate(R.layout.item_dish, parent, false); return new ViewHolder(view); } @Override public void onBindViewHolder(ViewHolder holder, int position) { Dish dish = list.get(position); holder.name.setText(dish.getName()); holder.price.setText("¥" + dish.getPrice()); Glide.with(holder.itemView.getContext()) .load(dish.getImage()) .placeholder(R.drawable.img_default) .into(holder.image); } // ViewHolder类与列表项控件绑定省略 }图片加载直接用Glide,它内部处理了缓存和缩放,不需要你自己写Bitmap逻辑。placeholder那张默认图很重要,开发阶段服务端经常因为图片路径不对返回404,Glide会显示占位图,至少界面不会崩。
图片URL是这条链路上最容易卡住的地方。开发时如果用模拟器,服务端返回的图片地址如果是“localhost:8080/upload/x.jpg”,在模拟器里是永远加载不出来的,因为模拟器里的localhost指向模拟器自己。这个问题放到下一章联调部分细说,先知道结论:图片不要存数据库的blob字段,存服务器磁盘路径或对象存储URL就行。
3.3 购物车与下单:App端的状态管理
购物车是纯客户端状态,服务端不管。技术上可以用一个HashMap维护菜品id和数量的映射,在点击加号时更新。核心逻辑可以单独抽一个购物车管理类:
public class CartManager { private Map<Integer, Integer> items = new HashMap<>(); // dishId -> count public void add(int dishId) { items.put(dishId, items.containsKey(dishId) ? items.get(dishId) + 1 : 1); } public void remove(int dishId) { if (!items.containsKey(dishId)) return; int count = items.get(dishId) - 1; if (count <= 0) { items.remove(dishId); } else { items.put(dishId, count); } } public List<OrderItemRequest> buildOrderItems() { List<OrderItemRequest> result = new ArrayList<>(); for (Map.Entry<Integer, Integer> entry : items.entrySet()) { result.add(new OrderItemRequest(entry.getKey(), entry.getValue())); } return result; } }下单按钮触发时,直接调用buildOrderItems拼JSON放进Request,然后发到第2.2节那个/order/create接口。有一个细节容易踩坑:下单成功之后要立刻清空购物车,否则用户切到别的页面回来,购物车角标还是红点,继续点下单就变成重复提交。如果服务端做了幂等,重复提交会被拒,但大多数毕设项目没有这个设计,清空动作得靠客户端自觉。
购物车状态和界面同步的问题,简单项目用EventBus或者接口回调都能解决,不必为了这个场景上全套LiveData和ViewModel架构。先把链路跑通,架构升级是后话。
4. 把项目从zip变成能点外卖的App:部署与联调实操
到了这一步,你已经把服务端和安卓端的代码都过了一遍。接下来要做的,是把零散代码变成真正能跑起来的系统。这个环节比写代码更考验耐心,因为80%的时间会花在“为什么我连不上”“为什么数据是空的”这种问题上。演示视频这时候是最有用的参照物——别一上来就自己瞎调,先看视频里的操作流程,照着复现一次。
4.1 先把数据库和服务端拉起来:Maven与SQL脚本执行顺序
任何联调都从数据层开始。常见做法是先找到项目里的sql文件,在Navicat或命令行里执行,把表和初始数据建好。然后打开服务端工程,检查application.properties里的数据库连接:
spring.datasource.url=jdbc:mysql://localhost:3306/waimai?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4 spring.datasource.username=root spring.datasource.password=你的密码这里最容易被卡住的是serverTimezone参数。MySQL 8.x连接串不带这个参数会直接报时区错误,加上Asia/Shanghai就不会有8小时的时间偏移问题。建库时注意选utf8mb4而不是utf8,不然菜名里的特殊字符会变成问号。
数据库就绪后用Maven启动服务端。IDE里打开导入的Maven工程,先执行一下clean,再执行install,最后跑主类。命令行方式也可以,但前提是配了Maven环境变量:
mvn clean package -DskipTests java -jar target/waimai-0.0.1-SNAPSHOT.jar参数说明:-DskipTests跳过测试代码,避免测试类没配环境导致构建失败;java -jar启动的是打包后的可运行jar,访问地址默认是本机8080端口。启动后浏览器直接打开http://localhost:8080/dish/list,能看到JSON格式的菜品列表,就证明服务端OK了。这个验证动作贯穿整篇的核心:先确认接口通,再去怀疑安卓端的代码。
4.2 模拟器联调:adb reverse还是10.0.2.2
服务端跑起来后,第二步是让安卓App连上它。模拟器环境有个容易混的概念:模拟器里的localhost是模拟器自己,不是你的开发机。访问你电脑上的服务端,两个办法二选一。
第一个办法是直接用特殊IP 10.0.2.2,这是Android模拟器专门给宿主机的保留地址。把baseUrl的IP改成10.0.2.2,模拟器里就能访问到电脑上的8080端口。这个办法简单直接,但换机器就要改配置,而且真机上完全用不了。
第二个办法更推荐,adb反向代理:
adb reverse tcp:8080 tcp:8080这条命令把模拟器的8080端口转发到开发机的8080端口。只要开发机上服务端在监听,模拟器就能用localhost:8080访问到它。好处是baseUrl不用改,保持一致。注意这条命令每次重新连接设备后都要再执行一次,如果你发现之前好端端的突然连不上了,先检查adb reverse是不是失效了。
用模拟器的另一个坑是图片加载。服务端返回的图片URL如果是localhost开头,模拟器永远加载不出来,原因同上——它是模拟器自己的localhost。解决方案是在服务端存图片时用相对路径,安卓端用baseUrl拼完整路径。
4.3 真机联调:baseUrl、明文传输与局域网访问
到了验收阶段,很多人会换上真机测一测,结果发现App连不上服务端。这里有三层问题要一起解决。
第一层是网络地址。真机不能用localhost,必须用开发机的局域网IP。查IP在命令行执行ipconfig或ifconfig,然后改HttpManager的BASE_URL。如果手机和电脑不在同一个WiFi下,这一步怎么改都没用。
第二层是Android 9开始的明文HTTP拦截。从Android 9(API 28)起,系统默认禁止App使用明文HTTP协议,你访问http://192.168.1.100:8080会直接抛CleartextNotPermitted异常。解决办法是给Manifest加配置:
<application android:usesCleartextTraffic="true" ...>这个属性表示允许明文流量。本地开发这么干没问题,上线前必须走HTTPS或者网络层换成加密连接,否则用户的请求内容能被局域网里任意抓包工具直接看到。
第三层是防火墙。Windows会让Java进程监听时弹一次防火墙提示,如果手滑点了取消,真机访问就会被挡住。排查方法很简单:同一局域网拿浏览器访问http://开发机IP:8080/dish/list,能出JSON说明服务端对局域网开放了,再看App的报错到底是什么。
5. 避坑清单:安卓外卖App联调中最常见的5类翻车
下面这几条是从大量同类项目中筛出来的高频问题,每一类我都经历至少三四次。按“现象、原因、解决”的方式写,方便你对号入座。
5.1 POST请求报415错误。现象:安卓端点击下单,日志里返回HTTP 415,服务端接口没有执行。 原因:OkHttp请求的ContentType设成了text/plain,而服务端用@RequestBody接收对象,类型不匹配直接被拒。 解决:在构建RequestBody时显式指定MediaType.parse("application/json; charset=utf-8")。另外检查一下请求头有没有带Accept字段,带错也会出现类似问题。
5.2 模拟器能连、真机连不上。现象:同一个baseUrl,模拟器里一切正常,换成手机就被告知连接超时。 原因:多半是baseUrl里写的是localhost或10.0.2.2,这两个地址在真机上分别指向手机自己和不存在的网段。还有可能是Windows防火墙拦了8080端口。 解决:baseUrl统一改成开发机局域网IP;防火墙开端口。记住模拟器和真机最好用同一份配置跑通一遍,再进入下一步测试。
5.3 接口返回的时间字段变成一长串数字。现象:订单列表里的时间为“1718700000000”这种东西,不是能读的日期格式。 原因:服务端用的是java.util.Date直接返回,序列化时被转成了毫秒时间戳,客户端没做转换。 解决:按第2.3节说的,在服务端统一把日期格式化成字符串。如果非要传Date类型,安卓端拿到毫秒数用SimpleDateFormat手动转也行,但每个页面都要写一遍太不划算。
5.4 下单成功,购物车没清空,重复点按钮生成了两笔订单。现象:用户提交一次订单,数据库里出现两条一模一样的记录。 原因:客户端没有在成功回调里清空CartManager,或者用户连点两次提交按钮,两次POST都成功到达服务端。 解决:成功回调里调用cartManager.clear();提交按钮加一个布尔标志位,请求期间禁用点击,防止重复提交。更稳妥的做法是加按钮倒计时,这个在外卖App里也是常见交互。
5.5 Glide加载图片一直显示默认图。现象:列表加载出来了,但图片全是占位图,服务端上传目录里明明有文件。 原因:最常见的是图片URL是localhost开头的绝对路径,或者服务端存的是反斜杠路径,Windows环境下特别容易踩。 解决:服务端返回相对路径如/upload/xxx.jpg,安卓端在Glide的load方法里拼上baseUrl。另外确认一下服务端有没有配置静态资源映射,Spring Boot里要写addResourceHandlers把upload目录暴露出来,否则浏览器直接访问图片URL也是404。
6. 让它不止于课程设计:接口验证、缓存与面试表达
把这个外卖App当成课程设计做完,和把它当成简历里能讲清楚的项目做完,是两码事。我做过的项目中,最后拉开差距的往往不是功能多,而是边界处理细。
先做一遍接口回归验证。App端水平有限,但接口是用Postman可以逐条验证的。按业务顺序建一个集合:注册登录、菜品列表、加购物车、下单、查订单、更新状态。每一条都确认正常返回和异常返回两种情形。比如下单接口,传空菜品列表时,服务端是否返回了业务错误码而不是500。这个验证表打印出来,你就能在第6章自信地向面试官描述接口的健壮性。
然后是缓存。列表接口每次打开都请求一次,对用户流量和服务端压力都不友好。给OkHttp加一个Cache,设置缓存大小和有效期:
Cache cache = new Cache(context.getCacheDir(), 10 * 1024 * 1024); OkHttpClient client = new OkHttpClient.Builder() .cache(cache) .build();配合服务端的Cache-Control响应头,菜品列表这类低频变更的数据可以做短时缓存,用户二次进入时秒开。注意订单接口千万别缓存,用户下单后要能看到最新状态。
面试时不要复述项目流程,讲两个你做过的设计方案。一个是订单金额处理——为什么用BigDecimal而不是double;另一个是下单接口的事务和幂等设计——怎么防止用户双击产生两笔订单。这两点问的是同一个东西:你是否理解数据准确性和并发下的边界问题。顺着这个方向再往深处讲,就自然过渡到Java面试题里常考的ACID、线程安全、索引设计这些主题上去了。
最后说一个我自己的教训。早年间做类似的外卖项目,全流程跑通后以为万事大吉,结果在演示时断了一次网,App直接崩溃,当场翻车。后来再写App,默认都会给网络请求加一个全局异常捕获,保证断网时弹Toast而不是崩掉。这个习惯让我在后面所有项目里都少挨了不少骂。你要是有空,先把断网、服务器重启、空列表这三种极端情况在这个项目上测一遍,改完你会回来感谢这个建议的。希望帮到你。
本文还有配套的精品资源,点击获取