Start Debugging

Como escrever um gerador de código-fonte para INotifyPropertyChanged

Um guia completo para construir seu próprio gerador de código-fonte incremental para INotifyPropertyChanged em C# 14 e .NET 11: a pipeline IIncrementalGenerator, atributos marcadores, saída de partial class, o padrão SetProperty e como manter compatibilidade com AOT.

Para gerar INotifyPropertyChanged (INPC) por conta própria, escreva um IIncrementalGenerator que encontre classes marcadas com um atributo personalizado, leia seus campos anotados com [ObservableProperty] e emita uma partial class que implemente a interface, exponha propriedades wrapper e dispare PropertyChanged por meio de um helper SetProperty. O gerador roda em tempo de compilação, contribui com zero custo em runtime além do encanamento padrão de INPC e remove cada linha de boilerplate manual de campo de apoio e setter. Este guia constrói o gerador de ponta a ponta sobre .NET 11 (preview 3) e C# 14, mas o mesmo código funciona contra qualquer consumidor que mire netstandard2.0 para o analisador, já que esse continua sendo o contrato que o Roslyn exige para geradores de código-fonte.

Por que escrever o seu quando o CommunityToolkit.Mvvm existe

A resposta conhecida é o CommunityToolkit.Mvvm, que entrega [ObservableObject], [ObservableProperty], [NotifyPropertyChangedFor] e uma pequena montanha de geradores bem testados. Para a maioria dos aplicativos, use isso. Este guia é para os casos em que você não pode:

Geradores de código-fonte também são um dos lugares mais limpos para tocar APIs do Roslyn em primeira mão, e INPC é o alvo canônico de “pequeno, bem definido, alta alavancagem”. Se você nunca escreveu um, este é um ponto de partida melhor do que tentar gerar código de registro de injeção de dependência ou configuração de EF Core.

As peças que você precisa entregar

Um gerador INPC completo tem três partes, cada uma em seu próprio projeto ou injeção <None>:

  1. Um atributo marcador que os consumidores aplicam a uma partial class. Convenção: [Observable] ou [GenerateInpc].
  2. Um atributo em nível de campo que marca o estado subjacente que o gerador deve expor como uma propriedade. Convenção: [ObservableProperty].
  3. O gerador incremental em si, empacotado para que o MSBuild o carregue como um analisador.

O atributo marcador é entregue mais facilmente via RegisterPostInitializationOutput, que permite ao gerador injetar o código-fonte do atributo na compilação do consumidor. Dessa forma, os consumidores adicionam um <ProjectReference> (ou um <PackageReference> com OutputItemType="Analyzer") e imediatamente têm os atributos disponíveis, sem necessidade de uma DLL de runtime separada.

Layout do projeto

O projeto do analisador deve mirar netstandard2.0, porque esse é o único TFM que o Roslyn carrega no IDE e no MSBuild de .NET Framework que instalações antigas do Visual Studio usam:

<!-- src/Inpc.SourceGenerator/Inpc.SourceGenerator.csproj -->
<!-- .NET 11 SDK, generator targets netstandard2.0 -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <Nullable>enable</Nullable>
    <IsRoslynComponent>true</IsRoslynComponent>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp"
                      Version="4.13.0"
                      PrivateAssets="all" />
  </ItemGroup>
</Project>

IsRoslynComponent faz o Visual Studio tratá-lo como um gerador para carregamento em tempo de design. EnforceExtendedAnalyzerRules é o conjunto de regras estilo analisador que sinaliza erros como string.Format com problemas de cultura dentro de geradores, onde reprodutibilidade importa.

O projeto consumidor o referencia como um analisador:

<!-- consumer .csproj -->
<ItemGroup>
  <ProjectReference Include="..\Inpc.SourceGenerator\Inpc.SourceGenerator.csproj"
                    OutputItemType="Analyzer"
                    ReferenceOutputAssembly="false" />
</ItemGroup>

ReferenceOutputAssembly="false" é crítico: você não quer a DLL do analisador no caminho de runtime do consumidor. Se você esquecer disso, o consumidor envia o Roslyn em runtime, o que são vários megabytes de peso morto e quebra Native AOT.

O atributo marcador, injetado em post-init

Dentro do gerador, registre a fonte do atributo antes de qualquer análise rodar. Isso garante que os consumidores possam usar os atributos sem um pacote separado:

// .NET 11, C# 14, generator-side code (netstandard2.0)
using Microsoft.CodeAnalysis;

[Generator]
public sealed class InpcGenerator : IIncrementalGenerator
{
    private const string AttributeSource = """
        // <auto-generated/>
        #nullable enable
        namespace Inpc;

        [global::System.AttributeUsage(
            global::System.AttributeTargets.Class, Inherited = false)]
        internal sealed class ObservableAttribute : global::System.Attribute { }

        [global::System.AttributeUsage(
            global::System.AttributeTargets.Field, Inherited = false)]
        internal sealed class ObservablePropertyAttribute : global::System.Attribute
        {
            public string? PropertyName { get; init; }
        }
        """;

    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        context.RegisterPostInitializationOutput(ctx =>
            ctx.AddSource("Inpc.Attributes.g.cs", AttributeSource));

        // pipeline registration follows in the next section
    }
}

Algumas escolhas não óbvias:

A pipeline incremental

Agora conecte a análise real. A API de gerador incremental do Roslyn tem duas metades: um SyntaxProvider que faz filtragem sintática barata a cada toque de tecla, e uma transformação que faz o trabalho semântico caro apenas quando o snapshot sintático muda:

// .NET 11, C# 14, generator-side
public void Initialize(IncrementalGeneratorInitializationContext context)
{
    context.RegisterPostInitializationOutput(ctx =>
        ctx.AddSource("Inpc.Attributes.g.cs", AttributeSource));

    var classes = context.SyntaxProvider
        .ForAttributeWithMetadataName(
            "Inpc.ObservableAttribute",
            predicate: static (node, _) => node is ClassDeclarationSyntax c
                && c.Modifiers.Any(SyntaxKind.PartialKeyword),
            transform: static (ctx, ct) => Extract(ctx, ct))
        .Where(static x => x is not null)
        .Select(static (x, _) => x!.Value);

    context.RegisterSourceOutput(classes,
        static (spc, model) => Emit(spc, model));
}

ForAttributeWithMetadataName é o ponto de entrada certo para qualquer gerador dirigido por atributo desde o Roslyn 4.3. Ele usa o índice de atributos do compilador, então o predicate roda apenas em sintaxe que já tem o nome de atributo correspondente. Isso é dramaticamente mais barato do que o padrão antigo CreateSyntaxProvider mais Where, e é o maior ganho individual de desempenho disponível.

O predicate impõe partial no nível de sintaxe, antes de qualquer modelo semântico existir. Isso pega o erro mais comum do consumidor (esquecer de partial) com a verificação mais barata possível.

Extraindo um modelo estável

A transformação deve retornar um valor que seja estruturalmente comparável. A camada de cache do Roslyn compara valores de modelo entre execuções para pular a reemissão quando nada mudou. Se você retornar símbolos (INamedTypeSymbol, IFieldSymbol), cada toque de tecla invalida o cache, porque os símbolos são iguais por referência apenas dentro de uma única compilação.

Use um record (ou readonly record struct) de strings simples:

// .NET 11, C# 14, generator-side
internal readonly record struct ClassModel(
    string Namespace,
    string ClassName,
    EquatableArray<PropertyModel> Properties);

internal readonly record struct PropertyModel(
    string FieldName,
    string PropertyName,
    string TypeName);

EquatableArray<T> é um wrapper fino em torno de ImmutableArray<T> que implementa Equals estrutural. O Roslyn não entrega um, mas cada projeto de gerador copia as mesmas seis linhas do toolkit:

// .NET 11, C# 14, generator-side
internal readonly record struct EquatableArray<T>(ImmutableArray<T> Items)
    : IEnumerable<T> where T : IEquatable<T>
{
    public bool Equals(EquatableArray<T> other) =>
        Items.AsSpan().SequenceEqual(other.Items.AsSpan());

    public override int GetHashCode()
    {
        var hash = new HashCode();
        foreach (var item in Items) hash.Add(item);
        return hash.ToHashCode();
    }

    public IEnumerator<T> GetEnumerator() =>
        ((IEnumerable<T>)Items).GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() =>
        ((IEnumerable)Items).GetEnumerator();
}

Esquecer disso e retornar um ImmutableArray<T> puro é o segundo bug de desempenho mais comum em geradores depois de usar CreateSyntaxProvider errado. ImmutableArray<T>.Equals é baseado em referência, então cada snapshot parece novo.

A função Extract real puxa campos do símbolo:

// .NET 11, C# 14, generator-side
private static ClassModel? Extract(
    GeneratorAttributeSyntaxContext ctx,
    CancellationToken ct)
{
    if (ctx.TargetSymbol is not INamedTypeSymbol type) return null;

    var properties = ImmutableArray.CreateBuilder<PropertyModel>();
    foreach (var member in type.GetMembers())
    {
        ct.ThrowIfCancellationRequested();
        if (member is not IFieldSymbol field) continue;

        var attr = field.GetAttributes().FirstOrDefault(a =>
            a.AttributeClass?.ToDisplayString() == "Inpc.ObservablePropertyAttribute");
        if (attr is null) continue;

        string property = attr.NamedArguments
            .FirstOrDefault(kv => kv.Key == "PropertyName")
            .Value.Value as string
            ?? Capitalize(field.Name.TrimStart('_'));

        properties.Add(new PropertyModel(
            FieldName: field.Name,
            PropertyName: property,
            TypeName: field.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)));
    }

    return new ClassModel(
        Namespace: type.ContainingNamespace.IsGlobalNamespace
            ? string.Empty
            : type.ContainingNamespace.ToDisplayString(),
        ClassName: type.Name,
        Properties: new EquatableArray<PropertyModel>(properties.ToImmutable()));
}

private static string Capitalize(string name) =>
    name.Length == 0 ? name : char.ToUpperInvariant(name[0]) + name[1..];

SymbolDisplayFormat.FullyQualifiedFormat produz nomes no estilo global::System.Collections.Generic.List<global::Foo.Bar>, o que contorna todo problema de resolução de namespace que o arquivo emitido poderia bater.

ct.ThrowIfCancellationRequested() dentro do loop importa mais do que você esperaria. O IDE cancela execuções do gerador agressivamente conforme o usuário digita; um gerador que ignora o token bloqueia o IntelliSense.

Emitindo a partial class

O passo de emissão é uma única caminhada de StringBuilder. Geradores tendem a crescer construtores baseados em Roslyn.SyntaxFactory que parecem bonitos e rodam devagar; um template de string está bom para código tão regular e é muito mais fácil de depurar:

// .NET 11, C# 14, generator-side
private static void Emit(SourceProductionContext ctx, ClassModel model)
{
    var sb = new StringBuilder(1024);
    sb.AppendLine("// <auto-generated/>");
    sb.AppendLine("#nullable enable");
    if (model.Namespace.Length > 0)
    {
        sb.Append("namespace ").Append(model.Namespace).AppendLine(";");
        sb.AppendLine();
    }

    sb.Append("partial class ").Append(model.ClassName)
      .AppendLine(" : global::System.ComponentModel.INotifyPropertyChanged");
    sb.AppendLine("{");
    sb.AppendLine("    public event global::System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;");
    sb.AppendLine();
    sb.AppendLine("    private bool SetProperty<T>(ref T storage, T value, string propertyName)");
    sb.AppendLine("    {");
    sb.AppendLine("        if (global::System.Collections.Generic.EqualityComparer<T>.Default.Equals(storage, value))");
    sb.AppendLine("            return false;");
    sb.AppendLine("        storage = value;");
    sb.AppendLine("        PropertyChanged?.Invoke(this,");
    sb.AppendLine("            new global::System.ComponentModel.PropertyChangedEventArgs(propertyName));");
    sb.AppendLine("        return true;");
    sb.AppendLine("    }");
    sb.AppendLine();

    foreach (var p in model.Properties)
    {
        sb.Append("    public ").Append(p.TypeName).Append(' ').Append(p.PropertyName)
          .AppendLine();
        sb.AppendLine("    {");
        sb.Append("        get => this.").Append(p.FieldName).AppendLine(";");
        sb.Append("        set => SetProperty(ref this.").Append(p.FieldName)
          .Append(", value, nameof(").Append(p.PropertyName).AppendLine("));");
        sb.AppendLine("    }");
        sb.AppendLine();
    }

    sb.AppendLine("}");

    string hint = string.IsNullOrEmpty(model.Namespace)
        ? $"{model.ClassName}.Inpc.g.cs"
        : $"{model.Namespace}.{model.ClassName}.Inpc.g.cs";
    ctx.AddSource(hint, sb.ToString());
}

Coisas que vale a pena notar:

Código do consumidor

O propósito do exercício inteiro:

// .NET 11, C# 14, consumer code
using Inpc;

[Observable]
public partial class PersonViewModel
{
    [ObservableProperty]
    private string _firstName = "";

    [ObservableProperty]
    private string _lastName = "";

    [ObservableProperty(PropertyName = "Age")]
    private int _ageYears;
}

O gerador emite as propriedades públicas FirstName, LastName e Age, o evento PropertyChanged e o helper SetProperty. O arquivo do consumidor permanece exatamente como você vê acima, sem encanamento de OnPropertyChanged e sem campos de apoio em lockstep.

Native AOT e trimming

Geradores rodam em tempo de build, então não pagam nada em runtime. A pergunta interessante é o que o código gerado custa em um app AOT ou trimado:

A coisa para ficar de olho é o binding XAML. WPF e Avalonia usam reflexão para descobrir propriedades INPC, então as configurações de trim para esses frameworks já optam por excluir tipos de view-model observáveis do trim via descritores. Os bindings compilados do MAUI removem essa necessidade inteiramente, e um gerador como este se compõe naturalmente com codegen estilo [BindableProperty] se você quiser os dois mundos.

Pegadinhas, em ordem de frequência

Um gerador construído assim tem cerca de 200 linhas de código, roda em microssegundos por mudança e substitui centenas de linhas de boilerplate manual por consumidor. Uma vez que você enviou um, o próximo (comandos, registro de injeção de dependência, máquinas de estado) é uma cópia do mesmo esqueleto.

Relacionado

Fontes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Voltar