При работе с Unity очень часто приходится обращаться к сущностям Unity (слоям коллизий, сортировочным слоям, тэгам, осям ввода, сценам) по их названиям. Если какую-то из них, например, переименовать в редакторе, то нужно не забыть, соответственно, подправить название в коде, иначе нас ждет ошибка. И ошибка эта возникнет не при компиляции, а во время выполнения, непосредественно в момент обращения по имени. Немного автоматизации спасет от таких неприятных сюрпризов.

На первый взгляд, для решения этой проблемы логично использовать шаблоны T4, но они мне показались неудобными (по крайней мере, при использовании именно в Unity-проекте), поэтому я выбрал другой подход. Использование CodeDom для решения такой мелкой проблемы может показаться оверинжинирингом, но личный опыт доказал состоятельность этого подхода: я написал свой незамысловатый генератор кода больше года назад, и с тех пор, не внося в него изменения, успешно пользовался им, что сэкономило мне немало нервов и порядочно секунд времени.

В данной статье мы рассмотрим написание аскетичного генератора кода с константами, содержащими названия слоев коллизий. Работа с названиями других сущностей делается аналогично.

Общий план действий таков:

  • Получить список названий слоев коллизий, имеющихся в проекте.
  • Сгенерировать сам код, содержащий класс с нужными константами.
  • Записать этот код в файл.
  • Побудить Unity немедленно скомпилировать добавленный/измененный файл.

Получаем список названий слоев коллизий


Тут все просто, если не бояться лезть в места, названные внутренними. Конкретнее, список имен слоев коллизий хранится как поле «внутреннего» класса.

        private static IEnumerable<string> GetAllLayers()
        {
            return InternalEditorUtility.layers;
        }

Генерируем сам код


У CodeGen слегка своя терминология (сравните, например, с терминологией в Roslyn), но, в целом, все соответствует синтаксическому дереву, присущему коду на C#. В порядке от корня к листьям, мы будем использовать следующее:

  1. CodeCompilationUnit — это сам генератор кода, который мы здесь, так сказать, конфигурируем.
  2. CodeNamespace — это пространство имен, в котором будет сидеть наш класс. Мы не будем оборачивать класс в явное пространство имен, но создать экземпляр CodeNamespace, все равно, придется.
  3. CodeTypeDeclaration — это сам класс.
  4. CodeMemberField — это член класса (в данном случае, объявление константы).
  5. CodePrimitiveExpression — это выражение с литералом (в данном случае, строка, который будет присваиваться константе).

Генерируем публичную строковую константу, у которой имя и значение совпадают с именем слоя коллизий.

private static CodeMemberField GenerateConstant(string name)
        {
            name = name.Replace(" ", "");

            var @const = new CodeMemberField(
                typeof(string),
                name);

            @const.Attributes &= ~MemberAttributes.AccessMask;
            @const.Attributes &= ~MemberAttributes.ScopeMask;
            @const.Attributes |= MemberAttributes.Public;
            @const.Attributes |= MemberAttributes.Const;

            @const.InitExpression = new CodePrimitiveExpression(name);
            return @const;
        }

Есть у CodeGen одно мелкое неудобство: он не умеет создавать статические классы. Связано это с тем, что он создавался на заре языка C#, когда в него еще не «завезли» статические классы. Будем выкручиваться: сымитируем статический класс запечатанным классом с приватным конструктором. Так поступали некоторые ранние пользователи C#, а использующие язык Java вынуждены и сейчас прибегать к этому.

private static void ImitateStaticClass(CodeTypeDeclaration type)
        {
            @type.TypeAttributes |= TypeAttributes.Sealed;

            @type.Members.Add(new CodeConstructor {
                Attributes = MemberAttributes.Private | MemberAttributes.Final
            });
        }

Наконец-то, соберем сам класс, с приватным конструктором и константами:

private static CodeCompileUnit GenerateClassWithConstants(
            string name,
            IEnumerable<string> constants)
        {
            var compileUnit = new CodeCompileUnit();
            var @namespace = new CodeNamespace();

            var @class = new CodeTypeDeclaration(name);

            ImitateStaticClass(@class);

            foreach (var constantName in constants)
            {
                var @const = GenerateConstant(constantName);
                @class.Members.Add(@const);
            }

            @namespace.Types.Add(@class);
            compileUnit.Namespaces.Add(@namespace);

            return compileUnit;
        }

Записываем код в файл



        private static void WriteIntoFile(string fullPath, CodeCompileUnit code)
        {
            Directory.CreateDirectory(Path.GetDirectoryName(fullPath));
            using (var stream = new StreamWriter(fullPath, append: false))
            {
                var writer = new IndentedTextWriter(stream);
                using (var codeProvider = new CSharpCodeProvider())
                {
                    codeProvider.GenerateCodeFromCompileUnit(code, writer, new CodeGeneratorOptions());
                }
            }
        }

Заставляем Unity немедленно «осознать» изменения


Это последний шаг, и он не требует существенного количества кода, поэтому пусть этим занимается та функция, которая будет реагировать на ввод пользователя.


        [MenuItem("Habr/Generate layers constants")]
        private static void GenerateAndForceImport()
        {
            const string path = @"Auto/Layers.cs";

            var fullPath = Path.Combine(Application.dataPath, path);
            var className = Path.GetFileNameWithoutExtension(fullPath);

            var code = GenerateClassWithConstants(className, GetAllLayers());
            WriteIntoFile(fullPath, code);

            AssetDatabase.ImportAsset("Assets/" + path, ImportAssetOptions.ForceUpdate);
            AssetDatabase.Refresh();
        }

Результат


Собираем все воедино:

Итоговый код генератора

namespace Habr
{
    using Microsoft.CSharp;
    using System.CodeDom;
    using System.CodeDom.Compiler;
    using System.Collections.Generic;
    using System.IO;
    using System.Reflection;
    using UnityEditor;
    using UnityEditorInternal;
    using UnityEngine;

    internal static class HabrCodeGen
    {
        [MenuItem("Habr/Generate layers constants")]
        private static void GenerateAndForceImport()
        {
            const string path = @"Auto/Layers.cs";

            var fullPath = Path.Combine(Application.dataPath, path);
            var className = Path.GetFileNameWithoutExtension(fullPath);

            var code = GenerateClassWithConstants(className, GetAllLayers());
            WriteIntoFile(fullPath, code);

            AssetDatabase.ImportAsset("Assets/" + path, ImportAssetOptions.ForceUpdate);
            AssetDatabase.Refresh();
        }

        private static CodeCompileUnit GenerateClassWithConstants(
            string name,
            IEnumerable<string> constants)
        {
            var compileUnit = new CodeCompileUnit();
            var @namespace = new CodeNamespace();

            var @class = new CodeTypeDeclaration(name);

            ImitateStaticClass(@class);

            foreach (var constantName in constants)
            {
                var @const = GenerateConstant(constantName);
                @class.Members.Add(@const);
            }

            @namespace.Types.Add(@class);
            compileUnit.Namespaces.Add(@namespace);

            return compileUnit;
        }

        private static CodeMemberField GenerateConstant(string name)
        {
            name = name.Replace(" ", "");

            var @const = new CodeMemberField(
                typeof(string),
                name);

            @const.Attributes &= ~MemberAttributes.AccessMask;
            @const.Attributes &= ~MemberAttributes.ScopeMask;
            @const.Attributes |= MemberAttributes.Public;
            @const.Attributes |= MemberAttributes.Const;

            @const.InitExpression = new CodePrimitiveExpression(name);
            return @const;
        }

        private static IEnumerable<string> GetAllLayers()
        {
            return InternalEditorUtility.layers;
        }

        private static void ImitateStaticClass(CodeTypeDeclaration type)
        {
            @type.TypeAttributes |= TypeAttributes.Sealed;

            @type.Members.Add(new CodeConstructor {
                Attributes = MemberAttributes.Private | MemberAttributes.Final
            });
        }

        private static void WriteIntoFile(string fullPath, CodeCompileUnit code)
        {
            Directory.CreateDirectory(Path.GetDirectoryName(fullPath));
            using (var stream = new StreamWriter(fullPath, append: false))
            {
                var tw = new IndentedTextWriter(stream);
                using (var codeProvider = new CSharpCodeProvider())
                {
                    codeProvider.GenerateCodeFromCompileUnit(code, tw, new CodeGeneratorOptions());
                }
            }
        }
    }
}

Кладем нашу утилиту в папку Editor, нажимаем Habr > Generate layers constants, получаем в проекте файл со следующим содержанием:


// ------------------------------------------------------------------------------
//  <autogenerated>
//      This code was generated by a tool.
//      Mono Runtime Version: 2.0.50727.1433
// 
//      Changes to this file may cause incorrect behavior and will be lost if 
//      the code is regenerated.
//  </autogenerated>
// ------------------------------------------------------------------------------



public sealed class Layers {
    
    public const string Default = "Default";
    
    public const string TransparentFX = "TransparentFX";
    
    public const string IgnoreRaycast = "IgnoreRaycast";
    
    public const string Water = "Water";
    
    public const string UI = "UI";
    
    public const string Habr = "Habr";
    
    private Layers() {
    }
}

Дальнейшие действия


Полученной утилите не хватает следующих вещей:

  • Чуть более удобного интерфейса с чуть более гибкими настройками.
  • Устойчивости к невалидным в C# названиям.
  • Генерации аналогичным образом констант для названий сортировочных слоев, сцен, тэгов и осей ввода.

Чтобы не тратить время на написание своего «велосипеда», вы также можете воспользоваться моим «велосипедом».
Поделиться с друзьями
-->

Комментарии (10)


  1. Leopotam
    03.09.2016 12:23

    А если сделать то же самое руками, собирая в StringBuilder, то кода получится в 3-4 раза меньше + будет работать быстрее (в том числе можно через InitializeOnLoad сделать автогенерацию) + будет статичный класс. Оверинженеринг в геймдеве не нужен.


    1. dog_funtom
      03.09.2016 20:37

      Скорость не имеет значения, поскольку после изменения кода Юнити обязательно подвешивается, как минимум, на секунду, каким бы не было незначительным изменение. По сравнению с этим, разница между StringBuilder, T4 или Codedom незначительна. В конечном итоге, InitializeOnLoad можно прилепить ко всем трем подходам с равным успехом.

      С остальным не могу поспорить, ваш прагматичный подход не хуже.


      1. Leopotam
        03.09.2016 23:40

        Вообще, имена слоев в чистом виде бесполезны, практически всегда нужны или их числовые представления или маски. Будет гораздо полезнее, если на выход будет подаваться вот такая простыня (для пустого проекта):

        using UnityEngine;
        namespace Client.Common {
            public static partial class UnityIdents {
                public static readonly int LayerDefault = LayerMask.NameToLayer ("Default");
                public static readonly int LayerTransparentFX = LayerMask.NameToLayer ("TransparentFX");
                public static readonly int LayerIgnoreRaycast = LayerMask.NameToLayer ("Ignore Raycast");
                public static readonly int LayerWater = LayerMask.NameToLayer ("Water");
                public static readonly int LayerUI = LayerMask.NameToLayer ("UI");
                public static readonly int LayerMaskDefault = 1 << LayerDefault;
                public static readonly int LayerMaskTransparentFX = 1 << LayerTransparentFX;
                public static readonly int LayerMaskIgnoreRaycast = 1 << LayerIgnoreRaycast;
                public static readonly int LayerMaskWater = 1 << LayerWater;
                public static readonly int LayerMaskUI = 1 << LayerUI;
                public const string TagUntagged = "Untagged";
                public const string TagRespawn = "Respawn";
                public const string TagFinish = "Finish";
                public const string TagEditorOnly = "EditorOnly";
                public const string TagMainCamera = "MainCamera";
                public const string TagPlayer = "Player";
                public const string TagGameController = "GameController";
            }
        }
        

        «partial» — чтобы можно было расширять дальше.
        Решение «в лоб».

        В гуе можно выставить путь до файла (имя класса возьмется из имени файла), неймспейс. Настройки сохраняются в проекте / репозитории и могут быть использованы всеми членами команды для перегенерации:


  1. kreol_dev
    03.09.2016 18:23

    Что значит статичных классов нет? Есть. MemberAttribute.Static добавить надо к CodeTypeDeclaration, да и всё


    1. dog_funtom
      03.09.2016 20:51

      Добавил. Полностью игнорируется, к сожалению: на выходе просто «public class Layers».


      1. kreol_dev
        03.09.2016 21:19

        Хм, открыл ILSpy — и правда игнорируется. Однако толку от него? Статический конструктор, например, всё равно работает.


  1. shai_hulud
    03.09.2016 19:08

    Вообще на T4 генерировать код проще (всё таки обычный шаблонизатор). Плюс он поддерживается студией/MonoDevelop и есть плагин для Юнити для автогенерации по зависимостям.


    1. dog_funtom
      03.09.2016 22:29

      Когда пробовал использовать T4 в Unity, там требовались какие-то лишние телодвижения и утилиты, чтобы это работало. Подробности уже не помню. В любом случае, сейчас, скорее всего, таких проблем нет, потому что, наверное, проблема возникала от того, что Unity дропала шаблоны из солюшена, а теперь есть соответствующая настройка.

      Забавная вещь с CodeGen в том, что он выглядит сложным со стороны, а если с ним подружиться, то все очень легко и непринужденно, как для мозга (думать), так и для пальцев (кодить).

      В конечном итоге, я считаю, что все три варианта из данной статьи и комментариев — T4, CodeGen и StringBuilder — одинаково хороши. Выбор только за индивидуальными вкусовыми предпочтениями.


  1. OlegGelezcov
    03.09.2016 22:55

    Забавно, но не очень полезно — все только для того, чтобы сгенерировать класс с константами, которые по своему определению меняться часто не должны, собственно как и слои в Unity вы не меняете как перчатки.

    Полезней новичку знать общий паттерн — «не обращаться к слоям по литеральным строковым названиям, а через константные или readonly поля, чтобы потом менять только эти поля, а не все вхождения строкового литерала»


    1. dog_funtom
      03.09.2016 23:02

      Взрослому проекту вредно (да и нет нужды) часто переименовывать/добавлять/удалять слои, а прототипу или молодому проекту не стоит ограничивать себя в этом: идеальное название приходит не сразу, идеальная категоризация получается не с первого раза, архитектура скачет почти так же лихо, как требования заказчика.