Featured image of post 【SOLID】SOLID原则项目实践

【SOLID】SOLID原则项目实践

在Unity中,遵循SOLID原则制作一款坦克飞机大战小游戏

教程参考Udemy-Niraj Vishwakarma

素材来源

SOLID原则简介

意大利面条式代码

这种代码指的是一团乱麻,所有行为都高度耦合的代码。比如,在 PlayerController.cs 这个脚本中,玩家的 Move(), Attack(), Jump()…等等行为都写到了一起,此外,还有 PlayAudio 等其他功能。

这样的代码极其脆弱且难以扩展,它就像意大利面一样,由于各个部分都缠绕在一起,你抽取了其中的一根,可能导致多个bug的出现。

解决方案

抛弃意大利面条式的代码结构,改为模块化结构。

在快速制作demo或者小型游戏时可以使用意大利面条式代码以快速验证效果。

但是如果预计将来项目会越来越大,新引入的游戏物体和功能会越来越多,那么事先建构良好的架构和模块化将是非常重要的。

SOLID原则是什么

总的来说它共有五大原则:

  • 单一职责原则 Single Responsibility Principle (SRP)
  • 开闭原则 Open-Closed Principle (OCP)
  • 里氏替换原则 Liskov Substitution Principle (LSP)
  • 接口隔离原则 Interface Segregation Principle (ISP)
  • 依赖倒置原则 Dependency Inversion Principle (DIP)

单一职责原则

基本原则

一个脚本,一个功能。SRP将大类的功能拆分成一个个小类,这些小类拥有独立的功能,可以复用且相互之间没有影响。就像乐高的小块积木一样,你可以用它来搭建各种风格各异的大模型。

好处

当每个脚本都有清晰的职责时,你就能随意替换它而不害怕破坏游戏的其他部分。

开闭原则

基本原则

类/模块/函数,对扩展开放、对修改关闭。

好处

通过创建新的类来添加新功能,从来避免破坏原来类的独立性,以此提升代码的模块化。

如何执行
  1. 为新的功能创建新的类
  2. 创建接口/抽象类,在具体的功能类中去实现接口/抽象类中的方法
  3. 用抽象方法去与不同的功能交互,而不是直接调用它们
  4. 提升多态的应用

里氏替换原则

基本原则

超类/父类物体应该可以被其子类替代而不影响其功能。

好处

允许超类/父类物体可以被其子类替代,而不用直接引用每个子类,这样可以确保系统在引入新的变量时的灵活性和可扩展性。

如何执行
  1. 创建基类并声明所有的方法
  2. 给具有相似行为的物体创建为子类
  3. 在其他需要交互的脚本中,直接引用父类而非子类

接口隔离原则

基本原则

一个类不应该去依赖一个它不会使用的方法。

总结

只写需要的、只执行相关的。

依赖倒置原则

基本原则

高级模块不应该与低级模块耦合,两者都应该依赖于抽象函数。

总结

依赖于抽象函数,而不是具体的实现。

如何执行
  1. 用接口创建抽象函数
  2. 通过具体的类来实现接口
  3. 用接口与具体的类交互

实践项目

我们通过一个2D的坦克飞机大战小游戏来展示在Unity游戏开发中如何实践SOLID原则。

核心玩法

  • 屏幕左侧有两种坦克,一种轻型坦克,玩家可以通过按下“L”来操作它;另一种是重型坦克,通过“H”键来操作
  • 轻型坦克可以左右移动、旋转炮管、发射轻型炮弹
  • 重型坦克不能移动,但是可以旋转炮管、发射重型炮弹
  • 屏幕右侧有可以向左移动的飞机,玩家通过击中它们来增加屏幕上的分数
  • 失败条件:如果飞机撞到坦克,坦克受损失活(无法通过按键操作)。两辆坦克均失活时,玩家失败

游戏/功能拆解

内容

背景图片、坦克(轻型和重型)、子弹(轻型和重型)、飞机、UI(分数、开始界面、游戏界面、结束界面)、音效(开火、飞机被击中、坦克被击中)

逻辑

  1. 游戏开始,按下L键可以激活轻型坦克(移动、旋转、发射炮弹)
  2. 按下H键可以激活重型坦克(旋转、发射炮弹)
  3. 飞机从右往左飞,右侧屏幕飞入、左侧屏幕飞出
  4. 坦克被飞机撞到即失活
  5. 两家坦克都失活,游戏结束

模块整理

开发步骤

  1. 场景摆放
  2. 坦克部分的开发
    • 移动和旋转
    • 开火
    • 实现两种坦克的选择
    • 生命值
  3. 飞机部分的开发
    • 飞机预制体
    • 飞机的生成
    • 生命值
  4. 游戏管理部分开发
    • 配置Game Events
    • AudioManager
    • UIManager
    • GameManager

功能实现及遵循的原则

1. 坦克的移动

在坦克的设计上遵循ISP(接口隔离)和DSP(依赖倒置)。

2. 坦克的开火

在开火时两种弹药的设计上遵循开闭原则(OCP)。我们在每次增加新的种类弹药时应该只做扩展,而不应该对 TankFire.cs 进行修改。在 TankFire.cs 中,应该通过接口而非直接调用相应炮弹的预制体。

此处需要注意的是,由于两种子弹都是经过接口与 TankFire.cs 交互的,但是接口不继承自 MonoBehaviour,无法实例化,所以需要将其隐式转换为 Component 来解决。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class TankFire : MonoBehaviour
{
    [SerializeField] private IProjectile iProjectile;
    [SerializeField] private Component projectileComponent;
    [SerializeField] private Transform firePos;

    private void Start()
    {
        iProjectile = projectileComponent as IProjectile;
    }

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            if (iProjectile != null)
                iProjectile.Fire(firePos);
        }
    }

3. 坦克选择器的实现

在这一部分遵循里氏替换(LSP)的原则进行设计。

如果项目比较小的话不用创建单独的 LightEngine.csHeavyEngine.cs ,全部写在 TankEngine 这个类中也可以。

4. 坦克生命值的实现

遵循依赖倒置原则(DIP),使用一个 IDamageable 的接口来和具体的类交互。这个具体的类可以是飞机、也可以是其他的碰撞物,如子弹、导弹等。

5. 飞机的移动

这部分仿照坦克移动的方式,复用 ImoveUp.cs 的代码,同样采用接口的方式实现,为将来新种类飞机的加入留下空间。

6. 飞机和子弹的自动销毁

我在此处采用统一的脚本 DestroyByTime.cs ,遵循单一职责原则,并未按照课程的方式分别处理。

7. 坦克、子弹、飞机的碰撞检测和生命值联动

这是对第四点的依赖倒置原则的最终实现。由于子弹和飞机都是碰撞后即销毁的情况,所以并没有直接实现 IDamageable接口,在坦克的生命值系统中实现了接口,这样以后即使新增了不同的子弹,对坦克造成不同的伤害,都能轻松实现。在对飞机或者其他类型的敌人中,如果需要设计不同类型的伤害,也可以设计为继承接口的方式灵活实现。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
//========== IDamageable接口 ==========
public interface IDamageable
{
    public void TakeDamage(float damageValue);
}

//================ 坦克的生命值系统 ===============
public class HealthSystem : MonoBehaviour, IDamageable
{
    private float _health = 100f;
    [SerializeField] private Image healthProgressBar;
    [SerializeField] private TankEngineBase currentTankEngine;

    public void TakeDamage(float damageValue)
    {
        _health -= damageValue;
        if (_health <= 0)
        {
            _health = 0;
            currentTankEngine.StopEngine();
        }

        float progressBarValue = _health / 100;
        healthProgressBar.fillAmount = progressBarValue;
    }
}

//================ 子弹生命值 =================
public class ShellHealth : MonoBehaviour
{
    [SerializeField] private float damage = 100f;

    private void OnCollisionEnter2D(Collision2D other)
    {
        IDamageable damageable = other.gameObject.GetComponent<IDamageable>();
        if(damageable != null)
        {
            damageable.TakeDamage(damage);
        }
        Destroy(gameObject);
    }
}

//============= 飞机的生命值 ==============
public class DroneHealth : MonoBehaviour
{
    [SerializeField] private float damage = 20f;
    private void OnCollisionEnter2D(Collision2D other)
    {
        // 通过接口调用TakeDamage
        IDamageable damageable = other.gameObject.GetComponent<IDamageable>();
        if (damageable != null)
        {
            damageable.TakeDamage(damage);
        }
        Destroy(gameObject);
    }
}

8. 用观察者模式对UI、音效、游戏状态进行管理

把下面的事件进行注册,这样通过统一的更新接口,观察者们在事件发生变化的时候可以相应被调用。本质是一个监听和响应系统。同时,这样遵循了单一职责原则,在 AudioManager.csUIManager.cs 等脚本中,只负责播放音效或管理UI的作用。

关于SOLID原则的建议

  • 先想简单的解决方案
  • 永远不要强迫自己使用SOLID原则,只在需要的时候使用(这也是我的体会)
  • 不要过分设计,尤其是你的设计已经超过了项目本身的范围
  • 永远试着使用模块化方式,让每个独立的模块像乐高玩具一样可以被重复使用(这是我在其他课程中学到的最有用的方法之一,真的非常省时省力)
  • 最初在代码设计上使用SOLID原则肯定会多花时间,但是如果是长远的扩展性很多的项目,这些投入肯定是值得的
  • 应用 => 失败 => 学习 => 应用 => 你会得到惊喜!

项目拓展与复盘

拓展

在本项目基础上增加了以下部分:

  • 增加中型坦克
    • 玩家通过按下“M”键来操作
    • 可上下移动,但是移动速度慢于轻型坦克、移动范围小于轻型坦克
  • 根据坦克类型给坦克增加不同的子弹:激光、导弹
    • 轻型坦克:激光(最少cooldown time),给敌人造成伤害最小,同时发射扇形的5束
    • 中型坦克:子弹(中等cooldown time),给敌人造成伤害中等
    • 重型坦克:导弹(cooldown time最长),给敌人造成伤害最大
  • 增加重型飞机
    • 在飞行中有上下随机小范围移动的变化
  • 改变飞机的生成规则,让游戏的难度随时间而增加
    • 游戏开始,重型飞机生成概率较低,随着时间而升高
  • 不同坦克收到伤害程度不同,重型坦克最慢

项目架构总览

项目采用了组件式架构 + 事件驱动 + 策略模式的混合设计。

文件夹结构
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
Assets/
├── Audio/              # 音效资源(射击、爆炸、受伤)
├── Fonts/              # 字体资源
├── OtherResources/     # 第三方美术资源
├── Prefabs/            # 预制体
│   ├── Drone/          # 无人机预制体(Light、Heavy)
│   ├── Projectile/     # 弹药预制体(3种弹药类型)
│   └── Tank/           # 坦克预制体(Light、Medium、Heavy)
├── Scenes/             # 场景文件
├── Scripts/            # 脚本核心
│   ├── Drone/          # 无人机逻辑
│   ├── GameUtil/       # 游戏事件系统
│   ├── Mgr/            # 管理器(Game、Audio、UI)
│   ├── Projectile/     # 弹药逻辑
│   └── Tank/           # 坦克逻辑
│       ├── Engine/     # 引擎/选择系统
│       ├── Fire/       # 射击系统
│       ├── Health/     # 生命系统
│       └── Move/       # 移动系统
└── Sprites/            # 精灵图资源
架构总览
模块 职责 核心类
Tank/Engine 坦克引擎切换系统,玩家可通过 L/M/H 键切换不同类型坦克 TankEngineBaseTankEngineLight/Medium/HeavyTankEngineSelector
Tank/Move 坦克移动系统,不同坦克有不同移动逻辑 TankMoveBaseLightTankMove/MediumTankMove/HeavyTankMoveIMoveYTankRotate
Tank/Fire 射击系统,不同弹药类型通过 IProjectile 接口多态化 TankFireIProjectileLightShell/MediumShell/RocketShell/LaserShellProjectile
Tank/Health 生命系统,使用 IDamageable 接口实现伤害统一接口 HealthSystemIDamageable
Drone 敌方无人机系统,含生成、移动、受击 DroneMoveBaseLightDroneMove/HeavyDroneMoveDroneHealthDroneSpawner
Mgr 全局管理器 GameMgr(游戏流程)、UIMgr(UI面板/计分)、AudioMgr(音效)
GameUtil 全局事件总线 GameEvents(静态事件类)
架构图

代码复用

观察者模式的写法

套路都是一样的,以 AudioManager 为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
//======== 1. 注册事件 =========
//========= GameEvents.cs =========
public static class GameEvents
{
    public static Action OnGameStarted;
    public static Action OnGameOver;
    public static Action OnTankFired;
    public static Action OnTankDamaged;
    public static Action OnDroneDestroy;
}

//========= 2. 在各个所需的地方监听事件 =========
//========= TankFire.cs =========
private void Update()
{
    if (Input.GetKeyDown(KeyCode.Space))
    {
        GameEvents.OnTankFired?.Invoke();
        if (projectile != null)
        {
            projectile.Fire(firePos);
        }

    }
}

//========== HealthSystem.cs ==========
public void TakeDamage(int damageValue)
{
    GameEvents.OnTankDamaged?.Invoke();
    _health -= damageValue;
    if (_health <= 0)
    {
        _health = 0;
        currentTankEngine.StopEngine();
    }

    float progressBarValue = _health / 100;
    healthProgressBar.fillAmount = progressBarValue;
}

//========== DroneHealth.cs ===========
public void TakeDamage(int damageValue)
{
    GameEvents.OnDroneDestroy?.Invoke();    // 飞机被子弹击中才会播放声音
    Destroy(gameObject);
}

//========== 3. 统一在AudioManager中根据监听的结果做出反应 ==========
//========== AudioMgr.cs =========
public class AudioMgr : MonoBehaviour
{
    [SerializeField] private AudioClip droneDestroyClip;
    [SerializeField] private AudioClip tankFiredClip;
    [SerializeField] private AudioClip tankDamagedClip;
    [SerializeField] private AudioSource audiosource;

    private void PlayDroneDetroyAudio()
    {
        audiosource.PlayOneShot(droneDestroyClip);
    }
    
    private void PlayTankFiredAudio()
    {
        audiosource.PlayOneShot(tankFiredClip);
    }
    
    private void PlayTankDamagedAudio()
    {
        audiosource.PlayOneShot(tankDamagedClip);
    }
    
    private void OnEnable()
    {
        SubscribeEvents();
    }

    private void OnDisable()
    {
        UnSubscribeEvents();
    }

    private void SubscribeEvents()
    {
        GameEvents.OnDroneDestroy += PlayDroneDetroyAudio;
        GameEvents.OnTankFired += PlayTankFiredAudio;
        GameEvents.OnTankDamaged += PlayTankDamagedAudio;
    }
    
    private void UnSubscribeEvents()
    {
        GameEvents.OnDroneDestroy -= PlayDroneDetroyAudio;
        GameEvents.OnTankFired -= PlayTankFiredAudio;
        GameEvents.OnTankDamaged -= PlayTankDamagedAudio;
    }
}
事件总线系统 — GameEvents

在任何项目中创建类似的静态事件类,用于模块间解耦通信。只需修改事件名称即可适配不同游戏。

1
2
3
4
5
6
7
8
public static class GameEvents
{
    public static Action OnGameStarted;
    public static Action OnGameOver;
    public static Action OnTankFired;
    public static Action OnTankDamaged;
    public static Action OnDroneDestroy;
}
接口驱动的伤害系统— IDamageable

所有需要受伤的对象(玩家、敌人、可破坏物)都实现此接口。攻击方只需调用 GetComponent<IDamageable>()?.TakeDamage(),完全解耦。

1
2
3
4
public interface IDamageable
{
    void TakeDamage(int damage);
}

项目中SOLID 原则应用分析

S — 单一职责原则 (Single Responsibility Principle)

坦克的移动、射击、旋转、生命被拆分为独立组件,而非放在一个巨大的 “Tank” 类中。这是 SRP 的典型实践。

职责
TankMoveBase 只负责移动逻辑
TankFire 只负责射击触发
TankRotate 只负责旋转瞄准
HealthSystem 只负责生命值管理
AudioMgr 只负责音效播放
UIMgr 只负责UI显示与计分
GameMgr 只负责游戏流程判定
DroneSpawner 只负责无人机生成
DroneHealth 只负责无人机受击逻辑
O — 开闭原则 (Open/Closed Principle)
  • 弹药系统IProjectile 接口使得添加新弹药类型(如 LaserShellProjectileRocketShellProjectile)时,不需要修改 TankFire 的代码,只需新建一个实现 IProjectile 的类
  • 移动系统IMoveY接口 + TankMoveBase允许新增坦克移动方式而不修改基类
  • 无人机移动DroneMoveBase允许通过继承扩展新的飞行行为(如 HeavyDroneMove 的随机偏移飞行)
L — 里氏替换原则 (Liskov Substitution Principle)
  • TankEngineBase的三个子类(TankEngineLightTankEngineMediumTankEngineHeavy)可以在TankEngineSelectorGameMgr中被无差别替换使用
  • GameMgr.CheckEngine() 遍历 List<TankEngineBase>,调用 GetStatus(),不关心具体是哪种引擎
  • DroneMoveBase的子类同理,可以在需要 DroneMoveBase 的地方互换
I — 接口隔离原则 (Interface Segregation Principle)
  • IMoveY:只定义 MoveY(float) 一个方法,不强迫实现者实现不相关的功能(如旋转、射击)
  • IProjectile:只定义 Fire(Transform) 一个方法,每种弹药只需关注"如何发射"
  • IDamageable:只定义 TakeDamage(int) 一个方法,任何可受伤对象实现它即可

接口粒度非常小,每个接口只包含一个方法,完美遵循 ISP。

D — 依赖倒置原则 (Dependency Inversion Principle)
  • DroneHealth中对伤害的处理依赖的是 IDamageable 接口而非具体的 HealthSystem 类:
1
2
IDamageable damageable = other.gameObject.GetComponent<IDamageable>();
damageable?.TakeDamage(damage);
  • TankFire依赖 IProjectile 接口来发射弹药,而非依赖具体的弹药类
  • TankMoveBase通过 this as IMoveY 将自身转为接口调用,高层逻辑依赖抽象而非具体实现

复盘

SOLID原则扩展性强,但是脚本量也是成倍增加。在最后用观察者模式写UI的时候几乎花了一整天,调试一个bug花了一个下午。对于初学者而言,在使用观察者模式的时候,监听代码放置的位置和其他代码的顺序也很重要,是很容易踩的坑。

另外,这是我第一次系统性地学习游戏的架构方式,在很多时候还是感觉比较抽象的,很多时候很难一下子想到。在做这个项目的时候,最直观的感受是项目扩展性确实很好,无需去老的脚本中改,只需要写新脚本新功能就可以了,也不存在新增一个功能多了三个bug的情况,这一点对大型项目或者是网络手游,真的非常重要。希望能随着我能力的不断提高、对优秀的复杂/大型项目见识的增加和个人项目复杂度的不断增加,提高我对架构的认知和实践能力。

Licensed under CC BY-NC-SA 4.0
最后更新于 Feb 22, 2026 11:13 +0800
发表了13篇文章 · 总计9万0千字
本博客已运行
使用 Hugo 构建
主题 StackJimmy 设计