引言
在日常的 Unity 开发中,当对一个游戏物体执行了 Destroy(gameObject)后,但在下一帧或者协程里,用某些高级语法检查它时,显示这个已经销毁的对象"不为空",然后系统就抛出了一个 MissingReferenceException。
这就是Unity的Fake Null(假空)现象。
什么是"Fake Null"?
要理解假空,我们首先需要了解Unity 引擎底层相关逻辑。Unity 引擎的核心逻辑(包括渲染、物理、内存管理)是基于 C++ 编写的,而我们平时写的业务脚本则是基于 C# (托管在 Mono 或 IL2CPP 虚拟机上)。
这就意味着,场景里的一个 GameObject,在内存中其实有两份实体:
- C++ 层的 Native Object(原生对象): 这是真正的实体,占据着大量的内存,包含所有的组件、Transform 数据等。
- C# 层的 Wrapper Object(包装对象): 这是一个极其轻量的"遥控器"或"空壳",它的唯一作用就是让你在 C# 脚本里能引用和操作那个底层的 C++ 实体。
假空现象产生的根本原因在于:这两者的生命周期是不一致的。
当你调用 Destroy(gameObject) 时,引擎会立即(或在帧末)销毁底层的 C++ 实体。但是,C# 层的那个"遥控器(引用变量)"由于还被你的脚本持有着,它并没有立刻消失,必须等待 C# 的垃圾回收器(GC)来清理它。
此时,如果去检查这个 C# 变量,它在 C# 内存空间里是确确实实存在的(不为 null) ,但它所指向的底层 C++ 对象已经被释放了。这就形成了所谓的"假空"。
UnityEngine.Object 的自定义运算符重载
为了解决上述生命周期错位带来的业务逻辑判断问题,Unity 在 UnityEngine.Object 基类中显式重载了 == 和 != 运算符 ,并重写了内置的隐式 bool 转换逻辑。
当你写出 if (myObject == null) 时,Unity 并没有使用 C# 默认的引用检查逻辑,而是向底层的 C++ 引擎查询: "这个 C# 引用对应的 C++ 实体还在吗?"
- 如果 C++ 实体已经被
Destroy了,Unity 就会强行让这个判断返回true。
现代C#语法糖的出现
随着 C# 版本的不断迭代,一些新的语法糖成了打破这个谎言的罪魁祸首。
由于这些语法糖是 C# 编译器层面的特性,它们完全绕过了 Unity 重载的 == 运算符,直接检查 C# 对象的引用。这就导致"假空"现象频繁出现。
踩坑一:空值条件运算符 ?.
csharp
// 假设 targetObj 在上一帧被 Destroy 了
// 你想安全地获取它的 Transform
Transform t = targetObj?.transform;
结果: 抛出 MissingReferenceException。 原因: ?. 检查的是 C# 包装对象是否为空。因为 GC 还没回收它,所以判断通过,继续执行后面的 .transform,此时尝试访问底层 C++ 数据,发现对象已死,直接崩溃。
踩坑二:空值合并运算符 ??
csharp
// 如果 currentEnemy 被销毁了,就给它赋一个新的
currentEnemy = currentEnemy ?? GetNewEnemy();
结果: currentEnemy 依然是那个失效的旧对象,并没有赋新值! 原因: 同样,?? 发现 C# 引用还在,认为它"不为空",于是返回了左侧那个失效的包装对象。
踩坑三:强行验证引用
csharp
if (object.ReferenceEquals(targetObj, null))
{
// 如果 targetObj 被 Destroy,这里永远进不来
}
结果 :与 ==或!=不同的是,System.Object.ReferenceEquals(A, B) 是 C# 最底层的内存比对方法,它直接对targetObj和null的地址进行比较,结果为false,说明只是Destroy无法真正使这个引用为空。 你必须手动将变量置空(例如 targetObject = null;),或者等这个变量的作用域结束(比如方法执行完毕,局部变量被销毁)。
如何避免假空?
1. 禁用 ?. 和 ??
对于所有继承自 UnityEngine.Object 的类型(如 GameObject, Transform, MonoBehaviour),严禁 使用 ?. 和 ?? 运算符。它们会绕过 Unity 底层检查,直接引发报错。
- ❌ 错误 :
var objName = myObject?.name; - ✅ 正确 :
if (myObject != null) { var objName = myObject.name; }
2. 使用 if (obj) 判空
推荐利用 Unity 的隐式布尔转换,代码更短且绝对安全。它会正确向底层验证 C++ 实例是否存活。
- ✅ 推荐 :
if (myObject) { ... }(等同于!= null) - ✅ 推荐 :
if (!myObject) { ... }(等同于== null)
3. Destroy 后立即置空
Destroy() 只会销毁底层 C++ 对象。如果当前变量不会立刻离开作用域(如类成员变量),必须手动置为 null,帮助 GC 快速回收 C# 内存。
csharp
Destroy(myObject);
myObject = null; // 斩断引用,防止假空,加速 GC
结语
Unity 引擎的高度封装特性虽然大幅提升了开发效率,但在特定场景下也潜藏着不易察觉的风险。深入理解"Fake Null"现象背后的 C++/C# 跨域架构隔离机制,不仅有助于规避非预期的运行时异常,也能在处理内存泄漏等性能优化问题时,为我们提供更清晰的排查思路。
希望这篇文章能帮你填平日常开发中的一个小坑,如果在实际项目中你也遇到过类似的底层机制坑,欢迎在评论区一起交流探讨!