大纲

  • 更快的 GameObject 空引用检查
  • 避免从 GameObject 取出字符串属性
  • 避免运行时修改 Transform 的父节点

更快的 GameObject 空引用检查

事实证明,对 GameObject 进行空引用判断会导致一些不必要的性能开销。为什么呢?

首先我们得知道, 与典型的 C# 对象相比,GameObject 与 MonoBehavior 是一种特殊对象,因为他们在内存中有两个含义:

  • 存在于管理 C# 代码的相同系统管理的内存中。此处是 C# 编写的托管代码。
  • 存在于另一个单独处理的内存空间中。此处是表示本机代码。

数据可以在这两个内存空间之间移动,但每次移动都会导致额外的 CPU 开销和可能的内存分配。**这种现象,通常称为 “跨越本机 - 托管的桥接”。**如果发生这种情况,就可能会为对象的数据生成额外的内存分配,以便跨桥复制。这些额外分配的内存,便会有垃圾收集器(GC)来最终进行内存的自动清理操作。

那么,对 GameObject 的简单空引用检查,就是会导致跨桥复制的一种情况:

if(gameobject != null)
{
    // TODO something
}

那么如何避免呢?可以使用另一种稍微看起来繁琐的方式:

if(System.Object.ReferenceEquals(gameobject, null))
{
    // TODO something
}

这个 System.Object.ReferenceEquals, 可以达到同样的判断效果,而且它的运行速度是上面那种简单判断的两倍。这既适用于 GameObject ,也适用于 MonoBehavior,还适用于其他 Unity 对象。

避免从 GameObject 取出字符串属性

通常来说,从对象中检索字符串属性与检索 C# 中的任何其他引用类型的属性是一样的,不应该增加额外的内存成本。然而,从 GameObject 中检索字符串属性是另外一种能触发 “跨越本机 - 托管的桥接” 的方式。

GameObject 中受此方式影响的两个属性是 tag 和 name。因此,在游戏过程中不应该使用这两个属性。但是,tag 系统和 name 属性,通常对于某些团队来说是一个重要问题。

例如,下面的代码会在循环迭代中导致额外的内存分配:

for(int i = 0; i < listObjs.Count; i++)
{
  if(listObjs[i].tag == "Player")
  {
    // TODO something
  }
}

标识一个对象的最好方式,是通过对象的组件和类的类型来标识对象,但有时这种方法会不太方便。

幸运的是,GameObject 提供了 CompareTag() 方法,它能完全避免本机 - 托管的桥接。

下面我们来进行一个简单的测试,来证明这个简单的改变是如何改变代码的:

void Update() {
  int numTest = 10000000;
  
  if(Input.GetKeyDown( KeyCode.A ))
  {
    for(int i = 0; i<numTest; i++)
    {
      if(gameobject.tag == "Player")
      {
        // TODO something
      }
    }
  }
  
  if(Input.GetKeyDown( KeyCode.B ))
  {
    for(int i = 0; i<numTest; i++)
    {
      if(gameobject.CompareTag("Player"))
      {
        // TODO something
      }
    }
  }
}

按下 A 键和 B 键,就能触发相应的循环,然后就可以在 Profile 中看到他们 CPU 和 Memory 上的不同:

  • 未使用 CompareTag,会额外分配大概 400M 字节的内存,
  • 在详细调用时间内可以看到,未使用 CompareTag 时的 GC Allocated 处理时间约为 2000 毫秒,而且还会在垃圾回收上再花费 400 毫秒。

**可以看出,我们应该尽量避免访问 tag 和 name 属性。**在需要比较 tag 的情况下,使用 CompareTag 。但是, name 属性是没有对应的方法的,因此我们应该尽量使用 CompareTag 或者其他方式来实现。

避免运行时修改 Transform 的父节点

**在 Unity 的早期版本(Unity5.3 和更早的版本)中,Transform 组件的引用通常是在内存中随机排列的。**这意味着在多个 Transform 上的迭代是相当缓慢的,因为存在缓存丢失的可能性。但这样做的好处是,修改 GameObject 的父节点,并不会导致显著的性能下降,因为 Transform 的操作在内存中很像堆数据结构,插入和删除的速度比较快。

但是,自动 Unity5.4 以后,Transform 组件的内存布局发生了很大的变化。从那时起, Transform 组件的父子关系操作起来更像动态数组,因此 Unity 尝试将所有共享相同父节点的 Transform 按顺序存储在预先分配好的内存缓冲区内的内存中,并在 Hierarchy 窗口中根据父元素下面的深度进行排序。这种数据结构允许在整个组中进行更快的迭代,这对物理和动画等多个子系统特别有利。但这种变化的缺点是,如果将一个 GameObject 的父对象重新指定为另一个对象,父对象必须将新的子对象放入预先分配的缓冲区中,并根据新的深度对所有这些 Transform 排序。另外,如果缓冲区没有足够的空间,就必须拓展缓冲区,以便深度优先的顺序能容纳新的子对象及其所有子对象。对于较深的、复杂的 GameObject 结构,这会需要一定的时间来完成。

通过 GameObject.Instantiate() 实例化新的 GameObject 时,存在这么两种方式:

var newObj = GameObject.Instantiate(gameobject);
newObj.transform = tragetTransform;
var newObj = GameObject.Instantiate(gameobject, tragetTransform);

第一种方式,会先将实例化的 GameObject 放到场景根节点下,此时已经导致了场景根节点的缓冲区变化,然后又把这个 GameObject 设置到 tragetTransform 下面,又会触发 tragetTransform 的缓冲区变化,然后又会丢弃场景根节点的缓冲区。 如果是复杂的 GameObject,这是非常耗时的。

而第二种方式,则完全避免了这三次缓冲区的变化。

还有另外一种可以降低这个过程的方法,是让根 Transform 在需要之前就预先分配一个更大的缓冲区,这样就不会在同一帧内拓展和丢弃缓冲区。这个方式就是修改 Transform 组件的 hierarchyCapacity 属性。如果能够预估到父元素包含的子 Transform 的数量,就可以节省大量不必要的内存分配。