Как понять Unity3d, если ты .NET разработчик

в 20:42, , рубрики: C#, Gamedev, gamedevelopment, memory management, performance, performance optimization, unity, unity туториал, unity уроки, unity3d
Как понять Unity3d, если ты .NET разработчик - 1

Ловили ли вы себя когда-нибудь на мысли, что, будучи C# .NET разработчиком, вы можете попробовать начать разрабатывать игры на Unity3d? Ведь язык используется тот же. А точно ли тот же? Точно ли код, написанный для .NET, может без проблем быть скопирован для выполнения в Unity3d приложении? Давайте в этом разберемся и поймем, какие дополнительные знания необходимы C# .NET разработчику, чтобы с комфортом разрабатывать игры.

Компиляция и исполнение

Начнем со сравнения того, как компилируется и исполняется наш код в .NET и в Unity3d. В .NET все происходит довольно прозрачно и понятно. C# код компилируется в IL, запаковывается в DLL или EXE файлы, и этот IL код выполняется на виртуальной машине. В этом подходе есть нюанс, который для C# разработчиков долгое время был почти непреодолимым препятствием для кроссплатформенной разработки. Этот нюанс - привязка к виртуальной машине. Еще совсем недавно не существовало .NET, а был только .Net Framework, который мог работать только на Windows. До появления .NET для решения задачи кроссплатформенной разработки самым популярным решением было использование виртуальной машины Mono, которая так же могла исполнять IL код и являлась альтернативой .Net Framework.

Unity3d изначально работал как раз таки на Mono виртуальной машине. Сейчас Mono - одна из опций того, как C# код может использоваться в Unity3d. Второй опцией является технология IL2CPP. IL2CPP это более новая технология, разработанная Unity Technologies, которая активно вытесняет Mono. Код, скомпилированный с помощью IL2CPP, работает быстрее и не требует предустановленную или вшитую в билд виртуальную машину. Касательно работы IL2CPP, название говорит само за себя. C# компилируется в IL с помощью Roslyn, затем IL транслируется в C++ и, наконец, C++ компилируется в машинный код. Компилятор С++ будет различаться для разных платформ. При этом работа кода с точки зрения C# остается крайне похожей на .NET. То есть мы имеем максимально похожий сборщик мусора, стандартную библиотеку и т.д.

Так что, с точки зрения компиляции, хоть она и отличается, разницы рядовой разработчик ощущать не будет. Из недостатков можно отметить, что почти невозможно предсказать во что именно скомпилируется твой код на разных платформах. Баги в IL2CPP тоже никто не отменял, но встретить их так же сложно, как и во внутрянке .NET.

Все DLL библиотеки, написанные на C# и содержащие IL, могут быть использованы в Unity3d. Они при компиляции будут прогнаны через IL2CPP и перекомпилены на C++.

Печальнее обстоит ситуация с версией C#. В Unity используется уже довольно старая 9-я версия C#, и то без поддержки некоторых фичей. Подробнее об этом можно почитать тут.

Библиотеки и совместимость

Как я уже сказал, библиотеки, написанные на C# могут компилироваться под разные платформы через IL2CPP, тем не менее есть ограничения. Unity поддерживает совместимость с .NET Framework и с .Net Standard библиотеками. Тем не менее это не означает, что библиотеки скомпилированные под .NET не будут работать. Возможно работать они будут, но не все. Зависит это от того есть ли в IL коде что-то, что Unity3d не сможет перевести в C++.

Стандартная библиотека работает в Unity3d с некоторыми оговорками. То есть у нас есть вся мощь обычного C# со всеми его коллекциями, многопоточностью, Task'ами, Linq, неткодом и т.д. Но, например, Thread и Task(в случае использования отдельных потоков) не будет работать на билдах под платформу WebGL(игры в браузерах). Это одно из самых явных ограничений, но иногда можно наткнуться и на другие связанные с работой той или иной платформы.

Сам Unity3d с точки зрения написания кода выступает в качестве обширной библиотеки в пространстве имен UnityEngine. Так же есть еще пространство имеет UnityEditor, которе содержит API редактора и используется для кастомизации различных окошек, сборки проекта, плагинов и т.д.

Разделение кода на модули

В .NET для разделения кода на отдельные модули мы привыкли использовать .sln и .csproj файлы. В Unity3d эти файлы тоже имеются, но только работать с ними на прямую нельзя. Unity3d сам создает .sln и .csproj, но делает он это исключительно для удобства разработчика, чтобы популярные IDE могли корректно использовать подсветку синтаксиса, ошибок и прочие удобства. Сам Unity никак не полагается на то, что написано в .sln и .cspoj ни на каком из этапов сборки проекта. Из-за этого часто возникающая ошибка у новичков это добавление какой-то библиотеки средствами IDE. А это значит, что просто поменяется .csproj файл. Unity об этом ничего не узнает и начнет плеваться ошибками при компиляции, не смотря на то, что в IDE никаких ошибок видно не будет.

Таким образом, чтобы повлиять на правила, по которым наш код будет разделяться на модули и какими свойствами эти модули будут обладать, нам нужно использовать внутренние инструменты Unity3d. Основная вещь с которой придется работать в этом контексте это Assembly Definition. Assembly Definition это аналог .cspoj в .NET. Для того, чтобы использовать Assembly Definition нам надо создать .asmdef файл в какой-то из папок проекта(это можно сделать через графический интерфейс Unity). После этого все .cs файлы в этой папке и всех подпапках будут восприниматься как отдельный модуль. Это значит, что для этого кода будет генерироваться отдельный .csproj файл и становится возможным указать зависимости нужные конкретно этому модулю, а так же этот модуль может сам быть использован, как зависимость. Опять же все это делается через графический интерфейс Unity3d и отражается в сгенерированных .csproj файлах.

По дефолту, если вы не создадите ни один .asmdef файл, то Unity автоматически поделит ваш код на 4 модуля исходя из названий папок, в которых лежат cs файлы:

  • Assembly-CSharp-Editor-firstpass - папки "*/Standard Assets/**/Editor", "*/Pro Standard Assets/**/Editor" и "*/Plugins/**/Editor"

  • Assembly-CSharp-firstpass - папки "*/Standard Assets", "*/Pro Standard Assets" и "*/Plugins"

  • Assembly-CSharp-Editor - папка "*/Editor"

  • Assembly-CSharp - любая другая папка

В целом, хорошей практикой считается использовать Assembly Definition, но не во всех проектах они есть. Тут главное, на что стоит обратить внимание, это разделение на Editor и Runtime модули. Editor это то, что будет использоваться только в редакторе, не попадет в билд игры. Такой код обычно используется для создания различных дополнительных инструментов для гейм-дизайнеров художников и программистов. В любом Assembly Definition тоже можно указать, что он предназначен только для редактора.

Подключение сторонних библиотек

Самый простой способ подключить какую-то стороннюю библиотеку это просто скопировать DLL в папку проекта. Unity автоматически анализирует все DLL, которые найдет в папке проекта и добавляет как зависимости к создаваемым .csproj. В настройках Assembly Definition можно указать явно, какие DLL добавлять в зависимости, а какие не добавлять. Можно добавлять не DLL а сразу исходный код. Наверное, для .NET разработчика это может показаться немного диким, но тем не менее это довольно популярный способ распространения плагинов и SDK для Unity3d. Есть даже специальный механизм архивации в .unitypackage файлы для удобного распространения файлов, которые должны быть импортированы в определенное место в проекте. Обычно, когда вы хотите скачать какой-нибудь open-source SDK, вы качаете .unitypackage файл с гитхаба, открываете его в редакторе Unity, он кладет вам нужные файлы исходников в проект или обновляет уже имеющиеся предварительно позволив посмотреть и выбрать, что из этого unitypackage нужно брать, а что не нужно. Некоторые SDK после установки unitypackage предлагают даже удалить какие-то устаревшие файлы с предыдущих версий этого SDK. Не знаю, правда, это фича интегрированная в редактор Unity или заслуга разработчиков SDK.

Более удобным способом управления зависимостями является Unity Package Manager. Это пакетный менеджер сильно похожий на npm с графическим интерфейсом в редакторе Unity. Как и в npm, мы имеем package.json файл у пакетов, которые используем в нашем проекте, где описана информация о пакете и его зависимостях. Каждый пакет имеет этот файл в корневом каталоге. Сам же проект имеет manifest.json вместо package.json(в отличии от npm). Но, хотя название другое, предназначение manifest.json похожее. Там описываются зависимости проекта и регистры пакетов откуда эти зависимости можно брать. Так же вместо версии зависимости можно указать ссылку на github репозиторий или конкретную директорию репозитроия, откуда эту зависимость нужно скачать. Можно указывать ссылки на приватные репозитории, предварительно настроив Git Credential Manager. К сожалению, ссылку на github можно указывать только для зависимостей в manifest.json. Иначе говоря, для транзитивных зависимостей(зависимости зависимостей) нельзя использовать github ссылки. Это не очень удобно для масштабной инфраструктуры больших проектов. Тут на помощь приходит возможность создавать свои регистры пакетов и размещать их где-то в облаке. Эти регистры могут быть как приватными, так и публичными.

Стоит отдельно упомянуть OpenUPM(Open Unity Package Manger). Это открытый регистр пакетов для стандартного Unity Package Manager, где любой может опубликовать свой пакет. Большинство пакетов, которые распространяются через unitypackage есть так же в OpenUPM. Еще многие пакеты из NuGet можно найти на OpenUPM. Тут надо понимать, что не все разработчики пакетов из NuGet (или каких бы то ни было других пакетов) заботятся о публикации на OpenUPM. Так что часто опубликованный на OpenUPM пакет это просто клон оригинального проекта подшаманенный для совместимости с OpenUPM. Тем не менее советую не сбрасывать такие пакеты со счетов. Конечно, если вы - уважающая себя компания, следящая за тем какой код попадает в проект, то лучшим решением будет сделать свой регистр пакетов, валидировать попадающие туда пакеты и качать все с него. Но если вы делаете небольшую игрушку или прототип и у вас нет ресурсов для поддержания солидной инфраструктуры вокруг проекта, то OpenUPM это отличное решение.

Ну и если уж я пишу эту статью для .NET разработчиков, грехом было бы не упомянуть NuGet for Unity. Это плагин, который добавляет удобный интерфейс для менеджмента NuGet пакетов в Unity. Не советую его использовать, не смотря на то, что я им пользовался очень активно. Главным минусом использования NuGet является то, что у вас в проекте появляется несколько источников и менеджеров зависимостей, что может быть еще той головной болью в больших проектах с большим количеством зависимостей. Менеджмет версий, а так же конфликты в транзитивных зависимостях станут большой проблемой.

Подытожив, моим советом будет стараться использовать Unity Package Manager с добавленным OpenUPM регистром везде, где это возможно. И прибегать к другим способам добавления зависимостей только, если вариант с Unity Package Manager не возможно использовать.

Архитектура Unity3d

Unity3d использует стандартную для многих игровых движков древовидную структуру организации объектов. У нас есть приложение, которое содержит несколько сцен(UnityEngine.Scene), которые содержат множество игровых объектов(UnityEngine.GameObject), которые, в свою очередь, являются контейнерами для компонентов(UnityEngine.Component). При этом GameObject'ы могут так же организовываться в иерархические структуры.

Как понять Unity3d, если ты .NET разработчик - 2

У всех GameObject'ов есть один обязательный компонент, который нельзя удалить - UnityEngine.Transform. Он отвечает за позиционирование объекта на сцене и его место в иерархии относительно других GameObject'ов. Остальные компоненты могут быть добавлены/удалены, как на этапе работы со сценой в редакторе, так и в рантайме из кода.

Точка входа

Думаю у нас уже достаточно контекста, чтобы перейти к более прикладным вещам. Ну и с чего еще начать, как не с точки входа в приложение. В Unity3d у нас нет доступа к Main функции. Вместо этого мы должны работать в рамках архитектуры Unity. Unity при старте приложения запускает первую сцену из списка сцен. По сути, единственное, что мы можем сделать, это создать свой компонент и добавить его на объект на стартовой сцене. Тогда код, написанный в компоненте начнет исполняться после загрузки сцены.

Чтобы создать компонент нам надо создать класс и унаследоваться от UnityEngine.MonoBehaviour. Тут сразу оговорюсь, что чтобы создать компонент можно унаследоваться от UnityEngine.Component, UnityEngine.Behaviour или UnityEngine.MonoBehaviour. Разница между ними не существенна для данной статьи, так что во всех примерах буду использовать самый распространенный, используемый "по дефолту", MonoBehaviour.

Вот простой пример кастомного компонента:

using UnityEngine;

public class MyComponent : MonoBehaviour {

  private void Start() {
    // do something on component start working
  }

  private void Update() {
    // do something every frame
  }

}

Теперь через редактор мы можем создать какое угодно количество экземпляров этого класса и прикрепить их к любому GameObject'у на сцене. Соответственно у каждого из этих компонентов метод Start будет вызываться в начале жизненного цикла компонента и Update - каждый кадр. Вызовы всех этих методов упорядочены в рамках Script-lifecycle. Этот цикл - довольно важный архитектурный элемент Unity3d. Когда вы продумываете свое взаимодействие с API движка нужно четко понимать на каком этапе этого цикла стоит размещать тот или иной код.

С этого момента начинаются "приколы" Unity3d. Обратите внимание, что методы Start и Update не оверрайдят никакие базовые методы, они не часть интерфейса, они даже не публичные. Тем не менее они вызываются third-party кодом. Я не встречал подобного подхода в .NET фреймворках. Тут Unity может позволить себе забить на правила языка и, занимая роль компилятора, интегрировать ваш код в свою систему событий без явных языковых конструкций, которые обычно для этого используются. То есть привязка идет именно к названию функций, как будто мы пишем на js. Методы могут быть private или public, не важно. Наследование с использованием виртуальных методов тоже возможно. То есть можно создавать компоненты, которые будут наследоваться от MyComponent и переопределять нужные виртуальные функции. В системе событий Unity3d есть много других методов, которые будут вызываться на том или ином этапе Script-lifecycle.

Забавно, что есть такой класс, как UnityEngine.UIBehaviour, от которого тоже можно отнаследоваться, чтобы создавать компоненты. Он сам унаследован от MonoBehaviour и главное, что добавляет этот класс это набор пустых виртуальных методов, которые являются евентами Unity связанными с UI. Но никто не мешает унаследоваться от MonoBehaviour и просто написать все нужны методы без переопределения виртуальных. Выглядит это немного странно, как будто Unity разработчики сами до конца не могут определиться как стоит оформлять функции из Script-lifecycle.

Создание компонентов

Итак мы уже познакомились с одной странностью C# кода в Unity. Дальше больше. Как я уже писал выше, компоненты можно добавлять через редактор сцены и через код в рантайме. Сначала пару слов про добавление в редакторе. Тут все просто. На сцене можно выбрать объект, в редакторе объекта нажать на кнопку "Add Component", выбрать MyComponent и он добавятся на объект. Тут имеет смысл упомянуть как это работает под капотом. Каждая сцена это отдельный файлик, в котором сериализовано все, что на этой сцене есть. То есть вся иерархия объектов и их компоненты. Когда мы добавляем компонент, мы создаем инстанс нашего класса, которы при сохранении сериализуется внутрь файла сцены. Соответственно, когда игра сбилдится и запустится, при загрузке сцены все наши компоненты десериализуются и заново создадутся их инстансы. Если мы укажем атрибут UnityEngine.SerializeField для каких-то из наших полей, то соответсвенно сможем сохранить какой-то компонент с конкретным значением этого поля. В редакторе даже появится UI элемент, чтобы менять это поле уже на добавленном компоненте. public поля сериализуются по умолчанию.

В рантайме добавлять компоненты тоже весьма просто. Для этого используется метод GameObject.AddComponent<T>(). Это не статический метод, так что нам стачала надо получить экземпляр какого-нибудь GameObject'а. В этом проблемы нет, Unity API содержит кучу способов получить тот или иной GameObject. Самым распространенным является свойство MonoBehaviour.gameObject. Вот пример компонента, который добавляет свою копию на тот же gameObject через через 3 секунды, после начала работы.

using UnityEngine;

public class CopySelfComponent : MonoBehaviour {

  private float _passedTime;

  private void Update() {
    if (_passedTime > 3f) 
    {
      // gameObject - публичное свойство MonoBehaviour
      gameObject.AddComponent<CopySelfComponent>();
    }
    else 
    {
      _passedTime += Time.deltaTime;
    }
  }

}

На этом моменте у прожженного разработчика может возникнуть вопрос. Как обстоят дела с зависимостями? Ни для кого не секрет, что программирование это про создание разнообразных классов и выстраивание понятных, точных и красивых взаимоотношений между ними. В большинстве языков программирования, как и в C#, для обозначения зависимостей класс используется конструктор. Я уже описал два способа создать компонент, но конструктор в них не фигурирует. Ответ вас убьет. Конструкторы для классов унаследованных от MonoBehaviour это, по факту, UB в C#. Кто не писал на плюсах и не знает про концепцию Undefined Behaviour скажу так - это функционал, который вы можете использовать, но результатом его работы не будет ничего хорошего и, чего бы вы не хотели сделать, у вас это вряд ли получится. Иначе говоря мы не можем использовать конструкторы для компонентов. Есть формальная замена конструкторам. Это метод Awake(). Этот метод - первое что будет вызвано у компонента при его создании(с оговорками в которые мы не будем сейчас вдаваться). Но опять же этот метод не принимает никаких аргументов. Обычно для инъекции зависимостей пишут отдельный метод и называют его Construct(...) или Initialize(...) и передают туда все нужные зависимости. Опять же многие сходу могут сказать чем пахнет такой подход. По сути мы получаем объект с неконсистентным состоянием сразу после создания. Это то, с чем приходится мириться. Решается это инкапсулированием создания в фабрики или в отдельные DI фреймворки, которые имеют свое API для создания объектов и компонентов. Вот так вот может выглядеть рядовой компонент, написанный с использованием одного из самых популярных DI фреймворков - Zenject'а:

using UnityEngine;
using Zenject;

public class MyComponent : MonoBehaviour {

  // тут мы даже не можем использовать readonly для зависимостей, так как они меняются после создания объекта.
  private IService1 _service1;
  private IService1 _service2;
  private IService1 _service3;

  // аттрибут, дающий DI фрейморку инфу о том, что это метод для инъекции зависимостей
  [Inject]
  public void Construct(
    IService1 service1,
    IService2 service2,
    IService3 service3) {
    _service1 = service1;
    _service2 = service2;
    _service3 = service3;
  }
  ...

}

С классами не унаследованными от MonoBehaviour все не так плохо. Их можно использовать так же, как и в .NET. Более того большая часть бизнес логики приложения в больших проектах пишется именно внутри обычных классов, а компоненты используются как прослойка для интеграции с самим движком.

Тонкости написания кода для игр

В целом я описал основные отличия в языке, но так же есть ряд моментов, свойственных для разработки на Unity3d. Прежде всего стоит понимать, что игра, по своей сути, довольно высоконагруженное приложение. Главным образом из-за графики. Но и с кодом нужно обращаться чуть более аккуратно, чтобы лишний раз не нагружать и так нагруженный девайс. В связи с этим стоит обращать внимание на следующие вещи:

Кеширование

Многие методы и свойства Unity предназначенные для получения объектов и компонентов стоят очень дорого. В связи с этим нужные нам объекты стоит кешировать в приватных полях или, хотя бы, локальных переменных. Например есть публичное свойство MonoBehaviour.transform. Да, кстати, нейминг API движка экзотичен для .NET разработчика и свойства начинаются с маленькой буквы, как и поля. Тем не менее почти во всех проектах, что я видел используется обычный для .NET нейминг. Так вот, есть свойство MonoBehaviour.transform. Оно возвращает компонент Transform для того объекта, на котором висит наш MonoBehaviour. Если мы захотим изменить позицию на основе уже имеющейся, то следующий код будет не самым эффективным:

transform.position = transform.position + Vector3.up; // обращение к transform происходит 2 раза

Правильнее будет написать так:

var cachedTransform = transform;
cachedTransform.position = cachedTransform.position + Vector3.up;

Если же transform активно используется на протяжении всей жизни компонента, то его стоит закешировать в поле во время выполнения Awake.

Конечно обращение к свойству transform или другому Unity3d API это все равно очень маленькое время. Но при использовании такого кода повсеместно в проекте, в том числе в методах на подобии Update, которые вызываются каждый кадр, может вызвать просадки фпс, что критично для игр.

### Создание объектов

Создание объектов тоже та вещь, на которую стоит обращать достаточно много внимания в Unity3d. Сейчас я говорю, как про объекты в Unity3d(GameObject'ы и MonoBehaviour'ы), так и про обычные экземпляры классов. Если быть кратким, то лучше всего, с точки зрения производительности, их не создавать вообще. Для этого в Unity3d активно используется пуллинг объектов. Далеко не новаторская идея. В Unity3d API уже есть готовый класс для этого - UnityEngine.Pool.ObjectPool<T>. Почему это важно в контексте разработки игр? Тут для Unity3d-объектов и обычных экземпляров классов есть разные причины:

Для обычных экземпляров классов проблема банальна - выделение памяти. Один из главных врагов производительности приложения это Сборщик Мусора. Как и в .NET сборщик мусора стопит исполнение кода для того, чтобы выполнить свою работу по менеджменту памяти. Активное создание объектов с коротким жизненным циклом может привести к частому запуску сборщика и соответственно к просадке FPS в эти моменты. Такие проблемы можно встретить, если в коде создаются различного рода коллекции для обработки и переноса данных, различные DTO и так далее.

Для GameObject'ов и компонентов выделение на них памяти тоже никто не отменял, но все же их срок жизни обычно побольше. Тут проблема опять же в медленном API, которое за это создание отвечает. Бывают такие ситуации, что мы хотим переходить от одного состояния игры к другому, попутно начиная отображать большие массивы объектов, допустим загрузка какого-то окружения, спавн большого количества юнитов или типа того. В этом случае нам хочется создавать наши игровые объекты как можно быстрее, чтобы в идеале не смущать пользователя просадками FPS или загрузочными экранами. В этих ситуациях нам и пригождается пуллинг объектов(прогретый в идеале). Тут не стоит забывать про баланс межнду используемой памятью и скоростью работы. Если пулить слишком много, то приложение будет занимать слишком много памяти и из этого тоже не выйдет ничего хорошего. Так что грамотно распределяйте ресурсы девайса, которые вам даны.

Структуры

По тем же соображениям производительности полезным навыком для разработчика будет умение вовремя применить структуру вместо класса для снижения нагрузки на сборщик мусора. Тут не буду особо распинаться, так как правила +/- те же, что и в .NET. Так же стоит обращать внимание на боксинг и не допускать лишнего переноса структур в кучу.

### Уничтожение объектов

С уничтожением объектов в Unity есть небольшой нюанс. Для обычных классов мы имеем сборщик мусора, но с объектами, которые являются частью движка все немного сложнее. Рассмотрим, например, GameObject. Если мы напишем new GameObject(), то получим новый экземпляр класса GameObject. При этом в иерархии Unity этот объект добавится в текущую открытую сцену. Кажется очевидным, что если в нашем коде не останется ссылок на этот экземпляр, то он все еще будет частью сцены и не будет удален сборщиком мусора. Для того чтобы явно удалить этот объект со сцены, нам надо вызвать статический метод UnityEngine.Object.Destroy(UnityEngine.Object) и передать наш экземпляр в параметр. Для GameObject или наследников MonoBehaviour это кажется очевидным, так как мы знаем, что этот объект часть сцены и видится логичным, что для его удаления нужно сделать какие-то явные действия. Но есть и менее очевидные случаи. Например, класс Texture2D представляет абстракцию над текстурой(картинкой). Мы можем создать экземпляр класса Texture2D и может показаться, что только мы на него ссылаемся и, когда мы потеряем все ссылки на него, он удалится сборщиком мусора. К сожалению, это не так. Правило тут такое: мы должны вызывать UnityEngine.Object.Destroy(UnityEngine.Object) для всех наследников UnityEngine.Object, которых явно создаем. Дело в том, что все C# объекты в Unity API являются лишь оберткой для нативных объектов, которые являются частью C++ кода движка. Если мы потеряем все ссылки на наш объект, то удалится только C# обертка. С++ объект под капотом же так и будет занимать память. В то же время, вызвав UnityEngine.Object.Destroy(UnityEngine.Object), мы удаляем только подкапотный C++ объект. Так что после UnityEngine.Object.Destroy(UnityEngine.Object) мы имеем тот же C# объект, но все методы которого будут выбрасывать ошибки, потому что объект, который действительно отвечает за логику, был уничтожен.

Интересным моментом тут является то, что нужно быть осторожным при сравнении наследников UnityEngine.Object с null. Оператор сравнения переопределен для UnityEngine.Object и помимо обычной проверки на то, что оба операнда являются null есть проверка на то, что внутренний C++ объект уничтожен. В этом случае сравнение объекта с null тоже вернет true. Это создает небольшую путаницу и делает сравнение с null сравнительно дорогой операцией, так что ее тоже не стоит использовать в часто вызывающемся коде.

Linq

Методы расширения Linq невероятно удобная вещь. Так же они невероятно эффективны в контексте Middleware подхода, когда разные слои программы могут последовательно работать с одним перечисляемым объектом не вызывая при этом лишних проходов по элементам. Но у Linq есть большой(в контексте Unity3d) недостаток. Методы расширения Linq возвращают объект, память на который выделена в куче. Это отсылает нас к предыдущим рассуждениям про лишнее создание объектов. Это, конечно, копейки, но опять же при повсеместном использовании в часто вызывающемся коде это может стать проблемой. Я советую продолжать использовать Linq в случае если вы используете Middleware подход, но стараться заменять Linq методы на обычные for'ы и foreach'и если используете их в рамках одного метода. Так же надо быть осторожным с методами выполняющими Linq запрос - ToList(), ToArray() и тд, так как они тоже драматично могут повлиять на использование памяти. Выполнять их нужно непосредственно перед тем, как вам понадобится результат работы Linq запроса. Впрочем, в этом плане разницы с .NET, я думаю, нет.

Многопоточность и асинхронность

С многопоточностью в Unity3d не просто. Как я уже сказал, на WebGL код отправленный на исполнение в другой поток вообще не будет работать. На других платформах проблем с многопоточностью именно в контексте корректности работы потоков в операционной системе я не встречал. Тем не менее практически все Unity API будет плеваться в вас ошибками в случае если вы захотите сделать какой-то вызов не из главного потока(Опытные юнитисты могут вспомнить по Job System, но там, на сколько я знаю есть только ограниченный доступ к Transform). С одной стороны приятно. Не надо сильно маяться с синхронизацией. Ни нам, ни разработчикам Unity3d. Многие разработчики Unity даже не знают про ключевое слово lock, которое, казалось бы, один из столпов многопоточного программирования в C#. С другой же стороны иногда хотелось бы заиспользовать мощь нескольких ядер для изменения свойств большого количества Unity объектов.

Тем не менее, под капотом Unity активно использует многопоточность и в API есть асинхронные операции для работы с загрузкой данных с жесткого диска, сетью и некоторыми другими тяжелыми операциями. Правда async/await синтаксис разработчики в Unity Technologies не жалуют, поэтому часто приходится довольствоваться коллбеками и эвентами. Впрочем, никто не мешает обернуть это в нормальное async API с помощью TaskCompletionSource и прочих приблуд.

Так же есть оригинальный подход к написанию асинхнонного кода в Unity, который называется корутины. Корутины - это, по сути, механизм, который позволяет через API Unity3d отдать в движок перечисление, каждый элемент которого указывает движку, когда нужно взять следующий элемент. В комбинации с оператором yield мы можем получить костыльный вариант async/await. Выглядит это как-то так:

public IEnumerator Coroutine() {
  Debug.Log("Do something");
  
  yield return new WaitForNextFrame();
  
  Debug.Log("Do something in the next frame")
    
  yield return new WaitForSeconds(2f);
  
  Debug.Log("Do something after two seconds");
  var t = Task.Delay(4000);
  
  yield return new WaitUntil(() => t.Completed);
  
  Debug.Log("Do something after 4 more seconds.");
}

...

someMonoBehaviourInstance.StartCoroutine(Coroutine());

...

Тут внимательный читатель заметит, что чтобы стартануть корутину мы должны сделать вызов у конкретного инстанса объекта. Преимуществом корутин можно отметить именно эту привязку к объекту. Корутина будет отменена при уничтожение этого объекта через UnityEngine.Object.Destroy(obj). Во всем остальном же корутины просто более неудобная версия async/await.

С Task'ами тоже в Unity3d есть проблемы. Во-первых Task'и работают на пуле потоков, а значит при использовании Task мы имеем все ту же проблему с невозможностью использовать UnityAPI. Во-вторых Task'и это классы и аллоцируются в куче, чего в Unity3d-разработке тоже всячески избегают. На выручку производительности приходи ValueTask, а так же его аналог для Unity - Unitask. Unitask это отдельный open-source плагин и аналог ValueTask с дополнительными оптимизациями и удобствами для работы с Unity API. Так же эта библиотека содержит различные методы расширения для классов из библиотеки Unity для удобного использования их с await.

Так же Unity3d имеет свою реализацию для SynchronizationContext. Он немного упрощает работу с Task'ами и позволяет не волноваться о том, что код после await может быть выполнен в другом потоке. Если объект, который мы await'им выполняет вычисления в главном потоке Unity, то код после await будет выполнен сразу после окончания этих вычислений. Если же мы используем многопоточность, то код после await будет выполнен на одной из ранних стадий обновления Script-lifecycle'а. Похожий контекст синхронизации используется в WPF.

Покрытие кода автотестами

Unity имеет встроенное в редактор окно для запуска и просмотра тестов. Сами автотесты работают на основе фреймворка NUnit с некоторыми оговорками. Во первых у нас есть два вида автотестов - PlayMode и EditMode. EditMode автотесты это, можно сказать, обычные автотесты NUnit. Вы можете создавать там обычные C# объекты, мокать зависимости с какой-нибудь библиотекой для моков(типа NSubstitute, который есть так же в OpenUPM), делать какие-то манипуляции и проверять итоговый результат. PlayMode тесты - это тесты в которых запускается Script-lifecycle. Там вы можете провеять, как работают ваши классы унаследованные от MonoBehaviour, как они ведут себя с течением времени, как реагируют на различные тригеры физики из Script-lifecycle и так далее.

На моем опыте мне встречалось не так много примеров проектов, где активно бы использовали PlayMode тесты. В основном вся бизнес логика максимально выносится в обычные C# классы, а наследники MonoBehaviour выступают просто прослойкой между вашей C# архитектурой и UnityAPI. Думаю главная причина тому это сложность в тестировании всего, что связано со Script-lifecycle. Логика работы движка с физикой и исполнением различных функций не имеет API для достаточно тонкой настройки, которая часто нужна при написании автотестов.

К неудобству тестов в Unity можно отнести отсутствие поддержки асинхнонных тестов. Тут на замену асинхронности приходят уже упомянутые выше корутины. Выглядет это как-то так:

[UnityTest] // специальный аттрибут для таких тестов
public IEnumerator AsyncTest() {
  // Arrange
  SomeService someSerivce = // create some service with mocked dependencies

  // Act
  Task<bool> asyncHandle = someService.SomeAsyncMethod();
  yield return new WaitUntil(() => asyncHandle.IsCompleted);

  // Arrange
  Assert(true, asyncHandle.Result);
}

Заключение

В целом, это основные вещи, которые полезно знать, когда начинаешь писать C# код в Unity3d. Если у вас есть что добавить или указать на какие-то ошибки вы можете написать в комментарии к статье или завести issue в GitHub.

Автор: Kekchpek

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js