[Из песочницы] Сравнение скорости разных вариантов взаимодействия скриптов Unity3D
Вступление
Я довольно посредственно знаю Unity, так как только относительно недавно начал изучать его и писать свой первый проект, поэтому эта статья ориентирована на таких же как я.
Я, как наверное и любой кто начинал писать на юнити, быстро понял, что самого банального метода взаимодействия (через синглтоны-менеджеры, Find, GetComponent и т.п.) становится недостаточно и нужно искать новые варианты.
И тут на сцену выходит система сообщений/уведомлений
Порывшись в разных статьях я нашел несколько различных вариантов реализации этой системы:
- На основе встроенного UnityEvents
- С использованием классической для C# пары Event/Delegate
- Еще один встроенный старый встроенный функционал SendMessage
В большинстве статей практически нет информации по быстродействию тех или иных подходов, их сравнению и прочее. Обычно встречается только такое упоминание о быстродействии «Используйте SendMessage только в крайних случаях, а лучше не используйте вообще»
Окей, у этого подхода, видимо, есть существенные проблемы со скоростью, но как тогда обстоят дела у других?
Какой то вменяемой и упорядоченной информации на этот вопрос я не смог найти (может плохо искал) и решил выяснить это опытным путем, а заодно и опробовать эти подходы на практике, что очень помогло избавиться от каши в голове после прочтения десятков статей.
Сравнивать решил эти 3 подхода, а так же обычный прямой вызов функции на объекте по его ссылке.
И как бонус — посмотрим наглядно, как медленно работает Find при поиске объекта каждый Update (о чем кричат все гайды для новичков) Погнали.
Подготовка скриптов
Для теста нам потребуется создать на сцене 2 объекта:
- Отправитель, назовем его Sender, создадим и прикрепим на него скрипт Sender.cs
- Получатель, назовем его Receiver, создадим и прикрепим на него скрипт Receiver.cs
Начнем с получателя Receiver.cs, т.к. тут будет меньше всего кода.
По правде говоря, сначала я думал ограничиться просто пустой функцией, которая будет вызываться извне. И тогда этот файл выглядел бы просто:
using UnityEngine;
public class Receiver : MonoBehaviour
{
public void TestFunction(string name)
{
}
}
Но в последствии, я решил засекать время выполнения всех вызовов/отсылки сообщений не только в отправителе, но еще и в получателе (для надежности).
Для этого нам понадобится 4 переменные:
float t_start = 0; // Начальное время измерения
float t_end = 0; // Конечное время измерения
float count = 0; // Текущий номер прохода
int testIterations = 10000; // Количество вызовов функции. Начнем с 10000 вызовов
И дописываем функцию TestFunction так, что бы она могла считать за какое время она выполнилась testIterations раз и выплюнуть эту инфу в консоль. В аргументах будем принимать строку testName, в которой будет приходить имя тестируемого способа, т.к. сама функция не знает кто ее будет вызывать. Эту информацию так же добавляем к выводу в консоль. В итоге мы получаем:
public void TestFunction(string testName)
{
count++; // Каждый вызов увеличиваем счетчик
// Если начинается цикл вызовов функции, то сохраняем время старта
if (count == 1)
{
t_start = Time.realtimeSinceStartup;
}
// Если цикл вызовов завершается, то сохраняем время окончания, выводим в консоль название вызывающего теста и общее время исполнения (t_end - t_start)
else if (count == testIterations)
{
t_end = Time.realtimeSinceStartup;
Debug.Log(testName + " SELF timer = " + (t_end - t_start));
count = 0;
}
}
С этим закончили. Наша функция будет сама считать время выполнения какого то цикла вызовов себя и выводить это в консоль вместе с именем того, кто ее вызывал.
Мы еще вернемся к ней для того, что бы подписаться на отправитель и для того, что бы менять количество вызовов (хотя, можно привязаться к такой же переменной в отправителе, что бы не менять в двух местах, либо передавать вторым аргументом в функции, но не будем тратить на это время)
using UnityEngine;
public class Receiver : MonoBehaviour
{
float t_start = 0; // Начальное время измерения
float t_end = 0; // Конечное время измерения
float count = 0; // Текущий номер прохода
int testIterations = 10000; // Количество вызовов функции
public void TestFunction(string testName)
{
count++; // Каждый вызов увеличиваем счетчик
// Если начинается цикл вызовов функции, то сохраняем время старта
if (count == 1)
{
t_start = Time.realtimeSinceStartup;
}
// Если цикл вызовов завершается, то сохраняем время окончания, выводим в консоль название вызывающего теста и общее время исполнения (t_end - t_start)
else if (count == testIterations)
{
t_end = Time.realtimeSinceStartup;
Debug.Log(testName + " SELF timer = " + (t_end - t_start));
count = 0;
}
}
}
Подготовка завершена. Переходим к написанию тестов.
Прямой вызов функции (Direct Call)
Переходим в Sender.cs и подготовим код для первого теста. Самый банальный и простой вариант — в Start () находим экземпляр получателя и сохраняем ссылку на него:
using System;
using UnityEngine;
using UnityEngine.Events;
public class Sender : MonoBehaviour {
float t_start = 0; // Начальное время измерения
float t_end = 0; // Конечное время измерения
int testIterations = 10000; // Количество вызовов функции
Receiver receiver;
void Start ()
{
receiver = GameObject.Find("Receiver").GetComponent();
}
Напишем нашу функцию DirectCallTest, которая будет заготовкой для всех остальных функций теста:
float DirectCallTest()
{
t_start = Time.realtimeSinceStartup;
for (int i = 0; i < testIterations; i++)
{
receiver.TestFunction("DirectCallTest");
}
t_end = Time.realtimeSinceStartup;
return t_end - t_start;
}
В каждой итерации мы вызываем на получателе нашу TestFunction и передаем название теста.
Теперь осталось сделать вывод в консоль и запуск этого теста, поэтому добавим в Start () строчку:
void Start ()
{
receiver = GameObject.Find("Receiver").GetComponent();
Debug.Log("DirectCallTest time = " + DirectCallTest());
}
Готово! Запускаем и получаем наши первые данные. (напомню, что результаты со словом SELF нам отдает та функция которую мы вызываем, а без SELF — та, которая вызывает)
Я буду оформлять их в такие таблички:
Название теста | Время теста |
---|---|
DirectCallTest timer | 0.0005178452 |
DirectCallTest SELF timer | 0.0001906157 |
(напомню, что результаты со словом SELF нам отдает та функция которую мы вызываем, а без SELF — та, которая вызывает)
Итак, данные в консоли и мы видим интересную картину — функция на получателе отработала в ~2,7 раза быстрее чем на отправителе.
Я так и не понял с чем это связано. Может в том, что на получателе после расчета времени дополнительно вызывается Debug.Log или в чем то другом… Если кто знает, то напишите мне и я внесу это в статью.
В любом случае нам это не особо важно, т.к. мы хотим сравнивать разные реализации между собой, поэтому переходим к следующему тесту.
Отправка сообщений через SendMessage
Старая и поносимая всеми кому не лень… посмотрим на что ты способна.
(Вообще, я не очень понимаю зачем она нужна, если для нее все равно нужна ссылка на объект как и в прямом вызове. Видимо, что бы не делать методы public, не понятно)
Добавляем функцию SendMessageTest:
float SendMessageTest()
{
t_start = Time.realtimeSinceStartup;
for (int i = 0; i < testIterations; i++)
{
receiver.SendMessage("TestFunction", "SendMessageTest");
}
t_end = Time.realtimeSinceStartup;
return t_end - t_start;
}
И строчку в Start ():
Debug.Log("SendMessageTest time = " + SendMessageTest());
Получаем такие результаты (чуть изменил структуры таблицы):
Название теста | Время теста на отправителе | Время теста на получателе |
---|---|---|
DirectCallTest | 0.0005178452 | 0.0001906157 |
SendMessageTest | 0.004339099 | 0.003759265 |
Ого, разница на один порядок! Продолжим писать тесты, а анализом займемся в конце, поэтому те, кто и так всем этим умеет пользоваться могут листать дальше до анализа. А это больше предназначено для тех, кто как и я — только изучают и выбирают для себя реализацию системы взаимодействия между компонентами.
Используем встроенные UnityEvents
Создаем в Sender.cs UnityEvent, на который в последствии мы подпишем нашего получателя:
public static UnityEvent testEvent= new UnityEvent();
Пишем новую функцию UnityEventTest:
float UnityEventTest()
{
t_start = Time.realtimeSinceStartup;
for (int i = 0; i < testIterations; i++)
{
testEvent.Invoke("UnityEventTest");
}
t_end = Time.realtimeSinceStartup;
return t_end - t_start;
}
Тааак, мы рассылаем всем подписавшимся сообщение о том, что событие произошло и хотим передать туда «UnityEventTest», но наш эвент не принимает аргументы.
Читаем мануал и понимаем, что для этого нам надо переопределить тип класса UnityEvent. Сделаем это, а так же внесем изменения в эту строчку:
public static UnityEvent testEvent= new UnityEvent();
Получается такой код:
[Serializable]
public class TestStringEvent : UnityEvent
{
}
public static TestStringEvent testStringEvent = new TestStringEvent();
Не забываем в UnityEventTest () заменить testEvent на testStringEvent.
Теперь подписываемся на событие в получателе Receiver.cs:
void OnEnable()
{
Sender.testStringEvent.AddListener(TestFunction);
}
Подписываемся в методе OnEnable () для того, что бы объект подписывался на события при активации на сцене (в том числе при создании).
Так же нужно отписаться от событий в методе OnDisable () который вызывается при отключении (в том числе удалении) объекта на сцене, но для теста нам это не надо, поэтому эту часть кода я не стал писать.
Запускаем. Все работает, отлично! Переходим к следующему тесту.
События C# на Event/Delegate
Помним, что нам надо реализовать event/delegate с возможностью отправки сообщения в качестве аргумента.
В отправителе Sender.cs создаем event и delegate:
public delegate void EventDelegateTesting(string message);
public static event EventDelegateTesting BeginEventDelegateTest;
Пишем новую функцию EventDelegateTest:
float EventDelegateTest()
{
t_start = Time.realtimeSinceStartup;
for (int i = 0; i < testIterations; i++)
{
BeginEventDelegateTest("EventDelegateTest");
}
t_end = Time.realtimeSinceStartup;
return t_end - t_start;
}
Теперь подписываемся на событие в получателе Receiver.cs:
void OnEnable()
{
Sender.testStringEvent.AddListener(TestFunction);
Sender.BeginEventDelegateTest += TestFunction;
}
Запускаем и проверяем. Отлично, все тесты готовы.
Бонус
Добавим ради интереса копии методов DirectCallTest и SendMessageTest, где в каждой итерации будем искать объект на сцене, перед обращением к нему, что бы новички могли понять насколько дорого совершать такие ошибки:
float DirectCallWithGettingComponentTest()
{
t_start = Time.realtimeSinceStartup;
for (int i = 0; i < testIterations; i++)
{
GameObject.Find("Receiver").GetComponent().TestFunction("DirectCallWithGettingComponentTest");
}
t_end = Time.realtimeSinceStartup;
return t_end - t_start;
}
float SendMessageTestWithGettingComponentTest()
{
t_start = Time.realtimeSinceStartup;
for (int i = 0; i < testIterations; i++)
{
GameObject.Find("Receiver").GetComponent().SendMessage("TestFunction", "SendMessageTestWithGettingComponentTest");
}
t_end = Time.realtimeSinceStartup;
return t_end - t_start;
}
Анализ результатов
Запускаем все тесты по 10000 итераций каждый и получаем такие результаты (я сразу отсортирую по времени выполнения цикла на нашем отправителе (Sender), т.к. на этом этапе я уже выяснил опытным путем, что время теста на получателе сильно отличалось из-за одного вызова Debug.Log, который выполнялся в 2 раза дольше чем сам цикл вызовов!
Название теста | Время теста на отправителе |
---|---|
DirectCallTest | 0.0001518726 |
EventDelegateTest | 0.0001523495 |
UnityEventTest | 0.002335191 |
SendMessageTest | 0.003899455 |
DirectCallWithGettingComponentTest | 0.007876277 |
SendMessageTestWithGettingComponentTest | 0.01255739 |
Для наглядности визуализируем данные (по вертикали время исполнения всех итераций, по горизонтали названия тестов)
Давайте теперь повысим точность наших тестов и повысим количество итераций до 10 млн.
Название теста | Время теста на отправителе |
---|---|
DirectCallTest | 0.1496105 |
EventDelegateTest | 0.1647663 |
UnityEventTest | 1.689937 |
SendMessageTest | 3.842893 |
DirectCallWithGettingComponentTest | 8.068002 |
SendMessageTestWithGettingComponentTest | 12.79391 |
В принципе, ничего не изменилось. Становится видно, что система сообщений на обычном Event/Delegate почти не отличается по скорости от Direct Call, чего не скажешь о UnityEvent и уж тем более SendMessage.
Два последних столбца, я думаю, навсегда отучат использовать поиск объекта в цикле/апдейте.
Заключение
Надеюсь кому то это будет полезно как маленькое исследование или как небольшой гайд по системам событий.
Полный код получившихся файлов:
using System;
using UnityEngine;
using UnityEngine.Events;
public class Sender : MonoBehaviour {
[Serializable]
public class TestStringEvent : UnityEvent
{
}
public delegate void EventDelegateTesting(string message);
public static event EventDelegateTesting BeginEventDelegateTest;
float t_start = 0; // Начальное время измерения
float t_end = 0; // Конечное время измерения
int testIterations = 10000000; // Количество вызовов функции
public static TestStringEvent testStringEvent = new TestStringEvent();
Receiver receiver;
void Start ()
{
receiver = GameObject.Find("Receiver").GetComponent();
Debug.Log("UnityEventTest time = " + UnityEventTest());
Debug.Log("DirectCallTest time = " + DirectCallTest());
Debug.Log("DirectCallWithGettingComponentTest time = " + DirectCallWithGettingComponentTest());
Debug.Log("SendMessageTest time = " + SendMessageTest());
Debug.Log("SendMessageTestWithGettingComponentTest time = " + SendMessageTestWithGettingComponentTest());
Debug.Log("EventDelegateTest time = " + EventDelegateTest());
}
float UnityEventTest()
{
t_start = Time.realtimeSinceStartup;
for (int i = 0; i < testIterations; i++)
{
testStringEvent.Invoke("UnityEventTest");
}
t_end = Time.realtimeSinceStartup;
return t_end - t_start;
}
float DirectCallTest()
{
t_start = Time.realtimeSinceStartup;
for (int i = 0; i < testIterations; i++)
{
receiver.TestFunction("DirectCallTest");
}
t_end = Time.realtimeSinceStartup;
return t_end - t_start;
}
float DirectCallWithGettingComponentTest()
{
t_start = Time.realtimeSinceStartup;
for (int i = 0; i < testIterations; i++)
{
GameObject.Find("Receiver").GetComponent().TestFunction("DirectCallWithGettingComponentTest");
}
t_end = Time.realtimeSinceStartup;
return t_end - t_start;
}
float SendMessageTest()
{
t_start = Time.realtimeSinceStartup;
for (int i = 0; i < testIterations; i++)
{
receiver.SendMessage("TestFunction", "SendMessageTest");
}
t_end = Time.realtimeSinceStartup;
return t_end - t_start;
}
float SendMessageTestWithGettingComponentTest()
{
t_start = Time.realtimeSinceStartup;
for (int i = 0; i < testIterations; i++)
{
GameObject.Find("Receiver").GetComponent().SendMessage("TestFunction", "SendMessageTestWithGettingComponentTest");
}
t_end = Time.realtimeSinceStartup;
return t_end - t_start;
}
float EventDelegateTest()
{
t_start = Time.realtimeSinceStartup;
for (int i = 0; i < testIterations; i++)
{
BeginEventDelegateTest("EventDelegateTest");
}
t_end = Time.realtimeSinceStartup;
return t_end - t_start;
}
}
using UnityEngine;
public class Receiver : MonoBehaviour
{
float t_start = 0; // Начальное время измерения
float t_end = 0; // Конечное время измерения
float count = 0; // Текущий номер прохода
int testIterations = 10000000; // Количество вызовов функции
void OnEnable()
{
Sender.testStringEvent.AddListener(TestFunction);
Sender.BeginEventDelegateTest += TestFunction;
}
public void TestFunction(string testName)
{
count++; // Каждый вызов увеличиваем счетчик
// Если начинается цикл вызовов функции, то сохраняем время старта
if (count == 1)
{
t_start = Time.realtimeSinceStartup;
}
// Если цикл вызовов завершается, то сохраняем время окончания, выводим в консоль название вызывающего теста и общее время исполнения (t_end - t_start)
else if (count == testIterations)
{
t_end = Time.realtimeSinceStartup;
Debug.Log(testName + " SELF timer = " + (t_end - t_start));
count = 0;
}
}
}
Используемая литература:
- Unity3D система сообщений или «мягкая связь» между компонентами
- События C# по-человечески
- UnityEvent
- event (Справочник по C#)
- События
- Intermediate Gameplay Scripting — Events
- Забытое секретное оружие Unity — UnityEvents
- Методы организации взаимодействия между скриптами в Unity3D