☰
Java集合实战:用ArrayList实现外卖系统完整案例
2026/10/13 15:55:15 网站建设 项目流程

学完Java里的数组和集合之后,很多人会有一种不太说得上来的感觉:ArrayList的用法好像都看明白了,add、remove、get、set这些方法背得滚瓜烂熟,但真要抛开教程去写一个像样的项目,脑子又瞬间一片空白。我见过不少初学Java的朋友几乎都卡在这一步,刷题能刷懂,一落到项目里就不会组织代码。今天想分享的这个综合案例,就是用ArrayList一步步去模仿一个外卖系统的核心流程:菜单维护、分类筛选、加购物车、下单结算、销量排序。这个案例本身不难,但胜在把集合最常用的操作全部串在一条完整业务链路上,非常适合刚学完Java基础、想通过项目巩固集合知识的朋友去跟练。

为什么偏偏拿外卖系统练手?因为外卖平台这类业务动作,天然就是为ArrayList“定制”的练习题。用户要浏览菜单、商家要改价格、平台要看销量排行榜,每一件事背后都是对一组对象进行增删改查、排序筛选和统计汇总。把这些动作剥开看,你会发现它们其实都是ArrayList的基本API在实际场景中的组合运用。这篇文章会把每一个模块的代码、设计思路、容易踩的坑全部拆开讲,你可以照着写,也可以在这个基础上继续往上加功能。

1. 为什么我建议用外卖系统来练ArrayList

1.1 外卖业务里的每一步,都踩在集合操作上

先看一个外卖系统最核心的链路:用户打开App看到菜单,选择菜品加入购物车,结算下单,商家收到订单后维护菜品、查看销量。这里面每个动作对应到代码,都是集合操作。我把最常用的映射关系列出来,你在写代码的时候可以对照着看:

业务动作对应的集合操作主要ArrayList API
展示菜单遍历所有菜品forEach / 增强for
搜索某道菜遍历并判断id或名称get / size / 条件判断
商家新增菜品在菜单末尾添加元素add(E e)
商家下架菜品按id或索引删除某个元素remove(int index) / remove(Object o)
修改价格找到目标元素后更新字段set / get / 索引遍历
按分类筛选遍历后收集满足条件的元素新建list + add
购物车合并同款遍历判断已有对象,有则加数量contains / 自定义遍历
下单结算遍历购物车累加金额增强for + 累加
按销量排序对集合内对象排序sort(Comparator)

这张表基本就是整个项目的功能地图。你会发现没有哪个地方需要多高深的技术,难点在于怎么把业务需求翻译成集合操作。很多新手写项目时思路散掉,本质上是这一步没做:没有把业务动作和API映射清楚。

1.2 为什么传统练手题不够用

以前大家练集合,最常见的是学生管理系统、图书管理系统。不是说这些案例不好,而是它们的业务动作太单一了——无非就是往列表里塞对象、遍历打印、按学号找对象。写完之后你确实练了API,但练完还是不太清楚集合在真实系统里是如何配合多个类共同工作的。

外卖系统的差别在哪?它里面有“状态”和“约束”。购物车不会允许你把同一道菜添加两遍,而是合并成一份数量2;下单之后菜单里这道菜的销量要增加;商家排序菜单时不能把原始数据顺序弄乱;订单生成后即使商品改价,历史订单金额也不能跟着变。这些约束逼着你去思考:数据放在哪个集合里、什么时候应该复制集合、什么时候不能直接引用原对象。这种思考,才是写项目真正要练的东西。

2. 先搭骨架:三条核心类与一份服务层的职责划分

2.1 需求拆分:先给外卖系统画一张功能清单

动手写代码之前,我习惯先把需求切成小块。你不需要一开始就设计出完美的架构,但至少得清楚这个系统要支持谁、做什么。我们把外卖系统简化成两个角色:用户和商家。

用户端的核心功能是:查看菜单、按分类筛选、把菜品加入购物车、提交订单并查看订单信息。商家端的核心功能是:维护菜品(增删改查)、查看订单、按价格或销量排序菜单、统计数据。整体功能一列,类就出来了:需要描述菜品、用户、订单、购物车条目,然后需要几个服务类分别负责菜单操作和订单操作。

很多人写这种案例时会犯一个毛病,把所有代码堆在Main里,一个main方法写几百行。这确实也能跑,但你写完之后没办法体会“对象各司其职”的价值。我的建议是至少拆成三个包:实体类、服务类、入口类。实体类只负责描述数据,服务类负责对数据的操作,入口类只做菜单交互和逻辑组装。

2.2 实体类的代码实现:Food、User、Order、CartItem

实体类是系统的“积木”。菜品是最核心的一个对象,我把常用字段设计成下面这样:

public class Food { private int id; // 菜品编号 private String name; // 菜品名称 private double price; // 单价(元) private int sales; // 累计销量 private String category; // 分类:热销、凉菜、饮品等 public Food() {} public Food(int id, String name, double price, int sales, String category) { this.id = id; this.name = name; this.price = price; this.sales = sales; this.category = category; } // 这里省略了所有getter和setter,实际写代码时用IDE生成即可 }

User比较简单,通常就是id、用户名、电话、地址。Order需要好好想想,因为订单和购物车之间关系密切。一个订单包含多道菜,每道菜还有数量,所以订单内部不应该是ArrayList<Food>,而应该是ArrayList<CartItem>。CartItem的作用是给Food套一层数量外壳:

public class CartItem { private Food food; private int count; public CartItem(Food food, int count) { this.food = food; this.count = count; } public void addCount(int num) { this.count += num; } // getter / setter }

Order类需要记录订单号、所属用户、菜品条目、总金额、状态和时间。金额计算放在Order内部比较合适,因为这是订单自身的行为:

public class Order { private String orderId; // 订单号 private int userId; // 下单用户id private ArrayList<CartItem> items; // 菜品列表(含数量) private double totalPrice; // 订单总金额 private String status; // 待支付、已支付、已送达、已取消 private String createTime; // 下单时间 public double calcTotal() { double sum = 0; for (CartItem item : items) { sum += item.getFood().getPrice() * item.getCount(); } this.totalPrice = sum; return sum; } // getter / setter }

2.3 为什么选ArrayList而不是数组或HashMap

很多人在这一步会问:用数组不行吗?用HashMap不也行吗?我逐个说下我的理解。

数组最大的问题是定长。外卖菜单今天10道菜,明天可能加1道变11道,后天可能下架2道变9道。数组存不下就报错,存多了浪费空间。最关键的是,从数组中间删除一个元素,你得手动把后面的元素全部往前挪,这个逻辑写起来烦、还容易出错。ArrayList把这些都封装好了,add和remove足够应付。

HashMap适合的场景是“按key查value”。如果我想按菜名快速找到菜品,HashMap确实很合适,但外卖菜单要做的往往不是单点查找,而是“按顺序展示全部菜品”“按价格排序”“按分类筛选”。这些操作都需要一个有序、可以重复、能整体遍历的集合,ArrayList天然符合。购物车合并同款这件事,有些人会用HashMap<Integer, CartItem>按菜品id存,也是可行方案,但在这个学习案例里,我用ArrayList+遍历去判断,更能练到基本操作,也更贴近一个初学阶段该掌握的思路。

3. 菜单管理模块:把ArrayList的增删改查装进真实业务

3.1 初始化菜单与遍历展示

我习惯把对菜单的所有操作集中在一个MenuService类里,内部持有一个私有集合,外部通过方法去操作它。先写一个最简单的初始化:

public class MenuService { private ArrayList<Food> menu = new ArrayList<>(); public void initData() { menu.add(new Food(1, "宫保鸡丁盖饭", 18.5, 120, "热销")); menu.add(new Food(2, "鱼香肉丝盖饭", 16.0, 98, "热销")); menu.add(new Food(3, "凉拌黄瓜", 8.5, 45, "凉菜")); menu.add(new Food(4, "酸梅汤", 5.0, 200, "饮品")); } public void printMenu() { System.out.println("编号\t名称\t价格\t销量\t分类"); for (Food food : menu) { System.out.println(food.getId() + "\t" + food.getName() + "\t" + food.getPrice() + "\t" + food.getSales() + "\t" + food.getCategory()); } } }

这里有一个细节:增强for遍历ArrayList,顺序是稳定的,就是你add进去的顺序。这对菜单展示非常重要,用户看到的菜品顺序不会乱跳。ArrayList底层是数组,所以遍历时是按照索引从0一路取到最后一个,这一点和LinkedList的遍历方式有区别,LinkedList挨个节点往下走,但在这个规模的数据量下差别完全感知不到。

3.2 添加菜品与删除菜品:按对象删还是按索引删

add方法很简单,直接调用集合的add就能把新菜品放到末尾:

public boolean addFood(Food food) { return menu.add(food); }

delete这一步就值得注意了。如果你直接写menu.remove(food),能删掉的前提是Food类重写了equals方法,否则remove底层比较的是对象地址,哪怕你有两个字段一模一样的Food对象,也删不掉。所以在业务代码里,更常见的是按id删除:先遍历找到索引位置,再调用remove(int index)。

public boolean removeFoodById(int id) { for (int i = 0; i < menu.size(); i++) { if (menu.get(i).getId() == id) { menu.remove(i); return true; } } return false; }

注意这里删除之后立即return,避免后面元素前移之后索引错乱。如果你用for循环连续删除匹配元素,还容易出现越界问题。如果场景是“删除所有满足条件的产品”,用Iterator或者removeIf更安全,这个坑我在后面专门说。

3.3 修改菜品:先找到位置再动手

修改价格这类操作,本质上就是“定位+更新”。定位可以用索引遍历,也可以用增强for拿到对象直接set。我建议用带索引的for循环,因为你可能同时需要知道这个对象在集合中的位置,后续要做批量操作时更方便。

public boolean updatePrice(int id, double newPrice) { if (newPrice <= 0) { System.out.println("价格必须大于0"); return false; } for (int i = 0; i < menu.size(); i++) { if (menu.get(i).getId() == id) { menu.get(i).setPrice(newPrice); return true; } } return false; }

这个业务校验看起来很基础,但很重要。真实场景里,你永远不会允许商家把价格改成负数。类似的校验应该处处有:删除不存在的菜品、添加重复id的菜品、购物车为空时下单,等等。写这些校验还有个好处,就是逼迫你去遍历集合做判断,等于把ArrayList的查询操作反复练到位了。

再补充一个查询扩展:按分类筛选菜品。这里有个良好的习惯——筛选结果返回一个新的ArrayList,而不是把原菜单里的对象搬走或删除:

public ArrayList<Food> findByCategory(String category) { ArrayList<Food> result = new ArrayList<>(); for (Food food : menu) { if (category.equals(food.getCategory())) { result.add(food); } } return result; }

这个方法会新建一个集合,把符合条件的元素放进去,原来的菜单完全不受影响。这样调用方可以自由地打印、再筛选、再排序,不会有副作用。初学阶段最容易犯的错就是“筛选一个列表就修改原列表”,后面排序时还会遇到同样的问题。

4. 下单流程:购物车如何用ArrayList处理数量和结算

4.1 购物车为什么不用ArrayList<Food>

菜单用的是ArrayList<Food>,购物车如果也直接存Food,就会遇到一个尴尬:用户点了两份宫保鸡丁盖饭,购物车列表里是否出现两个一模一样的Food?如果出现,那么结算时遍历累加确实能算对,但商家那边统计销量、做报表时就会很混乱,因为你没有“数量”的概念,只有“重复的条目”。

所以购物车要装CartItem,每个条目包含Food和count两个字段。加购时先遍历购物车,看看这个菜品是否已经在里面,如果已经存在就给对应CartItem的count加1,如果不存在就new一个CartItem放进去:

public class CartService { private ArrayList<CartItem> cart = new ArrayList<>(); public void addToCart(Food food, int num) { for (CartItem item : cart) { if (item.getFood().getId() == food.getId()) { item.addCount(num); return; } } cart.add(new CartItem(food, num)); } public ArrayList<CartItem> getCart() { return cart; } public void clearCart() { cart.clear(); } }

这段代码虽然只有几行,但它体现了业务逻辑和集合操作的结合:先查询再追加或者新增。很多人写到这里会想到“为什么不用contains判断”,因为contains需要重写equals,而且不能告诉你是哪一个条目,所以直接遍历更清晰。

4.2 下单结算:生成订单、更新销量、清空购物车

下单是一个多步骤的组合操作,放到OrderService里来做。核心流程是:检查购物车非空、创建订单对象、计算总金额、更新菜品销量、把订单加入订单列表、最后清空购物车。

public class OrderService { private ArrayList<Order> orders = new ArrayList<>(); public Order createOrder(User user, CartService cartService) { ArrayList<CartItem> cart = cartService.getCart(); if (cart == null || cart.isEmpty()) { System.out.println("购物车是空的,无法下单"); return null; } Order order = new Order(); order.setOrderId("D" + System.currentTimeMillis()); order.setUserId(user.getId()); order.setItems(new ArrayList<CartItem>(cart)); order.setStatus("待支付"); order.setCreateTime("2025-01-01 12:30:00"); // 计算金额并更新销量 order.calcTotal(); for (CartItem item : cart) { Food food = item.getFood(); food.setSales(food.getSales() + item.getCount()); } // 订单保存后清空购物车 orders.add(order); cartService.clearCart(); return order; } }

new ArrayList<CartItem>(cart)这一行值得好好解释。如果不复制,直接把cart这个引用交给order,那后面的clearCart会把购物车清空,订单里的items也会被同步清空,因为指向的是同一个ArrayList对象。复制一份能保证订单数据独立。但这只是浅拷贝,CartItem本身还是代理的对象,下面会专门讲这个坑怎么彻底解决。

4.3 “复制”购物车背后的深层含义

如果你把上面的代码跑一遍,会发现一个隐蔽的问题:购物车里的CartItem和订单里的CartItem是同一个对象。下单后清空购物车用的clearCart只会把列表清空,CartItem对象还留在订单里,这没问题。但如果你之后修改购物车里的某个CartItem数量,订单里对应的CartItem也会变,因为引用是同一份。

更麻烦的是Food对象的共享。购物车和菜单里存的是同一个Food引用,订单里的金额计算依赖Food的价格。商家后续改了菜单价格,而订单如果再次调用calcTotal,金额就变了。真实项目里,订单必须是一个快照:下单那一刻是什么价格,就得永远是什么价格。

解决思路是给CartItem写一个copy构造器,复制Food对象而不是共用引用:

public CartItem(CartItem source) { this.food = new Food(); this.food.setId(source.getFood().getId()); this.food.setName(source.getFood().getName()); this.food.setPrice(source.getFood().getPrice()); this.food.setSales(source.getFood().getSales()); this.food.setCategory(source.getFood().getCategory()); this.count = source.getCount(); }

然后下单时这样复制:

ArrayList<CartItem> snapshot = new ArrayList<>(); for (CartItem item : cart) { snapshot.add(new CartItem(item)); } order.setItems(snapshot);

这种“新对象要么独立创建、要么深拷贝”的思路,是写业务系统时非常核心的意识。初学者可能觉得多此一举,但真实系统里因为引用共享导致的线上事故,我听过不止一次。

5. 商家数据面板:排序、筛选与统计三件套实战

5.1 多条件排序:sort与Comparator的配合

商家后台经常要把菜单按价格、销量排序展示。ArrayList自带sort方法,配合Comparator可以一行搞定。按价格升序:

menu.sort(Comparator.comparingDouble(Food::getPrice));

按销量降序:

menu.sort((f1, f2) -> f2.getSales() - f1.getSales());

Lambda写法简洁但不太直观,我第一次学的时候总是记不住升降序到底谁减谁。你可以这样理解:Comparator的compare方法返回负数表示第一个参数排在前面,所以f2.getSales() - f1.getSales()意味着销量大的在前,就是降序。

多条件排序用thenComparing链式拼接。比如先按分类排序,同一分类里再按销量降序:

menu.sort(Comparator .comparing(Food::getCategory) .thenComparing(Comparator.comparingInt(Food::getSales).reversed()));

注意sort是原地排序,它会改掉menu里的顺序。如果只想展示排序后的结果,不破坏原有菜单结构,最稳妥的做法是先复制一份再排序:

ArrayList<Food> temp = new ArrayList<>(menu); temp.sort(Comparator.comparingDouble(Food::getPrice)); printFoodList(temp);

复制之后再排序,原菜单顺序保持不变。这个习惯和前面筛选返回新集合是同一个思想:对数据的“读操作”,尽量不要污染源数据。

5.2 筛选:分类查询与价格区间查询

分类查询在菜单管理那里已经写过。价格区间查询的思路也是一样的:新建结果集合,遍历原集合,满足条件的就add进去。这个模式几乎可以复用套用到任何“按条件找出一批对象”的场景。

public ArrayList<Food> findByPriceRange(double min, double max) { ArrayList<Food> result = new ArrayList<>(); for (Food food : menu) { if (food.getPrice() >= min && food.getPrice() <= max) { result.add(food); } } return result; }

如果你已经掌握了这个套路,后面想加一个“按名称模糊搜索”,也就是把条件改成food.getName().contains(keyword),方法结构完全不用变。很多人觉得写业务代码难,其实业务代码的骨架就是“遍历集合+条件判断+结果收集”,难点只是条件越写越复杂而已。

5.3 统计:销售总额与热销榜单

最后是商家数字面板。统计总销售额最直接的方式是遍历订单列表,把每一单的totalPrice累加起来:

public double getTotalIncome() { double total = 0; for (Order order : orders) { total += order.getTotalPrice(); } return total; }

热销榜则是先复制菜单,再按销量降序排序,取前N个。这里有一个细节:取前3个前要先判断集合长度是否小于3,否则会越界:

public void printHotRank(int topN) { ArrayList<Food> temp = new ArrayList<>(menu); temp.sort((f1, f2) -> f2.getSales() - f1.getSales()); int len = Math.min(topN, temp.size()); for (int i = 0; i < len; i++) { Food food = temp.get(i); System.out.println("第" + (i + 1) + "名:" + food.getName() + ",销量" + food.getSales()); } }

这些统计逻辑本质上都是遍历,练不到什么高深算法,但却是ArrayList最常见的生产级用法。把这几段代码写熟,你之后再看那些“统计订单金额”“商品销量榜”的需求,脑子里会立刻浮现出for循环和累加器的影子。

6. 做完这个案例,我反复踩过的四个ArrayList深坑

6.1 并发修改异常:为什么for-each里不能删

初学的时候,很多人想当然地写下这种代码:

for (Food food : menu) { if (food.getId() == 3) { menu.remove(food); } }

一运行直接抛java.util.ConcurrentModificationException。原因是增强for底层其实是用迭代器在遍历,迭代器内部维护了一个expectedModCount,你一调用menu.remove,集合的modCount就变了,两者对不上就立刻报错。这不是说“碰巧报错”,而是Java集合框架故意设计的快速失败机制。

正确做法有三种。一是用Iterator显式遍历并调用it.remove():

Iterator<Food> it = menu.iterator(); while (it.hasNext()) { Food f = it.next(); if (f.getId() == 3) { it.remove(); } }

二是从后往前倒着删,这样删掉后面的元素不会影响前面还没遍历的索引:

for (int i = menu.size() - 1; i >= 0; i--) { if (menu.get(i).getId() == 3) { menu.remove(i); } }

三是用Java 8的removeIf,一行解决:

menu.removeIf(f -> f.getId() == 3);

我个人最推荐removeIf,语义最清晰,也没有循环引用的心智负担。

6.2 remove的重载陷阱:到底删的是索引还是对象

ArrayList的remove有两个重载:remove(int index)和remove(Object o)。当集合里存的是Integer对象时,这个坑特别隐蔽:

ArrayList<Integer> list = new ArrayList<>(); list.add(10); list.add(20); list.add(30); list.remove(1); // 删除索引1位置的元素,结果是20被删了 list.remove(30); // 报错:IndexOutOfBoundsException list.remove(Integer.valueOf(30)); // 删除值为30的元素

为什么list.remove(30)会报错?因为30被自动装箱成了索引?不对,remove(int)优先匹配,所以它把30当成索引位置,而集合根本没有第30个元素。想让代码按“删除值为30的对象”执行,必须显式包成Integer对象,或者直接用removeIf。这个坑在写购物车删除功能时极其常见,尤其是商品id是int时,很多人以为在remove(id)是在删对象,其实删的是索引。

6.3 引用共享问题:订单里的价格为什么会“变”

前面第四章留了一个伏笔,这里必须展开。假设你没有做深拷贝,订单items里的CartItem和购物车共享同一个Food对象。下单后商家把菜单价格从18.5改成20,订单里那份宫保鸡丁的Food也变成20。如果用户再次查看订单,系统重新算了一遍金额,价格就是错的。

我写第一版外卖案例时就掉进过这个坑,而且当时还觉得很奇怪:明明下单的时候订单金额是37元,怎么过了两天变成40元了?查了半天才发现是引用共享导致的。这个问题在培训教程里经常一笔带过,但实际操作中非常致命。解决方案就是我在4.3里写的快照模式:订单里保存一份独立复制的菜品数据,并且计算完金额后就固定住,不要再用实时价格反查。

6.4 对扩容机制有个基本认知就够了

ArrayList底层是Object[]数组,默认初始容量是10,每次不够用就扩容到原来的1.5倍,也就是oldCapacity + (oldCapacity >> 1)。扩容会创建新数组,把旧数据全部复制过去。如果你提前知道菜单会有上万条数据,可以在构造时指定初始容量,减少扩容复制的次数:

ArrayList<Food> menu = new ArrayList<>(10000);

另外说一句,ArrayList不是线程安全的。真实的外卖系统肯定是多用户并发操作的,那时候需要加锁或者用CopyOnWriteArrayList这些并发集合。但在这个学习案例里,单线程模拟业务流程,先把API和设计思想练熟才是重点。

说实话,这几个坑我在不同项目里都见过不止一次。学集合不能光靠背API,你得知道底层怎么动、引用怎么走、异常为什么会抛。把上面这个外卖案例完整写一遍之后,你再回头去看之前背过的add和remove,会发现它们在你脑子里已经不是孤零零的方法,而是一整套解决业务问题的工具。这种“工具感”,就是做综合案例最大的收获。

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

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

立即咨询