Хабрахабр

[Из песочницы] Сравнение скорости разных вариантов взаимодействия скриптов 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
}

Но в последствии, я решил засекать время выполнения всех вызовов/отсылки сообщений не только в отправителе, но еще и в получателе (для надежности).

Для этого нам понадобится 4 переменные :

float t_start = 0; // Начальное время измерения float t_end = 0; // Конечное время измерения float count = 0; // Текущий номер прохода int testIterations = 10000; // Количество вызовов функции. Начнем с 10000 вызовов

В аргументах будем принимать строку testName, в которой будет приходить имя тестируемого способа, т.к. И дописываем функцию TestFunction так, что бы она могла считать за какое время она выполнилась testIterations раз и выплюнуть эту инфу в консоль. Эту информацию так же добавляем к выводу в консоль. сама функция не знает кто ее будет вызывать. В итоге мы получаем:

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; } }

Наша функция будет сама считать время выполнения какого то цикла вызовов себя и выводить это в консоль вместе с именем того, кто ее вызывал.
Мы еще вернемся к ней для того, что бы подписаться на отправитель и для того, что бы менять количество вызовов (хотя, можно привязаться к такой же переменной в отправителе, что бы не менять в двух местах, либо передавать вторым аргументом в функции, но не будем тратить на это время) С этим закончили.

Receiver.cs полностью

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)

Самый банальный и простой вариант — в Start() находим экземпляр получателя и сохраняем ссылку на него: Переходим в Sender.cs и подготовим код для первого теста.

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 — та, которая вызывает)

Может в том, что на получателе после расчета времени дополнительно вызывается Debug. Итак, данные в консоли и мы видим интересную картину — функция на получателе отработала в ~2,7 раза быстрее чем на отправителе.
Я так и не понял с чем это связано. 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() который вызывается при отключении (в том числе удалении) объекта на сцене, но для теста нам это не надо, поэтому эту часть кода я не стал писать.

Все работает, отлично! Запускаем. Переходим к следующему тесту.

Помним, что нам надо реализовать 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; }

Анализ результатов

на этом этапе я уже выяснил опытным путем, что время теста на получателе сильно отличалось из-за одного вызова Debug. Запускаем все тесты по 10000 итераций каждый и получаем такие результаты (я сразу отсортирую по времени выполнения цикла на нашем отправителе (Sender), т.к. 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. В принципе, ничего не изменилось.

Два последних столбца, я думаю, навсегда отучат использовать поиск объекта в цикле/апдейте.

Заключение

Надеюсь кому то это будет полезно как маленькое исследование или как небольшой гайд по системам событий.

Полный код получившихся файлов:

Sender.cs

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; }
}

Receiver.cs

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; } }
}

Используемая литература:

Спасибо за внимание!

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть