Jelajahi Sumber

Refactor Browser and Blazor startup code

Max Katz 2 tahun lalu
induk
melakukan
bd1928efac

+ 2 - 9
samples/ControlCatalog.Browser.Blazor/App.razor.cs

@@ -1,3 +1,5 @@
+using System;
+using System.Threading.Tasks;
 using Avalonia;
 using Avalonia.Browser.Blazor;
 
@@ -5,13 +7,4 @@ namespace ControlCatalog.Browser.Blazor;
 
 public partial class App
 {
-    protected override void OnParametersSet()
-    {
-        AppBuilder.Configure<ControlCatalog.App>()
-            .UseBlazor()
-            // .With(new SkiaOptions { CustomGpuFactory = null }) // uncomment to disable GPU/GL rendering
-            .SetupWithSingleViewLifetime();
-
-        base.OnParametersSet();
-    }
 }

+ 11 - 1
samples/ControlCatalog.Browser.Blazor/Program.cs

@@ -1,6 +1,8 @@
 using System;
 using System.Net.Http;
 using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Browser.Blazor;
 using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
 using Microsoft.Extensions.DependencyInjection;
 using ControlCatalog.Browser.Blazor;
@@ -9,9 +11,17 @@ public class Program
 {
     public static async Task  Main(string[] args)
     {
-        await CreateHostBuilder(args).Build().RunAsync();
+        var host = CreateHostBuilder(args).Build();
+        await StartAvaloniaApp();
+        await host.RunAsync();
     }
 
+    public static async Task StartAvaloniaApp()
+    {
+        await AppBuilder.Configure<ControlCatalog.App>()
+            .StartBlazorApp();
+    }
+    
     public static WebAssemblyHostBuilder CreateHostBuilder(string[] args)
     {
         var builder = WebAssemblyHostBuilder.CreateDefault(args);

+ 17 - 3
samples/ControlCatalog.Browser/Program.cs

@@ -1,6 +1,8 @@
 using System.Runtime.Versioning;
+using System.Threading.Tasks;
 using Avalonia;
 using Avalonia.Browser;
+using Avalonia.Controls;
 using ControlCatalog;
 using ControlCatalog.Browser;
 
@@ -8,15 +10,27 @@ using ControlCatalog.Browser;
 
 internal partial class Program
 {
-    private static void Main(string[] args)
+    public static async Task Main(string[] args)
     {
-        BuildAvaloniaApp()
+        await BuildAvaloniaApp()
             .AfterSetup(_ =>
             {
                 ControlCatalog.Pages.EmbedSample.Implementation = new EmbedSampleWeb();
-            }).SetupBrowserApp("out");
+            })
+            .StartBrowserApp("out");
     }
 
+    // Example without a ISingleViewApplicationLifetime
+    // private static AvaloniaView _avaloniaView;
+    // public static async Task Main(string[] args)
+    // {
+    //     await BuildAvaloniaApp()
+    //         .SetupBrowserApp();
+    //
+    //     _avaloniaView = new AvaloniaView("out");
+    //     _avaloniaView.Content = new TextBlock { Text = "Hello world" };
+    // }
+    
     public static AppBuilder BuildAvaloniaApp()
            => AppBuilder.Configure<App>();
 }

+ 0 - 3
samples/ControlCatalog.Browser/main.js

@@ -2,7 +2,6 @@
 // The .NET Foundation licenses this file to you under the MIT license.
 
 import { dotnet } from './dotnet.js'
-import { registerAvaloniaModule } from './avalonia.js';
 
 const is_browser = typeof window != "undefined";
 if (!is_browser) throw new Error(`Expected to be running in a browser`);
@@ -12,8 +11,6 @@ const dotnetRuntime = await dotnet
     .withApplicationArgumentsFromQuery()
     .create();
 
-await registerAvaloniaModule(dotnetRuntime);
-
 const config = dotnetRuntime.getConfig();
 
 await dotnetRuntime.runMainAndExit(config.mainAssemblyName, ["dotnet", "is", "great!"]);

+ 1 - 1
src/Avalonia.Base/Platform/DefaultPlatformSettings.cs

@@ -37,7 +37,7 @@ namespace Avalonia.Platform
             };
         }
 
-        public event EventHandler<PlatformColorValues>? ColorValuesChanged;
+        public virtual event EventHandler<PlatformColorValues>? ColorValuesChanged;
 
         protected void OnColorValuesChanged(PlatformColorValues colorValues)
         {

+ 10 - 4
src/Browser/Avalonia.Browser.Blazor/AvaloniaView.cs

@@ -30,12 +30,10 @@ public class AvaloniaView : ComponentBase
         builder.CloseElement();
     }
 
-    protected override async Task OnInitializedAsync()
+    protected override void OnAfterRender(bool firstRender)
     {
-        if (OperatingSystem.IsBrowser())
+        if (firstRender)
         {
-            await AvaloniaModule.ImportMain();
-
             _browserView = new Browser.AvaloniaView(_containerId);
             if (Application.Current?.ApplicationLifetime is ISingleViewApplicationLifetime lifetime)
             {
@@ -43,4 +41,12 @@ public class AvaloniaView : ComponentBase
             }
         }
     }
+
+    protected override void OnInitialized()
+    {
+        if (!OperatingSystem.IsBrowser())
+        {
+            throw new NotSupportedException("Avalonia doesn't support server-side Blazor");
+        }
+    }
 }

+ 16 - 21
src/Browser/Avalonia.Browser.Blazor/BlazorSingleViewLifetime.cs

@@ -1,33 +1,28 @@
-using System.Runtime.Versioning;
-
+using System;
+using System.Runtime.Versioning;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Browser.Interop;
 using Avalonia.Controls;
 using Avalonia.Controls.ApplicationLifetimes;
 
 namespace Avalonia.Browser.Blazor;
 
-public static class WebAppBuilder
+public static class BlazorAppBuilder
 {
-    public static AppBuilder SetupWithSingleViewLifetime(
-        this AppBuilder builder)
+    /// <summary>
+    /// Configures blazor backend, loads avalonia javascript modules and creates a single view lifetime.
+    /// </summary>
+    /// <param name="builder">Application builder.</param>
+    /// <param name="options">Browser backend specific options.</param>
+    public static async Task StartBlazorApp(this AppBuilder builder, BrowserPlatformOptions? options = null)
     {
-        return builder.SetupWithLifetime(new BlazorSingleViewLifetime());
-    }
+        options ??= new BrowserPlatformOptions();
+        options.FrameworkAssetPathResolver ??= filePath => $"/_content/Avalonia.Browser.Blazor/{filePath}";
 
-    public static AppBuilder UseBlazor(this AppBuilder builder)
-    {
-        return builder
-            .UseBrowser()
-            .With(new BrowserPlatformOptions
-            {
-                FrameworkAssetPathResolver = new(filePath => $"/_content/Avalonia.Browser.Blazor/{filePath}")
-            });
-    }
+        builder = await BrowserAppBuilder.PreSetupBrowser(builder, options);
 
-    public static AppBuilder Configure<TApp>()
-        where TApp : Application, new()
-    {
-        return AppBuilder.Configure<TApp>()
-            .UseBlazor();
+        builder.SetupWithLifetime(new BlazorSingleViewLifetime());
     }
 
     internal class BlazorSingleViewLifetime : ISingleViewApplicationLifetime

+ 3 - 2
src/Browser/Avalonia.Browser/AvaloniaView.cs

@@ -20,7 +20,7 @@ using static System.Runtime.CompilerServices.RuntimeHelpers;
 
 namespace Avalonia.Browser
 {
-    public partial class AvaloniaView : ITextInputMethodImpl
+    public class AvaloniaView : ITextInputMethodImpl
     {
         private static readonly PooledList<RawPointerPoint> s_intermediatePointsPooledList = new(ClearMode.Never);
         private readonly BrowserTopLevelImpl _topLevelImpl;
@@ -43,8 +43,9 @@ namespace Avalonia.Browser
         private bool _useGL;        
         private ITextInputMethodClient? _client;
 
+        /// <param name="divId">ID of the html element where avalonia content should be rendered.</param>
         public AvaloniaView(string divId)
-            : this(DomHelper.GetElementById(divId) ?? throw new Exception($"Element with id {divId} was not found in the html document."))
+            : this(DomHelper.GetElementById(divId) ?? throw new Exception($"Element with id '{divId}' was not found in the html document."))
         {
         }
 

+ 83 - 0
src/Browser/Avalonia.Browser/BrowserAppBuilder.cs

@@ -0,0 +1,83 @@
+using System;
+using System.Threading.Tasks;
+using Avalonia.Browser.Interop;
+
+namespace Avalonia.Browser;
+
+public class BrowserPlatformOptions
+{
+    /// <summary>
+    /// Defines paths where avalonia modules and service locator should be resolved.
+    /// If null, default path resolved depending on the backend (browser or blazor) is used.
+    /// </summary>
+    public Func<string, string>? FrameworkAssetPathResolver { get; set; }
+}
+
+public static class BrowserAppBuilder
+{
+    /// <summary>
+    /// Configures browser backend, loads avalonia javascript modules and creates a single view lifetime from the passed <see cref="mainDivId"/> parameter.
+    /// </summary>
+    /// <param name="builder">Application builder.</param>
+    /// <param name="mainDivId">ID of the html element where avalonia content should be rendered.</param>
+    /// <param name="options">Browser backend specific options.</param>
+    public static async Task StartBrowserApp(this AppBuilder builder, string mainDivId, BrowserPlatformOptions? options = null)
+    {
+        if (mainDivId is null)
+        {
+            throw new ArgumentNullException(nameof(mainDivId));
+        }
+        
+        builder = await PreSetupBrowser(builder, options);
+
+        var lifetime = new BrowserSingleViewLifetime();
+        builder
+            .AfterSetup(_ =>
+            {
+                lifetime.View = new AvaloniaView(mainDivId);
+            })
+            .SetupWithLifetime(lifetime);
+    }
+
+    /// <summary>
+    /// Loads avalonia javascript modules and configures browser backend.
+    /// </summary>
+    /// <param name="builder">Application builder.</param>
+    /// <param name="options">Browser backend specific options.</param>
+    /// <remarks>
+    /// This method doesn't creates any avalonia views to be rendered. To do so create an <see cref="AvaloniaView"/> object.
+    /// Alternatively, you can call <see cref="StartBrowserApp"/> method instead of <see cref="SetupBrowserApp"/>.
+    /// </remarks>
+    public static async Task SetupBrowserApp(this AppBuilder builder, BrowserPlatformOptions? options = null)
+    {
+        builder = await PreSetupBrowser(builder, options);
+
+        builder
+            .SetupWithoutStarting();
+    }
+
+    internal static async Task<AppBuilder> PreSetupBrowser(AppBuilder builder, BrowserPlatformOptions? options)
+    {
+        options ??= new BrowserPlatformOptions();
+        options.FrameworkAssetPathResolver ??= fileName => $"./{fileName}";
+
+        AvaloniaLocator.CurrentMutable.Bind<BrowserPlatformOptions>().ToConstant(options);
+        
+        await AvaloniaModule.ImportMain();
+
+        if (builder.WindowingSubsystemInitializer is null)
+        {
+            builder = builder.UseBrowser();
+        }
+
+        return builder;
+    }
+    
+    public static AppBuilder UseBrowser(
+        this AppBuilder builder)
+    {
+        return builder
+            .UseWindowingSubsystem(BrowserWindowingPlatform.Register)
+            .UseSkia();
+    }
+}

+ 30 - 10
src/Browser/Avalonia.Browser/BrowserPlatformSettings.cs

@@ -1,4 +1,5 @@
-using Avalonia.Browser.Interop;
+using System;
+using Avalonia.Browser.Interop;
 using Avalonia.Platform;
 
 namespace Avalonia.Browser;
@@ -7,25 +8,44 @@ internal class BrowserPlatformSettings : DefaultPlatformSettings
 {
     private bool _isDarkMode;
     private bool _isHighContrast;
-    
-    public BrowserPlatformSettings()
+    private bool _isInitialized;
+
+    public override event EventHandler<PlatformColorValues>? ColorValuesChanged
     {
-        var obj = DomHelper.ObserveDarkMode((isDarkMode, isHighContrast) =>
+        add
         {
-            _isDarkMode = isDarkMode;
-            _isHighContrast = isHighContrast;
-            OnColorValuesChanged(GetColorValues());
-        });
-        _isDarkMode = obj.GetPropertyAsBoolean("isDarkMode");
-        _isHighContrast = obj.GetPropertyAsBoolean("isHighContrast");
+            EnsureBackend();
+            base.ColorValuesChanged += value;
+        }
+        remove => base.ColorValuesChanged -= value;
     }
 
     public override PlatformColorValues GetColorValues()
     {
+        EnsureBackend();
+
         return base.GetColorValues() with
         {
             ThemeVariant = _isDarkMode ? PlatformThemeVariant.Dark : PlatformThemeVariant.Light,
             ContrastPreference = _isHighContrast ? ColorContrastPreference.High : ColorContrastPreference.NoPreference
         };
     }
+
+    private void EnsureBackend()
+    {
+        if (!_isInitialized)
+        {
+            // WASM module has async nature of initialization. We can't native code right away during components registration. 
+            _isInitialized = true;
+
+            var obj = DomHelper.ObserveDarkMode((isDarkMode, isHighContrast) =>
+            {
+                _isDarkMode = isDarkMode;
+                _isHighContrast = isHighContrast;
+                OnColorValuesChanged(GetColorValues());
+            });
+            _isDarkMode = obj.GetPropertyAsBoolean("isDarkMode");
+            _isHighContrast = obj.GetPropertyAsBoolean("isHighContrast");
+        }
+    }
 }

+ 20 - 31
src/Browser/Avalonia.Browser/BrowserSingleViewLifetime.cs

@@ -1,47 +1,36 @@
 using System;
+using System.Diagnostics.CodeAnalysis;
 using Avalonia.Controls;
 using Avalonia.Controls.ApplicationLifetimes;
 using System.Runtime.Versioning;
+using Avalonia.Browser;
 
-namespace Avalonia.Browser;
+namespace Avalonia;
 
-public class BrowserSingleViewLifetime : ISingleViewApplicationLifetime
+internal class BrowserSingleViewLifetime : ISingleViewApplicationLifetime
 {
     public AvaloniaView? View;
 
     public Control? MainView
     {
-        get => View!.Content;
-        set => View!.Content = value;
-    }
-}
-
-public class BrowserPlatformOptions
-{
-    public Func<string, string> FrameworkAssetPathResolver { get; set; } = new(fileName => $"./{fileName}");
-}
-
-public static class WebAppBuilder
-{
-    public static AppBuilder SetupBrowserApp(
-        this AppBuilder builder, string mainDivId)
-    {
-        var lifetime = new BrowserSingleViewLifetime();
-
-        return builder
-            .UseBrowser()
-            .AfterSetup(b =>
-            {
-                lifetime.View = new AvaloniaView(mainDivId);
-            })
-            .SetupWithLifetime(lifetime);
+        get
+        {
+            EnsureView();
+            return View.Content;
+        }
+        set
+        {
+            EnsureView();
+            View.Content = value;
+        }
     }
 
-    public static AppBuilder UseBrowser(
-        this AppBuilder builder)
+    [MemberNotNull(nameof(View))]
+    private void EnsureView()
     {
-        return builder
-            .UseWindowingSubsystem(BrowserWindowingPlatform.Register)
-            .UseSkia();
+        if (View is null)
+        {
+            throw new InvalidOperationException("Browser lifetime was not initialized. Make sure AppBuilder.StartBrowserApp was called.");
+        }
     }
 }

+ 2 - 2
src/Browser/Avalonia.Browser/Interop/AvaloniaModule.cs

@@ -11,13 +11,13 @@ internal static partial class AvaloniaModule
     public static Task ImportMain()
     {
         var options = AvaloniaLocator.Current.GetService<BrowserPlatformOptions>() ?? new BrowserPlatformOptions();
-        return JSHost.ImportAsync(MainModuleName, options.FrameworkAssetPathResolver("avalonia.js"));
+        return JSHost.ImportAsync(MainModuleName, options.FrameworkAssetPathResolver!("avalonia.js"));
     }
 
     public static Task ImportStorage()
     {
         var options = AvaloniaLocator.Current.GetService<BrowserPlatformOptions>() ?? new BrowserPlatformOptions();
-        return JSHost.ImportAsync(StorageModuleName, options.FrameworkAssetPathResolver("storage.js"));
+        return JSHost.ImportAsync(StorageModuleName, options.FrameworkAssetPathResolver!("storage.js"));
     }
 
     [JSImport("Caniuse.isMobile", AvaloniaModule.MainModuleName)]

+ 1 - 17
src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts

@@ -1,4 +1,3 @@
-import { RuntimeAPI } from "../types/dotnet";
 import { SizeWatcher, DpiWatcher, Canvas } from "./avalonia/canvas";
 import { InputHelper } from "./avalonia/input";
 import { AvaloniaDOM } from "./avalonia/dom";
@@ -7,19 +6,6 @@ import { StreamHelper } from "./avalonia/stream";
 import { NativeControlHost } from "./avalonia/nativeControlHost";
 import { NavigationHelper } from "./avalonia/navigationHelper";
 
-async function registerAvaloniaModule(api: RuntimeAPI): Promise<void> {
-    api.setModuleImports("avalonia", {
-        Caniuse,
-        Canvas,
-        InputHelper,
-        SizeWatcher,
-        DpiWatcher,
-        AvaloniaDOM,
-        StreamHelper,
-        NativeControlHost,
-        NavigationHelper
-    });
-}
 export {
     Caniuse,
     Canvas,
@@ -29,7 +15,5 @@ export {
     AvaloniaDOM,
     StreamHelper,
     NativeControlHost,
-    NavigationHelper,
-
-    registerAvaloniaModule
+    NavigationHelper
 };