【技术干货 】 实现高效项目管理的方法

2016年01月15日 14:02 0 点赞 1 评论 更新于 2025-11-21 19:35

一、Unity消息系统概述

Unity拥有一个消息系统,该系统用于在游戏运行时实现脚本内部方法的调用。这对于新用户而言,是一个简单且易于理解的概念。用户只需定义一个Update方法,Unity便会每帧调用该方法中的内容。

然而,经验丰富的开发者可能会产生以下疑问:

  1. 不清楚Update方法究竟是如何被调用的。
  2. 当一个场景中有多个对象时,不清楚这些Update方法的调用顺序。
  3. 认为这种代码风格不够智能。

二、UPDATE方法的调用机制

Unity并未使用System.Reflection进行方法的定位。具体而言,对于给定类型的MonoBehaviour,Unity会通过底层脚本进行检查,判断脚本在运行过程中(无论是使用Mono还是IL2CPP)是否定义了特定方法,同时检查其中的内容是否已被缓存。若检查到特定方法,就会将其添加到合适的列表中。例如,当一个脚本中定义了Update方法,该脚本会被添加到需要每帧更新的脚本列表中。在游戏运行时,Unity只需重复执行所有列表中的方法即可。因此,Update方法是public还是private并不影响其调用。

三、UPDATE方法的执行顺序

UPDATE方法的执行顺序由脚本执行顺序设置(Script Execution Order Settings,菜单:Edit > Project Settings > Script Execution Order)决定。手动设置1000个脚本的执行顺序并非明智之举,但可以对某些特定脚本的执行顺序进行微调。未来,Unity计划提供更便捷的方式来指定执行顺序,比如在代码中使用特性(Attribute)。

四、无法使用INTELLISENSE的问题

在Unity中,使用某些IDE编译C#脚本时,这些IDE大多无法识别特定方法的调用位置,这常导致警告信息以及代码导航困难。一些开发者通过创建一个名为BaseMonoBehaviour或类似名称的抽象类来扩展MonoBehaviour,并在项目中的每个脚本里继承该类。他们在这个抽象类中编写了一些有用的功能以及一系列虚的特殊方法。

这种结构能使代码在使用MonoBehaviour时更具逻辑性,但存在一个小缺点。所有继承自BaseMonoBehaviourMonoBehaviour都会被存储在Unity的内部更新列表中,这意味着所有脚本在每帧都会调用这些基本上没有实际作用的方法。虽然这些方法为空,但从C++到托管C#的调用存在成本开销。

五、调用10000个UPDATE的测试

为了深入研究这一问题,本文作者在Github上创建了一个示例项目,大家可以通过以下链接下载:https://github.com/valyard/Unity-Updates

该项目包含两个场景,可通过点击设备或在编辑器中按任意键进行切换:

  1. 第一个场景:使用特定代码创建了10000个MonoBehaviour
  2. 第二个场景:同样创建了10000个MonoBehaviour,不同的是,代码中不仅调用Update方法,还加入了一个由Manager脚本在每帧调用一次的自定义UpdateMe方法。

测试项目在两台iOS设备上分别编译为Mono和IL2CPP,发布设置均设为非开发模式。运行时间记录如下:

  1. 在第一次Update调用时设置一个Stopwatch(在Script Execution Order中配置)。
  2. LateUpdate时停止Stopwatch
  3. 将获得的计时时间均摊到几分钟上。

测试环境信息:

  • Unity版本: 5.2.2f1
  • iOS版本: 9.0

测试结果

  1. Mono:最初的测试结果显示耗时较长,后来发现是忘记将Script Call Optimization设为Fast but no exceptions。这表明该设置对性能有显著影响。
  2. Mono (fast but no exceptions):设置为Fast but no exceptions后,性能得到明显提升。
  3. IL2CPP:测试发现,这种优化对于IL2CPP同样有效,并且IL2CPP仍有改进空间。在撰写本文时,Scripting与IL2CPP团队正在努力提高性能,例如最新的Scripting分支内包含的优化可使测试运行速度提高35%。

六、接口调用、虚调用以及数组访问

测试结果表明,如果需要在每帧循环迭代包含10000个元素的列表,使用数组而非List会更高效。因为使用数组生成的C++代码更简单,数组访问速度也更快。在后续测试中,将List<ManagedUpdateBehavior>改为ManagedUpdateBehavior[]后,性能得到了显著提升。

七、深入剖析Unity的UPDATE调用过程

为了了解从C++调用C#函数较慢的原因,我们使用Apple Instruments的Time Profiler进行分析。需要注意的是,这并非Mono与IL2CPP的对比测试,讨论的大部分内容对Mono iOS构建同样适用。

在iPhone6上使用Time Profiler启动测试项目,记录几分钟的数据后,选择一分钟进行详细检视。从void BaseBehaviourManager::CommonUpdate<BehaviourManager>()这行代码开始的所有内容都是我们关注的重点。

具体调用过程

  1. UpdateBehavior.Update():在Time Profiler中可以看到Update方法以及IL2CPP调用它的方式(UpdateBehavior_Update_m18),但在调用该方法之前,Unity还执行了许多其他操作。
  2. 循环迭代所有的Behaviour:Unity会循环迭代所有的Behaviour并执行更新操作。特殊的迭代类SafeIterator确保了即使移除了列表中的下一项,整个循环也不会中断。仅仅是循环迭代所有已注册的Behaviour就花费了9979ms中的1517ms。
  3. 检测调用是否有效:Unity会进行一系列检测,确保调用的方法属于某个已激活、已初始化且Start方法已被调用过的GameObject。这些检测避免了在Update中销毁GameObject时游戏崩溃的问题,但这一步骤花费了9979ms中的2188ms。
  4. 准备调用方法:Unity创建了一个ScriptingInvocationNoArgs实例(代表了一个从原生到托管的调用)以及ScriptingArguments,然后命令IL2CPP虚拟机调用方法(scripting_method_invoke函数)。这一步消耗了9979ms中的2061ms。
  5. 调用方法scripting_method_invoke函数首先检测传入的参数是否有效(900ms),然后调用IL2CPP虚拟机的Runtime::Invoke方法(1520ms)。Runtime::Invoke方法首先检测方法是否存在(1018ms),而后调用一个生成的RuntimeInvoker函数获取方法签名(283ms),最后调用Update函数,这一步仅花费了42ms。

八、托管的更新

Manager测试中使用Time Profiler可以发现,大部分执行时间实际上都花在了UpdateMe函数上(或者说花在了IL2CPP调用ManagedUpdateBehavior_UpdateMe_m14上)。此外,IL2CPP还插入了一个null检测,以确保循环迭代的数组不为null

九、测试的公平性分析

需要指出的是,本次测试并非完全公平。Unity为了防止游戏出错或崩溃,进行了大量的检测工作,例如检查GameObject是否已激活、是否在Update循环中被销毁、对象上是否存在Update方法以及如何处理在Update循环中创建的MonoBehaviour等。而作者作为开发者,在架构Manager类时,清楚代码的具体行为以及游戏中不会出现的情况,但Unity并不具备这些信息。

十、建议与总结

在实际项目中,单一场景使用大量需要每帧执行代码的GameObject的情况并不少见。这些代码看似微不足道,但当数量巨大时,调用几千个Update方法的开销将变得显著。此时再修改游戏架构、重构对象为Manager样式可能为时已晚。因此,开发者在开始新项目时,应充分考虑这些性能因素。

来源:Unity官方社区

作者信息

洞悉

洞悉

共发布了 3994 篇文章