用 SOLID 设计原则开发 Unity 游戏

简介

SOLID是五个软件设计核心基本原则的助记首字母缩写:

  • 单一职责原则(Single responsibility)
  • 开闭原则(Open-closed)
  • 里氏替换原则(Liskov substitution)
  • 接口隔离原则(Interface segregation)
  • 依赖倒置原则(Dependency inversion)

深入理解这些概念可以帮助我们编写更易于理解、灵活且易于维护的代码。

单一职责原则

一个类应该只有一个改变的理由,即它的单一职责。

单一职责原则(SRP)指出每个模块、类或函数应该只负责一件事,并且只封装该部分逻辑。

我们应该用许多较小的组件来组装项目,而不是构建庞大的类。较短的类和方法更容易解释、理解和实现。

当我们创建一个GameObject时,它包含各种较小的组件。例如,它可能包括:

  • 一个MeshFilter,用于存储对3D模型的引用
  • 一个Renderer,用于控制模型表面在屏幕上的显示方式
  • 一个Transform,用于存储缩放、旋转和位置
  • 一个Rigidbody,用于与物理模拟交互
c# 复制代码
public class UnrefactoredPlayer : MonoBehaviour
{
     [SerializeField] private string inputAxisName;
     [SerializeField] private float positionMultiplier;
     private float yPosition;
     private AudioSource bounceSfx;
     private void Start()
     {
         bounceSfx = GetComponent<AudioSource>();
     }
     private void Update()
     {
         float delta = Input.GetAxis(inputAxisName) * Time.deltaTime;
         yPosition = Mathf.Clamp(yPosition + delta, -1, 1);
         transform.position = new Vector3(transform.position.x, yPosition * positionMultiplier, transform.position.z);
     }
     private void OnTriggerEnter(Collider other)
     {
         bounceSfx.Play();
     }
}

这个未重构的Player类包含了多种不同的职责:当玩家与物体发生碰撞时播放声音、管理输入以及处理移动等。尽管目前这个类的代码量还比较少,但随着项目的演进,它会变得越来越难以维护。建议将Player类拆分成几个更小的类。

cs 复制代码
[RequireComponent(typeof(PlayerAudio), typeof(PlayerInput), 
typeof(PlayerMovement))]
public class Player : MonoBehaviour
{
    [SerializeField] private PlayerAudio playerAudio;
    [SerializeField] private PlayerInput playerInput;
    [SerializeField] private PlayerMovement playerMovement;
    private void Start()
    {
        playerAudio = GetComponent<PlayerAudio>();
        playerInput = GetComponent<PlayerInput>();
        playerMovement = GetComponent<PlayerMovement>();
    }
}
public class PlayerAudio : MonoBehaviour
{
 ...
}
public class PlayerInput : MonoBehaviour
{
 ...
}
public class PlayerMovement : MonoBehaviour
{
 ...
}

开闭原则

在软件设计中,有一个叫做"开闭原则"的重要原则。它的意思是说,当我们设计一个类(class)的时候,应该让这个类易于扩展新的功能,但是不需要修改原来的代码。

举个例子,假设我们要写一个计算图形面积的程序。我们可以先写一个AreaCalculator的类,里面有计算矩形面积和圆面积的方法。

要计算矩形的面积,只需要知道矩形的宽和高;要计算圆的面积,只需要知道圆的半径和π的值。

c# 复制代码
public class AreaCalculator
{
    public float GetRectangleArea(Rectangle rectangle)
    {
        return rectangle.width * rectangle.height;
    }
    public float GetCircleArea(Circle circle)
    {
        return circle.radius * circle.radius * Mathf.PI;
    }
}
public class Rectangle
{
    public float width;
    public float height;
}
public class Circle
{
    public float radius;
}

这样写程序没什么问题,但是如果以后要给AreaCalculator添加新的图形,比如五边形、八边形,甚至更多其他图形,就必须在AreaCalculator"里一直加新的方法,让这个类变得越来越臃肿。

另一种方法是写一个"图形"的父类,在里面写一个处理各种图形的方法。但这样的话,方法里面就要写一堆 if else 的判断语句来区分不同的图形,扩展起来也很麻烦。

我们真正想要的,是在不改动"面积计算器"原有代码的情况下,让它能够灵活地支持新的图形。虽然目前的AreaCalculator能用,但它并没有遵守"开闭原则",因为添加新图形的时候不得不修改原来的代码。

实际上,我们可以定义出一个抽象类Shape,然后让Rectangle和Circle去实现,那么后期只需新增新的实现类,不改AreaCalculator的代码就可以拓展功能。

c# 复制代码
public abstract class Shape
{
    public abstract float CalculateArea();
}
c# 复制代码
public class Rectangle : Shape
{
    public float width;
    public float height;
    public override float CalculateArea()
    {
        return width * height;
    }
}
public class Circle : Shape
{
    public float radius;
    public override float CalculateArea()
    {
        return radius * radius * Mathf.PI;
    }
}
public class AreaCalculator
{
    public float GetArea(Shape shape)
    {
        return shape.CalculateArea();
    }
}

这种新的设计方式可以让调试变得更容易。如果新类引入了错误,你不必再去修改"AreaCalculator"的代码。原有的代码保持不变,你只需要检查新代码中是否有错误的逻辑。要充分利用接口和抽象。这有助于避免在代码逻辑中使用冗长的 switch 或 if 语句,因为那样以后会很难扩展。一旦你习惯了按照"开闭原则"来设计类,以后添加新代码就会变得更简单。

里氏替换原则

"里氏替换原则"意思是说子类必须能够替换掉它们的父类,而不会影响程序的正确性。

假设在游戏中需要一个叫做"Vehicle"的类,它将作为其他具体车辆类(Car、Trunk)的父类。在任何使用"Vehicle"类的地方,你都应该能够使用它的子类(Car、Trunk),而不会引起程序出错。

c# 复制代码
public class Vehicle
{
    public float speed = 100;
    public Vector3 direction;
    public void GoForward()
    {
        ...
    }
    public void Reverse()
    {
        ...
    }
    public void TurnRight()
    {
        ...
    }
    public void TurnLeft()
    {
        ...
    }
}

假设我们需要在地图上移动各种车辆。再写一个叫做"Navigator"的类,用来指挥车辆沿着特定的路径行驶。

c# 复制代码
public class Navigator
{
    public void Move(Vehicle vehicle)
    {
        vehicle.GoForward();
        vehicle.TurnLeft();
        vehicle.GoForward();
        vehicle.TurnRight();
        vehicle.GoForward();
    }
}

这个Navigator类的 Move 方法应该可以接受任何Vehicle对象,用它来移动汽车和卡车都没问题。但是,如果你想要再实现一个Train类呢?

"导航器"类中的 TurnLeft 和 TurnRight 方法显然不适用于火车,因为火车不能离开铁轨。如果把一个火车对象传入 Move 方法,程序运行到那几行代码时,会抛出一个未实现的异常,或者什么也不做。

一个类只能继承一个抽象类,但可以实现多个接口。为了满足里氏替换原则,我们用接口来重新设计。

c# 复制代码
public interface ITurnable
{
    public void TurnRight();
    public void TurnLeft();
}
public interface IMovable
{
    public void GoForward();
    public void Reverse();
}
c# 复制代码
public class RoadVehicle : IMovable, ITurnable
{
    public float speed = 100f;
    public float turnSpeed = 5f;
    public virtual void GoForward()
    {
        ...
    }
    public virtual void Reverse()
    {
        ... 
    }
    public virtual void TurnLeft()
    {
        ... 
    }
    public virtual void TurnRight()
    {
        ... 
    }
}
public class RailVehicle : IMovable
{
    public float speed = 100;
    public virtual void GoForward()
    {
        ...
    }
    public virtual void Reverse()
    {
        ...
    }
}
public class Car : RoadVehicle
{
    ...
}
public class Train : RailVehicle
{
    ...
}

通过接口实现来拆分父类,我们解决了子类不能实现父类方法的问题。

接口隔离原则

接口隔离原则简单来说就是子类不能去实现接口中用不到的方法,接口的职责要清晰。

下面的屎山代码就是典型的反例。

c# 复制代码
public interface IUnitStats
{
    public float Health { get; set; }
    public int Defense { get; set; }
    public void Die();
    public void TakeDamage();
    public void RestoreHealth();
    public float MoveSpeed { get; set; }
    public float Acceleration { get; set; }
    public void GoForward();
    public void Reverse();
    public void TurnLeft();
    public void TurnRight();
    public int Strength { get; set; }
    public int Dexterity { get; set; }
    public int Endurance { get; set; }
}

我们来重新设计接口,将原来的大接口拆分为4个接口,具体实现类去实现不同的接口,如下所示。

c# 复制代码
public interface IMovable
{
 public float MoveSpeed { get; set; }
 public float Acceleration { get; set; }
 public void GoForward();
 public void Reverse();
 public void TurnLeft();
 public void TurnRight();
}
public interface IDamageable 
{
 public float Health { get; set; }
 public int Defense { get; set; }
 public void Die();
 public void TakeDamage();
 public void RestoreHealth();
}
public interface IUnitStats 
{
 public int Strength { get; set; }
 public int Dexterity { get; set; }
 public int Endurance { get; set; }
}

public interface IExplodable 
{
 public float Mass { get; set; }
 public float ExplosiveForce { get; set; }
 public float FuseDelay { get; set; }
 public void Explode();
}

public class ExplodingBarrel : MonoBehaviour, IDamageable, IExplodable
{
 ...
}
public class EnemyUnit : MonoBehaviour, IDamageable, IMovable, IUnitStats
{
 ...
}

依赖倒置原则

依赖倒置原则其实就是高层模块不应该直接依赖低层模块,而是通过接口进行设计。 如下图所示,好的设计应该是高内聚,低耦合。

我们来看一个例子,Switch类直接依赖Door。

最大的问题就是扩展性差,如果下次加入了一个新的NewDoor类,Switch类要重新引入依赖,引起不必要的变更。

重构也很简单,我们引入一个 ISwitch 接口,解除Switch类对Door类的依赖。

不难发现,引入接口后,系统更加容易扩展,我们可以加入 Light类、NPC类等等。

c# 复制代码
public interface ISwitchable
{
    public bool IsActive { get; }
    public void Activate();
    public void Deactivate();
}

public class Switch : MonoBehaviour
{
    public ISwitchable client;
    public void Toggle()
    {
        if (client.IsActive)
        {
            client.Deactivate();
        }
        else
        {
            client.Activate();
        }
    }
}

public class Door : MonoBehaviour, ISwitchable
{
    private bool isActive;
    public bool IsActive => isActive;
    public void Activate()
    {
        isActive = true;
        Debug.Log("The door is open.");
    }
    public void Deactivate()
    {
        isActive = false;
        Debug.Log("The door is closed.");
    }
}

当然我们也可以使用抽象类来来支持静态成员变量、常量。

但最大的问题就是一个类只能继承一个抽象类。

对NPC类来说,它实际上应该继承Robot抽象类来复用代码,实现多个不同的接口来保证一定的拓展性。

我们在选用抽象类或者接口要根据自身需求,如下表所示。

相关推荐
重生之绝世牛码2 分钟前
Java设计模式 —— 【结构型模式】外观模式详解
java·大数据·开发语言·设计模式·设计原则·外观模式
小蜗牛慢慢爬行8 分钟前
有关异步场景的 10 大 Spring Boot 面试问题
java·开发语言·网络·spring boot·后端·spring·面试
shinelord明27 分钟前
【再谈设计模式】享元模式~对象共享的优化妙手
开发语言·数据结构·算法·设计模式·软件工程
A小白590830 分钟前
Docker部署实践:构建可扩展的AI图像/视频分析平台 (脱敏版)
后端
goTsHgo37 分钟前
在 Spring Boot 的 MVC 框架中 路径匹配的实现 详解
spring boot·后端·mvc
waicsdn_haha1 小时前
Java/JDK下载、安装及环境配置超详细教程【Windows10、macOS和Linux图文详解】
java·运维·服务器·开发语言·windows·后端·jdk
Q_19284999061 小时前
基于Spring Boot的摄影器材租赁回收系统
java·spring boot·后端
良许Linux1 小时前
0.96寸OLED显示屏详解
linux·服务器·后端·互联网
求知若饥1 小时前
NestJS 项目实战-权限管理系统开发(六)
后端·node.js·nestjs
左羊1 小时前
【代码备忘录】复杂SQL写法案例(一)
后端