Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Proposal] Unify the internals of controls and resources #13085

Open
Kir-Antipov opened this issue Sep 30, 2023 · 4 comments
Open

[Proposal] Unify the internals of controls and resources #13085

Kir-Antipov opened this issue Sep 30, 2023 · 4 comments

Comments

@Kir-Antipov
Copy link
Contributor

Description

Currently, CompileAvaloniaXamlTask creates the following infrastructure for user-defined controls:

public class FooControl : UserControl
{
    private static Action<object> !XamlIlPopulateOverride;

    private static void !XamlIlPopulateTrampoline(FooControl P_0)
    {
        if (!XamlIlPopulateOverride != null)
        {
            !XamlIlPopulateOverride(P_0);
        }
        else
        {
            !XamlIlPopulate(XamlIlRuntimeHelpers.CreateRootServiceProviderV3(null), P_0);
        }
    }

    private static void !XamlIlPopulate(IServiceProvider P_0, FooControl P_1)
    {
        // The actual populate logic
    }
}

This allows us to define a control as follows:

Name Type Member Description
Build Func<FooControl> .ctor A method dedicated to building the control
Populate Action<IServiceProvider, FooControl> !XamlIlPopulate A method dedicated to populating the control
PopulateOverride Action<FooControl> !XamlIlPopulateOverride An optional method capable of overriding the population logic of the control
PopulateTrampoline Action<FooControl> !XamlIlPopulateTrampoline A trampoline for the population logic

However, for resources like styles/resource dictionaries/etc. the generated code looks like this:

public class !AvaloniaResources
{
    public static ResourceDictionary Build:/BarResource.axaml(IServiceProvider P_0)
    {
        ResourceDictionary resourceDictionary = new ResourceDictionary();
        Populate:/BarResource.axaml(P_0, resourceDictionary);
        return resourceDictionary;
    }

    public static void Populate:/BarResource.axaml(IServiceProvider P_0, ResourceDictionary P_1)
    {
        // The actual populate logic
    }
}

Thus, a resource can be described as:

Name Type Member Description
Build Func<IServiceProvider, FooControl> Build:/BarResource.axaml A method dedicated to building the resource
Populate Action<IServiceProvider, FooControl> Populate:/BarResource.axaml A method dedicated to populating the resource

Although the structures above exhibit similarities, they don't conform to the same pattern. Most notably, resources lack the capability to override their population logic.

Proposal

To align controls and resources more closely in their internal representation, I propose modifying the generated code for the latter to resemble something along these lines:

  public class !AvaloniaResources
  {
+     private static Action<IServiceProvider, object> PopulateOverride:/BarResource.axaml;

      public static ResourceDictionary Build:/BarResource.axaml(IServiceProvider P_0)
      {
          ResourceDictionary resourceDictionary = new ResourceDictionary();
-         Populate:/BarResource.axaml(P_0, resourceDictionary);
+         PopulateTrampoline:/BarResource.axaml(P_0, resourceDictionary);
          return resourceDictionary;
      }


+     public static void PopulateTrampoline:/BarResource.axaml(IServiceProvider P_0, ResourceDictionary P_1)
+     {
+         if (PopulateOverride:/BarResource.axaml != null)
+         {
+             PopulateOverride:/BarResource.axaml(P_0, P_1);
+         }
+         else
+         {
+             Populate:/BarResource.axaml(P_0, P_1);
+         }
+     }

      public static void Populate:/BarResource.axaml(IServiceProvider P_0, ResourceDictionary P_1)
      {
          // The actual populate logic
      }
  }

Justification

This adjustment would make controls and resources more alike, as they would both loosely follow the same pattern:

Name Type Member Description
Build Func<T>
Func<IServiceProvider, T>
.ctor
Build:/T
A method dedicated to building the control/resource
Populate Action<IServiceProvider, T> !XamlIlPopulate
Populate:/T
A method dedicated to populating the control/resource
PopulateOverride Action<T>
Action<IServiceProvider, T>
!XamlIlPopulateOverride
PopulateOverride:/T
An optional method capable of overriding the population logic of the control/resource
PopulateTrampoline Action<T>
Action<IServiceProvider, T>
!XamlIlPopulateTrampoline
PopulateTrampoline:/T
A trampoline for the population logic

Moreover, this enables overriding the population logic of resources in the same manner as with regular user controls. Personally, I would say this is the most important part of this proposal. Recently, I developed a project that introduces hot reload capabilities to Avalonia – Kir-Antipov/HotAvalonia. The most significant hurdle I encountered was updating the population logic of recompiled styles/resource dictionaries, given the absence of a mechanism akin to !XamlIlPopulateOverride for those. At the moment, to achieve this, I had to commit a few federal crimes, and I would rather not do that in the future. This feature appears essential for implementing hot reload in Avalonia, whether done by third parties or natively by Avalonia itself (refer to this comment). Hence, I would greatly appreciate seeing this change implemented.

Additional Context

This is a relatively minor and non-breaking modification that augments the consistency within Avalonia's internals, has potential advantages for Avalonia when it eventually introduces built-in hot reload capabilities, and third parties could already benefit from it.

If you greenlight this change, I would be more than happy to contribute and implement it myself.

@maxkatz6
Copy link
Member

maxkatz6 commented Oct 1, 2023

There is no difference between XAML generation for controls and resources. But there is a difference, if XAML file has x:Class or not.
For instance, FluentTheme.xaml also generates that trampoline method because it has FluentTheme.xaml.cs. And this trampoline method is actually used inside of InitializeComponents() call.
See

var designLoaderField = new FieldDefinition("!XamlIlPopulateOverride",
and
// Find AvaloniaXamlLoader.Load(this) or AvaloniaXamlLoader.Load(sp, this) and replace it with !XamlIlPopulateTrampoline(this)
.

@maxkatz6
Copy link
Member

maxkatz6 commented Oct 1, 2023

In general, I don't see any issues with generating PopulateTrampoline for both. cc @kekekeks

@kekekeks
Copy link
Member

kekekeks commented Oct 1, 2023

Your approach with just calling Populate on an existing object has several problems:

  • it cant reset properties on the xaml root that are no longer set
  • it will break controls that have a collection as their [Content] property
  • any references to x:Name-ed controls will be broken

So, rather than just introducing more undocumented fields, I'd prefer to expose some API that would be useful for a hot-reload plugin:

static class DevRuntimeXamlHelpers
{
    public static void ReplaceClassXamlLoader(Type targetType, Action<object> loader);
    public static void ReRunClassXamlLoader(object loader);
    public static CompiledXamlInfo GetCompiledXamlInfo(Type targetType);
}

class CompiledXamlInfo
{
   // You need to reset those before reloading XAML
   IReadOnlyList<Avalonia.Data.Core.IPropertyInfo> AssignedRootProperties {get; } 
}

We also need to change the way x:Name fields are handled by our codebehind generator, right now they are injected into InitializeComponent, but if you are calling the Populate method again, those won't be updated. So we need some way to trigger those from the Populate method.

WPF has System.Windows.Markup.IComponentConnector interface and explicitly implements Connect(int connectionId, object target) from codebehind. We should probably do a similar thing.

@Kir-Antipov
Copy link
Contributor Author

There is no difference between XAML generation for controls and resources. But there is a difference, if XAML file has x:Class or not.

Oh, yeah, sure. I chose the terminology "controls" and "resources" since methods affiliated with class-less components are stored in the "!AvaloniaResources" class, and we usually don't create classes for resources, but usually do for controls.

Your approach with just calling Populate on an existing object has several problems:

  • it cant reset properties on the xaml root that are no longer set
  • it will break controls that have a collection as their [Content] property
  • any references to x:Name-ed controls will be broken

I'm aware of the second and third points, and they are fixable. However, could you please provide an example of the first one? I think I'm missing something.

I'd prefer to expose some API that would be useful for a hot-reload plugin

I'd prefer the official and properly implemented API built into the framework any day of the week. However, why not both? ;) Implementing such an API will require much more thought, effort, time, and testing than "introducing more undocumented fields", which, by the way, I don't see that way - they are not really new; those would be the same fields already used by "classed" components. So, the proposed change still brings the pros of more internal consistency, and it also makes these undocumented fields much more useful than they are at the moment.

We also need to change the way x:Name fields are handled by our codebehind generator, right now they are injected into InitializeComponent, but if you are calling the Populate method again, those won't be updated. So we need some way to trigger those from the Populate method.

While in my particular case with the setup I have, it's pretty easy to deal with re-initializing fields for named controls, I strongly agree that this process should be a part of the Populate method. Initializing x:Name fields without the call to Populate is invalid, and calling Populate without re-initializing those is not really valid either. So, they are clearly parts of the same process.

Here are my thoughts on how this could be implemented:

Avalonia could provide an interface for controls that contain named children, like this one:

public interface INamedControlContainer
{
    bool TryGetControl(string name, [NotNullWhen(true)] out object? control);

    bool TrySetControl(string name, object control);
}

With something like that being accessible, a code generator can create partial definitions for user controls as follows:

partial class Foo : INamedControlContainer
{
    internal Button Btn;

    bool INamedControlContainer.TryGetControl(string name, out object? control)
    {
        _ = name ?? throw new ArgumentNullException(nameof(name));

        switch (name)
        {
            case "Btn":
                control = Btn;
                return true;

            default:
                control = null;
                return false;
        }
    }

    bool INamedControlContainer.TrySetControl(string name, object control)
    {
        _ = name ?? throw new ArgumentNullException(nameof(name));
        _ = control ?? throw new ArgumentNullException(nameof(control));

        switch (name)
        {
            case "Btn":
                // TODO: Move the exception to some shared location.
                Btn = (control as Button) ?? throw new ArgumentException($"Invalid control type. Expected {typeof(Button)}. Received {control.GetType()}.", nameof(control));
                return true;

            default:
                return false;
    }
}

And then we can call the TrySetControl method inside Populate whenever a named control is built:

(P_1 as INamedControlContainer)?.TrySetControl("Btn", button);

Pros:

  • The code generation for x:Name fields is now fully self-contained, so the InitializeComponent generator can do its own thing, and this one can do its own.
  • Since it's self-contained, it's more flexible, making it easier to provide consumers with the ability to opt-out from the code generation if they either don't need it or want to implement the said interface as they see fit.
  • This approach is easily integratable into the existing infrastructure without any massive and/or breaking changes, with just a few more instructions in the Populate method.

Cons:

  • A slightly larger assembly size.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants