什么是单例模式?
简而言之,单例模式是指一个类,它只允许在任何时候存在一个实例,并且可以被任何其他类静态地访问。
它的目的是解决在项目中查找和访问单实例类的难题。
它可能看起来像这样:
public sealed class PlayerSingletonPlain
{
private static PlayerSingletonPlain _instance;
public static PlayerSingletonPlain Instance
{
get
{
if (_instance == null)
_instance = new PlayerSingletonPlain();
return _instance;
}
}
public int Health { get; set; }
}
或者,如果它是 MonoBehaviour,则看起来像这样:
public sealed class PlayerSingletonMono : MonoBehaviour
{
private static PlayerSingletonMono _instance;
public static PlayerSingletonMono Instance
{
get
{
if (_instance == null)
{
_instance = new GameObject("SingletonMono").AddComponent<PlayerSingletonMono>();
DontDestroyOnLoad(_instance.gameObject);
}
return _instance;
}
}
public int Health { get; set; }
}
以下是从另一个类访问单例的示例:
public sealed class UIHealthBar : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI _healthText;
private void Start()
{
//plain
_healthText.text = PlayerSingletonPlain.Instance.Health.ToString();
//mono
_healthText.text = PlayerSingletonMono.Instance.Health.ToString();
}
}
为什么单例模式又很不好
虽然单例模式是在代码中访问类的快速简便方法,但它也有很多缺点,这些缺点可能会在项目变得更大时演变成架构地狱。
以下列出了单例模式可能不是项目完美解决方案的一些原因:
对单个实例的基本限制
例如,引入多人游戏(即使是像分屏这样的本地游戏)也会破坏项目中所有与玩家相关的单例类的功能,并且过晚解决这种情况将需要进行大规模重构,并重新设计架构。
没有抽象和后期绑定
单例模式不允许使用任何抽象,因为你通过类的直接类型访问它。继承和在运行时更改实现(后期绑定)不是单例模式设计的一部分。
例如,你不能在玩家连接游戏手柄时交换 IPlayerInput 接口的两个不同实现(一个用于键盘+鼠标,另一个用于游戏手柄)。
隐藏的依赖关系
通过静态访问访问依赖关系使得很难仅通过查看类来了解它具有哪些依赖关系,因为它们隐藏在方法的代码深处。
相比之下,存储在字段中的依赖关系在类文件顶部很容易看到。
缺乏生命周期控制
你对单例实例的生命周期几乎没有控制权;它们在第一次需要时创建,并且只在玩家退出游戏时销毁。这可能会为游戏重启或加载保存等情况造成困难,如果单例用于像 Player 这样的游戏内对象。
单元测试
单例模式也使得单元测试变得非常困难,这对于某些情况和项目来说非常重要。
服务定位器替代方案
可以使用另一种模式——服务定位器来解决单例模式的“没有抽象”问题。
服务定位器可能看起来像这样:
public sealed class ServiceLocator
{
private readonly static Dictionary<Type, object> _services = new Dictionary<Type, object>();
public static void Register<T>(T service)
{
_services[typeof(T)] = service;
}
public static T Get<T>()
{
return (T) _services[typeof(T)];
}
}
以下是如何使用它的示例:
public interface IPlayer
{
public int Health { get; set; }
}
public sealed class Player : MonoBehaviour, IPlayer
{
public int Health { get; set; };
private void Awake()
{
ServiceLocator.Register<IPlayer>(this);
}
}
public sealed class UIHealthBar : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI _healthText;
private void Start()
{
_healthText.text = ServiceLocator.Get<IPlayer>().Health.ToString();
}
}
它解决了单例模式的部分问题:
-
抽象和后期绑定是可能的
-
手动生命周期控制是可能的,但它是全局可访问的
-
它解决了单元测试中的一些问题:可以创建模拟实现,但如果你在一个运行中运行多个测试,服务定位器中先前创建的实例仍然存在,你可能需要手动清除它们
还要记住,在访问未创建的服务或由于泛型而访问错误的服务类型时,可能会出现空引用。
依赖注入 (DI) 替代方案
为了解决单例模式的所有问题,可以使用依赖注入 (DI) 方法。
它围绕着建立依赖关系的 组合根 类设计。
以下是一个示例,可以更好地说明这一点:
public sealed class CompositionRoot : MonoBehaviour
{
[SerializeField] private Player _playerPrefab; //玩家预制件
[SerializeField] private UIHealthBar _uiHealthBar; //场景中现有的血条
private void Awake()
{
//创建一个新的玩家
Player player = Instantiate(_playerPrefab);
//将现有的血条初始化为它
_uiHealthBar.Init(player);
}
}
public interface IPlayer
{
public int Health { get; set; }
}
public sealed class Player : MonoBehaviour, IPlayer
{
public int Health { get; set; }
}
public sealed class UIHealthBar : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI _healthText;
private IPlayer _player;
public void Init(IPlayer player)
{
_player = player;
_healthText.text = _player.Health.ToString();
}
}
还要注意,通过 [SerializeField] 字段建立的依赖关系也可以被认为是 DI 注入的。
让我们看看它如何解决一些实际问题
拥有多个 Player 实例
要创建多个 Player 实例,我们只需要修改组合根并调整 UI 以显示多个血条:
public sealed class CompositionRoot : MonoBehaviour
{
[SerializeField] private Player _playerPrefab; //玩家预制件
[SerializeField] private UIHealthBar _uiHealthBarPrefab; //血条预制件
[SerializeField] private RectTransform _uiHealthBarsParent; //血条的父级
private void Awake()
{
//第一个玩家
//创建一个新的玩家
Player player1 = Instantiate(_playerPrefab);
//创建一个新的血条
UIHealthBar uiHealthBar1 = Instantiate(_uiHealthBarPrefab, _uiHealthBarsParent);
uiHealthBar1.Init(player1);
//第二个玩家
//创建一个新的玩家
Player player2 = Instantiate(_playerPrefab);
//创建一个新的血条
UIHealthBar uiHealthBar2 = Instantiate(_uiHealthBarPrefab, _uiHealthBarsParent);
uiHealthBar2.Init(player2);
}
}
管理抽象和后期绑定
现在假设游戏是多人游戏,我们希望第二个玩家是 IPlayer 的不同实现:
public sealed class PlayerMultiplayer : MonoBehaviour, IPlayer
{
public int Health { get; set; }
//... 从网络接收健康的某些多人游戏代码
}
public sealed class CompositionRoot : MonoBehaviour
{
[SerializeField] private Player _playerPrefab; //玩家预制件
[SerializeField] private PlayerMultiplayer _playerMultiplayerPrefab; //多人游戏玩家预制件
[SerializeField] private UIHealthBar _uiHealthBarPrefab; //血条预制件
[SerializeField] private RectTransform _uiHealthBarsParent; //血条的父级
private void Awake()
{
//第一个玩家
//创建一个新的玩家
Player player1 = Instantiate(_playerPrefab);
//创建一个新的血条
UIHealthBar uiHealthBar1 = Instantiate(_uiHealthBarPrefab, _uiHealthBarsParent);
uiHealthBar1.Init(player1);
//第二个玩家
IPlayer player2;
bool isMultiplayer = true; //检查是否是多人游戏
if (isMultiplayer)
{
//如果是多人游戏 - 创建多人游戏玩家
player2 = Instantiate(_playerMultiplayerPrefab);
}
else
{
//如果不是 - 创建默认玩家
player2 = Instantiate(_playerPrefab);
}
//创建一个新的血条
UIHealthBar uiHealthBar2 = Instantiate(_uiHealthBarPrefab, _uiHealthBarsParent);
uiHealthBar2.Init(player2);
}
}
单元测试
使用 DI 构建单元测试环境,创建和传递接口的模拟实现很容易,但请记住,单元测试类没有使用 [SerializeField] 字段的可能性,因此在某些情况下,需要其他方法来获取用于组合的依赖关系。
DI 注入类型
构造函数注入
如果可能,最优选的注入方式:
public sealed class HealthLogger
{
private IPlayer _player;
public HealthLogger(IPlayer player)
{
_player = player;
Debug.Log($"Player health: {_player.Health}");
}
}
Unity 的 MonoBehaviour 不能有构造函数,为了解决这个问题,你可以在 MonoBehaviour 继承者中编写 Init(..) 方法:
public sealed class UIHealthBar : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI _healthText;
private IPlayer _player;
//构造函数的替代
public void Init(IPlayer player)
{
_player = player;
_healthText.text = _player.Health.ToString();
}
}
属性注入
当依赖关系是可选时很有用
public sealed class UIHealthBar : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI _healthText;
public IPlayer Player { get; set; }
private void Update()
{
if (Player != null)
{
_healthText.text = Player.Health.ToString();
_healthText.gameObject.SetActive(true);
}
else
{
_healthText.gameObject.SetActive(false);
}
}
}
方法注入
当依赖关系仅在从其他类调用的特定方法中需要时可以使用
public sealed class UIHealthBar : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI _healthText;
public void UpdateHealth(IPlayer player)
{
_healthText.text = player.Health.ToString();
}
}
DI 框架和容器
有一些著名的开源 DI 框架,其中一些是为 Unity 专门设计的,例如 VContainer、Reflex 和 Zenject/Extenject。
它们不是在项目中实现 DI 所必需的,它们主要只是提供方便的 DI 容器。
DI 容器是一个有用的工具,可以使编写组合根和传递依赖关系变得更容易。它对于大型项目尤其有用,在大型项目中存在许多依赖关系,并且很容易因为手动处理它们而感到沮丧。
请记住,它是一个工具,仍然需要你以正确的方式使用它。
想了解更多游戏开发知识,可以扫描下方二维码,免费领取游戏开发4天训练营课程