Unity编程最佳实践
原文链接:Unity - Manual: Unity programming best practices
Unity编程最佳实践
Unity 的编程环境具有一些独特特性,在编写代码时需要比标准 C#/.NET 项目额外注意。以下是编写 Unity 应用程序代码时需要注意的关键问题总结,以及帮助您避免常见陷阱的最佳实践。
Unity 对象生命周期与引用
在 Unity 中编写 C# 时,需要小心比较对象是否相等或与 null 比较。对于继承自 UnityEngine.Object 的类型,Unity 使用了自定义版本的 C# 相等和不等运算符。这意味着,即使 myGameObject 技术上持有有效的 C# 对象引用, myGameObject == null 的空检查可能返回 true(反之 myGameObject != null 可能返回 false)。
Unity 的自定义相等行为和对象生命周期对代码有以下影响:
-
在需要确保排除已销毁对象的检查中,请确保对 Unity 对象使用
if (obj == null)而非ReferenceEquals。 -
在需要检查实际 C# 空引用时,请使用
ReferenceEquals或先转换为System.Object。 -
比较两个 Unity 对象是否相等时,请注意
obj1 == obj2可能返回 true,即使两个引用是不同的 C# 对象——如果其中一个或两个已被销毁并重新创建(例如通过撤销操作)。 -
自定义相等运算符比标准 C# 运算符慢。这通常不是问题,但在大规模和热点路径中需注意。
-
不要跨场景卸载缓存组件而不加保护,因为它们可能被销毁但仍作为没有非托管对应物的 C# 包装对象存在。
-
不要在静态字段中持有大型资源的强引用,因为它们会跨场景持续存在并阻止卸载。
-
销毁对象时,
Object.DestroyImmediate仅适用于编辑器,因此在运行时使用Object.Destroy并让 Unity 安排销毁。
避免 C# 终结器
不要在运行时代码中使用 C# 终结器,原因如下:
-
它们在单独的终结器线程上运行,而 Unity API 通常需要主线程。
-
它们以非确定性方式运行,导致不可预测的行为。
-
它们可能根本不会运行,除非应用程序进行垃圾回收并等待。
-
从终结器抛出的异常可能导致应用程序停止,除非特别处理。
-
它们的存在本身就增加了垃圾回收器的开销。
垃圾回收器开销与内存分配
Unity 应用程序中最需要警惕的性能风险之一是运行时代码分配内存并增加垃圾回收器开销,特别是在热点路径中。为避免此问题,请应用以下编码实践:
-
通过缓存和重用列表,以及使用非分配版本的方法来避免每帧分配。这包括在使用协程时缓存
WaitForSeconds和其他 yield 指令。 -
尽可能使用非分配版本的方法,并在
Awake中执行GameObject.GetComponent等昂贵操作并缓存返回对象的引用,而不是在Update中重复调用。 -
避免在运行时代码中使用 LINQ,特别是在每帧的
Update或FixedUpdate等热点路径中。System.Linq命名空间的方法可能创建不必要的分配,并涉及装箱和闭包。 -
避免重复的字符串操作,如字符串拼接。
-
识别并避免反射实例。
MonoBehaviour 更新循环优化
许多 Unity 项目的传统模式涉及使用 MonoBehaviour 脚本组件通过内置回调(如 MonoBehaviour.Update、MonoBehaviour.FixedUpdate 和 MonoBehaviour.LateUpdate)定期更新游戏状态,这些回调通常每秒运行多次。
这是一个简单的模式,在适当使用时仍然可以很好地工作,但它有一些关键性能风险,常常让缺乏经验的开发者措手不及:
-
Unity 每帧或其他常规事件函数的默认实现可能扩展性不佳。每个
Update函数都会产生 Unity 内部管理和与原生层交互的小开销。当您有许多这样的 MonoBehaviour 脚本时,累积开销可能产生显著的性能影响。 -
由于内置的更新函数运行频率极高,它们属于关键的代码路径,因此,你在其中编写的任何低效或高内存占用的操作,其负面影响都会被显著放大。缺乏经验用户的常见不良模式是许多 MonoBehaviour 脚本带有
Update函数,大部分时间不必要地运行,或在运行时不必要地内存密集。
为缓解这些风险,请考虑以下选项:
-
考虑将项目转换为使用 Unity 实体组件系统(ECS)的面向数据架构,以更好地扩展大量实体。
-
如果使用基于 MonoBehaviour 的架构:
-
为确保在热点路径中最小化托管内存影响,请参考为托管内存优化代码。
-
通过使用集中式更新管理器或自定义播放器循环来最小化活动
Update函数的数量。 -
请记住,即使在基于 MonoBehaviour 的项目中,您通常也可以使用 Unity 面向数据系统的特定功能。您可以在代码的性能关键部分使用 Jobs、Burst 编译部分代码、使用更高效的数据结构如
NativeArray,以及选择托管 API 的非托管替代方案(如变换操作)。
-
线程安全
虽然 Unity 具有多线程能力,但核心运行时是单线程的,UnityEngine 和 UnityEditor 命名空间中的大多数 API 只能从主线程调用。不要从后台线程引用 GameObject、Transform、Component 或资源 API。绝不要在主线程上使用 Task.Result 或 Task.Wait 进行等待,因为这会导致死锁。
在处理固有的异步和长时间运行的操作时,Unity 提供了 Awaitable 类作为 .NET Task 的 Unity 特定替代方案。Awaitable 使用对象池来减少分配,并感知 Unity 特定概念如 Update 和 FixedUpdate,这允许您等待任务并在播放器循环的特定点安排它们恢复执行。更多信息请参考使用 Awaitable 类进行异步编程。
对于较短寿命但计算密集型的并行化工作,Unity 提供了作业系统,可以进行 Burst 编译。
编译考虑
为获得最佳性能,重要的不仅是如何编写代码,还包括如何编译。天真地编译代码而不是主动定义某些源文件或代码区域适用于哪些上下文,会带来以下成本:
-
如果包含不必要的代码,会增加构建大小。
-
编译和重新编译以应用更改所花费的时间。这可能特别影响编辑器中的迭代时间。
-
如果为给定平台或上下文包含不适当的代码,可能会导致运行时错误。
Unity 提供了几种机制来帮助您控制代码的哪些部分为不同平台和上下文编译:
-
您可以使用程序集定义将源文件分组到可以单独编译的程序集中。这允许您将编辑器专用代码与运行时代码隔离,以及将平台特定代码与跨平台代码隔离。更多信息请参考将脚本组织到程序集中。
-
您可以使用
#if指令与脚本符号结合,根据目标平台或上下文排除代码的特定区域。例如,使用#if UNITY_EDITOR指令保护编辑器专用代码,将其从运行时构建中排除。 -
一个关键的 Unity 特定概念是在编辑器中进入和退出播放模式或重新编译脚本时的域重载。域重载耗时且会影响编辑器中编写和测试时的迭代时间。您可以禁用在进入播放模式时的域重载以改善迭代时间,但随后必须手动重置静态状态。
Unity 的分析工具
Unity 提供了各种工具来帮助您识别瓶颈并编写更高性能的代码。Project Auditor 工具可以分析您的项目代码以识别常见性能问题并建议修复。分析器可以通过提供有关 CPU 和 GPU 使用率、内存分配等的详细信息,帮助您识别代码中的运行时性能瓶颈。您还可以创建 Roslyn 分析器来强制执行编码标准并识别项目特定的性能问题。

浙公网安备 33010602011771号