关于Object.Destry和空检查

在 Unity3D 中总是要进行对象的销毁和Null 检查,虽然经常用,但是其中的一些细节,如果不太注意,很有可能就会踩坑,下面就总结一下 UnityEngine.Object 类型的销毁,以及空值检测相关问题。

Object.Destroy Vs Object.DestroyImmediate

Unity3D 提供了两种方式来销毁对象(GameObject/Component),分别是 Object.DestroyObject.DestroyImmediate,两者区别主要有两点:

  1. Destroy 提供了延迟销毁参数,可以延迟x秒后进行销毁,即使不指定延迟参数,Destroy也将推迟到当前更新结束,渲染开始之前执行
  2. DestroyImmediate 调用立即销毁对象;

User Manual 中关于 Object.Destroy 的相关说明 : Actual object destruction is always delayed until after the current Update loop, but will always be done before rendering.

关于Unity3D的事件更新流程,可以参考用户手册.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class NewBehaviourScript : MonoBehaviour
{
GameObject g1, g2;

void Start()
{
g1 = new GameObject();
g2 = new GameObject();

Destroy(g1);
DestroyImmediate(g2);

// Destroy 调用之后,不会立即销毁,需要等到更新结束
Debug.LogFormat("G1 destroy? ::{0}", g1 == null); // false
Debug.LogFormat("G2 destroy? ::{0}", g2 == null); // true

StartCoroutine(EndOfFrame());
}

IEnumerator EndOfFrame()
{
yield return new WaitForEndOfFrame();

// 当前帧结束,物体被销毁了
Debug.LogFormat("G1 destroy at frame end ? ::{0}", g1 == null); // true
}
}

输出结果如下:

1
2
3
G1 destroy? ::False
G2 destroy? ::True
G1 destroy at frame end ? ::True

Object 对象的 Null 检测

UnityEngine.Object 继承的对象,包括托管非托管两部分,当调用 Destroy 时,销毁的只是非托管部分,托管部分只能通过 C# 的垃圾回收器进行回收,先看下面一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Sample02 : MonoBehaviour
{
void Start()
{
GameObject g1 = new GameObject();

Object.DestroyImmediate(g1);

// 检测 g1 是否被销毁 :true
Debug.LogFormat("Is g1 destroy? {0}", g1 == null);

// 检测 g1 是否指向 null :false
Debug.LogFormat("Is g1 reference null? {0}", Object.ReferenceEquals(g1, null));
}
}

输出结果:

1
2
Is g1 destroy? True
Is g1 reference null? False

可以看出来,通过 g1 == null 来判断Object是否被销毁,而通过ReferenceEquals则没有办法判断,这是因为 g1 一直指向堆对象,除非我们显示的执行g1 = nullReferenceEquals(g1,null)才能返回 ture

C# 引用类型默认也会调用ReferenceEquals来比较相等性,那么为什么 g1 == null 却返回 true 呢,这是因为 基类UnityEngine.Object 重写了 operator== 操作符,具体的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static bool operator==(Object x,Object y)
{
bool xnull = (object)x == null;
bool ynull = (object)y == null;

if(xnull && ynull)
return true;

if(!xnull)
return !IsNativeObjectAlive(y);

if(!ynull)
return !IsNativeObjectAlive(x);

return x.m_InstanceID == y.m_InstanceID;
}

这里顺便说一句,Object 类还重写了 operator bool 操作符,所以 if(g1){} 的含义是,当g1非空时,执行相应的代码块。

接口类型的 Null 检测

在 C# 中,比较接口类型的相等性时,总是会采用RenferenceEquals 来判断,(对于值类型,需要先进行装箱,再进行比较),一般情况下,这点不会引起上面问题,但是当自定义的 MonoBehaviour 类实现某个接口时,如果对该接口类型进行相等性比较,其结果就可能不正确,还是看下面一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Sample02 : MonoBehaviour, IGreet
{
void Start()
{
Sample02 sample02 = gameObject.AddComponent<Sample02>();

IGreet ig = sample02;

DestroyImmediate(sample02);

Debug.LogFormat("Is obj destroy? {0}", sample02 == null);
Debug.LogFormat("Is interface equals null ? {0}", ig == null);
}
}

public interface IGreet { }

输出结果如下:

1
2
Is obj destroy? True
Is interface equals null ? False

是不是对结果感到很奇怪,其根本原因是因为,对于接口类型调用operator==,总是会调用 ReferenceEqual进行比较,这一点可以通过反编译 IL 代码进行验证,而 sample02 == null 则会调用基类方法。

那么对于接口类型,应该如何判断接口对象是否被销毁了呢,很简单,只需要给接口添加个判断方法,还是上面的例子,修改后的接口如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Sample02 : MonoBehaviour, IGreet
{
public bool IsDestroy()
{
return this == null;
}

void Start()
{
Sample02 sample02 = gameObject.AddComponent<Sample02>();

IGreet ig = sample02;

DestroyImmediate(sample02);

Debug.LogFormat("Is obj destroy? {0}", sample02 == null); // true
Debug.LogFormat("Is interface equals null ? {0}", ig == null); // false
Debug.LogFormat("Is interface destroy? {0}",ig.IsDestroy()); // true
}
}

public interface IGreet
{
bool IsDestroy();
}

在 UGUI 源码中,ICanvasElement 接口就是采用这种方法来实现,UIBehavior 类继承自 MonoBehavior,并实现了 ICanvasElement 接口

对于 Object 类型,避免使用 ?? 和 ?. 操作符

在用户手册中,明确说明了 Object 类型不支持 null-conditional operator (?.)the null-coalescing operator (??),这是因为 Object 类型,并没有提供这两个操作符的重载,可能造成某些不正确的行为,看下面例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Sample02 : MonoBehaviour
{
void Start()
{
GameObject g1 = new GameObject();

Object.DestroyImmediate(g1);

GameObject newGo = g1 ?? new GameObject("NewGo");

Debug.LogFormat("newGo == null : {0}", newGo == null);
}
}

输出结果:

1
newGo == null : True

产生这一结果的原因还是因为,operator ??默认采用 ReferenceEquals 来做空判断,造成最后newGo指向了已经销毁的 g1

虽然大多数情况下,使用operator ??operator ?. 也能得到正确的结果,但是最好避免使用。

总结

上面啰里啰嗦说了一大堆,其实主要想说的就四点:

  1. Destroy 是延迟销毁,销毁时机是当前帧更新结束,渲染开始之前;
  2. 除非变量显示设置为null,否则 ReferenceEquals(obj,null) 总是返回false;
  3. C# 中的 interface 采用ReferenceEquals 进行比较,无论值类型还是引用类型;
  4. MonoBehavior 实现接口时,对于接口类型变量,避免使用 interfaceTypeObj == null 来判断是否销毁;