Unity 中「假空」对象的本质
调用 Destroy(gameObject) 后,if (gameObject == null) 常常返回 True。但紧接着用 ReferenceEquals(gameObject, null) 或 gameObject is null 判断,结果却是 False。同一个变量,判空结果截然相反,这就是 Unity 经典的「假空」现象。
根本原因在于 Unity 的双层架构
Unity 引擎的核心是 C++ 编写的。游戏对象的变换组件、渲染状态、物理属性等实质数据,全部存放在 C++ 侧的非托管内存中。我们在 C# 脚本里操作的 GameObject 和 Component,本质上只是一个指向底层数据的「包装器」句柄。
当我们调用 Destroy 时,Unity 立即释放了 C++ 侧的原生内存,但 C# 托管堆上的这个包装器对象并没有被回收,它依然存活,只是内部指向 C++ 对象的指针被置为了无效。之所以 == 返回 True,是因为 Unity 重载了 GameObject 和 Component 的 == 运算符——这个重载不去比较引用地址,而是去检查“对应的 C++ 原生对象是否还存在”。如果不存在,就返回 True,以此模拟出“对象已销毁”的假象。
真正的陷阱藏在 .NET 内置判空方法中
ReferenceEquals 和 C# 7.0 引入的 is null 模式匹配,走的是 .NET 底层的引用比较。它们完全不理会 Unity 的重载,只看 C# 包装器本身是否为 null。由于包装器对象还在托管堆上,这两个方法永远返回 False。
如果你在代码里这样写:
if (obj is null) // 或是 ReferenceEquals(obj, null)
{
// 这里的逻辑永远不会触发
return;
}
obj.transform.position = Vector3.zero; // 直接抛出 MissingReferenceException这种写法在 Unity 中是致命错误。请记住:在 Unity 中判空,永远只认 == null 或隐式布尔转换(即 if (obj)),禁用 ReferenceEquals 和 is null 来判断对象的销毁状态。
「假空」引用的危害在于 GC 压力
「假空」引用的累积会产生真正的问题:增加 C# 托管堆的压力。失效的包装器本身虽然很轻量,但它依然占据托管内存。如果这些包装器被成员变量、List、Dictionary 等 GC 根长期持有,它们就不会被回收。频繁地创建和销毁 GameObject,又始终不释放这些根引用,托管堆上就会堆积大量死去的包装器。当堆内存达到阈值,.NET 的垃圾回收就会被频繁触发,而 GC 触发时会挂起所有线程,直接表现为游戏卡顿。
解决方案的核心是切断 GC 根
解决问题的关键不在于把变量赋值为 null 这个动作本身,而在于确保没有活的 GC 根指向这个失效包装器。
对于局部变量,调用 Destroy 后顺手置空即可。但对于容器类(如 List<GameObject>),仅将引用变量置空是无效的,因为容器里还存着这个条目的强引用。必须在 OnDestroy 回调或统一的清理函数中,主动将该条目从集合中移除。至于 Destroy 是异步执行的,这并不要紧——逻辑上既然调用了销毁,就该立刻把对象视为无效,切断所有代码路径对它的访问,异步延迟的只是 C++ 内存的归还时机,你的业务逻辑应该马上与它脱钩。
理解这一点后你会发现,Unity 对运算符的重载本质上是在兼容底层 C++ 对象的不可控生命周期,但这份便利换来了判空语义的混乱。脱离 .NET 的直觉,严格遵守 Unity 的判空规范,并时刻留意容器中的残留引用,是写出健壮且流畅 Unity 代码的基本功。