找回密码
 立即注册
查看: 1066|回复: 13

[笔记] 为什么说Unity能用单纯脚本实现的功能,尽量避免继承MonoBehavior,保持纯粹性?

[复制链接]
发表于 2021-3-30 08:07 | 显示全部楼层 |阅读模式
为什么说Unity能用单纯脚本实现的功能,尽量避免继承MonoBehavior,保持纯粹性?
发表于 2021-3-30 08:13 | 显示全部楼层
意外搜到古老问题,终结性回答一下。


这个说法本身确实有问题,应该这么说:
1、写新脚本、新的class时,先确认,我需要的是一个需要挂到物体上的组件,还是普通的类?
2、如果是自己管理的类,和组件没关系的,那么千万不要继承MonoBehaviour。
例如:用class定义一套道具数据,道具的数据和Unity没什么关系,当然不能继承MonoBehaviour。
3、如果是写一个脚本组件,而且需要挂载到物体上使用的,那一定要继承MonoBehaviour。
例如:所有通过组件扩展功能的都必然要继承MonoBehaviour,否则无法挂上去(笑)


只要想清楚了每一个class属于功能组件还是单纯的、独立的类,就可以很好的处理这个问题。
因此这个题目标题应当改为:能用单纯脚本实现的功能,尽量不要和Unity扯上关系,即避免继承MonoBehavior,保持纯粹性。
发表于 2021-3-30 08:14 | 显示全部楼层
原回答有疏漏之处,此处再次补充~
题主所问,其直接解答有2点:
一是为了保持内核独立,解耦引擎,为了未来的移植其他引擎做准备
二是为了保障性能,由于Unity的MonoBehavior底层机制原因,会有较大性能消耗(相对)
——————————
第一点,对内核独立而言,我是保持讨论态度的。
以前游戏引擎不成熟,写游戏用的DX接口、OP接口是很难看的,它们的API基本没多少相同,甚至渲染流水线到现在都不一样,一个新手很容易就写出高耦合的游戏,它的移植性能基本为0。因此很多教学书、教师都会很强调解耦代码。即使到了游戏引擎走向成熟,注重跨平台的现在,商业游戏解耦游戏引擎依然是主流。
(尤其是Unity当您开始用Lua的时候,没有解耦过的代码添加Lua功能真是个灾难)
从这一点上,我是相当赞同使用单纯的脚本实现功能的。
不过对独立开发者来说,解耦引擎毫无必要。因为“解耦”并不是独立开发者当下最紧迫的任务,产品才是最最重要的任务。深有体会。
第二点,是资源的问题。
Unity的MB机制会导致它的调用比内部调用慢一些,在优化角度来看用单纯的脚本实现功能是有必要的。
开发手机游戏的话,它是正确的,因为手机资源抓襟见肘。
开发网游服务端也是对的,要尽可能压榨服务器性能。
那么问题来了,追求极限性能为什么要用Unity?!
如果想要性能,开发安卓就应该从cocos或者直接从Activity搞起,开发服务器端直接就是Linux+c++。要性能,极限压榨,是个不错的想法。
但是如果你要用Unity,就该放手写脚本(当然蹩脚程序一个obj挂几十个compent的当我没说过),千万不要患上性能强迫症去花大把时间构造性能强大结构复杂的脚本。通常,一个游戏(Unity开发桌面程序或者动画同理)流畅与否取决于画面渲染,而不是脚本。脚本制作只要记住一点,只优化大量循环的代码就行了。经典的代码例如角色控制器,AI等。其他代码尽可能简洁。非游戏开发一般是不需要考虑这么多的,测试的时候注意有没有特别卡的地方就好了。除非客户或者上司特别强调性能,不然花点时间去调下画面明显更加合算。
总结,继承MonoBehaviour是有很多好处的,调试方便,思维顺畅,这些都是可以大大加快开发进程的。当然弊端也有不少,因此既不能强行脱离MonoBehaviour去开发游戏,也不能全部逻辑依赖MonoBehaviour,个中的拿捏程度,就看各位未来的开发需求了。
发表于 2021-3-30 08:16 | 显示全部楼层
帮改一下问题,顺便回答一下。
之前的问题确实不妥,“尽量避免使用MonoBehavior”,这显然是不对的。
本科阶段我们就学习过设计模式,即良好的程序要尽可能提高聚合性,降低耦合性。而巨量的MonoBehavior有大概率是会大大拉低聚合性,并使逻辑缠绕在一起提高耦合性的。耦合度高的代码不仅难以维护,还会造成巨量性能消耗。
举个栗子吧,比如你需要实现这么一个需求:做一个Left 4 Dead中的打僵尸的FPS射击实现,现在这个需求有以下几个技术点:
物理表现方面:
    僵尸要有布偶效果和动画互动,不停的奔跑并追杀主角。人物要携带角色控制器,简单的跑跳蹲趴走都要有。主角有枪能发射子弹,主角有刀能砍(与场景互动)。
渲染方面:
    僵尸数量巨大,需要在SRP中实现一套动画实例化的渲染管线,并靠脚本提交绘制指令。子弹击中僵尸会喷血,击中墙壁会有火花,考虑到Unity现有的粒子效果比较挫,需要自己实现一套基于GPGPU的粒子。僵尸身上喷出的血会溅到墙上,需要实现贴花。
逻辑方面:
    僵尸状态控制:攻击状态,血量等。人物状态控制:血量,体力等。
这里提供两种实现方案:
第一种:每把枪开一个Component拖到模型上,我们称之为GunController,在主角身上放一个Component负责移动控制,我们称之为PlayerController,每个僵尸身上拖一个Component,自动获取Mesh种类并提交给SRP进行绘制,并且存储着怪物的状态,我们称之为MonsterController, 粒子效果的脚本我们称之为Particle,贴花脚本称之为Decal。
那么这个调用状态将会是这样:GunController触发射线事件 -> 获取MonsterController,计算伤害等 -> 实例化粒子,实例化贴花。而僵尸攻击主角的过程则是MonsterController -> Collider -> PlayerController
与此同时,每帧MonsterController,Particle, Decal等都要依靠Update函数向SRP提交绘制指令, 这套系统还需要提供接口给其他组件,比如UI。
第二种:只开3个Component,一个管枪,一个管主角,剩下一个管所有僵尸。而实现则全部以struct和static class的形式,比如子弹计算,就直接使用静态函数计算射线碰撞等,并且用控制所有僵尸的脚本直接在字典中计算伤害,僵尸的状态则直接依靠脚本单例进行遍历,攻击时也直接向主角脚本发送请求。绘制指令全部统一提交,切枪,换枪时也只是切换枪的模型,动画,射击参数等。
对比这两种办法就能看到第二种只用了三个MonoBehavior, 而第一种则需要用满屏幕的Component,起码光僵尸身上人手一个Component数都数不过来了。而第二种方法从各个方面来讲都比第一种要好不少。首先,开发效率上,第一种需要不停的开脚本不停的拖,而且很难做模块化,因为每一部分都是与其他部分互相依赖的,所以必须在写代码之前祷告一翻,保佑自己没有一部分会出问题,这样一下子就能跑起来了,而第二种方法大多数都是静态的没有依赖的方法,本身可以直接进行单元测试和排查。维护效率上讲,这么一套链式调用过程基本一个月以后就是能跑的起来全看天了,更别提迭代和加功能了,而第二种将数据和逻辑进行模组化控制,版本迭代和需求变更也要容易得多。再说运行性能。MonoBehavior的Update等函数,是需要经过函数指针的,调用效率远不如普通函数,尤其是当数量巨大时这一点表现的更加明显,同时MonoBehavior脚本本身也会产生不菲的GC,最后的结果就是游戏各种GC卡顿,帧数大受影响。相反,反观后者则基本不会在运行时产生新的对象,可以说是0GC压力,甚至多线程并行运算也没问题, 比如僵尸的骨骼动画和状态因为使用统一的脚本控制,是可以直接使用Job System进行多线程优化的,运行性能比前者不知道高到哪里去了。
以前没有开发经验的时候,自己做游戏常常用前一种方法,拖的满屏幕各种Component,结果就是半个月过去望着满屏幕的Component一脸懵B,而现在有了一定的开发经验,在开发时就会刻意注意做好层级分割,到最后最上层的调用层总共剩不下几个MonoBehavior。
总结一下,尽可能少的使用Unity component,开发效率高,多人合作容易,可维护性可扩展性高,更容易做优化且运行效率更高。
发表于 2021-3-30 08:20 | 显示全部楼层
应该从MonoBehavior的机制来分析比较合适
如果你的类无需引擎提供的各种初始化, 更新及析构, 物理, 渲染等的回调. 最好不要继承MonoBehavior
继承后, 引擎会在事件触发时, 通过反射调用各种函数. 这是需要消耗性能的.

当然, 如果你的类压根没挂上GameObject. 理论上说, 应该没啥卵用

所以总结, 弄不继承MonoBehavior尽量不派生
发表于 2021-3-30 08:24 | 显示全部楼层
这种说法是错误的。
发表于 2021-3-30 08:33 | 显示全部楼层
其实我觉得 Unity3D之所以在Editor创建的每个类都继承自 MonoBehaviour的意思是 你丫的  别自己乱new
发表于 2021-3-30 08:36 | 显示全部楼层
我也在知乎也说过类似的话:没必要的情况不要继承mb。

1.首先你要理解基于组件开发是什么原理,理解各个接口(Awake Start等)调用顺序和作用

2.很多人不了解第一点会导致譬如初始化顺序问题,运行中修改代码导致Unity崩溃,性能下降等。(包括我也是,但项目大了,大船只能慢慢调头)

3.什么时候该继承mb?
它尽量满足其中一点:
   能独立于项目外运行,拖到其他项目,可以独立运行并实现功能,例如实现一个ProgressBar,它可以不受项目逻辑影响,外部只需要修改其几个属性或者调用方法,即可达到功能。
   需要实现Update Invoke等方法。
   预制件往往会挂一个mb脚本,用于保存它子对象的引用。这个应该用序列化脚本功能替代,但为了方便,经常这样使用。

4.什么时候不应该继承mb?
   最常遇见的情况是,一个VO类也继承mb,然后new的时候报一大堆警告。
   如果只为了实现Update等时间循环方法,建议实现一个TimeManager的功能。

5.mb中无需用到的Start Update等方法应删掉,不然unity一样会调用、一样有损耗。

6.还是Update,由于实现太方便了,继承mb,写一个Update方法即可每帧循环了,然后很多人就不适当使用甚至滥用。
例如在update里面FindObject等,这样效率会很低的。
例如属性的值明明可以在set的时候检验的,不需要每帧检验的,却这在了update方法里。
发表于 2021-3-30 08:40 | 显示全部楼层
个人觉得,一个很大的原因是继承mb以后,对象的生命周期就交由unity内部去管理了,这样就有可能会出现多个对象之间互相引用其中的组件时初始化顺序或者销毁顺序不明确的问题
发表于 2021-3-30 08:50 | 显示全部楼层
最近在思考这个问题。
首先我认为,不赞成继承Mono的人大概出于性能的考虑。因为继承Mono比较消耗性能。这个本人也说不出什么具体的原因,完全凭感觉猜测。也没有做过性能测试,所以不知道究竟对性能会产生多大影响。要是有人做过相关的性能测试,希望能分享一下!

但是如果不继承Mono,会有很多不方便的地方。
第一、不能使用Invoke和Coroutine了。
第二、调试不方便了,不能在Inspector和Debug tab看到参数。同时如果A不继承Mono,那么A这个类的List也无法在Debug界面看到。
第三、不自动调用Update之类的方法了,这个不算是太大的坏处

本人在开发中,觉得第二点特别难以忍受。其他的不便之处倒还能克服。
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

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

GMT+8, 2024-11-25 13:07 , Processed in 0.097191 second(s), 25 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

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