前言
依赖注入是.NET很常见到的一个话题,我已经见到过很多各种各样的依赖注入库了,而且至今还偶尔会有新的库出现。
但是在Unity中使用依赖注入这一做法的评价就不同了:有相当一部分观点认为依赖注入只在Web开发中使用比较好,将其引入Unity是强行迁移的不合适的做法。
既然都写这篇文章了,我对于这一做法自然持乐观态度。这可能也与我近期主要在做的东西有关:一个关卡编辑器。严格来说比起游戏,它更像是在做客户端。
不管怎么说,这篇文章就是用来分享一下我的一些自认为还算比较清晰的实践。由于我是使用VContainer进行开发,以下内容将默认你比较了解VContainer的使用方法。
层级模块化注入
依赖注入本质上就是对数据(广义上的数据,或者可以理解为“实例”)的一种管理方式,其本身很简单,就是注册一些东西,它们会在程序一开始运行时就被记录,然后按需分配给每个声明了“我需要哪些东西”的对象上。
我将需要注入的内容分成四类:核心数据、核心业务逻辑、辅助函数、上层UI逻辑。一个场景里的内容很可能会分成多个模块,而每个模块里都会包含这几类内容。同时,模块之间也会有依赖关系,子模块可以获取到其依赖的模块注册的内容。
接下来的讲述将以音乐游戏为例(虽然音游十分没有技术含量!),包括最大的关卡模块,以及控制Note下落的视图模块、接受玩家输入的输入模块、计算分数的计分模块等,
模块注册器
模块注册,说白了就是把一些注册的内容包装起来,避免全部平铺。
在VContainer里,这一功能通过IInstaller接口实现。LifetimeScope(VContainer里存储依赖的核心容器)调用IInstaller.Install而非直接自己注册即可。每个IInstaller都可以看作一个模块。但模块也是有依赖关系的,一个IInstaller可能需要调用另一个IInstaller,甚至会嵌套好几层,这就形成了一个树形结构。
而这不巧了吗,Unity里有一个最适合表达树形结构的东西——Hierarchy。每个IInstaller完全可以作为一个GameObject的脚本放在场景中,利用场景的功能找到自己的父模块是谁、都有哪些子模块。这种做法还有其他一些好处,下文会提到。
那么可以写出这样的代码:
1 | public class HierarchyLifetimeScope : LifetimeScope |
可以发现这种做法虽然名义上是将依赖分成模块了,但它们本质上还是注册到同一个Scope里了,也就是说父模块想要的话还是能拿到子模块里的依赖的,这显然不是很好。
VContainer里其实允许LifetimeScope之间存在父子关系,可以保证子Scope里的依赖一定不会被父Scope拿到。但直接用父子LifetimeScope替换HierarchyInstaller是不太可行的,因为一个Scope可能同时依赖两个并列的父Scope,也就是说尽管从模块的摆放上可以把各个模块拜访成树形,但它们的依赖关系却不止是树状。这算是VContainer的一个设计局限吧。要完善这一问题也是能做的,就是麻烦了点。不过从我目前的实践来说,全部注册到同一个Scope里并没有导致太多管理灾难,所以暂时不深究这块。
核心数据
每个模块的核心数据是这个模块里的业务逻辑会需要反复用到的数据。
- 关卡模块:最明显需要注册的就是关卡数据,包括谱面内容、关卡设置(如流速、是否自动)等;此外,还可以注入音乐播放器,用于获取音乐的当前时间、音乐长度等。
- 视图模块:注册音符实例的对象池。对Unity来说音游肯定是需要这么一个对象池的(如果上了DOTS之类更重的东西那另说)。这里这个对象池是一个简略的说法,它除了用来控制视图实例的生成和回收,还有获取场上现有的视图实例、将视图实例对应到谱面里的数据等。这些功能很可以组装成一个通用的类,不过要把它们拆开也无所谓,总之它们都属于数据的范畴。
- 输入模块:注册一个存储玩家输入的容器。
- 计分模块:注册分数、连击这些显然十分核心的数据。
核心业务逻辑
每个模块的核心业务逻辑就是用于操纵核心数据、将数据应用到视图实例上的逻辑。
- 关卡模块:如是否需要在生命周期的Start的时候开始播放音乐。
- 视图模块:根据当前的音乐时间,决定要生成哪些视图、要回收哪些视图,并决定在场视图的位置、宽度等属性。
- 输入模块:决定一次输入对应到了哪个/哪些Note,以及判定结果如何等,存储判定数据。
- 计分模块:根据输入模块的判定数据决定分数、连击等如何变化。
这里VContainer提供了一些专门用于在生命周期执行逻辑的接口,比如IInitializable、ITickable等,将实现了这些接口的类进行注册,就可以在类似Start()、Update()这样的时机执行相应逻辑。
从性能上来说,这是比在场景中挂一个空物体更好的做法,因为Unity神秘的早期设计将Transform直接与GameObject耦合,还有诸如此类的一些因素,导致GameObject理论上是一个相对偏重的对象。
但从实践上来说,如果真的把所有业务逻辑都写到普通类里,会是一件很麻烦的事:它在Inspector和业务系统之间凭空新造了一层衔接层,要让某个逻辑获取一个ScriptableObject乃至一个int,都得苦兮兮地在LifetimeScope里写一遍,然后注册一遍,然后在逻辑类里注入一遍,最后还要把逻辑类本身再注册一遍,相当于把Unity的可视化的Inspector和Hierarchy在代码里不可视化地重新造了一遍。
因此我的实践里最终还是用了“挂空物体”的做法。每个空物体放置在一个模块下,挂载单个逻辑类脚本,由模块的HierarchyInstaller发现并注册。
1 | public abstract class HierarchySystem<T> : MonoBehaviour, ISelfInstaller where T : HierarchySystem<T> |
这种做法还有其他的好处:
- 直观。打开场景就可以看到每个模块下都有些什么逻辑,不用打开代码盯着;一些只有这个逻辑会用到的参数也不用手动注册一遍,直接在Inspector里配就行。
- 可以利用其他一些GameObject的特性,比如父子物体之间的关联,以及动态地设置其active状态以在运行时启用或禁止一些逻辑。
- 可以利用Prefab机制,在不同场景中复用同一套逻辑模块,对该模块的修改能在场景间同步,还能方便地进行微调。
要么方便,要么性能略有欠缺。但这是VContainer的问题吗?当然不是。Inspector本质上是将实例的一些数据写到了场景这一“配置文件”里,然后通过类似反射的手段写入;而GameObject的特性、Prefab机制等,也是很直观的管理对象树的方案。这些并不是什么抛开了引擎就做不了的东西,只是Unity完全将它们作为了场景附属的机制。因此,理论上完全可以尝试自己重新建造一套这样的逻辑,从而在保持这些特性,甚至拓展更多更好用的功能的同时提高性能,只不过其工作量显然会很大,而对我来说目前的做法已经完全足够了。
辅助函数
其实就是依赖注入里常说到的Service。从实现上来说,它跟上一部分的核心业务逻辑没有太大差别。不过从规范上来说有两个值得一提的地方:
业务逻辑之间不应该互相依赖,但业务逻辑可以依赖Service。将一些业务逻辑提取成了一个类,就是为了明确逻辑的边界,也就是说,在场景里应当理论上可以毫无顾虑地随便删掉一个业务逻辑而不引起其他逻辑的任何依赖缺失。
业务逻辑通常无需提取接口,但Service应当提取接口。业务逻辑之所以不用提取接口,因为它已经是在场景里的一个独立单位了,如果有变更,在场景里删了换一个脚本就好。而Service因为会被依赖,考虑到保持其可更换性,有必要以接口的形式进行注册。
上层UI逻辑
倒是跟核心业务逻辑更加没有区别了,只是它们专门用来把注册的数据与UI关联起来。
唯一的区别在于,它们是可以依赖核心业务逻辑的。理论上其实也可以不这么做,因为业务逻辑完全可以把它们要公开的东西以依赖的形式进行注册,但UI毕竟是一些比较体力活的内容,如果业务逻辑公开的东西改了,几乎UI就是也要改,建立直接的依赖还能更好地提醒自己及时进行相应的变更。
多Scope的使用
虽然前面说过“直接用父子LifetimeScope替换HierarchyInstaller是不太可行的”,但也并不是说场景里就用不上多Scope了,以下举一个我实际开发中用到的多Scope的例子。
我制作的关卡编辑器同时拥有两个功能:选中一个轨道,在其周围出现一些代表其运动轨迹的结点(这个游戏的特色就是Note所在的轨道会随着时间变化其宽度和位置),同时,在UI上的编辑面板里也会出现运动轨迹对应的列表,方便查看和编辑各个结点的详细信息。
可以看到这两部分的视图对应的数据是相同的——都是轨道的运动轨迹信息,也就是说,一份数据需要在场上有两种完全不同的视图。这时便可以添加两个相同的子Scope:数据注册在主Scope里,而用于生成这些视图的对象池注册在子Scope里,并为两个子Scope的对象池赋予不同的Prefab。子Scope本身还可以存放一些两种视图共通的逻辑,比如何时生成和回收视图;而与视图相关的特殊逻辑,比如结点需要能被拖拽、UI列表需要能被编辑,这些就作为核心业务逻辑分别放在对应的子Scope下。
由此,同一份数据的两种不同视图间,既共享了一些共通逻辑,又能够轻松地添加各自的特殊逻辑。尤其重要的一点是,两种视图的来源数据完全相同,意味着它们之间可以产生联动,比如在屏幕上点击选中了一个结点,UI列表里对应的列表项就可以很自然地高亮——只要在主Scope里额外注册一个“当前选中的数据”即可。
总结
虽然全篇都在说“依赖注入”,但我觉得对上述实践而言,依赖注入只是实现“数据驱动”的一个手段,一种管理数据的方式,让业务逻辑可以更方便地获取到想要的数据和服务,仅此而已。
随着我在实践中不断完善这个框架,我发现我越来越不喜欢让场景中的实例物体能够“自治”,而更希望它仅仅只包含视觉层面的逻辑,而与具体业务有关的逻辑则交由场景中配置的系统实现。但二者说到底也并非矛盾吧,“自治”的物体其实就相当于将“一个业务逻辑管理哪些数据”的这一链条交给了引擎管理,而数据驱动就相当于将这个链条又拿回了自己的手上。孰优孰劣还得看具体场景下是想要快速开发还是保证系统性罢了。
以上。