找回密码
 立即注册
查看: 350|回复: 14

控制反转与unity3D(一)

[复制链接]
发表于 2023-2-17 07:08 | 显示全部楼层 |阅读模式
Unity3d是一款非常不错的游戏开发引擎。但是当遇到大型项目的时候,代码的可维护性会变得越来越重要,Unity本身的框架就会变的不是那么好用,所以本文接下来的讨论并不是针对非常小的或者非常简单的那种项目来说的,而是针对于大中型项目。
在研究了几年unity框架内在的问题之后,我意识到它们都有一些共同的本质:Unity在往实体中注入依赖的时候是非常困难的。
依赖可以被认为是一个对象想要执行其功能,需要另外一个对象的相应功能。比如一个类A执行代码的时候需要一个B类对象去执行相应代码,那么B就是A的一个依赖。当把B对象传入A的时候,我们就说依赖B注入到了对象A中。
问题

unity是基于实体框架概念的。这样一个框架带来了许多好处:引导用户更倾向于组合而不是继承,保持类小而干净,单一职责和模块化。看,多么完美的一个世界。
在unity中,gameobject被认为是实体,组件是基于monobehaviour的。组件给实体添加行为,理论上组件应该有非常完美的封装性。实体组件架构依赖于在组件中我们完成了所有的逻辑。理论上来说:
1.MonoBehaviour必须在他们挂载的GameObject上执行功能。这需要保证MonoBehaviour本身的封装性不要被打破。组件必须只处理它所挂载的实体。
2.MonoBehaviour是模块化的,具有单一职责的类,可以在GameObject之间重用。
3.扩展GameObject行为的方式是添加额外的monobehavior,而不是在现有的某个monobehavior中添加方法。一个组件必须只做一件事情来保证模块性。
4.GameObject不应该在某个概念并不是游戏中客观存在的实体,或者不具有可视化的情况下被创建。
5.MonoBehaviour只能知道它所挂载的GameObject之上的其他组件,但是它不能知道其他GameObject身上的组件。
前三点是显而易见的,即使你从未听说过实体组件框架。 组件设计允许在不使用继承的情况下向实体添加逻辑。 我们可以说组件允许用水平扩展的方式来代替特例化实体组件垂直(通常通过多态来特例化类)。组合由于继承是一个众所周知的模式,其好处已被广泛证明。 那么实体组件框架的伟大之处就是使组合方式非常自然。组件越小,就越容易重用它,这就是为使组件都单一职责后,会促使其更模块化。
对于第4点,unity本身会默认在GameObject上挂载Transform组件,就暗示这个对象需要被放置于游戏世界中。那么如果此GameObject并不是一个需要放置的东西,它还额外带有Transfrom组件,其实是一种浪费。
最后一点开始揭露真正的问题,Monobehaviour给它们挂载的gameobject添加功能,但是unity并没有提供非常优雅的方法让gameobject之间相互了解沟通。还有一个问题是有些情况下逻辑代表与实体并没有直接关系,可unity还是倾向于用monobehavior来组织逻辑。比如:有一个叫MonsterManager的管理对象必须管理游戏中存活的怪物,当一个怪物死亡后,这个对象需要如何获知?
目前有2中简单的解决方法:
1、MonsterManager作为一个monobehavior挂载在一个没有表现层的Gameobject上。所有怪物会用GameObject.Find或者Object.FindObjectOfType来查找monstermanager,在start或者awake的时候将自己放入monstermanager管理,然后monstermanager可以通过监听怪物死亡的事件来得知某怪物的死亡。
2、另外一种方法就是monstermanager是一个单件,它直接被用于monster的behavior中,怪物死亡的时候,就是直接调用单件的公共方法。
2种方法都要解决一个问题:依赖注入。在方法1中,monstermanager是monster的依赖。monster在不知道monstermanager的情况下是无法将自己放入其内的。在方法2中,依赖是通过全局变量解决的。你或许听说过全局变量和单件最好不要轻易使用,但你可能并不知道其中的原因。
GameObject.Find方法有什么问题?先不说这个方法是个非常慢的方法,它就是unity框架对大型项目开发支持无力的一个例子。如果有人给他的gameobject改了名字会造成什么结果?给gameobject改名或者删除gameobject是否要禁止?它还会造成一些运行时错误,这些错误不能在编译时发现。当非常多的gameobject是用此方法寻找的时候,这种场景是十分难管理的。这些方法应该要被unity框架废弃的。
Object.FindObjectOfType有什么问题?此方法可以被认为是一种Service Locator Pattern的错误实现。是因为他无法从接口中将服务的实现抽象出来。这是unity框架的另外一个问题,unity并不鼓励使用接口。接口是一个非常强力的概念,unity却引导用户去尽量使用behavior,尽管在monobehavior并不是特别必须的时候。
单件儿有什么问题?
自动单件被提出的时候,就有非常大的争议。我个人反对使用单件。到目前为止,我遇到的所有单件的使用,都会造成设计问题,而且难以通过重构进行修正。如果你问我为什么反对使用单件,我不会以常见的回答回答你(打破封装,隐藏依赖,不能编写合适的单元测试,把逻辑绑定到了实现而非抽象,使重构很困难),而是以实践的观点:你的代码在不久之后就会变得难以管理。这是因为单件的使用往往没有设计约束,表面上看会使代码更容易写,但之后当代码开始变成一坨坨的,没有结构流程的时候,就会很难理解。由一两个程序开发的小项目或许不会显现这个问题,但中型的项目会为此种架构付出代价的。试想一下,你可以在任何地方使用单件,没有限制,没有规则。怎样使程序不会变得一团乱麻?靠常识?不要自欺欺人了,常识在deadline快到的时候可一点用处都没有。
说实话,这些还不足以说不用单件。当你没有单件之外选择的时候,真正的问题就变成了当单件的封装性被打破的时候如何管理内部的状态。单件通常依赖公共方法,公共方法正常来说会改变内部的状态。当单件在其他地方乱用的时候,跟踪什么时候,以及状态是怎样变化的将会变得非常困难。重构也会变得非常危险,因为改变单件公共方法的调用顺序会发现单件出于一种意外的状态。单件的最大的问题是,他们不能设计成反转控制的方式。你总是会写些IsSingletonReady DisableThisSpecificBehaviour AccessToThisGlobalData类似的方法,让外部的实体控制了内部的状态,这种情况是非常难以追踪的,尤其是单件的类似全局变量的行为。
让我们清楚一点,注入依赖主要原因是我们要让对象间通信。没有任何形式的注入就不会有通信。因此,只在有限的情况下,单件才被接受。虽然可行,但是靠单件来解决所有对象间的沟通是完全不合理的。
最后,基本上 静态类,单件类,通常会是一个项目中大多数内存泄漏的原因。
解决方式:
有3种方式可以用来解决依赖的问题:
1、使用Service Locator pattern模式
2、手动通过构造函数或设置函数来注入依赖
3、通过IOC容器,利用反射。
当然还有单件。不考虑单件的话,我不喜欢用SLP模式,因为此模式本身是一个单件或者静态类,比起IOC容器它会有一些限制。unity并没有一个开始的位置用来注入依赖,手动注入又非常麻烦,并且在不是很清楚unity本身限制的时候很难用好。
所以IOC容器是一个不错的选择,当然不是唯一的选择。另外一篇文章会介绍什么是IOC容器,这里先给出一个简单的定义:IOC容器是一个用于创建并包含依赖的容器,这些依赖会注入到应用程序对象中。这被称为反转控制,因为对象的创建再也不用用户来进行了,它们会在它们被需要的时候由容器进行创建。
已经有一些IOC的容器了,有很多还是由C#写的。但是没有一个是为了unity写的,而且他们大多数都非常复杂。出于这些原因,我决定为unity创建一个非常简单的ioc容器。事实上创建一个基础的ioc容器非常简单,每个人都能做。我的实现只是让它简单的和unity一起使用。
文章翻译自:Inversion of Control with Unity3D – part 1
发表于 2023-2-17 07:10 | 显示全部楼层
挺好的,期待
发表于 2023-2-17 07:13 | 显示全部楼层
为什么不能用单件
发表于 2023-2-17 07:20 | 显示全部楼层
嗯,目前正在开发的项目也是这么处理的,不过是直接使用zenject来处理依赖注入的问题,如果采用控制反转的话,单例在里面会很别扭,也没有必要。
发表于 2023-2-17 07:23 | 显示全部楼层
正常的做法是逻辑中彻底弃用Monobehavior,Mb是只用于在预制体上由非技术人员设置属性用,以及在必要时获取事件,这样Unity和别的语言就没什么不同,规则也自然可以通用……
发表于 2023-2-17 07:32 | 显示全部楼层
但是这篇文章很多观点都让我很不满。我不管写这个的人是谁,还是说翻译的措辞有问题,比如Object.FindObjectOfType这东西和Service Locator pattern根本一点关系都没有,凭什么就成了“Service Locator pattern的错误实现”?这种(装逼)倾向性是非常不好的。

而且我一向也很讨厌依赖注入,控制反转这两个词。因为前者就是在“初始化对象后立即把它需要的依赖从外部赋值到它的属性内”,后者就是“初始化后在对象的内部(如构造函数)从别处取来依赖(一般用单例或反射)”,这本来就是大家正常编程都会干的事情(尤其是前者,几乎就是新手正常的做法),干嘛用个奇怪的专有名词?什么都搞个名词的话,在游戏这种复杂的结构里,得造出多少名词来?

当然,这篇文章可取的地方是让我们不直接使用单例而使用其他方式来获得Manager的实例,这点倒是对的。但是它的哪一个方案,最后其实都离不开单例,只是减少了单例的“直接”使用。

我大概也能猜到他所谓的IOC(控制反转)解决方案估计是:
1.在Monster内部使用Mb的属性直接反射获取Manager,但是因为Manger本身就应该是唯一的,你不能重新实例化,结果还是得用单例,只是没有显式的使用单例的调用代码而已。只有配合接口才有意义了。
2.弃用反射,而是直接用Service Locator的做法,用类名或者特定字符串做键,从别处去取实例,而这个实例是在另外的地方register的。当然Service Locator本身依然是单件。
3.用配置表来实现Service Locator,也就是没有手动register这一步了。但它既然说是简单的解决方案应该不会这么做。
希望解耦合就不依赖Manager本身而是依赖接口。但接口的麻烦之处大家都了解。
所谓的IOC容器就是在基类上放置注入代吗,然后派生为Monster。这样不用每次写一遍代码。

但不管怎么搞,依然还是调用一个外部的单件获得依赖的过程。IOC就是写在内部,不是IOC的就是写在外部。就算你没有显式调用,最后代码还是会这么调……

其实我UI部分用了很长时间的IOC容器,好用倒是好用(省掉了注入代码),原来用单例的地方都直接换成属性了……但其实也就这样,所以换了语言后就没再用了。说真的我们U3D程序员实在很难遇到啥真正的“大型项目”,这种方案真的很鸡肋……直接在基类的初始化函数里用单件取实例然后扔在属性里不就好了(这也仅仅是为了省掉每次调用单例那长段代码),而用接口解耦合就基本是在自讨苦吃了。
代码越少,BUG越少,交流成本也越低,这是个很实在的事情。

但假如你一定要用这些玩意儿,IOC容器确实是比较好的方案,比如单元测试的代码就比较精简(忘了他提没提了)。但前提是,你得写单元测试啊。
发表于 2023-2-17 07:36 | 显示全部楼层
本人技术也很浅显,但是总觉得写的并不是很好,就像上面的人说的,用了太多专业名词了,对新手来说一点都不友好,而且其实这些专有名词都是些很浅显的写法,就上像上面的人回答的一般大型项目都是逻辑脱离unity的做法,unity只是作为一个表现工具可以在你的逻辑末端需要用unity的时候调用一下,单件的确会有很大的问题,在大项目里单件越多会让项目变得维护,但这是根据项目规模来的,权衡利弊以后作出的最优解,如果有什么说的不好请指出,大家互相学习,互相勉励
发表于 2023-2-17 07:39 | 显示全部楼层
学习
发表于 2023-2-17 07:48 | 显示全部楼层
不错,确实是实际开发中需要思考的问题
发表于 2023-2-17 07:50 | 显示全部楼层
全局只有1个mono 飘过
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-16 11:30 , Processed in 0.098023 second(s), 25 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表