AvaloniaUI / Avalonia

Develop Desktop, Embedded, Mobile and WebAssembly apps with C# and XAML. The most popular .NET UI client technology
https://avaloniaui.net
MIT License
26.12k stars 2.26k forks source link

Avalonia and the generic host (Microsoft.Extensions) #5241

Open Lakritzator opened 3 years ago

Lakritzator commented 3 years ago

Is your feature request related to a problem? Please describe. No, there is no problem.

Describe the solution you'd like Make it possible to run Avalonia on the generic host

Describe alternatives you've considered Not using the generic host.

Additional context I've made a project hosting Windows Forms and WPF in the generic host, which you can find here: https://github.com/dapplo/Dapplo.Microsoft.Extensions.Hosting I've added samples for CaliburnMicro and ReactiveUI, and wanted to add a sample for Avalonia to it, when I stumbled on https://github.com/AvaloniaUI/Avalonia/issues/3538

@kekekeks can I find more details on why the generic host, Microsoft.Extensions, isn't suitable for Avalonia? Is this an issue with the used IoC container, or the threading model, or what?

I'm not saying it's not possible, I just wouldn't like it if I ended up wrapping one "host" into another.

kekekeks commented 3 years ago

WinForms/WPF don't require to be initialized, they are available and ready to use even before generic host is configured.

Avalonia requires a specific initialization procedure and specific platform-dependent startup and lifetime management (i. e. we don't control application lifetime when running on iOS or Android).

You can investigate the possibilities of running on generic host, but that will require re-implementing AppBuilder on top of it.

dazinator commented 3 years ago

I have a worker service (using the generic host) that I wanted to add a UI for. I looked for cross platform UI's and thought I'd give avalonia a try. My worker service runs as any of:

When bringing in Avalonia, I have ended up with code like the following in my main entry point. I have only tested this when running as a console app so far, so I am hoping it will work when running under systemd or windows service but that remains to be seen:

        private static Task _backgroundHostTask;
        private static Task<int> _uiTask;

         public static async Task<int> Main(string[] args)
        {
            _backgroundHostTask = CreateHostBuilder(args).Build().RunAsync();

            _uiTask = Task.Run(() =>
            {
                var app = BuildAvaloniaApp();
                return app.StartWithClassicDesktopLifetime(args, ShutdownMode.OnExplicitShutdown);
            });

            await _backgroundHostTask;
            var lifetime = Application.Current.ApplicationLifetime as IControlledApplicationLifetime;
            lifetime?.Shutdown(0);
            var result = await _uiTask;
            return result;
        }

I think there is an opportunity here for some better integration with the generic host, when you want to show a UI that is directly tied to the lifetime of the host.

The convention of the generic host is that:-

  1. Configuration is built first.
  2. Services are configure --> DI container is built.
  3. In a web app, Middleware pipeline is built.
  4. Application is ready to "start" serving users.

Typically a Startup class is implemented in .net core projects to allow the configuration of 2 and 3. I could envisage a step being added to allow the Startup class to build the avalonia app using AppBuilder:

public class Startup
{
   public void ConfigureServices(IServiceCollection services)
   {
   // for DI  etc.
   }

   public void Configure(IAppBuilder app)
   {
   // for configuring middleware if hosting web services etc.
   }

   public void ConfigureUI(AppBuilder avaloniaAppBuilder)
   {
      // avalonia stuff here?
   }
}

I think something like this would feel more natural to existing .net core projects wishing to add a UI e.g:

 public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .UseStartupWithUi<MyStartup>() // just an example showing one possible approach.
                .UseAvaloniaApp(); // causes the avalonia app to terminate with the host.

Something kind of extension methods like the ones demonstrated above would enable:

  1. The building of the avalonia app to be done in a startup class similar to where building DI, or middleware happens.
  2. Cause the lifetime of the built avalonia app to be shutdown when the IHostApplicationLifetime
  3. Cause the avalonia app UI to start displaying when the Host is start()'ed.

Forgive this pretty high level speculation around what the api's might look like, I don't have any concrete ideas but feel there is room for some possible enhancements in this area.

Lakritzator commented 3 years ago

@dazinator in https://github.com/dapplo/Dapplo.Microsoft.Extensions.Hosting I already made such UI workloads for WPF and Windows Forms possible on the generic host.

I'm not so well acquainted with Avalonia yet, and have a big backlog for releasing the next Greenshot. I would love to have a sample project with Avalonia, maybe you like to fit it in with a PR?

Have a look at https://github.com/dapplo/Dapplo.Microsoft.Extensions.Hosting/tree/master/src/Dapplo.Microsoft.Extensions.Hosting.Wpf how I made it possible for Wpf, the sample using this is here: https://github.com/dapplo/Dapplo.Microsoft.Extensions.Hosting/blob/master/samples/Dapplo.Hosting.Sample.WpfDemo/Program.cs#L60

carstencodes commented 1 year ago

Hi there,

sorry for getting back to after such a long time.

I actually stumbled across this issue when I tried to combine an GRPC Server with Avalonia.

My solution consists of some simple extension methods:

[SupportedOSPlatform("windows")]
[SupportedOSPlatform("linux")]
[SupportedOSPlatform("macos")]
public static IHostBuilder ConfigureAvaloniaAppBuilder<TApplication>(this IHostBuilder hostBuilder, Func<AppBuilder> appBuilderResolver, Action<AppBuilder> configureAppBuilder, IHostedLifetime? lifetime = null) where TApplication: Avalonia.Application
    {
        ArgumentNullException.ThrowIfNull(configureAppBuilder);

        hostBuilder.ConfigureServices((ctx, s) => {
            AppBuilder appBuilder = appBuilderResolver();
            configureAppBuilder(appBuilder);

            s.AddSingleton(appBuilder);

            if (appBuilder.Instance is null)
            {
                appBuilder.SetupWithoutStarting();
            }

            s.AddSingleton<Avalonia.Application>((_) => appBuilder.Instance!);
            s.AddSingleton<TApplication>((_) => (TApplication)appBuilder.Instance!);
            s.AddSingleton<IHostedLifetime>(p => lifetime ?? HostedLifetime.Select(p.GetRequiredService<ILoggerFactory>(), p.GetRequiredService<Avalonia.Application>().ApplicationLifetime));
        });

        return hostBuilder;
    }

and

[SupportedOSPlatform("windows")]
[SupportedOSPlatform("linux")]
[SupportedOSPlatform("macos")]
public async static Task<int> RunAvaloniaAppAsync(this IHost host, CancellationToken token = default)
    {
        IHostedLifetime lifetime = host.Services.GetRequiredService<IHostedLifetime>();
        Avalonia.Application application = host.Services.GetRequiredService<Application>();
        await host.StartAsync(token);
        int result = await lifetime.StartAsync(application, token);

        await host.StopAsync(token);

        await host.WaitForShutdownAsync(token);

        return result;
    }

whereas the IHostedLifetime interface is a wrapper around the Avalonia IApplicationLifetime.

Hence, my Program.Main.cs of the CommandSample now looks like this:

[STAThread]
        public static async Task<int> Main(string[] args) 
        {
            if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux())
            {
                return -1;
            }

            return await Host.CreateDefaultBuilder(args).ConfigureAvalonia<App>(
                a =>
                {
                    a.UsePlatformDetect().LogToTrace().UseReactiveUI();
                    a.SetupWithLifetime(
                        new ClassicDesktopStyleApplicationLifetime()
                        {
                            Args = args,
                            ShutdownMode = Avalonia.Controls.ShutdownMode.OnMainWindowClose,
                        }
                    );
                }
            )
            .ConfigureServices(s => s.AttachLoggerToAvaloniaLogger())    
            .Build().RunAvaloniaAppAsync();
        }

I think this is a good starting point as it actually wraps Avalonia around the generic host builder.

@kekekeks @MikeCodesDotNET

What do you think about this solution? Would you like to know more? Is this an acceptable way of running Avalonia with the generic host?

Regards Carsten

ArcadeArchie commented 1 year ago

@carstencodes could you please also show the implementation of HostedLifetime?

carstencodes commented 1 year ago

@ArcadeArchie Hi, Let me get back to you. I have talked to my manager about some points. We're currently in discussion.

maxkatz6 commented 1 year ago

@carstencodes @ArcadeArchie for the most part you can integrate MsHosting in a way easier way: https://github.com/AvaloniaUI/Avalonia.Samples/pull/64/files#diff-bb94de43b9844a0fc00604842958cf32199ebfa63e6ad7b36db7f4b1f03a3767R24-R62

A couple of problems with other solutions:

Having these problems in mind, it's way easier to initialize Avalonia first, and then run Host Builder from it. Typically from OnFrameworkInitializationCompleted.

maxkatz6 commented 1 year ago

I will try to add some sample description to make it a proper Avalonia.Samples project. But code itself is ready and can be used.

carstencodes commented 1 year ago
  • Main thread should still be the first thread to support macOS. Async main breaks it. Ok, noticed. (I'm not paying 1000€ just for testing purposes.), so see comment below:
  • It also should work with previewer. Note, previewer doesn't run Main method, but it runs the same Application. Ok, this can be fixed quite easily ...
// Initialization code. Don't use any Avalonia, third-party APIs or any
        // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
        // yet and stuff might break.
        [STAThread]
        public static void Main(string[] args) => HostBuilder.CreateDefaultBuilder(args).SetupAvaloniaApp<App>(BuildAvaloniaApp, a => a.UseClassicDesktopRuntime(args, Avalonia.Controls.ShutdownMode.OnMainWindowClose)).ConfigureServices(s => s.AttachLoggerToAvaloniaLogger())    
            .Build().RunAvaloniaApp();

        // Avalonia configuration, don't remove; also used by visual designer.
        public static AppBuilder BuildAvaloniaApp()
            => AppBuilder.Configure<App>()
                .UsePlatformDetect()
                .LogToTrace()
                .UseReactiveUI();

At that point, there should not be a change to the behavior.

  • Host.StartAsync/StopAsync are needed only if you want to have background services. It's not necessary for logging, DI, httpclient factory...

Yes, but if there are background services, it will be.

  • In a cross-platform environment, you won't always have a "lifetime" object that will tell when application is closing. Mobile apps don't tell that. Browser doesn't as well. But on desktop you can use Avalonia IControlledApplicationLifetime.Exit event. Alalternatively, you can use try/finally block in Main method to handle app closure.

That's why I restricted the methods to desktop operating systems (see SupportedOSPlatformAttribute)

Having these problems in mind, it's way easier to initialize Avalonia first, and then run Host Builder from it. Typically from OnFrameworkInitializationCompleted.

I've experienced issues with this approach, but at that point I was not that experienced with Avalonia. But migrating this approach to Avalonia caused me much headaches.

If the pitfalls are that high, how about adding a custom library helping developers to bypass these issues? Like Avalonia.Extensions.Hosting or so?

maxkatz6 commented 1 year ago

Yep, there is nothing wrong with your approach since you know what actual target devices you need to run your app on. With little changes, it can work on macOS too, which would cover all desktop platforms.

The problem is that it won't be really easily possible to have universal Avalonia.Hosting library that covers all supported platforms. Different lifetimes support, different Main method specifics (sync only on macOS, async only on Browser...). As for now, I am planning to complete that samples page which can be used by developers as a reference, especially since it's simple and is enough for most apps.

But if the community implements Avalonia.Hosting deeper integration, for desktop only or more, would be also useful. It can be also mentioned as an integrated alternative approach in Avalonia.Samples.

carstencodes commented 11 months ago

@ArcadeArchie Sorry for coming back to you so late with this. I didn't forget about you (or this topic), but due to health conditions and many work, this moved a little to the bottom of the priority list.

I've created a gist containing my implementation hoping you find it interesting and helpful.

As @maxkatz6 stated, MacOS should use the sync revision, but I haven't tested it.

@maxkatz6 Glad to hear, that there is nothing wrong with it.

I am actually willing to do my part. So, if this is fine, maybe a first implementation only for desktop platforms can be set-up more easily from my implementation.

I am actually struggling with the idea of starting the host within the XPF Application, as - to me - it doesn't really fit in there. The application should from my side be target and not source for DI, but that is a question of personal taste and opinions - as it is usually done in Software Development.

NeverMorewd commented 2 months ago

An implementation of generic host for avaloniaui desktop app https://github.com/NeverMorewd/Hosting.Avaloniaui