Вступление
Я довольно посредственно знаю 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 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;
void Start ()
{
receiver = GameObject.Find("Receiver").GetComponent<Receiver>();
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<Receiver>().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<Receiver>().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
Leopotam
Замечания:
1. Тестить в самом редакторе нельзя (код всегда собирается в DEBUG-режиме), обязательно нужно собирать standalone-билд и замерять в нем.
2. Нельзя просто покрутить цикл N-раз и взять результаты. Нужно запустить цикл M-раз и усреднить — это сгладит различные сайд-эффекты типа изменения частоты процессора и прочих вещей.
3. Эвенты не равняются прямым вызовам — внутри есть проверки + защита списка вызовов от изменения. Т.е мы можем изменить список подписчиков на событие прямо в процессе самого вызова события и это ничего не поломает. Другое дело, что каждая подписка / отписка вызывает memory allocation, поэтому эвенты желательно обрабатывать своим списком с циклом по нему.
Как пример, можно посмотреть тесты отсюда.
Goldseeker
По пункту 3 не так, внутри ивента(на самом деле делегата) нет защиты списка вызовов от изменений, просто делегат это иммутабельный тип — каждое изменение порождает копию.
Leopotam
Даже не так. Эвент, это обертка над списком, потому что сам эвент может принимать новых подписчиков и создавать копию старого списка с изменениями. Этот список будет использоваться при последующих вызовах, создавая видимость иммутабельности.
Goldseeker
Нет, не так. Ивент, по-умолчанию, это обертка над мультикаст делегатом, а мультикаст делегат внутри использует массив, а не список(см. referencesource.microsoft.com/#mscorlib/system/multicastdelegate.cs).
Нет никакой иллюзии иммутабельности, а есть настоящая неизменяемость — делегаты не могут быть изменены после создания, любые операции надо ними приводят к созданию копии массива(cм., например, реализацию сложения делегатов: referencesource.microsoft.com/#mscorlib/system/multicastdelegate.cs,fcbf8bdc05d28aeb,references)
Leopotam
«Список» — имелось ввиду хранилище, а не конкретная реализация в виде List. К тому же ссылка некорректна, в юнити не используется последняя версия .net framework. :)
А я что написал? Снаружи остается тот же самый объект-обертка, внутри создается новая копия хранилища подписчиков на основе текущей + изменения в подписке.
Goldseeker
Объект не тот же самый, объект новый. Ссылка корректна моновская имплементация совпадает с референсной в большинстве случаев.
Leopotam
Что?
И у меня теперь OnTest не указывает на тот же самый объект, содержащий внутри себя массив на 3 подписчика?
Goldseeker
Объект который лежит под OnTest(сам OnTest это не объект это синтаксический сахар, в лучшем случае поле класса), указывает на объект содержащий 3 подписчика, но это не тот же самый объект что лежал под OnTest до добавления в него первого делегата
Вот убедительно показывающий неизменяемость делегата пример:
Вывод:
A:
1
2
B:
1
Leopotam
Все верно, сам затупил:
Присваивание нового инстанса же.
Goldseeker
Вот пример ещё лучше конкретно про ивенты, это иллюстрация ошибки, которую я видел от опытного программиста в реальном проекте.
Какой по вашему будет вывод?
2
Потому что объект под ивентом иммутабельный
zazila Автор
Спасибо, проведу тесты в standalone и добавлю инфу
Leopotam
Тогда еще и способ подсчета времени следует поменять — точность Time.realtimeSinceStartup никуда не годится.
zazila Автор
А какой использовать? Я не нашел лучше варианта
Leopotam
Я же кинул линк (первый комментарий), там есть пример: leopotam.com/3
zazila Автор
Спасибо, изучу это и внесу изменения в соответствии с новыми данными
Goldseeker
Не хватает теста явного хранения списка подписчиков реализующих общий интерфейс. Вызовы виртуальных функций должны быть дешевле ивентов/делегатов.
zazila Автор
Можно какой нибудь пример реализации? Не уверен, что правильно понимаю о чем речь, но интересно протестировать
Goldseeker
Я имею ввиду простую реализацию паттерна observer, супер простой пример вот:
andrewdrone
Спасибо за статью. Рельно удивлен что реализация через делегат настолько быстрее чем Event System.
Mordoniy
Вывод в лог — жутко прожорливая операция, не удивлюсь если основную разницу дает именно вывод в лог.
drobasergey
Спасибо за статью.
Про Debug.Log он действительно очень тормозит.
Можно попробовать без него или посмотреть профайлером.
Mordoniy
Что касательно SendMessage, единственное ее преимущество — универсальность, она отправляется на GameObject и отправляет сообщение всем компонентам в объекте, у кого есть соответствующий метод, тот и выполняет. На сколько знаю сейчас используется в основном в плагинах что бы передавать сообщения на пользовательские скрипты при необходимости.