- PVSM.RU - https://www.pvsm.ru -

Генерация типизированных ссылок на элементы управления Avalonia с атрибутом x:Name в XAML с помощью C# Source Generators

Генерация типизированных ссылок на элементы управления Avalonia с атрибутом x:Name в XAML с помощью C# Source Generators - 1

В апреле 2020-го года разработчиками платформы .NET 5 был анонсирован [1] новый способ генерации исходного кода на языке программирования C# — с помощью реализации интерфейса ISourceGenerator [2]. Данный способ позволяет разработчикам анализировать пользовательский код и создавать новые исходные файлы [3] на этапе компиляции. При этом, API новых генераторов исходного кода схож с API анализаторов Roslyn [4]. Генерировать код можно как с помощью Roslyn Compiler API [5], так и методом конкатенации обычных строк.

В данном материале рассмотрим процесс реализации ISourceGenerator для генерации типизированных ссылок на элементы управления AvaloniaUI [6], объявленные в XAML. В процессе разработки научим генератор компилировать XAML с помощью API компилятора XamlX [7], используемого в AvaloniaUI [8], и системы типов XamlX, реализованной поверх API семантической модели Roslyn [9].

Постановка задачи

С помощью новых генераторов исходного кода может получиться элегантно решить широкий спектр задач, включая генерацию шаблонного кода, который было бы не очень интересно и совсем не продуктивно писать вручную. Например, в приложениях, использующих AvaloniaUI [6] — фреймворк для разработки кроссплатформенных приложений с графическим интерфейсом, о котором недавно вышла статья на Хабре [10] — нередко приходится писать следующий код для получения ссылки на элемент управления, объявленный в XAML:

TextBox PasswordTextBox => this.FindControl<TextBox>("PasswordTextBox");

Элемент типа TextBox с именем PasswordTextBox при этом объявлен в XAML следующим образом:

<TextBox x:Name="PasswordTextBox"
         Watermark="Please, enter your password..."
         UseFloatingWatermark="True"
         PasswordChar="*" />

Получать ссылку на элемент управления в XAML может понадобиться в случае необходимости применения анимаций, программного изменения стилей и свойств элемента управления, или использования кроссплатформенных типизированных привязок данных ReactiveUI, таких, как Bind [11], BindCommand [11], BindValidation [12], позволяющих связывать компоненты View и ViewModel без использования синтаксиса {Binding} в XAML-разметке.

public class SignUpView : ReactiveWindow<SignUpViewModel>
{
    public SignUpView()
    {
        AvaloniaXamlLoader.Load(this);

        // Привязки данных ReactiveUI и ReactiveUI.Validation.
        // Можно было бы схожим образом использовать расширение разметки Binding,
        // но некоторые разработчики предпочитают описывать биндинги в C#.
        // Почему бы не облегчить им (и многим другим) жизнь?
        //
        this.Bind(ViewModel, x => x.Username, x => x.UserNameTextBox.Text);
        this.Bind(ViewModel, x => x.Password, x => x.PasswordTextBox.Text);
        this.BindValidation(ViewModel, x => x.CompoundValidation.Text);
    }

    // Шаблонный код для типизированного доступа к именованным
    // элементам управления, объявленным в XAML.
    TextBox UserNameTextBox => this.FindControl<TextBox>("UserNameTextBox");
    TextBox PasswordTextBox => this.FindControl<TextBox>("PasswordTextBox");
    TextBlock CompoundValidation => this.FindControl<TextBlock>("CompoundValidation");
}

Код геттеров свойств, позволяющих получить типизированный доступ к элементам управления из XAML-файла, соответствующего SignUpView, выглядит шаблонным. Было бы неплохо научиться генерировать этот код, чтобы, с одной стороны, избежать многословности, а с другой стороны — чтобы избежать возможных ошибок и опечаток в имени элемента управления, объявленного в XAML, или в имени его типа.

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

Пример входных и выходных данных

Мы ожидаем, что на вход наш генератор исходного кода будет получать два файла. Для компонента представления с именем SignUpView, данными файлами будут являться XAML-разметка SignUpView.xaml, и code-behind файл SignUpView.xaml.cs, содержащий логику пользовательского интерфейса. Например, для файла разметки пользовательского интерфейса SignUpView.xaml:

<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        x:Class="Avalonia.NameGenerator.Sandbox.Views.SignUpView">
    <StackPanel>
        <TextBox x:Name="UserNameTextBox"
                 Watermark="Please, enter user name..."
                 UseFloatingWatermark="True" />
        <TextBlock Name="UserNameValidation"
                   Foreground="Red"
                   FontSize="12" />
    </StackPanel>
</Window>

Содержимое файла SignUpView.xaml.cs будет выглядеть следующим образом:

public partial class SignUpView : Window
{
    public SignUpView()
    {
        AvaloniaXamlLoader.Load(this);
        // Мы хотим иметь доступ к типизированным элементам управления вот здесь,
        // чтобы, например, писать код наподобие вот такого:
        // UserNameTextBox.Text = "Violet Evergarden";
        // UserNameValidation.Text = "An optional validation error message";
    }
}

А сгенерированное содержимое SignUpView.xaml.cs должно будет выглядеть следующим образом:

partial class SignUpView
{
    internal global::Avalonia.Controls.TextBox UserNameTextBox => this.FindControl<global::Avalonia.Controls.TextBox>("UserNameTextBox");
    internal global::Avalonia.Controls.TextBlock UserNameValidation => this.FindControl<global::Avalonia.Controls.TextBlock>("UserNameValidation");
}

Префиксы global:: здесь нужны для избежания коллизий пространств имён. Дополнительно, необходимо полностью указывать имена типов также для избежания коллизий. По аналогии с WPF, мы маркируем генерируемые свойства как internal. В случае использования partial-классов базовый класс можно указывать только в одной из частей partial-класса, поэтому в сгенерированном коде мы опускаем указание базового класса — таким образом пользователи нашего генератора смогут наследоваться от какого угодно наследника Window, будь то ReactiveWindow<TViewModel>, или другой тип окна.

Следует заметить, что при вызове метода FindControl обход дерева элементов производиться не будет — Avalonia хранит именованные ссылки на элементы управления в словарях, называемых INameScope в терминологии Avalonia. При желании, Вы можете изучить исходный код методов FindControl [13] и FindNameScope [14] на GitHub.

Реализуем интерфейс ISourceGenerator

Простейший генератор исходного кода, который ничего не делает, выглядит следующим образом [3]:

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

    public void Execute(GeneratorExecutionContext context) { }
}

В методе Initialize предлагается проинициализировать новый генератор исходного кода, а в методе Execute — выполнить все важные вычисления, и при необходимости добавить сгенерированные файлы исходного кода в контекст выполнения с помощью вызова метода context.AddSource(fileName, sourceText). При этом, файл проекта генератора исходного кода выглядит следующим образом:

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFramework>netstandard2.0</TargetFramework>
        <LangVersion>preview</LangVersion>
        <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
        <IncludeBuildOutput>false</IncludeBuildOutput>
    </PropertyGroup>
    <ItemGroup>
        <PackageReference
            Include="Microsoft.CodeAnalysis.CSharp"
            Version="3.8.0-5.final"
            PrivateAssets="all" />
        <PackageReference
            Include="Microsoft.CodeAnalysis.Analyzers"
            Version="3.3.1"
            PrivateAssets="all" />
    </ItemGroup>
    <ItemGroup>
        <None Include="$(OutputPath)$(AssemblyName).dll"
              Pack="true"
              PackagePath="analyzers/dotnet/cs"
              Visible="false" />
    </ItemGroup>
</Project>

Давайте, для начала, добавим в сборку проекта, ссылающегося на генератор, некоторый атрибут, с помощью которого пользователи нашего генератора будут помечать классы, для которых необходимо генерировать типизированные ссылки на элементы управления Avalonia, объявленные в XAML. Изменим код нашего генератора следующим образом:

[Generator]
public class NameReferenceGenerator : ISourceGenerator
{
    private const string AttributeName = "GenerateTypedNameReferencesAttribute";
    private const string AttributeFile = "GenerateTypedNameReferencesAttribute";
    private const string AttributeCode = @"// <auto-generated />
using System;
[AttributeUsage(AttributeTargets.Class, Inherited=false, AllowMultiple=false)]
internal sealed class GenerateTypedNameReferencesAttribute : Attribute { }
";

    public void Initialize(GeneratorInitializationContext context) { }

    public void Execute(GeneratorExecutionContext context)
    {
        // Добавим код атрибута в файл 'GenerateTypedNameReferencesAttribute.cs' 
        // проекта разработчика, который решит воспользоваться нашим генератором.
        context.AddSource(AttributeFile,
            SourceText.From(
                AttributeCode, Encoding.UTF8));
    }
}

Пока ничего сложного — мы объявили исходный код атрибута, имя файла, и имя атрибута как константы, с помощью вызова SourceText.From(code) обернули строку в исходный текст, и затем добавили новый исходный файл в проект с помощью вызова context.AddSource(fileName, sourceText). Теперь в проекте, который ссылается на наш генератор, мы можем помечать интересующие нас классы с помощью атрибута [GenerateTypedNameReferences]. Для классов, помеченных данным атрибутом, мы будем генерировать типизированные ссылки на именованные элементы управления, объявленные в XAML. В случае рассматриваемого примера с SignUpView.xaml, code-behind данного файла разметки должен будет выглядеть вот так:

[GenerateTypedNameReferences]
public partial class SignUpView : Window
{
    public SignUpView()
    {
        AvaloniaXamlLoader.Load(this);
        // Мы пока только собираемся генерировать именованные ссылки.
        // Если раскомментировать код ниже, проект не скомпилируется (пока).
        // UserNameTextBox.Text = "Violet Evergarden";
        // UserNameValidation.Text = "An optional validation error message";
    }
}

Нам необходимо научить наш ISourceGenerator следующим вещам:

  1. Находить все классы, помеченные атрибутом [GenerateTypedNameReferences];
  2. Находить соответствующие классам XAML-файлы;
  3. Извлекать полные имена типов элементов интерфейса, объявленных в XAML-файлах;
  4. Вытаскивать из XAML-файлов имена (значения Name или x:Name) элементов управления;
  5. Генерировать partial-класс и заполнять его типизированными ссылками.

Находим классы, маркированные атрибутом

Для реализации такой функциональности API генераторов исходного кода предлагает реализовать и зарегистрировать интерфейс ISyntaxReceiver, который позволит собрать все ссылки на интересующий синтаксис в одном месте. Реализуем ISyntaxReceiver, который будет собирать все ссылки на объявления классов сборки пользователя нашего генератора:

internal class NameReferenceSyntaxReceiver : ISyntaxReceiver
{
    public List<ClassDeclarationSyntax> CandidateClasses { get; } =
        new List<ClassDeclarationSyntax>();

    public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
    {
        if (syntaxNode is ClassDeclarationSyntax classDeclarationSyntax &&
            classDeclarationSyntax.AttributeLists.Count > 0)
            CandidateClasses.Add(classDeclarationSyntax);
    }
}

Зарегистрируем данный класс в методе ISourceGenerator.Initialize(GeneratorInitializationContext context):

context.RegisterForSyntaxNotifications(() => new NameReferenceSyntaxReceiver());

Теперь, во время выполнения нашего генератора, мы можем получить доступ ко всем ClassDeclarationSyntax в сборке, и в цикле найти все классы, маркированные необходимым образом:

// Добавим в CSharpCompilation исходник нашего атрибута.
var options = (CSharpParseOptions)existingCompilation.SyntaxTrees[0].Options;
var compilation = existingCompilation.AddSyntaxTrees(CSharpSyntaxTree
    .ParseText(SourceText.From(AttributeCode, Encoding.UTF8), options));

var attributeSymbol = compilation.GetTypeByMetadataName(AttributeName);
var symbols = new List<INamedTypeSymbol>();
foreach (var candidateClass in nameReferenceSyntaxReceiver.CandidateClasses)
{
    // Извлечём INamedTypeSymbol из нашего класса-кандидата.
    var model = compilation.GetSemanticModel(candidateClass.SyntaxTree);
    var typeSymbol = (INamedTypeSymbol) model.GetDeclaredSymbol(candidateClass);

    // Проверим, маркирован ли класс с помощью нашего атрибута.
    var relevantAttribute = typeSymbol!
        .GetAttributes()
        .FirstOrDefault(attr => attr.AttributeClass!.Equals(
            attributeSymbol, SymbolEqualityComparer.Default));

    if (relevantAttribute == null) {
        continue;
    }

    // Проверим, маркирован ли класс как 'partial'.
    var isPartial = candidateClass
        .Modifiers
        .Any(modifier => modifier.IsKind(SyntaxKind.PartialKeyword));

    // Таким образом, список 'symbols' будет содержать только те
    // классы, которые маркированы с помощью ключевого слова 'partial'
    // и атрибута 'GenerateTypedNameReferences'.
    if (isPartial) {
        symbols.Add(typeSymbol);
    }
}

Находим подходящие XAML-файлы

В Avalonia действуют соглашения именования XAML-файлов и code-behind файлов для них. Для файла с разметкой с именем SignUpView.xaml файл code-behind будет называться SignUpView.xaml.cs, а класс внутри него, как правило, называется SignUpView. В нашей реализации генератора типизированных ссылок будем полагаться на данную схему именования. Файлы разметки Avalonia на момент реализации генератора и написания данного материала могли иметь расширения .xaml или .axaml, поэтому код, определяющий имя XAML-файла на основании имени типа будет иметь следующий вид:

var xamlFileName = $"{typeSymbol.Name}.xaml";
var aXamlFileName = $"{typeSymbol.Name}.axaml";
var relevantXamlFile = context
    .AdditionalFiles
    .FirstOrDefault(text =>
         text.Path.EndsWith(xamlFileName) ||
         text.Path.EndsWith(aXamlFileName));

Здесь, typeSymbol имеет тип INamedTypeSymbol и может быть получен в результате обхода списка symbols, который мы сформировали на предыдущем этапе. А ещё здесь есть один нюанс. Чтобы файлы разметки были доступны как AdditionalFiles, пользователю генератора необходимо их дополнительно включить в проект с использованием директивы MSBuild <AdditionalFiles />. Таким образом, пользователь генератора должен отредактировать файл проекта .csproj, и добавить туда вот такой <ItemGroup />:

<ItemGroup>
    <!-- Очень важная директива, без которой генераторы исходного
         кода не смогут выпотрошить файлы разметки! -->
    <AdditionalFiles Include="***.xaml" />
</ItemGroup>

Подробное описание <AdditionalFiles /> можно найти в материале New C# Source Generator Samples [15].

Извлекаем полные имена типов из XAML

Этот этап является самым сложным, но в то же время наиболее интересным. Дело в том, что нельзя просто взять и получить информацию о пространстве имён, в котором находится элемент управления, объявленный в XAML-разметке. А нам, из-за нашего желания избежать коллизий и генерировать вменяемый код, который всегда будет компилироваться и работать, позарез нужно уметь получать полную квалификацию пространства имён, в котором находится тип.

Хорошая новость заключается в том, что фреймворк AvaloniaUI [6] использует новый компилятор XamlX [16], целиком написанный @kekekeks [17]. Этот компилятор мало того, что не имеет рантайм-зависимостей, умеет находить ошибки в XAML на этапе компиляции, работает намного быстрее загрузчиков XAML из WPF, UWP, XF и других технологий, так ещё и предоставляет нам удобный API для парсинга XAML и разрешения типов. Таким образом, мы можем позволить себе подключить XamlX [16] в проект исходниками (git submodule add ://repo ./path), и написать свой собственный MiniCompiler, который наш генератор исходного кода будет вызывать для компиляции XAML и получения полной информации о типах, даже если они лежат в каких-нибудь сторонних сборках. Реализация XamlX.XamlCompiler [16] в виде нашего маленького MiniCompiler, который мы собираемся натравливать на XAML-файлы, имеет вид:

internal sealed class MiniCompiler : XamlCompiler<object, IXamlEmitResult>
{
    public static MiniCompiler CreateDefault(
        RoslynTypeSystem typeSystem,
        params string[] additionalTypes)
    {
        var mappings = new XamlLanguageTypeMappings(typeSystem);
        foreach (var additionalType in additionalTypes)
            mappings.XmlnsAttributes.Add(typeSystem.GetType(additionalType));
        var configuration = new TransformerConfiguration(
            typeSystem,
            typeSystem.Assemblies[0],
            mappings);
        return new MiniCompiler(configuration);
    }

    private MiniCompiler(TransformerConfiguration configuration)
        : base(configuration,
               new XamlLanguageEmitMappings<object, IXamlEmitResult>(),
               false)
    {
        Transformers.Add(new NameDirectiveTransformer());
        Transformers.Add(new DataTemplateTransformer());
        Transformers.Add(new KnownDirectivesTransformer());
        Transformers.Add(new XamlIntrinsicsTransformer());
        Transformers.Add(new XArgumentsTransformer());
        Transformers.Add(new TypeReferenceResolver());
    }

    protected override XamlEmitContext<object, IXamlEmitResult> InitCodeGen(
        IFileSource file,
        Func<string, IXamlType, IXamlTypeBuilder<object>> createSubType,
        object codeGen, XamlRuntimeContext<object, IXamlEmitResult> context,
        bool needContextLocal) =>
        throw new NotSupportedException();
}

В нашем MiniCompiler мы используем дефолтные трансформеры XamlX [16] и один особенный NameDirectiveTransformer [18], тоже написанный @kekekeks [17], который умеет преобразовывать XAML-атрибут x:Name в XAML-атрибут Name для того, чтобы впоследствии обходить полученное AST и вытаскивать имена элементов управления было проще. Такой NameDirectiveTransformer [18] выглядит следующим образом:

internal class NameDirectiveTransformer : IXamlAstTransformer
{
    public IXamlAstNode Transform(
        AstTransformationContext context,
        IXamlAstNode node)
    {
        // Нас интересуют только объекты.
        if (node is XamlAstObjectNode objectNode)
        {
            for (var index = 0; index < objectNode.Children.Count; index++)
            {
                // Если мы встретили x:Name, заменяем его на Name и 
                // продолжаем обходить потомков XamlAstObjectNode дальше.
                var child = objectNode.Children[index];
                if (child is XamlAstXmlDirective directive &&
                    directive.Namespace == XamlNamespaces.Xaml2006 &&
                    directive.Name == "Name")
                    objectNode.Children[index] =
                        new XamlAstXamlPropertyValueNode(
                            directive,
                            new XamlAstNamePropertyReference(
                                directive, objectNode.Type, "Name", objectNode.Type),
                            directive.Values);
            }
        }
        return node;
    }
}

Фабрика MiniCompiler.CreateDefault принимает первым аргументом любопытный тип RoslynTypeSystem, который вы не найдёте в исходниках XamlX [16]. Данный тип реализует интерфейс IXamlTypeSystem, а это значит, что всё самое сложное только начинается. Чтобы наш маленький компилятор заработал внутри нашего генератора исходного кода, нам необходимо реализовать систему типов XamlX [16] поверх API семантической модели компилятора Roslyn [9]. Для реализации новой IXamlTypeSystem пришлось реализовывать много-много интерфейсов (IXamlType для классов, IXamlAssembly для сборок, IXamlMethod для методов, IXamlProperty для свойств и др). Реализация IXamlAssembly, например, выглядит вот так:

public class RoslynAssembly : IXamlAssembly
{
    private readonly IAssemblySymbol _symbol;

    public RoslynAssembly(IAssemblySymbol symbol) => _symbol = symbol;

    public bool Equals(IXamlAssembly other) =>
        other is RoslynAssembly roslynAssembly &&
        SymbolEqualityComparer.Default.Equals(_symbol, roslynAssembly._symbol);

    public string Name => _symbol.Name;

    public IReadOnlyList<IXamlCustomAttribute> CustomAttributes =>
        _symbol.GetAttributes()
            .Select(data => new RoslynAttribute(data, this))
            .ToList();

    public IXamlType FindType(string fullName)
    {
        var type = _symbol.GetTypeByMetadataName(fullName);
        return type is null ? null : new RoslynType(type, this);
    }
}

После реализации всех необходимых интерфейсов мы наконец сможем распарсить XAML инструментами XamlX, создать инстанс нашей реализации RoslynTypeSystem, передав ей в конструктор CSharpCompilation, которую мы уже извлекли из контекста генерации на предыдущем этапе, и трансформировать полученное в результате парсинга AST в AST с включённой информацией о пространствах имён и типах:

var parsed = XDocumentXamlParser.Parse(xaml, new Dictionary<string, string>());
MiniCompiler.CreateDefault(
    // 'compilation' имеет тип 'CSharpCompilation'
    new RoslynTypeSystem(compilation),
    "Avalonia.Metadata.XmlnsDefinitionAttribute")
    .Transform(parsed);

Готово! Осталось извлечь все именованные объекты из дерева — и дело в шляпе.

Находим именованные объекты XAML

На предыдущем этапе мы уже рассмотрели трансформер AST XamlX [7], реализующий IXamlAstTransformer, а теперь давайте рассмотрим и напишем посетителя узлов этого AST, реализующий интерфейс IXamlAstVisitor. Наш посетитель будет выглядеть следующим образом:

internal sealed class NameReceiver : IXamlAstVisitor
{
    private readonly List<(string TypeName, string Name)> _items =
        new List<(string TypeName, string Name)>();

    public IReadOnlyList<(string TypeName, string Name)> Controls => _items;

    public IXamlAstNode Visit(IXamlAstNode node)
    {
        if (node is XamlAstObjectNode objectNode)
        {
            // Извлекаем тип AST-узла. Данный тип нам вывел XamlX в
            // процессе взаимодействия с нашей RoslynTypeSystem.
            //
            var clrType = objectNode.Type.GetClrType();
            foreach (var child in objectNode.Children)
            {
                // Если мы в результате обхода потомков встретили свойство,
                // которое называется 'Name', и при этом внутри 'Name' лежит строка,
                // то добавляем в список элементов '_items' имя и CLR-тип элемента AST.
                //
                if (child is XamlAstXamlPropertyValueNode propertyValueNode &&
                    propertyValueNode.Property is XamlAstNamePropertyReference named &&
                    named.Name == "Name" &&
                    propertyValueNode.Values.Count > 0 &&
                    propertyValueNode.Values[0] is XamlAstTextNode text)
                {
                    var nsType = $@"{clrType.Namespace}.{clrType.Name}";
                    var typeNamePair = (nsType, text.Text);
                    if (!_items.Contains(typeNamePair))
                        _items.Add(typeNamePair);
                }
            }

            return node;
        }

        return node;
    }

    public void Push(IXamlAstNode node) { }

    public void Pop() { }
}

Процесс парсинга XAML и извлечения типов и имён XAML-элементов теперь выглядит так:

var parsed = XDocumentXamlParser.Parse(xaml, new Dictionary<string, string>());
MiniCompiler.CreateDefault(
    // 'compilation' имеет тип 'CSharpCompilation'
    new RoslynTypeSystem(compilation),
    "Avalonia.Metadata.XmlnsDefinitionAttribute")
    .Transform(parsed);

var visitor = new NameReceiver();
parsed.Root.Visit(visitor);
parsed.Root.VisitChildren(visitor);

// Теперь у нас есть и типы, и имена элементов.
var controls = visitor.Controls;

Генерируем типизированные ссылки

Наконец, можно перейти к заключительному этапу разработки нашего генератора исходного кода. У нас есть всё, что было нужно для полного счастья — и типы, и пространства имён, и имена элементов. А это значит, что нам необходимо сгенерировать partial-класс, сложив туда ссылки на все найденные именованные элементы пользовательского интерфейса, объявленные в XAML. Метод, генерирующий такой partial-класс, будет иметь вид:

private static string GenerateSourceCode(
    List<(string TypeName, string Name)> controls,
    INamedTypeSymbol classSymbol,
    AdditionalText xamlFile)
{
    var className = classSymbol.Name;
    var nameSpace = classSymbol.ContainingNamespace
        .ToDisplayString(SymbolDisplayFormat);
    var namedControls = controls
        .Select(info => "        " +
                       $"internal global::{info.TypeName} {info.Name} => " +
                       $"this.FindControl<global::{info.TypeName}>("{info.Name}");");
    return $@"// <auto-generated />
using Avalonia.Controls;
namespace {nameSpace}
{{
    partial class {className}
    {{
{string.Join("n", namedControls)}   
    }}
}}
";
}

Добавим полученный код в контекст выполнения GeneratorExecutionContext:

var sourceCode = GenerateSourceCode(controls, symbol, relevantXamlFile);
context.AddSource($"{symbol.Name}.g.cs", SourceText.From(sourceCode, Encoding.UTF8));

Готово!

Результат

Инструментарий Visual Studio понимает, что при изменении XAML-файла, включённого в проект как <AdditionalFile />, необходимо вызвать генератор исходного кода ещё раз, и обновить сгенерированные исходники. Таким образом, при редактировании XAML-файлов, ссылки на новые элементы управления, добавляемые в XAML в процессе разработки, будут автоматически становиться доступными из C#-файла с расширением .xaml.cs.

ezgif-1-f52e7303c26f

Исходный код генератора доступен на GitHub [19].

Интеграция генераторов исходного кода с JetBrains Rider и ReSharper [3] доступна в последних EAP, что позволяет утверждать, что реализованная технология является кроссплатформенной, и будет работать на Windows, Linux, и macOS. В дальнейшем мы собираемся заинтегрировать получившийся генератор в Avalonia, чтобы в новых версиях фреймворка генерация типизированных ссылок стала доступна из коробки. А вот так выглядит обновлённый пример кода из самого начала статьи, с биндингами и ReactiveUI.Validation [12]:

[GenerateTypedNameReferences]
public class SignUpView : ReactiveWindow<SignUpViewModel>
{
    public SignUpView()
    {
        AvaloniaXamlLoader.Load(this);
        this.Bind(ViewModel, x => x.Username, x => x.UserNameTextBox.Text);
        this.Bind(ViewModel, x => x.Password, x => x.PasswordTextBox.Text);
        this.BindValidation(ViewModel, x => x.CompoundValidation.Text);
    }
}

Ссылки

Автор: Artyom V. Gorchakov

Источник [22]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/c-2/359270

Ссылки в тексте:

[1] был анонсирован: https://habr.com/ru/post/503172/

[2] ISourceGenerator: https://docs.microsoft.com/en-us/dotnet/api/microsoft.codeanalysis.isourcegenerator?view=roslyn-dotnet

[3] создавать новые исходные файлы: https://blog.jetbrains.com/dotnet/2020/11/12/source-generators-in-net-5-with-resharper/

[4] анализаторов Roslyn: https://habr.com/ru/post/455952/

[5] Roslyn Compiler API: https://blog.zwezdin.com/2013/code-generating-with-roslyn/

[6] AvaloniaUI: http://avaloniaui.net/

[7] XamlX: https://github.com/kekekeks/xamlx

[8] AvaloniaUI: https://github.com/avaloniaui/avalonia

[9] семантической модели Roslyn: https://docs.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/get-started/semantic-analysis

[10] вышла статья на Хабре: https://habr.com/ru/company/skbkontur/blog/524518/

[11] Bind: https://www.reactiveui.net/docs/handbook/data-binding/#types-of-bindings

[12] BindValidation: https://github.com/reactiveui/reactiveui.validation

[13] FindControl: https://github.com/AvaloniaUI/Avalonia/blob/15968cb2c0aa115545e6766ee321ffd9eaa6d8d0/src/Avalonia.Controls/ControlExtensions.cs#L54

[14] FindNameScope: https://github.com/AvaloniaUI/Avalonia/blob/15968cb2c0aa115545e6766ee321ffd9eaa6d8d0/src/Avalonia.Styling/Controls/NameScopeExtensions.cs#L108

[15] New C# Source Generator Samples: https://devblogs.microsoft.com/dotnet/new-c-source-generator-samples/

[16] XamlX: https://github.com/kekekeks/XamlX

[17] @kekekeks: https://habr.com/ru/users/kekekeks/

[18] NameDirectiveTransformer: https://github.com/AvaloniaUI/Avalonia/blob/master/src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/XNameTransformer.cs

[19] GitHub: https://github.com/AvaloniaUI/Avalonia.NameGenerator

[20] Avalonia.NameGenerator: https://github.com/avaloniaui/avalonia.namegenerator

[21] Контур: https://habr.com/ru/company/skbkontur/

[22] Источник: https://habr.com/ru/post/530454/?utm_source=habrahabr&utm_medium=rss&utm_campaign=530454