maui icon indicating copy to clipboard operation
maui copied to clipboard

Binding source generator

Open simonrozsival opened this issue 10 months ago • 1 comments

Description of Change

This PR introduces a new convenient API that developers can use in their C# markup to use "compiled bindings" (aka typed bindings) instead of reflection-based bindings with string paths.

Example use case:

// in .NET 8
MyLabel.SetBinding(Label.TextProperty, "Text");

// in .NET 9
MyLabel.SetBinding(Label.TextProperty, static (Entry entry) => entry.Text);

The new binding has several advantages:

  • better performance (benefits are already summarized in the docs: https://learn.microsoft.com/en-us/dotnet/maui/fundamentals/data-binding/compiled-bindings?view=net-maui-8.0#performance)
  • intellisense while editing
  • compile-time check of path validity

Overview

  1. The source generator identifies calls to SetBinding<TSource, TProperty>
    • The lambda must be passed directly to the invocation, it can't be passed through a variable
  2. For each identified SetBinding call, the SG generates an interceptor method
  3. In the interceptor method, we instantiate TypedBinding<TSource, TProperty> based on the input lambda with the appropriate setter (if applicable) and handlers array

Sample source generator output

using Microsoft.Maui.Controls;
using MyNamespace;
var label = new Label();
label.SetBinding(Label.TextProperty, static (MySourceClass s) => (((s.A as X)?.B as Y)?.C as Z)?.D);

namespace MyNamespace
{
    public class MySourceClass
    {
        public object? A { get; set; }
    }

    public class X
    {
        public object? B { get; set; }
    }

    public class Y
    {
        public object C { get; set; } = null!;
    }

    public class Z
    {
        public MyPropertyClass D { get; set; } = null!;
    }

    public class MyPropertyClass
    {
    }
}
Generated code...
//------------------------------------------------------------------------------
// <auto-generated>
//     This code was generated by a .NET MAUI source generator.
//
//     Changes to this file may cause incorrect behavior and will be lost if
//     the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
#nullable enable

namespace System.Runtime.CompilerServices
{
        using System;
        using System.CodeDom.Compiler;

        [GeneratedCodeAttribute("Microsoft.Maui.Controls.BindingSourceGen, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "1.0.0.0")]
        [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
        file sealed class InterceptsLocationAttribute : Attribute
        {
                public InterceptsLocationAttribute(string filePath, int line, int column)
                {
                        FilePath = filePath;
                        Line = line;
                        Column = column;
                }

                public string FilePath { get; }
                public int Line { get; }
                public int Column { get; }
        }
}

namespace Microsoft.Maui.Controls.Generated
{
        using System;
        using System.CodeDom.Compiler;
        using System.Runtime.CompilerServices;
        using Microsoft.Maui.Controls.Internals;

        [GeneratedCodeAttribute("Microsoft.Maui.Controls.BindingSourceGen, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "1.0.0.0")]
        file static class GeneratedBindableObjectExtensions
        {

                [GeneratedCodeAttribute("Microsoft.Maui.Controls.BindingSourceGen, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "1.0.0.0")]
                [InterceptsLocationAttribute(@"Path\To\Program.cs", 4, 7)]
                public static void SetBinding1(
                        this BindableObject bindableObject,
                        BindableProperty bindableProperty,
                        Func<global::MyNamespace.MySourceClass, global::MyNamespace.MyPropertyClass?> getter,
                        BindingMode mode = BindingMode.Default,
                        IValueConverter? converter = null,
                        object? converterParameter = null,
                        string? stringFormat = null,
                        object? source = null,
                        object? fallbackValue = null,
                        object? targetNullValue = null)
                {
                        Action<global::MyNamespace.MySourceClass, global::MyNamespace.MyPropertyClass?>? setter = null;
                        if (ShouldUseSetter(mode, bindableProperty))
                        {
                                setter = static (source, value) =>
                                {
                                        if (value is null)
                                        {
                                                return;
                                        }
                                        if (source.A is global::MyNamespace.X p0
                                                && p0.B is global::MyNamespace.Y p1
                                                && p1.C is global::MyNamespace.Z p2)
                                        {
                                                p2.D = value;
                                        }
                                };
                        }
                        var binding = new TypedBinding<global::MyNamespace.MySourceClass, global::MyNamespace.MyPropertyClass?>(
                                getter: source => (getter(source), true),
                                setter,
                                handlers: new Tuple<Func<global::MyNamespace.MySourceClass, object?>, string>[]
                                {
                                        new(static source => source, "A"),
                                        new(static source => (source.A as global::MyNamespace.X), "B"),
                                        new(static source => ((source.A as global::MyNamespace.X)?.B as global::MyNamespace.Y), "C"),
                                        new(static source => (((source.A as global::MyNamespace.X)?.B as global::MyNamespace.Y)?.C as global::MyNamespace.Z), "D"),
                                })
                        {
                                Mode = mode,
                                Converter = converter,
                                ConverterParameter = converterParameter,
                                StringFormat = stringFormat,
                                Source = source,
                                FallbackValue = fallbackValue,
                                TargetNullValue = targetNullValue
                        };
                        bindableObject.SetBinding(bindableProperty, binding);
                }


                private static bool ShouldUseSetter(BindingMode mode, BindableProperty bindableProperty)
                        => mode == BindingMode.OneWayToSource
                                || mode == BindingMode.TwoWay
                                || (mode == BindingMode.Default
                                        && (bindableProperty.DefaultBindingMode == BindingMode.OneWayToSource
                                                || bindableProperty.DefaultBindingMode == BindingMode.TwoWay));
        }
}

TODOs

  • [x] Run the new unit tests in CI - tests are now running as part of the dotnet-test cake script
  • [ ] Optimize the source generator pipeline
  • [ ] Use the new GetInterceptableLocation API in Roslyn https://github.com/dotnet/roslyn/issues/72133
  • [x] Implement support for Indexers
  • [ ] Cover edge cases
  • [ ] Improve diagnostics
  • [ ] Validate the interceptors work with C# hot reload
  • [ ] Write documentation

Issues Fixed

Fixes #19912 Fixes #20574 Contributes to #18658

/cc @jonathanpeppers @StephaneDelcroix @PureWeen @mattleibow @jkurdek @vitek-karas

simonrozsival avatar Apr 09 '24 13:04 simonrozsival

We should use the new GetInterceptableLocation API in Roslyn https://github.com/dotnet/roslyn/issues/72133

simonrozsival avatar Apr 16 '24 13:04 simonrozsival

@StephaneDelcroix I rebased the PR and I believe it could now be merged. Could you please have a look?

simonrozsival avatar Jun 04 '24 10:06 simonrozsival

@simonrozsival this is awesome, thank you!

Have you evaluated the use of TypedBinding? That would be even faster :) Edit: probably it is a stupid question given that performance is very close.

albyrock87 avatar Jun 19 '24 17:06 albyrock87

@albyrock87 yeah, the generated code uses TypedBinding under the hood :)

simonrozsival avatar Jun 19 '24 17:06 simonrozsival