Unity有个消息系统,该系统用来实现游戏运行时脚本内部方法的调用。这是个非常简单和容易理解的概念,特别对新用户来说。只需定义一个Update方法,便可以每帧调用该方法中的内容。
一个经验丰富的开发者肯定会对此产生疑问:
1.不清楚这个方法究竟是如何被调用的。2.不清楚当一个场景中有多个对象时,这些方法是如何调用顺序。
3.这种代码风格不是十分智能。
UPDATE是怎么被调用的
UPDATE方法们的执行顺序是什么
无法使用INTELLISENSE
这个结构可以使你在代码中使用MonoBehaviour时更有逻辑性,但存在一个小缺点。我打赌你已经发现了……你所有的MonoBehaviour都会储存在Unity的内部更新列表里,你所有的脚本都会在每帧里调用所有这些基本上什么也没用的方法!有人可能会问为什么会有人关心一个空方法?因为这些从C++到托管C#的调用有成本上的开销。让我们来看看成本为何。
调用10000个UPDATE
(2) 在第二个场景中,创建了另外10000个MonoBehaviour。不过,不同的是,这个代码中并不是只调用Update,而是像下面这样,加入了一个由Manager脚本在每帧都调用一次的自定义UpdateMe方法。
测试项目在两台iOS设备上被编译为Mono以及IL2CPP,发布设置中都设为非开发模式。它们的运行时间记录如下:1.在第一次Update调用时设置一个Stopwatch (在Script Execution Order中配置)2.在LateUpdate时停止Stopwatch
3.将获得的计时时间均摊到几分钟上
Unity版本: 5.2.2f1iOS版本: 9.0
Mono
哇!好多时间!测试肯定哪里出了问题!实际上,我只是忘了把Script Call Optimization 设为Fast but no exceptions,但是现在我们能看到这种设置对性能的影响了……至于IL2CPP不必太在意。
Mono (fast but no exceptions)
OK,这样好多了,让我们切换到IL2CPP。
IL2CPP
这里我们发现两件事情:1.这个优化对于IL2CPP同样有用2.IL2CPP仍有改进空间,而且在写这篇文章的同时Scripting 与IL2CPP团队正在努力提高性能。比如,最新的Scripting分支内包含的优化可以让测试运行快35%。
我接下来就会介绍Unity在幕后做了些什么,但是现在让我们修改下Manager代码,将它提速5倍!
我接下来就会介绍Unity在幕后做了些什么,但是现在让我们修改下Manager代码,将它提速5倍!
接口调用,虚调用以及数组访问
结果告诉我们,如果你想在每帧里都循环迭代拥有10000个元素的列表,那应该使用数组而不是List,因为这样生成的C++代码会更简单,而数组访问就是要快很多的。在下一个测试中,我把List<ManagedUpdateBehavior> 改为了ManagedUpdateBehavior[]。
这看起来好多了!
解救之道!
如果你以前没有使用过Instruments,右边是按照执行时间排序的函数,以及它们调用的其他函数。最左边的列是以毫秒为单位的CPU时间,以及这些函数及其调用的函数所占的CPU时间百分比。左边第二列是函数自己的执行时间。注意,在这个实验中Unity并没有将CPU使用完,所以我们能看到在60秒间隔内有10秒的CPU时间花在了我们的Update上。显然,我们关心的是那些执行时间最长的函数。用疯狂的Photoshop技术,将一些区域做了颜色区分,以便你能明白到底发生了什么。
UpdateBehavior.Update()
在中间你能看到我们的Update方法,以及IL2CPP是如何调用它的 ——UpdateBehavior_Update_m18。但是Unity在那之前还做了很多其他事。循环迭代所有的Behaviour
Unity循环迭代所有的Behaviour并执行更新。特殊的迭代类SafeIterator确保了即使移除了列表中的下一项,整个循环也不会中断。仅仅是循环迭代所有已注册的Behaviour就用了9979ms中的1517ms。检测调用是否有效
下一步,Unity做了一堆检测,确保调用的方法是属于某个已激活已初始化且Start方法已调用过的GameObject的。你肯定不希望在Update里销毁一个GameObject时让游戏崩溃,对吧?这些检测花去了整个9979ms中的另外2188ms。准备调用方法
Unity创建了一个ScriptingInvocationNoArgs实例 (代表了一个从原生到托管的调用)以及ScriptingArguments,然后命令IL2CPP虚拟机调用方法(scripting_method_invoke函数)。这一步消耗了整个9979ms中的2061ms。调用方法
scripting_method_invoke函数检测传入的参数是否有效(900ms),然后调用IL2CPP 虚拟机的Runtime::Invoke方法 (1520ms)。开始时,Runtime::Invoke检测方法是否存在 (1018ms)。而后,它调用一个生成的RuntimeInvoker函数获取方法签名(283ms)。接着再依次调用我们的Update函数,根据Time Profiler,这一步花了42ms。一个漂亮的彩色表格。
托管的更新
现在让那个我们在Manager测试上使用下Time Profiler。你在屏幕截图上可以看到,还是同样的一些方法(有些方法因为执行时间少于1ms,甚至都没出现),但是大部分的执行时间实际上都花在了UpdateMe函数上(或者说花在了IL2CPP调用ManagedUpdateBehavior_UpdateMe_m14上)。另外,IL2CPP还插入了一个null检测,确保我们循环迭代的数组不会为null。下面这个图片使用了相同的颜色。
所以,你现在怎么看,我们应该忽略那小小的方法调用吗?
有关测试的几句话
老实说,这个测试并不是完全公平的。Unity为了防止你的游戏出错或崩溃,做了很多了不起的事:这个GameObject是否已激活?它是否在Update循环中被销毁了?对象上是否存在Update方法?怎么处理在这个Update循环中创建的MonoBehaviour?——我的Manager脚本没有处理这其中任何一项,仅仅是循环迭代了一堆的对象,调用它们的Update而已。在真实世界中,Manager脚本可能会更加复杂,执行得更慢。但是,我是个开发者——我知道我的代码要做什么,我架构我的Manger类时,知道可能的行为是什么,什么不会出现在我的游戏中。而不幸的是,Unity并不知道这些。
你应该怎么做?
当然这完全视你的项目而定,但实战中碰到一个游戏在单一场景中使用大量需要在每帧都执行一些代码的GameObject的情况并不少见。通常这看起来都是些不起眼的小代码,似乎不会影响到任何东西,但当其数量非常巨大时,调用几千个Update方法的开销将变得显著。这个时候再去修改游戏架构,重构这些对象为Manager样式,可能已经为时已晚。你现在有数据了,在你开始下一个项目时考虑下吧。
来源:Unity官方社区