关于Unity null check的误区

前言

这篇博文的起因还得追溯到我在学习UGUI源码时遇到的“奇怪”代码:

public abstract class UIBehaviour : MonoBehaviour
{
    // Other code...

    public bool IsDestroyed()
    {
        // Workaround for Unity native side of the object
        return this == null;
    }
}

当时我很诧异为什么需要专门写一个方法来执行这么简单的运算,所以就查阅了一些相关资料,结果发现了一些有趣的事,甚至让我想起曾经怎么也查不出来的Bug是怎么发生的。
本来参考了以下几篇博文:
Custom == operator, should we keep it?
Checking for null references in Unity

Unity 自定义的 == 操作符

如果你翻看过 UnityEngine.Object 中的API,会发现其中悄咪咪的对 “==” 进行过重载操作:

namespace UnityEngine
{
    public class Object
    {
        // Other code...

        public static bool operator ==(Object x, Object y);
    }
}

注意!这里所有的Object都是在UnityEngine命名空间下的,和C#中的 System.Ojbect (可简写为object)没有任何关系。也正因为这样,Unity重载的 == 只在用于两个 UnityEngine.Object 之间时才会生效。

根据Unity官方的说法,他们这么做不是吃饱了撑的,而是出于两个方面的考虑:

1. 提供更好的调试信息

当我们在C#脚本中声明了public UnityEngine.Object变量时, 把该脚本添加到某个GameObject上,Inspector窗口中就会显示一个Object的插槽,开发者就可以通过直接在Editor中拖拽其他GameObject来绑定引用。虽然表面上看这个Object 变量的默认值是 null 。但是实际上它们默认指向一个 “fake null”,即伪装为null的对象。经过Unity重载的 “==” 操作符可以识别这种“fake null”对象,并进行对应的操作。尽管这些“fake null”对象看起来很怪异,但是我们可以在它们的内存空间中保存和语义上下文相关的信息。得益于这些信息,当开发者尝试在“fake null”对象上调用方法或者访问字段时,可以获得更多有用的调试信息。如果不对“==”进行重载的话,我们在访问指向null的对象时只会抛出 NullReferenceException的异常和栈追踪,却无从得知是哪个GameObject上的哪个MonoBehaviour脚本中的哪个字段包含了该null引用。
我们可以通过测试验证上述的说法,在GameObject上添加以下脚本:

using UnityEngine;

public class TestNull : MonoBehaviour
{
    // 1. 只有当 m_Object 为public且被序列化时才会被默认赋值为 'fake null'
    // 可以添加以下声明测试效果:
    // private GameObject m_GameObject;
    // [System.NonSerialized]
    public Object m_Object;
    // 2. 只要继承自 UnityEngine.Object 都会被重载操作符
    // public GameObject m_Object;
    public void Start()
    {
        print(m_Object == null);  // true
        print((object)m_Object == null);  // false
        print(object.ReferenceEquals(m_Object, null));  // false
    }
}

在编辑器中运行结果和预期效果相同。

2. 用于判断C++侧的Object是否被销毁

当我们获取一个 UnityEngine.Object对象时,它几乎不包含任何东西。这是因为Unity引擎实际是使用C/C++搭建,所有和GameObject相关的信息(name, GameObject包含的组件列表,HideFlags 等等)实际上都存放在C++端。C#端唯一包含的东西就是一个指向内存中对象的指针,所以C#端的对象又被称为“wrapper objects”1。那些继承自UnityEngine.Object的对象都在C++端显式管理(回想C++中的new和delete)。当我们加载一个新场景或者调用 Object.Destroy(myObject)时,C++的对象才会被销毁和释放内存。相比之下, C#中对象的生命周期并不是由开发者控制,而是依赖于C#中的自动内存管理机制——通过一个垃圾回收器(Garbage Collector, GC)。这就意味着很有可能在C++中的对象已经被销毁后,C#中依然有与之对应的 “Wrapper Object”,指向已经被销毁的内存空间。如果使用“==”将Destroy后的对象和null进行比较,会发现返回结果为true,尽管实际上C#中的对象并不为null。

3. 重载“==”带来的问题

尽管基于上述两个理由对“==”进行重载还算合乎情理,但是也会带来一些其他方面的问题。

  • 从逻辑上来看不够直观。虽然进行重载后可以通过“==”判断 UnityEngine.Object 对应的C++数据是否被销毁,但是C#中的对象依然存在。
  • 经过重载后的“==”会比预期的更慢。
  • 当使用 ?? 2:操作符时会出现不一致性。这是因为 ?? 进行null检查时使用C#原始的“==”而不是经过Unity重载后的。
using System.Collections;
using UnityEngine;

public class TestNull : MonoBehaviour
{   
    IEnumerator Start()
    {
        Object _object = new GameObject();
        Destroy(_object);

        yield return new WaitForSeconds(1.0f);  // Destroy 完全销毁_object 需要时间

        print(_object == null);  // true
        print((object)_object == null);  // false

        _object = _object ?? new GameObject();  // 使用未重载的“==”进行比较,不会创建新的对象
        print(_object == null);  // true
        print((object)_object == null);  // false

        if (_object == null)  // 使用重载过的“==”进行比较,结果为true
        {
            _object = new GameObject();
        }
        print(_object == null);  // false
        print((object)_object == null);  // false
    }
}

经过测试,实际结果和我们预想的一致。为了避免出现意想不到的错误,我建议在写代码时:
1. 不要使用??语法糖,直接使用“==”进行判断
2. Destroy后可以使用 _object = null 释放失效的引用,便于GC进行回收。

UGUI源码解析

回到开头的疑问,为什么需要为UIBehaviour 准备 IsDestoyed 方法?实际上,UI组件的父类Graphic就是继承自UIBehaviour,并且还实现了ICanvasElement接口。但是由于ICanvasElement接口并没有重载“==”方法,所以需要实现该接口的类提供一个方法判断对象是否被销毁且不为null。因为UIBehaviour继承自UnityEngine.Object,所以在UIBehaviour中的操作符“==”就可以用于判断对象是否被销毁。

总结

  • 所有的UnityEngine.Object都对“==”操作符进行了重载,当我们使用“==”进行null检测时,表达式的含义就不再是对象是否为null,而是对象是否被销毁。
  • 手动将UnityEngine.Object 赋值为null可以使对象从‘fake null’状态进入真正的null状态。
  • 可以通过将对象强转为System.Object来查看其是否真的是null。

  1. Wrapper意为包装,封装,由于C#端并没有实际的数据,只充当调用的接口,所以得名。 ↩
  2. ??是由C#提供的语法糖,obj1 = obj1??obj2 等价于 if (obj1 != null) obj1 = obj1; else obj1 = obj2;
    针对上面几点我们也可以通过实际测试来验证: ↩

你可能感兴趣的:(unity开发)