找回密码
 立即注册
查看: 186|回复: 5

Unity3D开发中 使用C# 很多文档都有提到这个“避免使用构造 ...

[复制链接]
发表于 2023-2-20 11:01 | 显示全部楼层 |阅读模式
“7.避免使用构造函数 不要在构造函数中初始化任何变量,使用Awake或Start实现这个目的。即使是在编辑模式中Unity也自动调用构造函数,这通常发生在一个脚本被编译之后,因为需要调用构造函数来取向一个脚本的默认值。构造函数不仅会在无法预料的时刻被调用,它也会为预设或未激活的游戏物体调用。”
很多文档都有提到这个,但不是很理解什么意思?
构造函数不要初始化变量?
这样的算吗:GUI.Button(new Rect(20, 40, 80, 20), "Level 1")
new出来的变量算初始化吗? 最好能举出来具体例子,谢谢
发表于 2023-2-20 11:08 | 显示全部楼层
理解的有点问题,这个“避免使用构造函数”是建立在继承了MonoBehaviour的基础上,而rect, Vector4, Matrix4x4这些都是纯粹的数据类型,是struct,这个属于C#基础这里就不讲了,具体说一下为什么MonoBehavior要有自己的生命周期而不是用Constructor Destructor。
首先是为了好看和安全,如果用Constructor,那就必须要在初始化的时候加一个Super::BeginPlay()之类的这种,保证引擎底层的初始化代码都执行到,但是这真的很丑,因为如果用户忘了加这一句,引擎可能就完全崩掉了,这不符合Unity的开发原则,所以Awake,OnDestroy这些要留给引擎去执行,作为开发者不应该存在干扰托管资源的行为,否则就是undefined behavior。
其次是底层代码要和C#的托管配合,Unity使用的是Boehm GC,属于非增量(2019刚刚有了增量)的标记型GC,是被动的,那么极有可能这个物体即使标记为销毁,也会在很长时间内存留在内存中,而Destructor就不知道什么时候执行了,那么这种时候一个可行的解决方案就是只让上层C#当个外壳,底层全都是Native Code,C#层通过Property和底层互动,这样生命周期就完全可控了,还能面向托管堆做一些类似池化这样的优化,何乐不为?所以这就必须要求用户使用一套自定义的生命周期,而不是依赖托管堆。
发表于 2023-2-20 11:10 | 显示全部楼层
在unity里,MonoBehaviour 的构造函数析构函数 都是在另外一个线程调用的,不是游戏的主线程,这里不能调用任何UnityEngine相关的API,因为UnityEngine相关的API都不是线程安全的(个别Unity允许的api除外),而Awake,Start,还有Destory则保证都是游戏主线程调用,没有上述问题。另外任何类的析构函数也可能是在gc线程被调用,所以不要在析构函数里写线程不全的代码。
不仅构造函数,在类声明里也不允许直接构造成员,等同于隐式构造,一样有线程问题。
发表于 2023-2-20 11:12 | 显示全部楼层
MonoBehaviour有两个生命周期,一个是作为C#对象的周期,一个是作为Component的周期。
构造函数代表第一个,Awake代表第二个。
Editor环境下Editor的代码和脚本代码在同一个AppDomain里,对象的生命周期会表现的跟Player环境下不一样。比如Editor中构造函数被调用的次数和时机跟build出来的游戏不一样,这样就不容易保证正确性。
另外一个关键原因是构造函数是在Unity内部的Loading线程上执行的,一是不能使用Unity API,二是需要考虑同步问题,就更难保证正确了。
所以没什么事还是别用构造函数,readonly字段之类只能在构造阶段初始化的成员,尽量不要用,设计上需要用也要保证初始化代码尽可能简单。
发表于 2023-2-20 11:22 | 显示全部楼层
这段话的确是Unity官方文档(
Unity Script Reference)提到的,请着重记忆并在继承MonoBehaviour的类中养成把Awake()当做构造函数的习惯。但这个也仅如
@eldereal提到的,“只适用在继承MonoBehaviour的类上”。
个中理由:Unity可能会在多个时间呼叫该构造函数,所以可能把你的变量的值给冲掉,即使是prefab和inactive的东西都可能被呼叫构造函数。
发表于 2023-2-20 11:31 | 显示全部楼层
首先你应该明确这个规定的限制范围:只是针对MonoBehaviour的派生类,为什么,因为其他的类就是正常的C#类,你在里面写Awake方法,就是个普通的叫Awake的方法,并不会被自动调用。
原因也很简单,MonoBehaviour代表的是一个组件,在Unity3D里面组件是必须依附于GameObject存在的,对于这种类你永远都不会使用new来创建一个实例,而是使用AddComponent或GetComponent方法来获取。Unity3D的开发人员并不想让你知道这个类是具体什么时候new出来的,你只要关心我想要的时候能够拿到一个引用就可以了。
你引用的这条限制,在我理解来看实际是说:“MonoBehaviour的生命周期并不是从构造函数开始的,而是从Awake开始的”。在构造函数中初始化变量,一般来说目的是将类的状态设置到一个“初始状态”,对于MonoBehaviour来说,你相当于在这个类的生命周期之外设定了它的状态,此时这个代码就是不严谨的,而应该放在Awake里面去。
因此,在我的理解来看,你应该避免做的是在构造函数里设定任何跟类的内部状态有关的事情。举例来说,如果我在做一个计数器,有一个count变量,这个变量就明显是内部状态,count=0这句话就应该放在Awake中。另外,程序不是一个类组成的,在实际工程中,有时候一个类的状态依赖于其他类的状态,你举例的GUI.Button方法可以看做设定了GUI类的内部状态,这个代码也不应该放在构造函数中(这个例子不好之处在于GUI类的方法只能在onGUI中调用,不能在任何其他地方,包括Awake中使用,如果换用一个其它全局变量可能更能说明问题)
另一方面,我们可以做一些不牵扯到类的内部状态的事情,举个例子:
class A{
    public int count;
    public int price;
    public string name;
}

class AComponent : MonoBehaviour{
    A inner;

    public int Count {get{return inner.count;} set{ inner.count = value;}}
    public int Price {...}
    public String Name {...}

    public AComponent(){
        inner = new A();
    }

    public Awake(){
        Count=0;
        Price=10;
        Name="Something";
    }
}
在这样一个类的构造方法中我创建了一个A对象给了inner,原因在于我这个类的内部状态并不在inner本身,而是在于inner的内部状态,此时创建出来inner不是为了设定状态,而是为了让几个属性永远都能用而已。实际状态设定还是在Awake中。
当然这个new也可以写在Awake中,因为毕竟实际用到这几个属性的时候Awake应该是已经调用过了,但这两者还是有略微的区别,就是何时创建这个对象的问题。如果你做过手游,应该会知道手机环境上new一个东西的效率并不是很高(由于这个东西不是临时对象,所以不讨论GC问题),写在构造函数里会在资源加载时调用,写在Awake里会在第一次使用(例如它依附的GameObject第一次变成active)时调用。
假设这个A类占用很大内存,甚至可能会出现内存不够的crash,那么这两者就有区别了,写在new中,这个申请内存会在资源加载时,此时游戏大概会有一个loading的界面,卡一点是可以接受的,如果crash掉了,也会给用户一个明显的暗示就是内存不够导致的crash,是不是关一些后台程序再来试试。而放在Awake中,可能发生的是用户第一次触发某个功能,放某个技能,就会卡一下,如果crash掉了,那就是在说我这个功能没做好,所以崩了,这种体验比起上一种是要差一些的。
结论:大部分情况下把所有的初始化写在Awake里就行,写在构造函数里并不是完全没有应用场景,但比较少见,是一种具体问题具体分析的优化策略,写的时候需要脑子清楚,知道自己为什么要这么写。
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

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

GMT+8, 2024-6-20 09:28 , Processed in 0.096704 second(s), 25 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

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