Java集合实战:用ArrayList实现外卖系统完整案例

发布时间:2026/10/13 15:55:20
Java集合实战:用ArrayList实现外卖系统完整案例
学完Java里的数组和集合之后很多人会有一种不太说得上来的感觉ArrayList的用法好像都看明白了add、remove、get、set这些方法背得滚瓜烂熟但真要抛开教程去写一个像样的项目脑子又瞬间一片空白。我见过不少初学Java的朋友几乎都卡在这一步刷题能刷懂一落到项目里就不会组织代码。今天想分享的这个综合案例就是用ArrayList一步步去模仿一个外卖系统的核心流程菜单维护、分类筛选、加购物车、下单结算、销量排序。这个案例本身不难但胜在把集合最常用的操作全部串在一条完整业务链路上非常适合刚学完Java基础、想通过项目巩固集合知识的朋友去跟练。为什么偏偏拿外卖系统练手因为外卖平台这类业务动作天然就是为ArrayList“定制”的练习题。用户要浏览菜单、商家要改价格、平台要看销量排行榜每一件事背后都是对一组对象进行增删改查、排序筛选和统计汇总。把这些动作剥开看你会发现它们其实都是ArrayList的基本API在实际场景中的组合运用。这篇文章会把每一个模块的代码、设计思路、容易踩的坑全部拆开讲你可以照着写也可以在这个基础上继续往上加功能。1. 为什么我建议用外卖系统来练ArrayList1.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需要好好想想因为订单和购物车之间关系密切。一个订单包含多道菜每道菜还有数量所以订单内部不应该是ArrayListFood而应该是ArrayListCartItem。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 ArrayListCartItem 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天然符合。购物车合并同款这件事有些人会用HashMapInteger, CartItem按菜品id存也是可行方案但在这个学习案例里我用ArrayList遍历去判断更能练到基本操作也更贴近一个初学阶段该掌握的思路。3. 菜单管理模块把ArrayList的增删改查装进真实业务3.1 初始化菜单与遍历展示我习惯把对菜单的所有操作集中在一个MenuService类里内部持有一个私有集合外部通过方法去操作它。先写一个最简单的初始化public class MenuService { private ArrayListFood 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 ArrayListFood findByCategory(String category) { ArrayListFood result new ArrayList(); for (Food food : menu) { if (category.equals(food.getCategory())) { result.add(food); } } return result; }这个方法会新建一个集合把符合条件的元素放进去原来的菜单完全不受影响。这样调用方可以自由地打印、再筛选、再排序不会有副作用。初学阶段最容易犯的错就是“筛选一个列表就修改原列表”后面排序时还会遇到同样的问题。4. 下单流程购物车如何用ArrayList处理数量和结算4.1 购物车为什么不用ArrayListFood菜单用的是ArrayListFood购物车如果也直接存Food就会遇到一个尴尬用户点了两份宫保鸡丁盖饭购物车列表里是否出现两个一模一样的Food如果出现那么结算时遍历累加确实能算对但商家那边统计销量、做报表时就会很混乱因为你没有“数量”的概念只有“重复的条目”。所以购物车要装CartItem每个条目包含Food和count两个字段。加购时先遍历购物车看看这个菜品是否已经在里面如果已经存在就给对应CartItem的count加1如果不存在就new一个CartItem放进去public class CartService { private ArrayListCartItem 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 ArrayListCartItem getCart() { return cart; } public void clearCart() { cart.clear(); } }这段代码虽然只有几行但它体现了业务逻辑和集合操作的结合先查询再追加或者新增。很多人写到这里会想到“为什么不用contains判断”因为contains需要重写equals而且不能告诉你是哪一个条目所以直接遍历更清晰。4.2 下单结算生成订单、更新销量、清空购物车下单是一个多步骤的组合操作放到OrderService里来做。核心流程是检查购物车非空、创建订单对象、计算总金额、更新菜品销量、把订单加入订单列表、最后清空购物车。public class OrderService { private ArrayListOrder orders new ArrayList(); public Order createOrder(User user, CartService cartService) { ArrayListCartItem 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 ArrayListCartItem(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 ArrayListCartItem(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(); }然后下单时这样复制ArrayListCartItem 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里的顺序。如果只想展示排序后的结果不破坏原有菜单结构最稳妥的做法是先复制一份再排序ArrayListFood temp new ArrayList(menu); temp.sort(Comparator.comparingDouble(Food::getPrice)); printFoodList(temp);复制之后再排序原菜单顺序保持不变。这个习惯和前面筛选返回新集合是同一个思想对数据的“读操作”尽量不要污染源数据。5.2 筛选分类查询与价格区间查询分类查询在菜单管理那里已经写过。价格区间查询的思路也是一样的新建结果集合遍历原集合满足条件的就add进去。这个模式几乎可以复用套用到任何“按条件找出一批对象”的场景。public ArrayListFood findByPriceRange(double min, double max) { ArrayListFood 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) { ArrayListFood 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()IteratorFood 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对象时这个坑特别隐蔽ArrayListInteger 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)。扩容会创建新数组把旧数据全部复制过去。如果你提前知道菜单会有上万条数据可以在构造时指定初始容量减少扩容复制的次数ArrayListFood menu new ArrayList(10000);另外说一句ArrayList不是线程安全的。真实的外卖系统肯定是多用户并发操作的那时候需要加锁或者用CopyOnWriteArrayList这些并发集合。但在这个学习案例里单线程模拟业务流程先把API和设计思想练熟才是重点。说实话这几个坑我在不同项目里都见过不止一次。学集合不能光靠背API你得知道底层怎么动、引用怎么走、异常为什么会抛。把上面这个外卖案例完整写一遍之后你再回头去看之前背过的add和remove会发现它们在你脑子里已经不是孤零零的方法而是一整套解决业务问题的工具。这种“工具感”就是做综合案例最大的收获。