单例专精(三)单例的最佳实践
单例的特性就是有风险的同时带来了便利
单例的特性
单例有两个特性:
- 保证一个类仅有一个实例。
- 提供一个全局访问的访问点(Instance)
从特性我们可以总结出:
- 单例有生命周期,有状态(内部的数据),因为单例是对象实例。
- 单例可以在全局的任何一个地方获取。
风险也有两点:
- 如果是懒加载(第一次访问时创建实例)的单例,则看到单例调用时,不知道哪里做了初始化。
- 任意位置可以访问,项目规模一旦增大就会容易造成混乱。
全局访问的风险
生命周期不确定的风险与全局访问的风险相比,后者造成的危害是慢性的,也是相对严重的。
我们来展示单例的一般使用:
1 | namespace FrameworkDesign2021.BestSingleton |
以上代码,就是游戏管理器,实现了一些方法。
使用的代码如下:
1 | GameManager.Instance.Play(); |
这样使用是常见的使用方式,但是风险在哪里呢?
风险在GameManager.Instance中的Instance属性。
在使用中,我们拿到了完整的GameManager对象。那么关于这个对现象的一切,我们都是有办法获取的。
我们再看一下以下的代码
1 | GameManager.Instance.Score += 5; |
Instance暴露太多了,而且以上的三行代码非常可能是顺序相关的。
例如,我们调整代码的使用顺序,如下:
1 | GameManager.Instance.Score += 5; |
这段代码的结果可能与之前的结果不一样。
我们需要尽量控制API的暴露,不可以全部暴露出来,甚至Instance这个东西,笔者也不希望暴露出来
1 | namespace FrameworkDesign2021.BestSingleton |
GameManager发生了一下改变:
- 单例的实现使用MonoSingletonProperty实现的,并且是private访问权限。
- Score、Health改成私有的权限了。
- 对外开放的API都是静态的,比如Play、Pause、Resume、GameOver
- Play增加了两个参数
使用方式如下:
1 | GameManager.Play(5,5);//三句合一 |
这样就可以避免,调用顺序不对的问题了,而且我们非常严格地控制了API的暴露,从而减缓了全局访问的风险。
当前的GameManager结构如下。
GameManager(Instance)
Instance ——> Play
——>Pause
——>Resume
这就是单例的最佳实践,实际上QFramework的UIKit则使用的就是此结构
1 | UIKit.OpenPanel<UIMainPanel>(); |
生命周期不稳定的风险
懒加载会在第一次访问的时候创建实例。此时会出现一些问题。
比如同一帧做了大量的资源加载操作(在单例的初始化时),造成大量的GC。
避免这种问题的办法非常简单,就是控制单例的初始化时机。比如,在GameManager类中加上Init方法
1 | namespace FrameworkDesign2021.BestSingleton |
这样我们就可以在合适的时机,主动去初始化这个单例。比如进入游戏场景前,或者项目初始化时,代码如下:
1 | GameManager.Init(); |
一定要记住这个SingletonKit内的单例都是懒加载(不管是普通单例还是Mono单例),也就是Mono单例的创建不依赖Awake和Start这种生命周期函数,所以要用这种方式。
小结
关于单例的使用:
- 严格控制对外暴露的API
- 严格控制单例的使用范围(下一篇介绍)
- 严格控制单例对象的生命周期(创建、初始化、销毁)