Перейти к содержанию

Метапрограммирование на C# в Unity

Писать шаблонный код вроде свойств с уведомлением об изменениях вручную утомительно. С помощью Roslyn Source Generators в Unity вы можете пометить поля атрибутом, а свойства и события будут сгенерированы на этапе компиляции и доставлены через сборку‑анализатор Unity. В статье показаны как простой генератор на основе текстового шаблона, так и более гибкий подход через Syntax API, плюс практические рекомендации по внедрению в ваших проектах.

Mar 18, 2024

Что автоматизировать

Любите Factorio? Это прекрасная игра об автоматизации процессов. Мой любимый этап — когда игроки переходят от ручной сборки фабрик к их тиражированию по чертежам, которые автоматически собирают маленькие боты. К чему это? Похожим образом в разработке мы можем автоматизировать рутинное написание шаблонного кода. В частности, в этом посте мы рассмотрим автоматизацию генерации кода в Unity с использованием Roslyn Source Generators.

Предположим, у вас есть класс с полем, изменения значения которого нужно отслеживать:

public class MyClass
{
    private int m_Counter; // Хотелось бы отслеживать его изменения
}

В C# существует несколько подходов: от использования BindableField, реализации интерфейсов IObservable, до создания события вместе со свойством, которое срабатывает при изменении значения. Например, это можно реализовать так:

public class MyClass
{
    private int m_Counter;
    public event System.Action<int> OnCounterChanged;

    public int Counter
    {
        get => m_Counter;
        set
        {
            if (m_Counter != value)
            {
                m_Counter = value;
                OnCounterChanged?.Invoke(m_Counter);
            }
        }
    }
}

Такую реализацию легко понять, но она быстро становится обременительной, когда добавляется всё больше полей, требующих аналогичного мониторинга. Вместо того чтобы конструировать дополнительные классы или интерфейсы для инкапсуляции этого паттерна, мы предлагаем другой путь: поручить генерацию этого шаблонного кода Roslyn.

Знакомство с Roslyn Source Generators

Roslyn Source Generators — это мощная функциональность .NET, позволяющая разработчикам C# генерировать и добавлять исходный код в проекты на этапе компиляции. Это часть платформы компилятора Roslyn, предоставляющая API для анализа и генерации кода C#.

Source Generators не модифицируют исходники напрямую. Они работают похоже на анализаторы кода, но вместо предупреждений или ошибок создают новый исходный код, который автоматически компилируется вместе с остальным проектом. Это открывает возможности автоматизировать множество аспектов разработки: генерировать шаблоны, автоматически создавать API на основе метаданных, внедрять повторяющиеся паттерны без ручного копирования и вставки.

Однако интеграция Source Generators непосредственно в Unity требует обходного пути: мы разрабатываем генератор как внешнюю библиотеку под .netstandard 2.0, компилируем её в DLL, импортируем в Unity и помечаем как Roslyn Analyzer. Подробности реализации в этом посте опускать будем (обратитесь к документации Unity по roslyn analyzers), но сама концепция открывает двери для автоматизации различных задач разработки в Unity.

Сначала определим критерий, по которому будем искать поля для подписки. Пусть это будет атрибут Subscription. Поскольку наш класс будет дополняться с помощью Roslyn, его нужно объявить как partial. Остальную работу сделает Roslyn (в целях простоты опустим некоторые нюансы и не будем анализировать пространства имён, вложенные классы и т. п.).

public class Subscription: Attribute {}

public partial class MyClass
{
    [Subscription]
    private int m_Counter;
}

Реализация SourceGenerator — подход на шаблонах

Какие инструменты предоставляет Roslyn?

  • Syntax Tree (синтаксическое дерево): структурированное иерархическое представление исходного кода. Оно позволяет анализировать код в поисках структур и паттернов, которые нужно обработать или дополнить.
  • Symbol Information (символическая информация): семантический контекст элементов кода — классов, методов, свойств и т. д. Это помогает понимать не только структуру, но и смысл кода, что важно для корректной, контекстно‑зависимой генерации.

Чтобы работать с синтаксическим деревом и генерировать собственный код, нам потребуется сам SourceGenerator. Он должен реализовывать интерфейс ISourceGenerator и иметь атрибут [Generator].

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

[Generator]
public class SimpleSourceGenerator : ISourceGenerator
{
    public void Initialize(GeneratorInitializationContext context)
    {
    }

    public void Execute(GeneratorExecutionContext context)
    {
    }
}

В методе Initialize мы подключим ISyntaxReceiver, который выполнит поиск всех полей, помеченных атрибутом. Пример такого приёмника:

public class SubscriptionSyntaxReceiver : ISyntaxReceiver
{
    public readonly List<FieldDeclarationSyntax> FieldsWithSubscriptionAttribute = new();

    public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
    {
        if (syntaxNode is FieldDeclarationSyntax fieldDeclarationSyntax)
        {
            var attributes = fieldDeclarationSyntax.AttributeLists.SelectMany(a => a.Attributes);
            if (attributes.Any(a => a.Name.ToString() == "Subscription"))
            {
                FieldsWithSubscriptionAttribute.Add(fieldDeclarationSyntax);
            }
        }
    }
}

Класс SubscriptionSyntaxReceiver реализует интерфейс ISyntaxReceiver и вызывается для каждого узла синтаксического дерева. В процессе он ищет объявления полей с атрибутом Subscription и сохраняет их в список FieldsWithSubscriptionAttribute. ISyntaxReceiver применим, когда нам нужно «выбрать» что‑то из дерева кода. Мы могли бы сделать это и напрямую в методе Execute класса SimpleSourceGenerator, где есть доступ ко всему дереву. Например, так:

public void Execute(GeneratorExecutionContext context)
{
    foreach (var syntaxTree in context.Compilation.SyntaxTrees)
    {
        foreach (var node in syntaxTree.GetRoot().DescendantNodes())
        {
            // Здесь можно обрабатывать те же узлы.
        }
    }
}

Тем не менее использование ISyntaxReceiver эффективнее, поскольку позволяет Roslyn собрать необходимые узлы ещё на стадии компиляции, а не перебирать их вручную.

Подключим ISyntaxReceiver к SimpleSourceGenerator.

    public void Initialize(GeneratorInitializationContext context)
    {
        context.RegisterForSyntaxNotifications(() => new SubscriptionSyntaxReceiver());
    }

И напишем наш генератор классов на базе строкового шаблона кода.

  public void Execute(GeneratorExecutionContext context)
    {
        if (context.SyntaxReceiver is not SubscriptionSyntaxReceiver receiver)
            return;

        foreach (var field in receiver.FieldsWithSubscriptionAttribute)
        {
            var fieldName = field.Declaration.Variables.First().Identifier.Text;
            var fieldType = ((PredefinedTypeSyntax)field.Declaration.Type).Keyword.Text;
            var className = ((ClassDeclarationSyntax)field.Parent).Identifier.Text;
            var cleanedName = fieldName.TrimStart('m', '_');
            var propertyName = char.ToUpper(cleanedName[0]) + cleanedName.Substring(1);
            var eventName = $"On{propertyName}Changed";

            var source = $@"
public partial class {className}
{{
    public event System.Action<{fieldType}> {eventName};

    public {fieldType} {propertyName}
    {{
        get => {fieldName};
        set
        {{
            if ({fieldName} != value)
            {{
                {fieldName} = value;
                {eventName}?.Invoke({fieldName});
            }}
        }}
    }}
}}";
            context.AddSource($"{className}_{fieldName}_Subscription.cs", source);
        }
    }

Этот код делает следующее:

  • Генератор проверяет, что context.SyntaxReceiver имеет ожидаемый тип для сбора нужных синтаксических узлов.
  • Затем он итерируется по полям с атрибутом Subscription, собранным ранее SubscriptionSyntaxReceiver.
  • Для каждого поля по шаблону формируется код события и свойства, реагирующих на изменение этого поля.
  • Сгенерированный код добавляется в контекст компиляции как новый исходный файл, что обеспечивает интеграцию сгенерированных свойств и событий в класс.

Результатом работы генератора будет желаемый код:

public partial class MyClass
{
    event System.Action<int> OnCounterChanged;
    public int Counter
    {
        get => m_Counter;
        set
        {
            if (m_Counter != value)
            {
                m_Counter = value;
                OnCounterChanged?.Invoke(m_Counter);
            }
        }
    }
}

Более гибкий подход — SyntaxNodes

Бывают ситуации, когда неудобно или невозможно сразу написать весь код в виде строкового шаблона, и хочется собирать фабрики методов, классов и прочих конструкций напрямую из синтаксических узлов, по шагам. В контексте компилятора Roslyn SyntaxNode представляет элемент абстрактного синтаксического дерева (AST), отражающего структуру исходного кода программы. Каждый SyntaxNode неизменяем, то есть его состояние нельзя менять после создания. Такая иммутабельность обеспечивает надёжность и предсказуемость при анализе и трансформации кода: любые «изменения» приводят к созданию новых узлов вместо модификации существующих. Это упрощает работу с кодом, делая процесс безопаснее и понятнее.

Roslyn позволяет комбинировать подходы. Часть кода можно создавать из текстовых шаблонов, а часть — через узлы. Там, где нужно, мы создаём узлы, расширяем их содержимое и переиспользуем как маленькие строительные блоки. Где удобнее — конструируем код на базе узлов; где проще — используем шаблоны.

Давайте немного прокачаем наш генератор, смешав оба метода, чтобы он стал гибче. Небольшое предупреждение: погружение в синтаксические узлы даёт отличный контроль, но код может стать менее читаемым.

Генерация пустого частичного класса:

    public static ClassDeclarationSyntax GeneratePartialClass(string className)
    {
        return SyntaxFactory.ClassDeclaration(className)
            .AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword), SyntaxFactory.Token(SyntaxKind.PartialKeyword));
    }

Теперь метод генерации события:

 public EventFieldDeclarationSyntax GenerateEvent(string fieldType, string eventName)
    {
        var eventIdentifier = SyntaxFactory.Identifier(eventName);
        var actionType = SyntaxFactory.GenericName(
                SyntaxFactory.Identifier("System.Action"))
            .WithTypeArgumentList(
                SyntaxFactory.TypeArgumentList(
                    SyntaxFactory.SingletonSeparatedList<TypeSyntax>(
                        SyntaxFactory.IdentifierName(fieldType))));

        return SyntaxFactory.EventFieldDeclaration(
            SyntaxFactory.VariableDeclaration(actionType)
                .WithVariables(
                    SyntaxFactory.SingletonSeparatedList(
                        SyntaxFactory.VariableDeclarator(eventIdentifier))));
    }

А для свойства давайте полностью распарсим код из шаблона (мы могли бы собрать его по частям — из аксессоров get/set, объявления свойства, модификаторов, условий и т. п., но для примера разберём как цельный узел из шаблона):

    public PropertyDeclarationSyntax GenerateProperty(string fieldType, string propertyName, string fieldName, string eventName)
    {
        var propertyCode = $@"
  public {fieldType} {propertyName}
     {{
         get => {fieldName};
         set
         {{
             if ({fieldName} != value)
             {{
                 {fieldName} = value;
                 {eventName}?.Invoke({fieldName});
             }}
         }}
     }}
";
        return (PropertyDeclarationSyntax)SyntaxFactory.ParseMemberDeclaration(propertyCode);
    }

Соберём части нашего генератора вместе и посмотрим, что получилось. Итого мы приходим к следующему:

 public void Execute(GeneratorExecutionContext context)
    {
        if (context.SyntaxReceiver is not SubscriptionSyntaxReceiver receiver)
            return;

        foreach (var field in receiver.FieldsWithSubscriptionAttribute)
        {
            var fieldName = field.Declaration.Variables.First().Identifier.Text;
            var fieldType = ((PredefinedTypeSyntax)field.Declaration.Type).Keyword.Text;
            var className = ((ClassDeclarationSyntax)field.Parent).Identifier.Text;
            var cleanedName = fieldName.TrimStart('m', '_');
            var propertyName = char.ToUpper(cleanedName[0]) + cleanedName.Substring(1);
            var eventName = $"On{propertyName}Changed";

            //create event and property
            var eventDeclaration = GenerateEvent(fieldType, eventName);
            var propertyDeclaration = GenerateProperty(fieldType, propertyName, fieldName, eventName);

            //create class and add event and property to it
            var classNode = GeneratePartialClass(className)
                .AddMembers(eventDeclaration)
                .AddMembers(propertyDeclaration);

            //create compilation unit and add it to the build
            var unit = SyntaxFactory.CompilationUnit().AddMembers(classNode);
            var code = unit.NormalizeWhitespace().ToFullString();

            context.AddSource($"{className}_{fieldName}_Subscription.cs", code);
        }
    }

Итак, мы рассмотрели два подхода к генерации кода, которые позволяют автоматизировать создание подписок. Что дальше? Этой методикой можно решать широкий спектр задач генерации: реализовывать паттерны, писать конвертеры классов, автоматизировать реализации интерфейсов, облегчать настройку Dependency Injection. Помимо генерации, подход позволяет анализировать существующий код и выявлять потенциальные проблемы ещё до их проявления. Такая двойственность — генерация и анализ — не только ускоряет разработку, но и повышает качество и надёжность кода.

Учебные материалы Microsoft: https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/source-generators-overview

Документация Unity: https://docs.unity3d.com/Manual/roslyn-analyzers.html

Для дополнительных примеров можно посмотреть мой плагин: https://github.com/dr-vij/UTools-Generators