На первый взгляд, для решения этой проблемы логично использовать шаблоны T4, но они мне показались неудобными (по крайней мере, при использовании именно в Unity-проекте), поэтому я выбрал другой подход. Использование CodeDom для решения такой мелкой проблемы может показаться оверинжинирингом, но личный опыт доказал состоятельность этого подхода: я написал свой незамысловатый генератор кода больше года назад, и с тех пор, не внося в него изменения, успешно пользовался им, что сэкономило мне немало нервов и порядочно секунд времени.
В данной статье мы рассмотрим написание аскетичного генератора кода с константами, содержащими названия слоев коллизий. Работа с названиями других сущностей делается аналогично.
Общий план действий таков:
- Получить список названий слоев коллизий, имеющихся в проекте.
- Сгенерировать сам код, содержащий класс с нужными константами.
- Записать этот код в файл.
- Побудить Unity немедленно скомпилировать добавленный/измененный файл.
Получаем список названий слоев коллизий
Тут все просто, если не бояться лезть в места, названные внутренними. Конкретнее, список имен слоев коллизий хранится как поле «внутреннего» класса.
private static IEnumerable<string> GetAllLayers()
{
return InternalEditorUtility.layers;
}
Генерируем сам код
У CodeGen слегка своя терминология (сравните, например, с терминологией в Roslyn), но, в целом, все соответствует синтаксическому дереву, присущему коду на C#. В порядке от корня к листьям, мы будем использовать следующее:
- CodeCompilationUnit — это сам генератор кода, который мы здесь, так сказать, конфигурируем.
- CodeNamespace — это пространство имен, в котором будет сидеть наш класс. Мы не будем оборачивать класс в явное пространство имен, но создать экземпляр CodeNamespace, все равно, придется.
- CodeTypeDeclaration — это сам класс.
- CodeMemberField — это член класса (в данном случае, объявление константы).
- 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)
kreol_dev
03.09.2016 18:23Что значит статичных классов нет? Есть. MemberAttribute.Static добавить надо к CodeTypeDeclaration, да и всё
dog_funtom
03.09.2016 20:51Добавил. Полностью игнорируется, к сожалению: на выходе просто «public class Layers».
kreol_dev
03.09.2016 21:19Хм, открыл ILSpy — и правда игнорируется. Однако толку от него? Статический конструктор, например, всё равно работает.
shai_hulud
03.09.2016 19:08Вообще на T4 генерировать код проще (всё таки обычный шаблонизатор). Плюс он поддерживается студией/MonoDevelop и есть плагин для Юнити для автогенерации по зависимостям.
dog_funtom
03.09.2016 22:29Когда пробовал использовать T4 в Unity, там требовались какие-то лишние телодвижения и утилиты, чтобы это работало. Подробности уже не помню. В любом случае, сейчас, скорее всего, таких проблем нет, потому что, наверное, проблема возникала от того, что Unity дропала шаблоны из солюшена, а теперь есть соответствующая настройка.
Забавная вещь с CodeGen в том, что он выглядит сложным со стороны, а если с ним подружиться, то все очень легко и непринужденно, как для мозга (думать), так и для пальцев (кодить).
В конечном итоге, я считаю, что все три варианта из данной статьи и комментариев — T4, CodeGen и StringBuilder — одинаково хороши. Выбор только за индивидуальными вкусовыми предпочтениями.
OlegGelezcov
03.09.2016 22:55Забавно, но не очень полезно — все только для того, чтобы сгенерировать класс с константами, которые по своему определению меняться часто не должны, собственно как и слои в Unity вы не меняете как перчатки.
Полезней новичку знать общий паттерн — «не обращаться к слоям по литеральным строковым названиям, а через константные или readonly поля, чтобы потом менять только эти поля, а не все вхождения строкового литерала»dog_funtom
03.09.2016 23:02Взрослому проекту вредно (да и нет нужды) часто переименовывать/добавлять/удалять слои, а прототипу или молодому проекту не стоит ограничивать себя в этом: идеальное название приходит не сразу, идеальная категоризация получается не с первого раза, архитектура скачет почти так же лихо, как требования заказчика.
Leopotam
А если сделать то же самое руками, собирая в StringBuilder, то кода получится в 3-4 раза меньше + будет работать быстрее (в том числе можно через InitializeOnLoad сделать автогенерацию) + будет статичный класс. Оверинженеринг в геймдеве не нужен.
dog_funtom
Скорость не имеет значения, поскольку после изменения кода Юнити обязательно подвешивается, как минимум, на секунду, каким бы не было незначительным изменение. По сравнению с этим, разница между StringBuilder, T4 или Codedom незначительна. В конечном итоге, InitializeOnLoad можно прилепить ко всем трем подходам с равным успехом.
С остальным не могу поспорить, ваш прагматичный подход не хуже.
Leopotam
Вообще, имена слоев в чистом виде бесполезны, практически всегда нужны или их числовые представления или маски. Будет гораздо полезнее, если на выход будет подаваться вот такая простыня (для пустого проекта):
«partial» — чтобы можно было расширять дальше.
Решение «в лоб».
В гуе можно выставить путь до файла (имя класса возьмется из имени файла), неймспейс. Настройки сохраняются в проекте / репозитории и могут быть использованы всеми членами команды для перегенерации: