unity中的原生对象与托管对象
序
最初在工作中接触到引擎原生层相关的问题,是项目实现的延时执行模块,模块提供接口来进行委托的延时调用:
// 返回一个token用于后续的CancelDelayCall
m_DelayExecuteGuidance = GameFacade.CurrentGame.DelayCall(8.0f, OnPlayerGuideFinished);
// 延时执行的代码示例
private void OnPlayerGuideFinished()
{
// 作为MonoBehaviour的成员方法,访问gameObject
Debug.Log(gameObject.name);
// something else...
}
延时执行模块与协程不同,不会因为gameObject的销毁而取消执行,假设在指定的8s后,组件绑定的gameObject已经销毁,那么试图在访问时就会产生这样的报错:
可以看到延时模块仍然保留了对MonoBehaviour的引用,但在试图通过property访问gameObejct的时候发生了报错,且提示代码位置为“managed-to-native”,体现了本文的主体:引擎的托管(managed)代码对原生(native)代码的访问,下面我们就集中介绍Unity引擎中的原生对象与托管对象。
原生对象与托管对象
Unity作为C++实现的游戏引擎,在运行时大部分的逻辑和内存管理都是在原生层(C++ code)进行,这里就将引擎在原生层创建的对象称为原生对象(native object),基于引擎的游戏逻辑中创建的对象成为托管对象(managed object)。当Unity创建一个Object的时候,存在着核心的原生层面的逻辑,另一部分则是更为常用的托管层面的C#逻辑,下面就先从Unity引擎中的Object说起。
Understanding Unity Engine Objects
节选翻译自 https://blog.eyas.sh/2020/10/unity-for-engineers-pt5-object-component/
Unity引擎运行时使用C++实现,大多数引擎的要素(gameObject以及component等等)都处于C++侧。你也许也知道Unity引擎的API是以C#形式提供的。这些API为你提供了对Unity原生对象的访问,以至于这些对象用起来就像是C#对象一样亲切易懂。
UnityEngine.Object
在继承链的顶端的是UnityEngine.Object
。大多数情况下都提供name
字符串,GetInstanceID()
接口,以及一系列比较器(equality
comparers)。
这个类同时提供了静态方法 static void Destroy(Object obj)
来销毁一个 UnityEngine.Object
以及它的一切子类。当一个Object被销毁时,这个对象的原生层部分已被从内存中释放,而相对小的托管层部分将会在它没有被引用时被垃圾回收。
因为你在托管层对UnityEngine.Object
的引用可能指向一个已经被销毁的原生层对象,所以UnityEngine.Object
重写了C#的operator==
以及operator!=
让一个已被销毁的Object能够表现为null。所以对已在原生层被销毁的Object进行null判定会返回true,或者直接引发NullRefrenceException
,来告诉你该对象已经被销毁。
可以在C#侧看到Object实现中,若与null进行比较,会调用IsNativeObjectAlive
进行判定。
// UnityEngine.Object.CompareBaseObjects
private static bool CompareBaseObjects(Object lhs, Object rhs)
{
bool flag1 = (object) lhs == null;
bool flag2 = (object) rhs == null;
if (flag2 && flag1)
return true;
if (flag2)
return !Object.IsNativeObjectAlive(lhs);
return flag1 ? !Object.IsNativeObjectAlive(rhs) : lhs.m_InstanceID == rhs.m_InstanceID;
}
进行一下回顾(非原文内容)
在文章开始时我们给出了例子GameFacade.CurrentGame.DelayCall
,现在我们在延时执行的代码中加上这些判断语句,并记录关键日志:
private void OnPlayerGuideFinished()
{
Debugger.Log("[UINewAutoPickUpSettingController.OnPlayerGuideFinished]");
if (this == null)
{
Debugger.Log("[UINewAutoPickUpSettingController.OnPlayerGuideFinished] this is null");
}
var mono = this as MonoBehaviour;
if (mono == null)
{
Debugger.Log("[UINewAutoPickUpSettingController.OnPlayerGuideFinished] mono is null");
}
if (m_View.GuideCoverBtn == null)
{
Debugger.Log("[UINewAutoPickUpSettingController.OnPlayerGuideFinished] m_View.GuideCoverBtn is null");
}
if (m_View.GuideCoverBtn.gameObject == null)
{
Debugger.Log("[UINewAutoPickUpSettingController.OnPlayerGuideFinished] m_View.GuideCoverBtn.gameObject is null");
}
m_View.GuideCoverBtn.gameObject.SetActive(false);
}
运行测试时,我们会发现这些尽管托管层仍持有这些MonoBehaviour的引用,但这些引用已经全部被判定为null,这是由于我们销毁gameObject时,原生层对应的对象已经被从内存中销毁,对应内内存空间被释放,所以尽管托管层的Object
对象还没有被垃圾回收,对象仍然可以被访问,但对自身的null判定会全部为true。
同时我们也要注意到null判定为true是因为operator==
的重写,但是仍然指向一个托管层的Object
对象,这个对象的成员(如m_View,是一个纯C#侧对象)仍然是可以被访问的,只在最终访问到原生层对象,或者试图获取原生层对象时报错。
上图为运行日志结果,报错的为UnityEngine.Component
中的getter
方法:
/// <summary>
/// <para>The game object this component is attached to. A component is always attached to a game object.</para>
/// </summary>
/// <footer><a href="https://docs.unity3d.com/2018.4/Documentation/ScriptReference/30_search.html?q=Component-gameObject">`Component.gameObject` on docs.unity3d.com</a></footer>
public extern GameObject gameObject { [FreeFunction("GetGameObject", HasExplicitThis = true), MethodImpl(MethodImplOptions.InternalCall)] get; }
可以看到这里就是在试图获取原生层gameObject时抛出了错误
GameObject & Component
GameObject
此部分内容进行省略,可跳转到原文查看完整内容
GameObject
从Object
继承而来,用以代表你场景中的一切对象。一个GameObject持有一个Component的列表,且至少拥有一个Component,即Transform,用以描述GameObject的空间信息,如position和rotation等。
Component
此部分内容进行省略,可跳转到原文查看完整内容
Unity通过Component对象的组合来定义gameObject的行为表现,这是被游戏引擎(游戏开发者)成为Entity Component System的核心描述/信条... 容易混淆的是Unity也将他们推出的次世代高性能游戏编程范式(paradigm)成为ECS,即不仅引擎逻辑,游戏逻辑也使用ECS来实现,从而成为一种数据驱动的设计。
GameObject的行为通过Component的组合来驱动,用户实现的组件通常需要继承类MonoBehaviour
。对于Component来说:
- 必须归属于一个GameObject,通过属性
gameObject
进行访问 - 可以接收到messages,用于驱动自身的特殊行为(即事件方法)
Component最重要的功能由Unity Messages驱动(也称为Unity Event Functions),这些都是引擎在特定时间下触发的callback方法,详情可以参考官方文档。
若你需要在特定时机执行一些逻辑,添加这些message的同名方法即可,引擎运行时(runtime)会使用反射来进行方法调用,这就是为什么你在这些方法上看不到override
关键字。每个Component类型的Update
,LateUpdate
,FixedUpdate
这些消息都只会被反射获取一次,所以不用担心反射在每个游戏帧都被执行(有关内容可以参阅
https://blog.unity.com/technology/1k-update-calls)。
Behaviour
是可以被设置enabled / disabled
状态的Component,在disable的状态下,一些事件方法将不会执行。而MonoBehaviour
是可以使用协程(受Unity
Engine管理)的Behaviour。
开发的时候遇到一个问题,为什么unity项目中的C#代码会提示无法去new一个component呢(new GameObject却是可行的)。原因是component是需要依赖gameObject存在的,需要gameObject驱动各种生命周期。而C#的new行为并不能在创建component指派component依附的对象,这就导致一些功能不能正常作用。参考了unity论坛此回答:https://answers.unity.com/questions/653904/you-are-trying-to-create-a-monobehaviour-using-the-2.html
要点
- Unity object在原生层销毁后表现为null,
== null
判定可能做了一些你料想之外的工作 - 因此,其他的null运算符(如?.)可能不起作用
- Unity Messages 可以是私有的!
- 如果没有必要的话,不要声明
Update
这样的方法用于override等,这会让引擎多调用一些额外的方法 - 对无用的Object或者Component进行disable是限制游戏逻辑或节省CPU开销的一种方式,但这些对象仍然会占用内存
原生对象与托管对象的引擎管理
原计划是搬运一下参考文章3中,结果粗略看下来参考3更多是说一些序列化 / 反序列化在Editor / AssetBundle中的应用以及实现原理,而没有太多涉及游戏运行时的内容,后续深入学习的时候再补充了