maui
maui copied to clipboard
Binding source generator
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
- 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
- For each identified
SetBinding
call, the SG generates an interceptor method - 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
We should use the new GetInterceptableLocation
API in Roslyn https://github.com/dotnet/roslyn/issues/72133
@StephaneDelcroix I rebased the PR and I believe it could now be merged. Could you please have a look?
@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 yeah, the generated code uses TypedBinding under the hood :)