Unity性能优化(5):脚本篇

目录

0.测试运行时间的两种方法

1.使用Profiler.BeginSample(string name) 和 Profiler.EndSample();

 方法二:使用C# Stopwatch

1.使用最快的方法获取组件

2.移除空的回调函数

3.缓存组件引用

4.共享计算输出

5.注意Update()、LateUpdate()、OnGUI()等方法

6.更快的GameObject空引用检查

7.使用合适的数据结构

8.关注缓存Transform的变化

9.避免在运行时使用Find()方法

10.避免使用SendMessage()方法

11.合理使用 for 或 foreach

 12.禁用不在视角内的脚本或对象

13.在比较距离的时候,建议使用距离的平方而不是距离

14.使用对象池

15.使用异步操作


0.测试运行时间的两种方法

1.使用Profiler.BeginSample(string name) 和 Profiler.EndSample();

Profiler.BeginSample(string name)和Profiler.EndSample()是Unity中的性能分析工具Profiler类提供的方法,用于在性能分析中标记开始和结束的样本。

Profiler类可以用于测量代码块的性能,以帮助我们找到应用程序的瓶颈和优化的机会。BeginSample方法和EndSample方法一起使用,可以将代码块标记为一个样本,并在性能分析报告中显示该样本的执行时间等信息。

用法示例:

void MyMethod()
{
    Profiler.BeginSample("My Method"); // 开始样本

    // 执行一些代码块

    Profiler.EndSample(); // 结束样本
}

然后打开Profiler窗口,根据峰值找到代码的位置。

完整的示例:

声明一个变量string str, 循环100,000次对其复制,然后看一下内容和时间的消耗。为了方便捕捉峰值,我们在1秒之后,通过Debug.LogError的方法暂停Unity,必须打开Error Pause。

 代码:

using UnityEngine;
using UnityEngine.Profiling;

public class BeginSampleAndEndSample : MonoBehaviour
{

    // Update is called once per frame
    void Update()
    {
        if (Input.GetKeyDown(KeyCode.W))
        {
            TestFun();
            //1s之后暂停Unity,必须勾选Error pause
            Invoke("StopUnity", 1f);
        }
    }

    private void TestFun()
    {
        string str = "";
        //开始采样
        Profiler.BeginSample("My Str");
        //代码块
        for (int i = 0; i < 10000; i++)
        {
            str += i.ToString();
        }
        //结束采样
        Profiler.EndSample();
    }

    void StopUnity()
    {
        Debug.LogError("StopUnity");
    }
}

 输出结果:

 方法二:使用C# Stopwatch

C#中的Stopwatch类是一个用于测量时间间隔的实用工具,通常用于性能测试和代码分析。Stopwatch类提供了一组方法和属性,可以方便地开始、停止和测量时间。

封装了一个复制类,方便使用

using System;
using System.Diagnostics;

public class TimeCounter : IDisposable
{
    private string _timerName;
    private int _numTests;
    private Stopwatch _watch;

    /// <summary>
    /// 在构造时,启动时Stopwatch
    /// </summary>
    /// <param name="timerName">计时器名字</param>
    /// <param name="numTests">运行(循环)数</param>
    public TimeCounter(string timerName, int numTests)
    {
        _timerName = timerName;
        _numTests = numTests;
        if (_numTests <= 0)
            _numTests = 1;
        _watch = Stopwatch.StartNew();
    }
    /// <summary>
    /// 当using块结束时自动释放
    /// </summary>
    public void Dispose()
    {
        _watch.Stop();
        float  ms= _watch.ElapsedMilliseconds;
        UnityEngine.Debug.Log($"方法名:{_timerName},总耗时: {ms} 毫秒,每次循环耗时:{ms/_numTests},循环总数:{_numTests}");
    }
}

测试代码:

int runCount = 1000000;
using (new TimeCounter("<string>", runCount))
{
    for (int i = 0; i < runCount; i++)
    {
        VerticalLayoutGroup com = this.GetComponent("VerticalLayoutGroup") as VerticalLayoutGroup; 
    }
}

using (new TimeCounter("<T>", runCount))
{
    for (int i = 0; i < runCount; i++)
    {
        VerticalLayoutGroup com = this.GetComponent<VerticalLayoutGroup>();
    }
}

using (new TimeCounter("<typeof>", runCount))
{
    for (int i = 0; i < runCount; i++)
    {
        VerticalLayoutGroup com = this.GetComponent(typeof(VerticalLayoutGroup)) as VerticalLayoutGroup;
    }
}

输出结果:

以后我们会经常用到这个辅助类计算方法的运行时间。

1.使用最快的方法获取组件

在 Unity 中,GetComponent有三个重载,GetComponent<T>()、GetComponent("string")、GetComponent(typeof(T)),三个的运行时间略有不同。由于在不同版本对此方法进行了优化,每个Unity版本的时间会略有不同。我们再看一下,使用unity内置的方法测试三个方法的消耗。

代码示例:

private void TestFun()
{
    int runCount = 1000000;
    Profiler.BeginSample("__My string");
    for (int i = 0; i < runCount; i++)
    {
        VerticalLayoutGroup com = this.GetComponent("VerticalLayoutGroup") as VerticalLayoutGroup;
    }
    Profiler.EndSample();

    Profiler.BeginSample("__My <T>");
    for (int i = 0; i < runCount; i++)
    {
        VerticalLayoutGroup com = this.GetComponent<VerticalLayoutGroup>();
    }
    Profiler.EndSample();

    Profiler.BeginSample("__My typeof");
    for (int i = 0; i < runCount; i++)
    {
        VerticalLayoutGroup com = this.GetComponent(typeof(VerticalLayoutGroup)) as VerticalLayoutGroup;
    }
    Profiler.EndSample();
}

输出结果:

2.移除空的回调函数

MonoBehaviour 组件在场景中第一次实例化时,Unity 会将任何定义好的回调添加到一个函数指针列表中,并在关键时刻调用这个列表。然而,重要的是要认识到,即使函数体是空的,Unity 也会挂接到这些回调中。核心Unity 引擎没有意识到这些函数体可能是空的,它只知道方法已经定义,必须获取方法,然后在必要时调用该方法。因此,如果将这些回调的空定义分散在整个代码库中,那么引樂调用它们会产生一定的开销,将浪费少量的 CPU 资源。

3.缓存组件引用

Update()每帧都会执行,如果在Update()中查找组件会很消耗性能,解决办法是在Awake()、Start()函数中查找组件,并缓存起来。

4.共享计算输出

这种情况常常出现在一下情况:

  • 在场景中找到对象
  • 从文件中读取数据
  • 解析数据(如 XML或JSON)
  • 在大列表或深层的信息字典中找到内容
  • 轨迹计算,
  • 光线追踪等。

5.注意Update()、LateUpdate()、OnGUI()等方法

因为Update()、LateUpdate()、OnGUI()每帧都在执行,所以尽可能的减少这些方法的使用。如果一定要用,还可以考虑是否必须每帧都执行。如果不需要,可以进行减少运行次数的处理。

float _timer=0f;
void Update()
{
    _timer += Time.deltaTime;
    //每秒执行5次
    if(_timer > 0.2f)
    {
        Fun();
        _timer =0;
    }
}

void Fun()
{
    Console.WriteLine("执行");
}

6.更快的GameObject空引用检查

事实证明,对 GameObject 执行空引用检查会号致一些不必要的性能才销。

从数据来看,System.Object.ReferenceEquals(go, null) 的效率是 go != null 的4倍。这是因为与典型的C#对象相比,Gamcobject 和MonoBehaviour 是特殊对象,因为它们在内存中有两个表示:一个表示在于与管理C#代码相同的系统管理的内存中,C#代码是用户编写的(托管代码):另一个表示存在于另一个单独处理的内存空问中(本机代码)。数据可以在这两个内存空间之间移动,但是每次移动都会导致额外的 CPU 开销和可能的额外内存分配。这种效果通常称为跨越本机-托管的桥接。

7.使用合适的数据结构

在Unity中我们常常用到数组、list、字典、queue、stack等数据结构,下边是把100w条字符串分别存入结构中,看看他们的内存和时间开销。

测试代码:

Profiler.BeginSample("__My array");
string[] arr = new string[runCount];
for (int i = 0; i < runCount; i++)
{
    arr[i] = "abcdefghijklmn";
}
Profiler.EndSample();

Profiler.BeginSample("__My List");
List<string> list=new List<string>();
for (int i = 0; i < runCount; i++)
{
    list.Add("abcdefghijklmn");
}
Profiler.EndSample();

Profiler.BeginSample("__My Dictionary");
Dictionary<int, string> dic = new Dictionary<int, string>();
for (int i = 0; i < runCount; i++)
{
    dic.Add(i, "abcdefghijklmn");
}
Profiler.EndSample();

Profiler.BeginSample("__My Queue");
Queue<string> queue = new Queue<string>();
for (int i = 0; i < runCount; i++)
{
   queue.Enqueue("abcdefghijklmn");
}
Profiler.EndSample();

Profiler.BeginSample("__My Stack");
Stack<string> stack = new Stack<string>();
for (int i = 0; i < runCount; i++)
{
    stack.Push("abcdefghijklmn");
}
Profiler.EndSample();

输出结果:

 从结果来看,最优的是数组,其次是List、Stack、Queue,内存消耗约等于数组的两倍。最差的是字典。内存占用约等于数组的9倍,耗时约等于数组的6倍。

测试一下遍历数组、list、字典、queue、stack

 最快的还是数组和List。最慢的是Queue和Stack,Queue和Stack是基于链表的数据结构。当你遍历一个Queue或Stack时,必须从头到尾依次遍历每个元素。

8.关注缓存Transform的变化

在项目中,我们常常修改Transform。为了看出修改Transform有多耗性能,跟之前的测试数据结构放在一起比较。

测试代码如下:

Profiler.BeginSample("__My Get World");
for (int i = 0; i < runCount; i++)
{
    child.position = Vector3.zero;
    child.rotation = Quaternion.identity;
    child.localScale = Vector3.one;
}
Profiler.EndSample();

Profiler.BeginSample("__My Get local");
for (int i = 0; i < runCount; i++)
{
    child.localPosition = Vector3.zero;
    child.localRotation = Quaternion.identity;
    child.localScale = Vector3.one;
}
Profiler.EndSample();

Profiler.BeginSample("__My Math");
for (int i = 0; i < runCount; i++)
{
    
}
Profiler.EndSample();

输出结果: 

 通过结果我们发现,修改Transform不只是简单的赋值而已。因为修改Transform组件属性,会向组件(如:COllider、Rigidbody、Light、Camera)发送内部通知,这些组件也必须进行处理,因为物理和渲染系统都需要知道Transform的新值,并相应地更新。

9.避免在运行时使用Find()方法

Find()方法是根据名称来在场景中搜索游戏对象。它会遍历整个场景层次结构,直到找到匹配的对象。如果场景中有大量的游戏对象,这个过程可能会消耗较多的时间和计算资源。

为了避免使用Find()方法,可以考虑以下替代方案:

使用引用:在脚本中直接将需要引用的游戏对象作为公共字段、属性以及序列化的私有字段,然后在编辑器中将其赋值。这样可以避免在运行时进行查找操作。

使用标签和标记:在编辑器中为游戏对象添加标签或自定义的标记,然后使用FindWithTag()或FindObjectsWithTag()方法根据标签进行查找。这种方式可以减少查找的范围,提高性能。

使用缓存:在启动或初始化阶段,将需要频繁访问的游戏对象缓存起来,以避免重复的查找操作。当游戏对象发生变化时,及时更新缓存。

使用静态类或单利模式。

10.避免使用SendMessage()方法

SendMessage()方法是一种用于向游戏对象发送消息的方法。尽管这是一种方便的方式,可以在不知道接收方具体脚本类型的情况下发送消息,但建议尽量避免使用SendMessage()方法。以下是一些原因:

运行时效率低下:SendMessage()方法在发送消息时会遍历整个游戏对象及其子对象的组件列表,并通过反射来查找和调用相应的方法。这个过程比直接调用方法要慢得多,因此会降低运行时的效率。

难以调试和维护:由于SendMessage()使用字符串参数来匹配方法名,这种方式容易出错,而且难以在编译时进行检查。这给代码的调试和维护带来了困难,特别是在重构或更改方法名时,需要手动检查和更新所有SendMessage()调用。

缺乏类型安全性:由于SendMessage()方法没有在编译时进行检查,因此无法保证传递给它的方法名和参数是有效的。这可能导致在运行时出现错别字、拼写错误或传递错误数量的参数等问题。

替代方案:

直接调用方法:如果知道接收方的脚本类型,可以直接调用方法。这样可以避免SendMessage()的性能开销和潜在的错误。GetComponent<Player>().attack("****");

委托和事件(观察者模式):使用委托和事件系统来实现对象之间的通信。这种方式更加类型安全,并且可以在编译时进行检查。

接口实现:定义接口,并让接收方的脚本实现该接口。这样可以通过接口引用来直接调用接收方的方法,避免使用字符串参数。

11.合理使用 for 或 foreach

for循环的工作原理:for循环是使用一个计数器和一个条件表达式来控制循环次数。每次循环迭代时,通过索引直接访问数组或集合的元素,因此没有额外的开销。这种直接的、索引访问方式在处理大型数据集时更高效。

foreach循环的工作原理:foreach循环是通过迭代器来遍历集合的元素,使用foreach语法时,编译器会隐式地为你创建一个迭代器。每次循环迭代时,都会通过迭代器来获取下一个元素。这种迭代器的工作方式导致额外的性能开销,包括方法调用、对象创建和内存分配。

需要注意的是,性能差异可能在迭代次数非常大的情况下才会显著影响,对于小规模数据集,两种循环方式的性能差异可能较小或可以忽略不计。因此,在编写代码时应根据具体情况选择最适合的循环方式,以兼顾性能和代码可读性。

消耗对比:

 12.禁用不在视角内的脚本或对象

如果许多正在处理的内容在玩家的事视野之外,或者只是太远而显得不重要,就可以选择不处理他们。unity有两个很好的方法帮我们实现这个需求——OnBecameVisible() 、OnBecameInvisible()。

13.在比较距离的时候,建议使用距离的平方而不是距离

当需要比较两个点之间的距离时,通常会使用向量的Magnitude(长度)或Distance(距离)方法来计算距离。然而,计算平方根(sqrt)操作是一种相对较为耗时的操作,而计算平方值则比较快速。因此,在比较距离时,直接比较距离的平方而不是距离可以提高计算速度。

14.使用对象池

  • 在代码中创建对象池,用于重用频繁创建和销毁的对象,如子弹、敌人等。
  • 当需要使用对象时,从对象池中获取对象,而不是每次都新建。
  • 在对象不再使用时,将其放回对象池而不是销毁。

15.使用异步操作

将耗时的操作(如资源加载、IO操作)放在异步处理中进行,避免阻塞主线程。使用 Unity 的异步加载函数,如 AssetBundle.LoadAsync()。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值