Автоматизация поддержания соответствия между названиями слоев в редакторе и коде с помощью CodeDom

в 8:46, , рубрики: codegen, collision layers, input axes, scenes, sorting layers, tags, unity, unity editor, unity3d

При работе с 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# названиям.
  • Генерации аналогичным образом констант для названий сортировочных слоев, сцен, тэгов и осей ввода.

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

Автор: dog_funtom

Источник


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


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