优化您的移动游戏性能:来自Unity顶尖工程师的关于分析、内存和代码架构的建议

THOMAS KROGH-JACOBSEN / UNITY TECHNOLOGIESSenior Technical Content Marketing Manager
Jun 23, 2021|15 Min
优化您的移动游戏性能:来自Unity顶尖工程师的关于分析、内存和代码架构的建议
为方便起见,此网页已进行机器翻译。我们无法保证翻译内容的准确性或可靠性。如果您对翻译内容的准确性有疑问,请参阅此网页的官方英文版本。
我们的Unity Studio Production团队对源代码了如指掌,支持众多Unity客户以便他们能充分利用引擎。团队的日常工作包括深入剖析客户项目,搜寻其在速度、稳定性与效率方面有待优化的部分。本次,我们请到了这支Unity最为资深的软件工程师团队来分享一些移动游戏优化方面的专业知识。

我们的工程师在分享中可谓是倾囊相授,如此多的锦囊妙计无法在一篇博文完整地讲完。相反,我们决定将他们的知识积累编纂成一本完整的电子书(您可以在这里下载),以及一系列博文,重点介绍这些75个以上的可操作性技巧。

在系列的首篇文章中,我们将着重介绍怎样借助性能分析、内存优化和代码架构来提高游戏的性能。在未来的几周内,我们将再发表两篇文章:一篇讨论UI Physics,另一篇讨论音频和资源、项目配置和图形。

想现在查看完整系列吗?免费下载完整电子书

话不多说,直接开讲!

性能分析

优化工作的第一个步骤便是通过性能分析来搜集性能数据,这也是移动端优化的第一步。

尽早、经常并在目标设备上进行分析

Unity Profiler提供有关您应用程序的基本性能信息,但如果您不使用它,它无法帮助您。尽早对项目进行性能分析,不要拖到发售前。对每一个故障或性能尖峰彻查到底。对你自己的项目性能有一个清晰的认知,可帮助你更轻松地发现新问题。

Unity编辑器内的性能分析可以揭示出游戏不同系统的相对性能,而在运行设备上进行分析可让你获取更为准确的性能奥秘。经常性地在目标设备上分析开发版。同时为最高配置与最低配置的设备进行性能分析和优化。

除了Unity Profiler,您还可以利用iOS和Android的原生工具进行进一步的性能测试:

某些硬件可以利用额外的分析工具(例如,Arm Mobile StudioIntel VTuneSnapdragon Profiler)。有关更多信息,请参见使用Unity分析应用程序

专注于优化正确的领域

如果游戏出现性能问题,切忌自行猜测或揣测成因,一定要使用Unity Profiler和平台专属工具来准确找出卡顿的问题来源。

不过,这里所说的优化并不都适用于你的应用。在某个项目中适用的方法不一定适用于你的项目。找出真正的性能瓶颈,将精力集中在有实际效用的地方。

了解Unity Profiler的工作原理

Unity Profiler可帮助你在运行时检测出卡顿或死机的原因,更好地了解特定帧或时间点上发生了什么。工具默认启用CPU和内存监测轨,你也可以根据需要启用额外的分析模块,包括渲染器、音频和物理(如极度依赖物理模拟的游戏或音游),

或使用Unity Profiler来测试应用程序的性能和资源分配。
或使用Unity Profiler来测试应用程序的性能和资源分配。

通过勾选Development BuildAutoconnect Profiler将应用程序构建到您的设备,或手动连接以加快应用启动时间。

编辑器中的构建设置

选中需要分析的目标平台。Record按钮跟踪您应用程序播放的几秒钟(默认300帧)。转到Unity > Preferences > Analysis > Profiler > Frame Count,如果您需要更长的捕获时间,可以将其增加到2000。当然更长的录制帧数会让Unity编辑器占用更多的CPU资源和内存,但其在特定情形下的作用非常大。

这是一个基于仪器的分析器,分析明确包裹在ProfileMarkers中的代码时序(例如MonoBehaviour的Start或Update方法,或特定API调用)。在使用Deep Profiling设置时,Unity可以分析出每次函数调用的开始与结尾,准确地呈现出导致应用性能放缓的代码部分。

编辑器中的时间线视图
你可以借助Timeline视图来明确应用最为依赖的是CPU还是GPU。

在分析游戏时,我们建议同时分析性能高峰与帧平均成本。在分析帧率过低的应用时,较为有效的方法是分析并优化每一帧中运行成本较高的代码。在尖峰处首先分析繁重的运算(如物理、AI、动画)和垃圾数据收集。

点击窗口中的某帧,接着使用TimelineHierarchy视图进行分析:

  • Timeline可显示特定帧耗时的可视化图表,帮助你直观地看到各项活动以及不同线程之间的关系。你可使用该选项来了解项目主要依赖的是CPU还是GPU。
  • Hierarchy将显示分组的ProfileMarkers层级,这使您能够根据毫秒(Time msSelf ms)的时间成本对样本进行排序。您还可以计算帧上函数的Calls调用次数以及托管堆内存(GC Alloc)。
按时间成本对ProfileMarkers进行排序
Hierarchy视图允许按照耗时长短对ProfileMarkers进行排序。

完整的Unity Profiler概述可在这里了解。初来乍到的用户也可以观看这段Introduction to Unity Profiling

在优化项目中的任何内容之前,请保存Profiler .data文件。实施您的更改,并比较保存的.data 之前之后的修改。剖析、优化和比较,清空再重复,如此循环往复来提高性能。

使用Profile Analyzer

该工具可以汇总多帧Profiler数据,由用户来挑选出那些问题较大的帧。如果你想了解项目更改后Profiler的相应改变,Compare视图允许您加载和区分两个数据集,以便您可以测试更改并改善结果。Profile Analyzer可通过Unity的Package Manager获取。

在编辑器中深入了解Profile Analyzer
Profiler Analyzer可以很好地补充Profiler,可以进一步深入分析帧与标记数据。

为每帧设定一个时间预算

你可以设立一个目标帧率,为每帧划定一个时间预算。理想情况下,一个以30 fps运行的应用每帧应占有约33.33毫秒(1000毫秒/30帧)。同样地,60 fps每帧约为16.66毫秒。

设备可以在短时间内超过预算(如过场动画或加载过程中),但绝不能长时间如此。

设备温度优化

对于移动设备而言,长时间占用最大时间预算可能会导致设备过热,操作系统可能会启动CPU与GPU降频保护。我们建议每帧仅占用约65%的时间预算,保留一定的散热时间。常见的帧预算为:30 fps为每帧22毫秒,60 fps为每帧11毫秒。

大多数移动设备不像桌面设备那样有主动散热功能,因此环境温度可以直接影响性能。

如果设备发热严重,Profiler可能会察觉并汇报这块性能低下的部分,即使其只是暂时性问题。为了应对分析时设备过热,分析应分成小段进行。这样便能允许设备散热、模拟出真实的运行条件。我们的建议是,在进行性能分析前后,预留10-15分钟用于设备散热。

分清GPU与CPU依赖程度

Profiler可在CPU耗时或GPU耗时超出帧预算发出警告,它将弹出下方以Gfx为前缀的标记:

  • 如果您看到Gfx.WaitForCommands标记,这意味着渲染线程已准备就绪,但您可能在等待主线程的瓶颈。
  • Gfx.WaitForPresent表示主线程正在等待GPU递交渲染帧。
内存

Unity会采取自动化内存管理来处理由用户生成的代码与脚本。值类型本地变量等小型数据会被分配到内存堆栈中,大型数据和持久性存储数据则会被分配到托管内存中。

垃圾数据收集器会定期识别并删除未被使用的托管内存,这个自动流程在检查堆的对象时可能导致游戏卡顿或运行放缓。

这里,优化内存便是指关注托管内存的分配与删除时机,将内存垃圾回收的影响降到最低。详情请在Understanding the managed heap中了解。

在编辑器中查看内存分析器
Memory Profiler中的帧数据记录、检视与比较。

使用内存分析器

Memory Profiler属于一个独立的分析模块,可以截取托管数据堆内存的状态,帮助你识别出数据碎片化和内存泄漏等问题。

在Tree Map视图中点击一个变量便可跟踪其在内存原生对象上的状态。你可在此处找出由纹理过大或资源重复加载而导致的常见内存消耗问题。

请在这里了解如何使用Unity中的内存分析器优化内存占用。你也可以查看我们的官方内存分析器文档

降低内存垃圾回收(GC)对性能的影响

Unity使用的是博姆-德梅尔斯-韦瑟垃圾回收器,它会中止主线程代码运行,在垃圾回收工作完成后再让其恢复运行。

请注意,部分多余的堆内存分配会造成GC耗能高峰:

  • 字符串:在C#中,字符串属于引用类型,而非值类型。我们需要减少不必要的字符串创建或更改操作,尽量避免解析JSON和XML等由字符串组成的数据文件,将数据存储于ScriptableObjects,或以MessagePack或Protobuf等格式保存。如果你需要在运行时构建字符串,可使用StringBuilder类。
  • Unity函数调用:某些函数会创建堆内存分配。我们需要缓存数组引用,避免在循环进行中进行数组的内存分配,且尽量使用那些不会产生垃圾回收的函数。比如使用GameObject.CompareTag,而不是使用GameObject.tag手动比对字符串(因为返回一个新字符串会产生垃圾数据)。
  • 打包:避免在引用类型变量处传入值类型变量。因为这样做会导致系统创建一个临时对象,在背地里将值类型转换为对象类型(如int i = 123; object o = i),从而产生垃圾回收的需求。尽量使用正确的类型覆写来传入想要的值类型。泛型也可用于类型覆写。
  • 协同程序:虽然yield不会产生垃圾回收,但新建WaitForSeconds对象会。我们可以缓存并复用WaitForSeconds对象,不必在yield中再度创建。
  • LINQ和正则表达式:这两者都会产生来自幕后装箱的垃圾。如果需要追求性能,请尽量避免使用LINQ和正则表达式,转而使用for循环和列表来创建数组。

如果可能,定时处理垃圾回收

如果你确定垃圾回收带来的卡顿不会影响游戏特定阶段的体验,你可以使用System.GC.Collect来启动垃圾数据收集。

请在理解自动化内存管理中了解怎样妥善地使用这项功能。

使用增量式垃圾回收(Incremental GC)分散垃圾回收的工作负载

增量式垃圾回收不会在程序运行期间长时间地中断运行,而会将总负荷分散到多帧,形成零碎的收集流程。如果垃圾数据收集对性能产生了较大的影响,可以尝试启用这个选项来降低GC的处理高峰。你可以使用Profile Analyzer来检验此功能的实际作用。

增量垃圾回收器的概览
使用增量垃圾回收来降低GC处理高峰。
编程和代码架构

Unity的PlayerLoop包含许多可与引擎核心互动的函数。该结构包含一些负责初始化和每帧更新的系统,所有脚本都将依靠PlayerLoop来生成游戏体验。

在分析时,你会在PlayerLoop下看到用户使用的代码(Editor代码则位于EditorLoop下)。

放大查看分析器
Profiler将显示在整个引擎运行过程中的自定义脚本、设置和图形。
PlayerLoop的视图

了解PlayerLoop和脚本生命周期

你可以使用以下技巧和窍门来优化脚本。

深入理解Unity PlayerLoop

确保你理解Unity帧循环的执行顺序。每个Unity脚本都会按照预定的顺序运行事件函数,你应该理解AwakeStartUpdate和其他创建脚本生命周期的函数之间的区别。

请参考脚本生命周期流程图以了解事件函数的具体执行顺序。

减少每帧运行的代码量

有许多代码并非要在每帧上运行,将不必要的逻辑移出UpdateLateUpdateFixedUpdate。这些事件函数可以保存那些必须每帧更新的代码,任何无须每帧更新的逻辑都不必放入其中,只有在相关事物发生变化时,这些逻辑才需被执行。

如果你必须使用Update,可以考虑让代码每隔n帧运行一次。这种划分运行时间的方法也是一种将繁重工作负荷化整为零的常见技术。在下方例子中,我们每三帧运行一次ExampleExpensiveFunction

private int interval = 3;

void Update()
{
    if (Time.frameCount % interval == 0)
    {
        ExampleExpensiveFunction();
    }
}

避免在Start/Awake中加入繁重的逻辑

当首个场景加载时,每个对象都会调用如下函数:

  • Awake
  • OnEnable
  • Start

在应用完成第一帧的渲染前,我们须避免在这些函数中运行繁重的逻辑。否则,应用的加载时间会出乎意料地长。

请在事件函数的执行顺序中详细了解首个场景的加载。

避免加入空的Unity事件

即使是空的MonoBehaviours也会占用资源,因此我们应该删除空的UpdateLateUpdate方法。

如果你想用这些方法进行测试,请使用预处理指令(preprocessor directives):

#if UNITY_EDITOR
void Update()
{
}
#endif

如此一来,在编辑器中的Update测试便不会对构建版本造成不良的性能影响。

删去Debug Log语句

Log声明(尤其是在UpdateLateUpdateFixedUpdate中)会拖慢性能。因此我们需要在构建之前禁用Log语句。

你可以用预处理指令编写一条Conditional属性来轻松禁用Debug Log。比如下方这种的自定义类:

public static class Logging
{
    [System.Diagnostics.Conditional("ENABLE_LOG")]
    static public void Log(object message)
    {
        UnityEngine.Debug.Log(message);
    }
}
ENABLE_LOG的视图
添加自定义预处理指令可以实现脚本的切分。

用自定义类生成Log信息时,你只需在Player Settings中禁用ENABLE_LOG预处理指令,所有的Log 语句便会一下子消失。

使用哈希值、避免字符串

Unity底层代码不会使用字符串来访问Animator、Material和Shader属性。出于提高效率的考虑,所有属性名称都会被哈希转换成属性ID,用作实际的属性名称。

在Animator、Material或Shader上使用Set或Get方法时,我们便可以利用整数值而非字符串。后者还需经过一次哈希处理,并没有整数值那么直接。

使用Animator.StringToHash来转换Animator属性名称,用Shader.PropertyToID来转换Material和Shader属性名称。

选择正确的数据结构

由于数据结构每帧可能会迭代上千次,因此其结构对性能有着较大的影响。如果你不清楚数据集合该用List、Array还是Dictionary表示,可以参考C#的MSDN数据结构指南来选择正确的结构。

避免在运行时添加组件

在运行时调用AddComponent会占用一定的运行成本,Unity必须检查组件是否有重复或依赖项。

实例化预制件(Prefab)时,组件已经配置完成,通常性能更强。

缓存GameObjects和组件

调用GameObject.FindGameObject.GetComponentCamera.main(2020.2以下的版本)会产生较大的运行负担,因此这些方法不适合在Update中调用,而应在Start中调用并缓存。

下方例子展示了一种低效率的GetComponent多次调用:

void Update()
{
    Renderer myRenderer = GetComponent<Renderer>();
    ExampleFunction(myRenderer);
}

其实GetComponent的结果会被缓存,因此只需调用一次即可。缓存的结果完全可在Update中重复使用,不必再度调用GetComponent

private Renderer myRenderer;

void Start()
{
    myRenderer = GetComponent<Renderer>();
}

void Update()
{
    ExampleFunction(myRenderer);
}

使用对象池

实例化(Instantiate)和销毁(Destroy)方法会产生需要垃圾回收数据、引发垃圾回收(GC)的处理高峰,且其运行较为缓慢。与其经常性地实例化和销毁GameObjects(如射出的子弹),不如使用对象池将对象预先储存,再重复地使用和回收。

对象池的放大视图
在这个例子中,对象池创建了20个PlayerLaser实例以供重用。

在游戏特定时间点(如显示菜单画面时)创建可复用的实例,来降低CPU处理高峰的影响,再用一个集合来形成“对象池”。在游戏期间,实例可在需要时启用/禁用,用完后可返回到池中,不必再进行销毁。

SampleScene层级的放大视图
PlayerLaser对象池目前尚未激活,正等待玩家射击。

这一来你就可以减少托管内存分配的次数、防止产生垃圾回收的问题。

请在此处了解如何在Unity中创建一个简单的对象池系统。

使用ScriptableObjects

固定不变的值或配置信息可以存储在ScriptableObject中,不一定得储存于MonoBehaviour。ScriptableObject可由整个项目访问,一次设置便可应用于项目全局,但它并不能直接关联到GameObject上。

我们可在ScriptableObject中用字段来存储值或设定,然后在MonoBehaviours中引用该对象。

流程图显示了一个名为Inventory的ScriptableObject,保存了各种GameObjects的设置。
用作“Inventory(物品栏)”的ScriptableObject可保存多个游戏对象的设定。

下方的ScriptableObject字段可有效防止多次MonoBehaviour实例化产生的数据重复。

请在Introduction to ScriptableObjects教程中了解如何使用ScriptableObjects。你也可以参考此处的文档。

下载完整的性能优化技巧

在系列的下一篇章,我们将仔细研究图形和GPU的优化。不过,如果你现在想访问团队提供的所有技巧和窍门,完整版电子书可在这里下载。

电子书封面,"优化你的移动游戏性能"

下载我们的电子书

如果你想了解更多关于集成支持服务的信息,并希望为你的团队提供直接联系工程师、专家建议和项目最佳实践指导的机会,请查看Unity的成功计划这里

敬请期待更多优化技巧

我们希望帮助每位Unity创作者发挥出自己项目的最大潜力,如果你有任何想要深入发掘的优化课题,请在评论中给我们留言。