ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

动物运动会系统:设计模式大作业实战指南与代码实现

动物运动会系统:设计模式大作业实战指南与代码实现 简介《动物运动会》是一套基于Java语言的综合性设计模式教学项目面向需要完成设计模式大作业、课程设计或希望深入理解软件架构的开发者。整个系统并非零散案例而是将三十一种设计模式整合在同一应用框架内其中包括抽象工厂、适配器等经典实现展示各模式在运动会场景下的协作方式。压缩包共三百三十个文件以一百六十二个Java源文件为主体便于阅读与二次开发另附一百六十三个编译后的类文件、文档设计说明及工程配置文件整体仅一点二八兆字节轻量易用。目前共有一千八百五十九人学习下载内容包含完整源码、UML类图和系统效果说明可帮助理清模式选择、接口设计与扩展思路。对于正在撰写课程报告、准备答辩或自学设计模式的读者这是一份具有完整性和参考价值的实践资料。 最近好几个学弟学妹私信问我设计模式大作业怎么选题我每次都推荐一个非常经典又不容易翻车的方向动物运动会系统。原因很简单这个场景足够生活化类与类之间的协作关系清晰设计模式往上一套既不会显得生搬硬套又有很强的扩展空间——老师看了觉得你动了脑子你自己写起来也不会像在背八股。今天我就把这套系统的完整设计思路、模式落地方案、关键代码写法以及答辩避坑经验一次讲清楚。很多同学拿到“设计模式大作业”这个题目时容易犯一个毛病为了用模式而用模式把代码搞得特别绕。比如一个简单的起床操系统硬是套上装饰器加观察者加桥接最后类数量比功能还多。动物运动会的好处在于它的业务天然就带着“创建”、“策略选择”、“事件通知”、“状态切换”这些设计模式高频场景不需要你强行找角度模式就像螺丝一样自己咬合上去。1. 系统设计先把场景建模想明白1.1 需求拆解与分析动物运动会系统核心需求大致是这么几条有若干种动物参赛比如猫、狗、鸟、鱼有若干种比赛项目比如跑步、游泳、跳高、飞行每场比赛要有开始、进行、结束的过程比赛结束后要产生成绩最好还能排名系统要能灵活地增加新动物或新比赛项目而不是改动大量已有代码。就这几条需求其实已经把模式选型逼出来了。动物种类多、将来可能要扩展新动物这就是典型的“创建对象不直接new”的场景工厂模式上场比赛项目各不相同但流程骨架相同——准备、检录、开跑、计时、公布成绩这就是模板方法模式的最佳示范不同动物在同一个项目中能力不同成绩计算方式不同又可以把成绩策略提取出来用策略模式动态切换比赛成绩出来以后播报系统、计分板、记录系统都要响应这就是观察者模式的主场。一句话总结需求里的“扩展性”和“联动性”是所有模式选型的出发点而不是反过来先选模式再去编需求。1.2 动物与竞赛的核心抽象在设计类结构之前我建议先画一个极简的领域模型。动物这个体系抽象出一个Animal父类是肯定的它至少要有name、species属性以及一个共通的run()或者perform()能力。但这里有个很容易想歪的点动物在比赛时它比赛的方式是跟项目相关的跑步和游泳对同一只动物来说差别很大。如果你直接把run()、swim()全部塞进Animal里那每次新增比赛项目都要改所有动物类开闭原则直接破功。所以更合理的做法是把“比赛能力”从“动物本身”中解耦出来。动物只负责自身基本属性比赛能力单独抽象为接口比如RunBehavior、SwimBehavior每个动物的具体行为类去实现这些接口。这样新增比赛项目时只要新增一个行为接口和对应的实现类完全不需要动到动物的代码。竞赛侧需要一个Race抽象类代表比赛流程比赛的各个阶段作为模板方法固定下来阶段的细节延迟到子类实现。同时还需要一个ScoreStrategy来处理不同项目的计分逻辑——跳高按高度、跑步按耗时、飞行按距离这种差异用策略模式处理非常自然。成绩出来后Race内部持有一组监听器通过观察者模式通知所有关注成绩的对象。2. 设计模式落地方案2.1 简单工厂与工厂方法动物创建先解决最基础的创建问题。如果运动会系统里到处都是new Cat()、new Dog()代码确实也能跑但问题非常明显main方法或者调用方会高度耦合具体类一旦要统一给所有动物加上编号、注册到比赛系统、初始化健康状态就得把所有new的地方全部翻出来改。工厂模式就是把这些创建逻辑收拢到一处。我用的方案是简单工厂AnimalFactory它接收一个字符串或枚举类型内部用switch或者Map返回对应的动物实例。对一个大作业来说简单工厂程度刚刚好代码清晰模式识别的名称也好解释。你还可以再进一步做工厂方法模式也就是让每个动物子类对应一个子工厂专门负责创建该类动物这样做的好处是进一步把创建流程的每一步比如日志记录、装备发放、检录时间锁定都放进子工厂自由定制。补充一个实操细节工厂类内部创建动物时建议把动物初始能力值也一起注入比如Cat默认奔跑速度30km/h、Fish不能跑步只能游泳。这个初始化数据可以用一个配置类存起来工厂从配置里取值这样后续改数值不需要重新编译也更好跟老师解释“我对扩展开放”。2.2 模板方法模式稳定比赛流程骨架这一块是整个系统最值得展开讲的部分。比赛流程看似简单其实有严格时序检录 → 就位 → 开始 → 计时 → 结束 → 成绩统计 → 颁奖。这个流程长度固定、顺序固定但每个环节的具体实现随项目不同而变化。这正是模板方法模式最经典的适用场景。我设计了一个抽象类Race里面用final修饰的runRace()方法定义流程骨架流程中每个步骤调用对应的抽象方法或钩子方法。子类RunRace、SwimRace、HighJumpRace只重写自己关注的那几个步骤。这里有一个容易踩坑的点模板方法不要写得太碎也不要写得太粗。太碎的话子类要重写的方法过多代码冗余太粗的话钩子太少子类之间的流程差异无处安放。我实践下来最佳粒度是把流程拆成6步左右其中prepare()和start()是公共的直接由父类实现doRace()和calculateScore()是每个项目核心差异点必须由子类实现announceResult()在父类里调用观察者广播即可。模板方法模式的关键词理解是“流程复用”答辩时最好能画一个普通的时序图标出哪些是父类的方法、哪些是子类重写的方法老师一般看到这个图就知道你真的懂了。2.3 策略模式灵活切换计分与能力策略策略模式在整个系统里其实出现两次第一次是动物的运动能力第二次是比赛的计分机制。这里我重点说说计分机制。不同比赛项目的排名方式完全不同。跑步和游泳看谁用时最短跳高和跳远看谁高度/远度最大飞行类项目可能还要结合距离和姿态分。如果把这些计分规则硬编码进Race的子类里每个子类的score方法里写一坨if-else虽然也能跑但一旦新增计分规则就得不停修改已经写好的类。策略模式的解法是定义ScoreStrategy接口接口里有double calculateScore(RaceResult result)方法。每种计分规则做成独立策略类比如TimeScoreStrategy、HeightScoreStrategy、LengthScoreStrategy。Race持有的不是一个固定算法而是一个ScoreStrategy引用通过构造函数或setter注入。这样设计最强的地方在于支持运行时切换策略。比如有的赛事突然改规则取消高度上限限制你只需要在代码里换一个新的HeightUnlimitedScoreStrategy完全不影响其他逻辑。答辩时把这个“运行时切换”的演示做出来非常加分。动物能力这一侧的策略选择也有讲究。我在前面提到了Animal组合行为接口其实这就是策略模式结合组合优于继承思想的典型写法。Animal内部持有MoveStrategy接口引用不同动物通过构造函数注入不同的策略实现。比如Bird注入FlyingMoveStrategyFish注入SwimmingMoveStrategy。这比单纯用继承去表达“鸟会飞、鱼会游”要灵活得多因为有些动物是多种技能组合的比如鸭子既能跑又能游泳还能飞到一定高度多继承在Java里不支持但组合多个策略却毫无压力。2.4 观察者模式成绩播报与计分板更新比赛结束后成绩信息要触达多个对象现场播报员要念出成绩大屏幕计分板要刷新数据官方记录系统要存档历史数据可能还有自媒体平台的实时热点更新器。如果让Race在比赛结束点挨个调用这些对象的更新方法代码会耦合到爆炸新增一个信息接收方就要修改Race类。观察者模式在这里作用非常突出。Race内部维护一个ListRaceListener提供registerListener()和unregisterListener()方法比赛结束时通过notifyListeners(RaceResult result)遍历列表调用每个监听器的onRaceFinished(result)方法。播报员、计分板、记录系统各自实现RaceListener接口然后在系统启动时注册到对应的Race对象上。这个模式的扩容体验非常爽。运动会系统上线后老师如果说要加一个“历史最佳成绩比对屏”你只需要写一个BestRecordCompareBoard implements RaceListener两分钟注册进去Race类一行都不改动。观察者模式本质上是把“事件发生源”和“事件响应方”解耦让两个方向的扩展都对各自独立。2.5 单例模式与状态模式的辅助作用设计模式大作业如果想拿高分展示出“模式组合”的意识非常重要。也就是说不要一个个模式孤立使用而是让模式之间产生协作。这里说两个辅助但有存在感的模式。第一个是单例模式。运动会中的比赛场地和总控台是整个系统里逻辑上唯一的存在场地资源不能重复创建总控台保存所有注册信息。我用饿汉式单例实现了VenueManager和EventCenter饿汉式的写最简单线程安全问题天然规避答辩时还能顺手讲出“为什么用饿汉而不是懒汉”这种经典问题。第二个是状态模式。这个我用来处理动物的参赛状态生命周期比如READY待赛、RACING比赛中、FINISHED完赛、DISQUALIFIED取消资格。如果把状态转换逻辑用if-else写在动物类里随着状态增多代码会越来越乱。用状态模式的话Animal内部持有AnimalState引用状态转换调用transitionTo()方法直接切换对象。状态对象本身可以是单例也可以用枚举实现。这个设计对大部分同学来说已经是一个亮点了因为状态模式在所有大作业里的使用频率比工厂观察者低不少老师会眼前一亮。3. 关键代码实现与详解3.1 动物抽象类与行为策略先贴出Animal的基础代码这段对应上面说的“组合优于继承”思路public abstract class Animal { protected String name; protected String species; protected AnimalState state; protected MoveStrategy moveStrategy; public Animal(String name, String species) { this.name name; this.species species; this.state ReadyState.getReadyState(); } public void setMoveStrategy(MoveStrategy moveStrategy) { this.moveStrategy moveStrategy; } public String move() { return moveStrategy.move(this); } public void updateState(AnimalState newState) { this.state newState; } public abstract String introduce(); }这里MoveStrategy是一个接口move()方法接收动物对象并返回一句描述。比如FlyingMoveStrategy返回“XXX扇动翅膀以每小时XX公里的速度飞行”RunningMoveStrategy返回“XXX四腿发力狂奔追赶”。这样就把“行为”从“动物”中抽离了。然后是具体动物类public class Dog extends Animal { public Dog(String name) { super(name, 犬科); this.setMoveStrategy(new RunningMoveStrategy()); } Override public String introduce() { return 我是一只叫 name 的狗擅长奔跑和游泳很有运动精神。; } }工厂类的实现细节我推荐用枚举配合Map做比纯switch可读性高public class AnimalFactory { private static final MapString, SupplierAnimal ANIMAL_CREATORS new HashMap(); static { ANIMAL_CREATORS.put(cat, Cat::new); ANIMAL_CREATORS.put(dog, Dog::new); ANIMAL_CREATORS.put(bird, Bird::new); ANIMAL_CREATORS.put(fish, Fish::new); } public static Animal createAnimal(String type, String name) { SupplierAnimal creator ANIMAL_CREATORS.get(type.toLowerCase()); if (creator null) { throw new IllegalArgumentException(未知的动物类型: type); } Animal animal creator.get(); // 利用反射设置名字或者让Supplier变成二元函数 return animal; } }这里有个细节上面的createAnimal没展示名字注入因为Supplier是无参的。如果动物类构造需要传name我建议把Map改成BiFunctionString, String, Animal或者直接用lambda形态完成名字绑定否则就要在工厂里再处理。这个小瑕疵在答辩时说清楚你的取舍思路反而是加分项。3.2 比赛调度器与模板方法流程比赛部分Race抽象类的模板方法这样写public abstract class Race { protected String raceName; protected ListAnimal participants new ArrayList(); protected ScoreStrategy scoreStrategy; protected ListRaceListener listeners new ArrayList(); public final void runRace() { prepare(); checkIn(); start(); doRace(); finish(); calculateAndAnnounce(); } protected void prepare() { System.out.println(raceName 场地准备工作完成裁判就位。); } protected abstract void checkIn(); protected void start() { System.out.println(所有参赛选手就位发令枪响。); } protected abstract void doRace(); protected void finish() { System.out.println(比赛结束选手减速并确认成绩。); } protected void calculateAndAnnounce() { RaceResult result scoreStrategy.calculateResult(participants); notifyListeners(result); } public void registerListener(RaceListener listener) { listeners.add(listener); } private void notifyListeners(RaceResult result) { for (RaceListener listener : listeners) { listener.onRaceFinished(result); } } }子类实现时只需要关注checkIn()和doRace()。比如SwimRace的doRace()里模拟鱼类和狗类在水中前进用moveStrategy的个性化输出制造比赛过程描述HighJumpRace的doRace()里按不同动物身体参数计算跳跃高度再交给高度计分策略排序。有一个细节值得展开runRace()方法必须加上final关键字。这是模板方法模式的规则之一防止子类重写整个流程从而保证流程骨架不被破坏。这个点老师问到的概率极高要能说明白为什么。3.3 观察者与成绩播报联动当一个运动员冲过终点需要让所有观察者及时响应。核心代码如下public interface RaceListener { void onRaceFinished(RaceResult result); } public class Announcer implements RaceListener { private String name; public Announcer(String name) { this.name name; } Override public void onRaceFinished(RaceResult result) { System.out.println(【解说员 name 】 result.getWinner() 夺得了 result.getRaceName() 的冠军成绩是 result.getChampionScore()); } } public class ScoreBoard implements RaceListener { Override public void onRaceFinished(RaceResult result) { // 把结果写入内存中的排行榜并在大屏渲染 System.out.println(【计分板】正在刷新排行榜……); } }RaceResult里包含比赛名称、全部选手成绩、冠军姓名、夺冠成绩、最后更新时间。这个数据对象格式统一之后所有观察者拿到的都是完整数据之后各自决定怎么处理互不干扰。在main方法里把Announcer和ScoreBoard注册到RunRace对象上再执行runRace()控制台就会依次输出播报和计分板刷新日志整个系统的运行效果非常直观适合录成演示视频。4. 文档写作要点与答辩避坑4.1 文档结构与UML图要求大作业压缩包名带着“文档”两个字说明文档的分量不轻。我给自己的文档定的结构是需求分析、技术选型、总体设计、核心类图、模式分析和应用场景、代码说明、运行效果截图、总结与反思。最后一个“总结与反思”一定要写真实的体会比如“使用模板方法后新增项目的时间从30分钟缩短到5分钟”用具体数据说明模式的价值。UML类图是大作业的重点也是很多同学的痛点。不需要画完整的巨型UML重点画出三类关系继承关系动物抽象类和具体动物、组合关系动物和MoveStrategy、依赖关系Race依赖ScoreStrategy、集合容器和RaceListener。老师看了类图就能确认你的类设计是提前规划过的而不是写着写着为了凑模式硬加出来的。画图工具方面免费的draw.io足够语法上用PlantUML也行。但如果对PlantUML不熟悉我建议直接用draw.io手动画控制细节更精准导出的PNG图片放在文档里也更清晰。类图放在文档第二到三页第一页放项目简介和运行环境。4.2 老师高频提问整理答辩阶段老师通常聚焦三个方向为什么要用这个模式、和另一个模式有什么区别、换一种模式会不会更好。这里把高频问题梳理一下模板方法模式和策略模式有什么区别我的回答思路模板方法关注流程骨架复用一般通过继承实现父类控制流程子类重写步骤策略模式关注算法行为的动态切换通过组合委托实现对象可以随时更换算法。简单说模板方法是“流程复用”策略模式是“算法互换”。简单工厂和工厂方法有什么区别回答思路前者创建逻辑集中在一个工厂类通过参数区分产品类型后者把创建动作延迟到子类工厂每个工厂对应一种产品新增产品时开一个子工厂即可。大作业规模用简单工厂足够但能讲出什么时候该升级为工厂方法就行。观察者模式中被观察者持有观察者列表会不会内存泄漏这个问题如果被问到属于加问题。可以答如果观察者长时间不用且没有移除确实会导致对象无法被GC回收。所以我在系统中提供了unregisterListener()方法在广播系统关闭时手动解绑。这个回答能展示你对内存管理的意识很加分。另外建议在答辩前准备一个“模式重构对比”的小实验同样的功能先写一版不用设计模式的代码再写一版用了模式的对比增量开发时改动的行数差异。比如增加一种新比赛项目普通代码要改多少行用模板方法模式要改多少行用数据说话老师绝对认可。5. 常见问题与排查技巧我在开发和指导学生开发这套系统的过程中总结了一些特别常见的问题列成速查表供大家直接对照排查。问题现象原因分析解决方式编译报错“类Race的runRace无法从外部调用”可能把模板方法写在非抽象父类中且子类里误重写了流程方法检查runRace方法定义在抽象类中并加final子类只重写步骤方法运行结果裁判播报全部在其它比赛之前输出观察者列表没有在正确的比赛对象上注册或runRace的notifyListeners没放在最后一步检查registerListener是否注册到了同一个Race实例确认计算成绩后再广播新增动物类型后工厂处报空指针Map的key没配对或代码中用了未注册的类型字符串在createAnimal中增加判空逻辑抛出明确的IllegalArgumentException附带合法类型列表状态转换错乱如直接调用处于RACING状态的动物参赛状态模式下状态对象没有校验转换合法性在状态的transitionTo方法中加入合法性判断非法转换直接拒绝并输出提示程序能跑但看不到任何“运动会”的感觉输出内容太干瘪只有成绩没有过程描述结合各动物MoveStrategy的描述文本模拟比赛解说过程让输出富有故事性UML图与代码不一致画图是纯手工代码写完了没同步更新先用代码完成主体再倒推画类图保证图是代码的真实映射压缩包解压后发现缺少文档打包时文档放在out目录没被一起打包提交前一定要重新解压测试一遍确认zip里有代码、README、运行截图、UML图还有一个非常实用的调试技巧在开发阶段给Race的每个步骤加一行调试日志不然你看到异常时很难定位是哪一步出的问题。等全部调通后再统一把调试日志关掉或者降级为注释保持正式输出的清爽。6. 从大作业到能力提升的一点延伸做完这套系统之后我的感受是这个大作业的最大价值不在于“为了交差”而在于把一个一个孤立的设计模式变成可以互相协作、解决真实问题的工具链。你会在写代码的过程中慢慢产生模式直觉比如当你发现一个类的职责越来越多时会下意识地想到是否该拆分策略了当你发现新增功能要改旧代码时会立刻警惕是否违背了开闭原则。这种本能的建立比背会23种模式的定义重要得多。最后再分享一个小技巧把所有模式的应用场景和代码位置做成一个索引表放在文档最后。这个索引表包括模式名称、使用位置、解决的问题、核心类名四列即可。答辩时教授问到哪里你都可以迅速定位展示出你对项目的掌控力。这个细节很小但在很多答辩现场都是区分“背代码”和“懂设计”的关键信号。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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