前言
UnityEngine.Object类作为 Unity 中所有内建对象的基类,可以在 Unity 中被任意引用。继承自System.Object,并且重载了==、!=和bool几个特殊的操作符。
问题
问题从一行错误日志开始。在最近的一个线上项目中收到这样一条错误上报:
MissingReferenceException: The object of type 'GameObject' has been destroyed but you are still trying to access it. Your script should either check if it is null or you should not destroy the object.
定位到具体代码,大致如下:
Transform transform = gameObject?.transform;
当前 GameObject 已经被销毁仍然尝试读取其 transform,但是这里明明已经使用?.操作符判定了,为什么还会出现这个问题?下面尝试模拟这种情况。
GameObject gameObject = new GameObject("go");
DestroyImmediate(gameObject);
Transform transform = gameObject?.transform;
运行上面的代码,确实抛出了一样的错误异常。难道是?.操作符的问题?下面我们换一种方式来 check null,如下:
GameObject gameObject = new GameObject("go");
DestroyImmediate(gameObject);
Transform transform;
if (gameObject != null)
{
transform = gameObject.transform;
}
运行这一段代码,没有错误异常抛出,说明 check null 成功。为什么?.操作符会检验失败?带着疑问来深入看一下编译器为我们生成的部分 CIL 代码。
探究
在探究之前重载运算符,首先补充一段关于 UnityEngine.Object 知识。在 Unity 中,所有实际的 UnityEngine.Object 的数据均存储在一段本地原生的对象空间中,与 CLR 无关;在 CLR 层中的 UnityEngine.Object 对象实际上是一个指向本地原生对象的指针重载运算符,这类对象又被称为 “wrapper objects”。
原生对象的生命周期由 Unity 管理,在加载新场景、显式调用Destroy方法(当前帧的某个时刻执行)或调用DestroyImmediate方法(立即执行)时原生对象会被销毁。C# 中的 UnityEngine.Object 对象的销毁回收由 CLR 决定。因此就可能出现一种情况: 本地原生对象已经被销毁,但是由于 CLR GC 未发生,导致 C# 中的 UnityEngine.Object 对象未被回收。
了解了这个知识点,下面开始探究问题所在。
看看第一段使用了?.操作符生成的的 CIL 代码指令:
.locals init (
[0] class [UnityEngine.CoreModule]UnityEngine.GameObject gameObject,
[1] class [UnityEngine.CoreModule]UnityEngine.Transform transform
)
IL_0001: ldstr "go"
IL_0006: newobj instance void [UnityEngine.CoreModule]UnityEngine.GameObject::.ctor(string)
IL_000b: stloc.0
IL_000c: ldloc.0
IL_000d: call void [UnityEngine.CoreModule]UnityEngine.Object::DestroyImmediate(class [UnityEngine.CoreModule]UnityEngine.Object)
// check null 部分指令
IL_0013: ldloc.0
IL_0014: brtrue.s IL_0019
IL_0016: ldnull
IL_0017: br.s IL_001f
IL_0019: ldloc.0
IL_001a: call instance class [UnityEngine.CoreModule]UnityEngine.Transform [UnityEngine.CoreModule]UnityEngine.GameObject::get_transform()
IL_001f: stloc.1
IL_0020: ret
上面生成的 CIL 指令,从IL_0001到IL_000d主要是生成新的 GameObject(存在于 Managed Heap 中) 并将其引用存入 Record frame 的第 0 个变量处,然后调用DestroyImmediate方法销毁这个 GameObject。
接着就是?.操作符部分的 CIL 指令(从IL_0013开始)。首先加载当前 Record frame 中的第一个变量值(这里是 GameObject 的引用)到 Evaluation stack 中,使用brtrue.s判断这个引用的对象是否为空,不为空则跳转到IL_0019处开始调用get_transform方法,为空则返回 null。这样看下来?.操作符就是判断了当前 GameObject 引用对象是否为空,和普通 System.Object 对象!= null处理类似。
那么绕过空检测出现错误异常的原因也就知道了。当我们调用DestroyImmediate方法销毁一个 GameObject 对象的时候,此时在底层对应的原生对象是被销毁了,但是其存在于 CLR 层的引用对象(方法结束后对象临时引用变量被释放销毁,存放于 Managed heap 中的 GameObject 对象等待 GC 释放)并未被销毁,若使用?.操作符在 CLR 层面判断不为空,跳转到get_transform方法所在指令附近获取 transform,由于底层的 GameObject 已经被销毁所以底层抛出 ‘MissingReferenceException’ 异常。
那么为什么使用常规!= null形式判空没出现这个问题了,再来看看这种方式生成的 CIL 代码:
.locals init (
[0] class [UnityEngine.CoreModule]UnityEngine.GameObject gameObject,
[1] class [UnityEngine.CoreModule]UnityEngine.Transform transform,
[2] bool
)
// 生成 GameObject 以及销毁 GameObject ...
// check null 部分指令
IL_0013: ldloc.0
IL_0014: ldnull
IL_0015: call bool [UnityEngine.CoreModule]UnityEngine.Object::op_Inequality(class [UnityEngine.CoreModule]UnityEngine.Object, class [UnityEngine.CoreModule]UnityEngine.Object)
IL_001a: stloc.2
IL_001b: ldloc.2
IL_001c: brfalse.s IL_0027
IL_001f: ldloc.0
IL_0020: callvirt instance class [UnityEngine.CoreModule]UnityEngine.Transform [UnityEngine.CoreModule]UnityEngine.GameObject::get_transform()
IL_0025: stloc.1
IL_0027: ret
前面的指令基本都相同,我们主要看看判空部分。首先加载了生成的 GameObject 以及 null,然后在IL_0015处调用了 UnityEngine.Object 类重载的op_Inequality操作符(operator !=)判断 GameObject 是否不为空,如果判定为空则跳转到IL_0027指令结束方法,否则从IL_001a处继续执行指令获取 transform。下面就来重点解读 UnityEngine.Object 重载的!=操作符的实现部分。
首先看看源码:
public static bool operator !=(Object x, Object y)
{
return !Object.CompareBaseObjects(x, y);
}
具体的实现在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);
if (flag1)
return !Object.IsNativeObjectAlive(rhs);
return lhs.m_InstanceID == rhs.m_InstanceID;
}
上面的方法用来比较两个 UnityEngine.Object 对象是否相等,lhs 和 rhs 分别代表操作符两边的参数:
在我们的测试代码中分别是 GameObject 对象和 null。通过前面的分析指导,调用DestroyImmediate方法销毁一个 GameObject 对象时仅底层对应的原生对象是被销毁了,但是其存在于 CLR 层的引用对象未被销毁;所以上面方法中flag1为 false,flag2为 true,最终返回结果由 UnityEngine.Object 类的IsNativeObjectAlive方法决定,这个方法正是判断 CLR 层所在 GameObject 对应的原生对象是否处于 Alive 状态。
在测试代码中,调用了DestroyImmediate方法销毁了原生的对象,最终CompareBaseObjects方法返回 true,重载的!=操作符返回 false。因此get_transform方法所在的指令不会被调用,从而不会出现错误异常。
!= 和 bool 操作符
!=操作符同重载的==操作符逻辑刚好相反;bool操作符具体实现也是调用CompareBaseObjects方法:
public static implicit operator bool(Object exists)
{
return !Object.CompareBaseObjects(exists, (Object) null);
}
从上面源码可以看出bool操作符内部同样使用了CompareBaseObjects方法将判定对象与 null 比较,如果判定对象为 null 返回 false,否则返回 true。
总结
到这里应该清楚某些情况下出现错误异常的原因了,在Unity 文档中关于 Object 有下面这样一句提及:
This class doesn't support the null-conditional operator (?.) and the null-coalescing operator (??).
UnityEngine.Object 类重载了==、!=以及bool操作符,对于这几类操作 Unity 会结合 CLR 层的对象以及其底层对应原生对象来得到比较结果。
所以对于 UnityEngine.Object 类(子孙类)类型相关变量使用?.和??操作符一定要谨慎,有时显式的使用null判断或bool重载符对 UnityEngine.Object 往往更“安全”。
关于 MonoBehaviour 可序列化的域
对于 MonoBehaviour 可序列化的域,在 Editor 模式下就算这些域没有真正的被“赋值”,Unity 默认也会为其 CLR 层所在的 GameObject 对象默认设置上 “fake null” object (底层原生对象不会被赋值),通过这样的小 track Unity 能够为开发者调试提供更多的有用信息。
参考
往期精选
声明:发布此文是出于传递更多知识以供交流学习之目的。若有来源标注错误或侵犯了您的合法权益,请作者持权属证明与我们联系,我们将及时更正、删除,谢谢。
限时特惠:本站每日持续更新海量设计资源,一年会员只需29.9元,全站资源免费下载
站长微信:ziyuanshu688