Вступление
Я не много знаю о тонкостях работы 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<Receiver>();
}
Напишем нашу функцию 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<Receiver>();
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<string>
{
}
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<Receiver>().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<Receiver>().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 System.Collections;
using UnityEngine;
using UnityEngine.Events;
public class Sender : MonoBehaviour {
[Serializable]
public class TestStringEvent : UnityEvent<string>
{
}
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;
System.Diagnostics.Stopwatch stopWatch;
void Start ()
{
receiver = GameObject.Find("Receiver").GetComponent<Receiver>();
stopWatch = new System.Diagnostics.Stopwatch();
StartCoroutine(Delay5sec()); //Делаем задержку, что бы точно все прогрузилось
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());
// stopWatch.Elapsed.Seconds();
}
IEnumerator Delay5sec()
{
yield return new WaitForSeconds(5);
}
float UnityEventTest()
{
//t_start = Time.realtimeSinceStartup;
stopWatch.Reset();
stopWatch.Start();
for (int i = 0; i < testIterations; i++)
{
testStringEvent.Invoke("UnityEventTest");
}
//t_end = Time.realtimeSinceStartup;
//return t_end - t_start;
stopWatch.Stop();
return stopWatch.ElapsedMilliseconds / 1000f;
}
float DirectCallTest()
{
//t_start = Time.realtimeSinceStartup;
stopWatch.Reset();
stopWatch.Start();
for (int i = 0; i < testIterations; i++)
{
receiver.TestFunction("DirectCallTest");
}
//t_end = Time.realtimeSinceStartup;
//return t_end - t_start;
stopWatch.Stop();
return stopWatch.ElapsedMilliseconds / 1000f;
}
float DirectCallWithGettingComponentTest()
{
//t_start = Time.realtimeSinceStartup;
stopWatch.Reset();
stopWatch.Start();
for (int i = 0; i < testIterations; i++)
{
GameObject.Find("Receiver").GetComponent<Receiver>().TestFunction("DirectCallWithGettingComponentTest");
}
//t_end = Time.realtimeSinceStartup;
//return t_end - t_start;
stopWatch.Stop();
return stopWatch.ElapsedMilliseconds / 1000f;
}
float SendMessageTest()
{
//t_start = Time.realtimeSinceStartup;
stopWatch.Reset();
stopWatch.Start();
for (int i = 0; i < testIterations; i++)
{
receiver.SendMessage("TestFunction", "SendMessageTest");
}
//t_end = Time.realtimeSinceStartup;
//return t_end - t_start;
stopWatch.Stop();
return stopWatch.ElapsedMilliseconds / 1000f;
}
float SendMessageTestWithGettingComponentTest()
{
//t_start = Time.realtimeSinceStartup;
stopWatch.Reset();
stopWatch.Start();
for (int i = 0; i < testIterations; i++)
{
GameObject.Find("Receiver").GetComponent<Receiver>().SendMessage("TestFunction", "SendMessageTestWithGettingComponentTest");
}
//t_end = Time.realtimeSinceStartup;
//return t_end - t_start;
stopWatch.Stop();
return stopWatch.ElapsedMilliseconds / 1000f;
}
float EventDelegateTest()
{
//t_start = Time.realtimeSinceStartup;
stopWatch.Reset();
stopWatch.Start();
for (int i = 0; i < testIterations; i++)
{
BeginEventDelegateTest("EventDelegateTest");
}
//t_end = Time.realtimeSinceStartup;
//return t_end - t_start;
stopWatch.Stop();
return stopWatch.ElapsedMilliseconds / 1000f;
}
}
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;
}
}
}
— — = = UPDATE = = — -
Мне написали некоторые важные замечания, которые я хотел бы прокомментировать в статье:
1. Тестить в самом редакторе нельзя (код всегда собирается в DEBUG-режиме), обязательно нужно собирать standalone-билд и замерять в нем.
- Нельзя просто покрутить цикл N итераций и взять результаты. Нужно запустить цикл M-раз по N итераций и усреднить — это сгладит различные сайд-эффекты типа изменения частоты процессора и прочих вещей.
- Способ подсчета времени следует поменять — точность Time.realtimeSinceStartup никуда не годится.
Спасибо всем за эти уточнения, так как некоторые достаточно существенные.
Кстати, это все есть в этой статье — Performance tests of Event, ActionList, Observer, InterfaceObserver. Советую прочитать и учитывать эту информацию.
Что касается нашего теста — 1) и 3) я обязательно проверю прямо сейчас, а вот пункт 2) конкретно в нашем тесте я пропущу, т.к. в нашем случае мы не гонимся за точностью каждого результата, а сравниваем их между собой. Я запускал руками тест несколько раз и не увидел каких либо серьезных отклонений в результатах, поэтому пока пропустим это (если дойдут руки, то реализуем и этот момент)
Первое что мы проверим — это
Тест в собранном приложении
Тут все просто — собираем приложение под винду(предварительно добавим задержку в 5с перед запуском тестов. На всякий случай, что бы все точно прогрузилось) так же на 10млн итераций и запускаем.
Далее идем C:\Users\username\AppData\LocalLow\CompanyName\ProductName\output_log.txt
если у вас винда (или смотрим документацию) и изучаем логи.
И тут видим интересные изменения:
- Во первых: узнаем, что в релизе все работает в 3-10 раз быстрее чем в редакторе! Я, например, не думал, что разница такая существенная/
- Во вторых: узнаем, что прямой вызов с поиском объекта каждую итерацию не такой уж и медленный. Конечно, медленнее чем UnityEvents, но уже быстрее чем SendMessage. Хотя, возможно дело в том, что на сцене просто мало объектов… нужно исследовать это отдельно
Получаем такое сравнение для релиза
Кроме поменявшихся местами двух вышеуказанных тестов больше ничего не поменялось и график выглядит таким же как и в дебаге, т.е. DirectCall и DelegateEvent все так же быстрее чем UnityEvent и остальные
Оптимизируем подсчет времени
Хотя, как и пункт 2), оптимизация подсчета времени вряд ли даст нам какие то изменения в расстановке сил, мы сделаем этот пункт, что бы узнать как сильно отличаются эти два подхода между собой. Погнали.
Меняем все наши:
t_start = Time.realtimeSinceStartup;
t_end = Time.realtimeSinceStartup;
return t_end - t_start;
На:
// объявляем
System.Diagnostics.Stopwatch stopWatch;
// инициализируем в Start()
stopWatch = new System.Diagnostics.Stopwatch();
// обнуляем и запускаем перед циклом
stopWatch.Reset ();
stopWatch.Start ();
// тут цикл теста
stopWatch.Stop ();
//получаем результат
return stopWatch.ElapsedMilliseconds / 1000f;
(в конце я выложу весь итоговый код)
Итак, изучая полученные данные можно сказать, что отличие Time.realtimeSinceStartup от System.Diagnostics.Stopwatch не существенные в нашем случае и их можно списать на погрешность (На самом деле в тесте DirectCallWithGettingComponentTest есть отличие почти в 4 раза в дебаг версии, но в релизе все соотношение опять возвращается. Увидите это в итоговой таблице). Как я и говорил — вид графика не изменился:
Делаем итоговую табличка со всеми значениями (время затраченное на выполнение 10млн итераций). Данные по последним тестам в разных вариантах debug/release, unity/c# time(способ измерения времени) :
Название теста | Debug, Unity time | Debug, C# time | Release, Unity time | Release, C# time |
---|---|---|---|---|
DirectCallTest | 0,1496105 | 0,15 | 0,0498426 | 0,047 |
EventDelegateTest | 0,1647663 | 0,155 | 0,0478754 | 0,047 |
UnityEventTest | 1,689937 | 1,657 | 0,5706475 | 0,462 |
DirectCallWithGettingComponentTest | 8,068002 | 2,112 | 0,8793411 | 0,851 |
SendMessageTest | 3,842893 | 3,938 | 1,364239 | 1,375 |
SendMessageTestWithGettingComponentTest | 12,79391 | 6,752 | 2,250246 | 2,244 |
Подводим итоги — хотя, скорость выполнения существенно изменилась в релизной версии с новым способом измерения времени, но соотношение времени выполнения тестов не изменилось. DirectCall и EventDelegate все так же в лидерах и быстрее UnityEvent в 10раз. А вот DirectCall с поиском объекта в каждой итерации обогнал даже обычный SendMessage..
Так же из этого апдейта мы узнали, что версия в релизе работает быстрее чем в редакторе в 3-10 раз.
P.S. Еще был комментарий, что за результат надо брать время выполнения одной итерации, а не общее время выполнения. В нашем случае я не вижу надобности в этом, т.к. эти цифры обратно пропорциональны друг другу и ничего, кроме чуть другого вида графика не изменится, особенно выводы.
Используемая литература:
- Unity3D система сообщений или “мягкая связь” между компонентами
- События C# по-человечески
- UnityEvent
- event (Справочник по C#)
- События
- Intermediate Gameplay Scripting — Events
- Забытое секретное оружие Unity — UnityEvents
- Методы организации взаимодействия между скриптами в Unity3D
- Performance tests of Event, ActionList, Observer, InterfaceObserver