挖一挖 Unity 中脚本的执行顺序

当我们把 Unity 脚本绑定到对象上,游戏运行时对象上的脚本会被执行。

在项目开发中,我们可能会遇到过这种问题: 如果对象 A 脚本中使用了对象 B 的某个脚本的实例,但是 A 在 B 还没有初始化时就调用了 B 脚本实例中的方法,这样就会出现异常。所以脚本的执行顺序得控制好。

首先,有如下场景。在场景中由上到下,依次添加对象;随后对象中的脚本依次绑定(此处的脚本都是 Unity 脚本):

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
28
29
30
31
32
场景 Scene
├── GameObject0
│ └── Script4.cs
│ ├── Awake()
│ ├── OnEnable()
│ ├── Start()
│ ├── Update()
│ ├── OnDisable()
│ └── OnDestroy()
├── GameObject1
│ └── Script3.cs
│ ├── Awake()
│ ├── OnEnable()
│ ├── Start()
│ ├── Update()
│ ├── OnDisable()
│ └── OnDestroy()
└── GameObject2
├── Script0.cs
│ ├── Awake()
│ ├── OnEnable()
│ ├── Start()
│ ├── Update()
│ ├── OnDisable()
│ └── OnDestroy()
└── Script1.cs
├── Awake()
├── OnEnable()
├── Start()
├── Update()
├── OnDisable()
└── OnDestroy()

记住添加脚本的顺序,也是由上到下为游戏对象依次添加的。运行后结果是:

1
2
3
4
5
6
7
8
9
10
11
12
13
GameObject2.Script1.Awake > GameObject2.Script1.OnEnable >
GameObject2.Script0.Awake > GameObject2.Script0.OnEnable >
GameObject1.Script3.Awake > GameObject1.Script3.OnEnable >
GameObject0.Script4.Awake > GameObject0.Script4.OnEnable >
GameObject2.Script1.Start > GameObject2.Script0.Start >
GameObject1.Script3.Start > GameObject0.Script4.Start >
GameObject2.Script1.Update > GameObject2.Script0.Update >
GameObject1.Script3.Update > GameObject0.Script4.Update >
... >
GameObject1.Script3.OnDisable > GameObject1.Script3.OnDestroy >
GameObject2.Script0.OnDisable > GameObject2.Script1.OnDisable >
GameObject2.Script0.OnDestroy > GameObject2.Script1.OnDestroy >
GameObject0.Script4.OnDisable > GameObject0.Script4.OnDestroy

多次运行或者切换场景再运行,会发现结果还有些不一样。下面就依次看看导致不同结果出现的原因。

首先是第一个问题 - 多次运行或切换场景回来在运行,GameObject2、GameObject1 和 GameObject0 执行顺序可能会有先后。

首先看看第一部分的问题,不同脚本的执行先后顺序会出现不同的结果。先来看看 Unity 文档对控制脚本执行顺序得解决办法:

Scripts can be added to the inspector using the Plus “+” button and dragged to change their relative order. Note that it is possible to drag a script either above or below the Default Time bar; those above will execute ahead of the default time while those below will execute after. The ordering of scripts in the dialog from top to bottom determines their execution order. All scripts not in the dialog execute in the default time slot in arbitrary order.

Unity 提供了一种脚本执行顺序的解决方案,在 Edit > Project Settings > Script Execution Order 中我们可以自定义脚本的执行顺序。设置框中间的 default time 区域,这是脚本默认(脚本未设置 Execution Order)的执行区间。在设置框中,自上到下脚本依次按顺序执行。文档中最后说到,未设置 Execution Order 的脚本会在 default time 的时间间隙中随机执行

上面的设置,会修改脚本的 meta 文件中 executionOrder 的值,类似 executionOrder: -50,这个值越小该脚本越先执行。

上面最后一句就是我们遇到的情况。因为在测试中,我们所有的脚本都未设置 Execution Order,难道这些脚本就一直是随机执行的吗?再次多次进行测试,结果发现不同对象上的脚本执行顺序后面就会以固定的先后顺序去执行了。此刻就想,Unity 总会有东西记住了这个顺序,然后才能以这个固定的顺序去执行吧!

我们知道,场景文件其实就是一个 YAML(配置文件的一种格式),其中记录了整个场景的配置信息。那么会不会在这个文件中有记录顺序了?当向场景中添加一个对象的时候,会增加以下配置信息:

unity_script_execution_order_1.jpeg

第一行 --- !u!1 &1609942489 后面的数字对应了新增加的对象的 fileID,m_Component 里面就是该对象身上拥有的组件,可以通过 fileID 找到对应的组件信息。

场景的配置文件中,我们测试的三个对象的配置如下:

m_Name fileID 在配置文件中的行数
GameObject0 927923936 253
GameObject1 1609942489 296
GameObject2 384326570 116

向场景添加这三个对象的时候是依次按顺序添加的,但每次添加对象对应的配置信息在场景配置文件中的位置是随机的,且生成的 fileID 也是随机且唯一的。而处于 default time(未自定义脚本执行顺序)的脚本的随机执行顺序和这个 fileID 就有关系

  • 当第一次编辑场景的 Session 一直未被销毁时(切换场景或重启 Unity),脚本执行的顺序和挂载脚本的顺序有关。对于不同对象上的不同脚本,后挂载脚本对象上的脚本会先执行(且等该场景中所有脚本某个方法均执行完后才会轮到下一个方法)。所以上面表格中那几个对象上挂载的脚本执行顺序是: GameObject2 > GameObject1 > GameObject0。

  • 但是当这个编辑场景的 Session 被更新(切换场景或重启 Unity),脚本的执行顺序开始以一种固定的顺序执行。fileID 越小的对象,其上挂载的脚本越先执行。所以上面表格中那几个对象上挂载的脚本执行顺序是: GameObject2 > GameObject0 > GameObject1。

为了验证是 fileID 影响的执行结果,我们尝试将 GameObject1 以及其组件的 fileID 都改的比 GameObject0 要小但是比 GameObject2 对象的要大。

按照上述方法修改之后的执行顺序结果是: GameObject2 > GameObject1 > GameObject0。

这就证明了 fileID 就是影响不同对象上脚本(这些脚本都处于 default time 执行)执行顺序的一个点,fileID 越小的对象,其上挂载的脚本越先执行。Unity 文档中说这类脚本是随机执行的,是因为当你向场景中添加一个对象时,它的 fileID 是随机生成的(大小也随机)而且有可能被更新,所以就有了 'All scripts not in the dialog execute in the default time slot in arbitrary order'。

看完了不同对象的 fileID 是如何影响不同的脚本执行顺序的,接下来再来看看同一个对象上的不同脚本执行顺序。

为对象挂载一个脚本的时候,它所在场景的配置信息中也会有一些改变。首先它的 m_Component 这个配置下会增加一个新的 component 信息,类似 - component: {fileID: 384326573},然后场景配置中也会增加对应的这个 fileID 的 component 配置信息。如果是脚本,component 信息中会有一个 m_Script 属性,类似 m_Script: {fileID: 11500000, guid: a4969ee1e69314f91be663e63e413a51, type: 3},这里面的 guid 对应的就是脚本的 meta 文件的 guid,最终就能找到需要加载脚本的。

有一点需要注意的是,对象绑定的脚本的 fileID 是根据对象本身的 fileID 开始往下递增,如果 fileID 存在则跳过继续查找下一个可用的值作为 fileID。新增的 component 放在对象 m_Component 中最后的位置

比如下面在 GameObject2 对象上新增了几个脚本,此时它的 m_Component 有以下这些值:

fileID Script Type Script 索引位置
384326571 Transform 0
384326572 MonoBehaviour Script0.cs 1
384326573 MonoBehaviour Script1.cs 2
384326574 MonoBehaviour Script2.cs 3
  • 对同一对象上的不同脚本,其最终执行顺序也是由那个脚本所在对象配置中的 fileID 大小决定的,fileID 越小的脚本越先被执行。表格里的索引位置反映其在 Unity 编辑器中的位置,索引越大越靠下显示。

所以上面表格中脚本执行顺序是: Script0.cs > Script1.cs > Script2.cs。

现在修改 Script0.cs 的 fileID 位于 Script2.cs 的 fileID 后一位,修改后运行发现执行顺序也变成了 Script1.cs > Script2.cs > Script0.cs。

接下来就是第二个问题 - 某些杂乱无章的回调。

Unity 事件回调方法中,有时候看起来很有规律,而某些方法却又显得杂乱无章。不过这些事件回调都遵循 Unity Script Lifecycle,大致有以下几点:

  • 场景中所有脚本某个方法均执行完后才会开始执行这些脚本的下一阶段的方法。

  • 生命周期中的特定方法都是一起调用的,比如全部 Start 执行完后才会开始执行下一阶段的方法。但是 OnEnable 方法不会等待 Awake 方法全部执行完后再执行,而是执行完一个脚本上的 Awake 方法就会紧接着执行这个脚本上的 OnEnable,它们可以看成是一体的。

  • 另外有区别之处的是 OnDisableOnDestroy 方法,它们执行的顺序是:

    • 不同对象上的不同脚本,执行没有先后规律,而且是成对执行;
    • 同一个对象中的不同脚本上,这两个方法是按照脚本在 m_Component 中的索引位置按顺序执行的,且不是成对执行。索引自小到大对应的脚本中 OnDisable 方法依次执行,这个对象中所有挂载的脚本的 OnDisable 方法都执行完后,在按照执行 OnDisable 方法的顺序依次执行 OnDestroy 方法。

这两个问题都弄清除怎么回事了,文章开始我们遇到的需求就有了解决思路。

  • 如果一个对象上的脚本使用了未初始化脚本中的某个方法导致出现异常,首先查看这些脚本是否设置了 Execution Order 导致顺序有问题;

  • 如果都未设置 Execution Order,查看这些脚本所在的配置文件中的 fileID,通常 fileID 越小会越先被执行。

但是,这样有时还是不能解决问题。比如对象 A 上的脚本在 Awake 方法调用了对象 B 上的脚本中的单例对象,且对象 B 上的脚本后挂载(这样这个脚本就会先初始化),在这种情况下,如果 B 上的那个脚本单例也是在 Awake 方法初始化,没有任何问题,但是如果延迟到 Start 方法才初始化,同样会出现异常情况,所以最好是使用以下方案解决:

  • Awake 方法中初始化,但是其他调用方在 Start 方法再调用保证足够安全(由 Unity 事件机制保证)