diff --git a/controlgallery/AtomUIGallery.Desktop/GalleryApplication.cs b/controlgallery/AtomUIGallery.Desktop/GalleryApplication.cs index 09b2b60d2..d476e3473 100644 --- a/controlgallery/AtomUIGallery.Desktop/GalleryApplication.cs +++ b/controlgallery/AtomUIGallery.Desktop/GalleryApplication.cs @@ -6,7 +6,8 @@ using AtomUI.Localization; using AtomUI.Theme; using AtomUI.Toolkits.GalleryBase; using AtomUIGallery.Localization; -using Avalonia.Controls.ApplicationLifetimes; +using AtomUIGallery.Workspace.Views; +using Window = Avalonia.Controls.Window; namespace AtomUIGallery.Desktop; @@ -38,22 +39,6 @@ public partial class GalleryApplication : BaseGalleryApplication builder.UseSplash(SplashMode.Auto, splash => splash .WithLogo(new Uri("avares://AtomUIGallery.Desktop/Assets/Images/AppLogo.iconset/icon_128x128@2x.png")) .WithTitle("AtomUI Gallery")); - } - - public override void Initialize() - { - base.Initialize(); - } - - public override void OnFrameworkInitializationCompleted() - { - switch (ApplicationLifetime) - { - case IClassicDesktopStyleApplicationLifetime desktop: - desktop.MainWindow = CreateWorkspaceWindow(); - break; - } - - base.OnFrameworkInitializationCompleted(); + builder.UseMainWindow(static _ => Task.FromResult(new WorkspaceWindow())); } } diff --git a/docs/architecture/systems/startup/lifecycle.md b/docs/architecture/systems/startup/lifecycle.md index c1d4f1609..4cb237100 100644 --- a/docs/architecture/systems/startup/lifecycle.md +++ b/docs/architecture/systems/startup/lifecycle.md @@ -102,12 +102,13 @@ public static void AtomUIStartup.Attach(Application application); 生命周期已启动)。不是经典桌面生命周期(headless、single-view)时不投递,直接进入 `Ready`。判定主窗体: 1. 配置了 `UseMainWindow`:在 UI 线程执行工厂(可 await,经 `AtomUIMainWindowContext.Progress` 汇报进度),把返回的 - 窗口赋给 `desktop.MainWindow` 并显示。应用同时自行给 `desktop.MainWindow` 赋值视为配置错误。 + 窗口赋给 `desktop.MainWindow` 并显示;主窗体首个合成批次完成后才隐藏并关闭 Splash。应用同时自行给 + `desktop.MainWindow` 赋值视为配置错误。 2. 否则 `desktop.MainWindow` 已赋值(传统写法、Prism):就是它。 3. 否则视为没有主窗体:立即关闭 Splash,状态 → `Ready`。 -主窗体确定后状态 → `Presenting`;等它 `Opened`(上限 3 s)后的首个渲染批次(上限 3 s),然后 Splash 淡出并关闭, -状态 → `Ready`。两个超时只写 Warning 并直接交接,防止最小化或被遮挡的窗口让 Splash 永不关闭。没有 Splash 时同样按 +主窗体确定后状态 → `Presenting`;等它 `Opened`(上限 3 s)后的首个渲染批次(上限 3 s),然后立即隐藏并关闭 Splash, +状态 → `Ready`。传统宿主自行显示的主窗体在该首帧后才淡出 Splash。两个主窗体等待超时只写 Warning 并直接交接,防止最小化或被遮挡的窗口让 Splash 永不关闭。没有 Splash 时同样按 此规则推进状态。主窗体在等待期间关闭、或用户关闭 Splash 触发启动取消时,关闭/取消信号会立即中断 `Opened` 与合成批次等待,不继续消耗 3 秒超时,也不记作成功首帧或 Ready。 diff --git a/docs/architecture/systems/startup/overview.md b/docs/architecture/systems/startup/overview.md index 0a4280f07..1c0ca8709 100644 --- a/docs/architecture/systems/startup/overview.md +++ b/docs/architecture/systems/startup/overview.md @@ -1,8 +1,8 @@ # AtomUI 启动系统架构 启动系统负责从 `AppBuilder` 配置到"主窗体完整出现"之间的全部 AtomUI 编排:收集应用配置、在平台初始化期间于后台 -准备全局 Token、可选地显示启动 Splash、一次性提交 AtomUI 内核(本地化、主题、自有服务),并在主窗体首个渲染批次 -完成后交接。它横跨 `AtomUI.Core`(生命周期与提交)、`AtomUI.Controls`(Splash 控件与呈现器)和 +准备全局 Token、可选地显示启动 Splash、一次性提交 AtomUI 内核(本地化、主题、自有服务),并编排 Splash 与主窗体的交接。 +主窗体首帧后立即隐藏并关闭 Splash。它横跨 `AtomUI.Core`(生命周期与提交)、`AtomUI.Controls`(Splash 控件与呈现器)和 `AtomUI.Generator`(`RegisterServices` 挂接生成器)。 ## 设计目标 @@ -40,7 +40,7 @@ StartWithClassicDesktopLifetime → SetupUnsafe Initialize() App.axaml;Prism 在此创建并显示 Shell AfterApplicationSetup 守卫:本实例未执行 Attach 则抛出带修复说明的异常 OnFrameworkInitializationCompleted 传统写法在此创建主窗体 -主循环:判定主窗体 → 等其首个渲染批次 → Splash 淡出关闭 → Ready +主循环:创建主窗体 → 显示并等待首个渲染批次 → 隐藏并关闭 Splash → Ready ``` ## 模块职责 diff --git a/docs/architecture/systems/startup/splash.md b/docs/architecture/systems/startup/splash.md index 4d0b6a638..a05d80252 100644 --- a/docs/architecture/systems/startup/splash.md +++ b/docs/architecture/systems/startup/splash.md @@ -1,6 +1,6 @@ # Splash -启动 Splash 在 AtomUI 内核提交之前显示,覆盖提交与主窗体构建的耗时,并在主窗体首个渲染批次完成后淡出。生命周期中的 +启动 Splash 在 AtomUI 内核提交之前显示,覆盖提交与主窗体构建的耗时。主窗体首个合成批次完成后,Splash 立即隐藏并随后关闭。生命周期中的 位置见[启动生命周期](lifecycle.md);接入示例见[启动指南](../../../guides/startup/overview.md#2-启用-splash)。 ## 1. 类型 @@ -94,21 +94,22 @@ ControlTheme。它合并在默认主题之后,同键优先。两种写法: ## 6. 交接、错误态与关闭 -- **交接**:主窗体首个渲染批次完成后,对窗口根合成视觉做 150 ms 透明度淡出,然后取消置顶并关闭窗口。 +- **交接**:主窗体首个合成批次完成后立即隐藏 Splash,取消命中与置顶;窗口关闭投递到下一轮 UI 调度,避免在渲染回调中 + 同步销毁合成目标触发 Avalonia 合成器重入。UseMainWindow 与传统宿主均遵循此顺序,避免 Splash 消失后主窗体尚未上屏。 - **进度**:框架只在状态切换时发布进度,Splash 默认显示不确定进度;应用经 `UseMainWindow` 的 `AtomUIMainWindowContext.Progress` 汇报时,更新消息并切换为确定进度。 - **错误态**:提交失败或主窗体工厂抛错时,`Status` 切换为 `Error`,消息显示异常的根消息;用户点击关闭部件或按 Esc 后 - 关闭窗口,启动按[出错与取消](lifecycle.md#7-出错与取消)继续。错误态隐藏进度条时也停止其合成帧请求; + 关闭窗口,启动按[出错与取消](lifecycle.md#7-出错与取消)继续。呈现失败时若 Splash 已透明,恢复其不透明度、置顶与命中。 + 错误态隐藏进度条时也停止其合成帧请求; 普通页面预览被自身或祖先隐藏、或从 VisualTree 脱离时同样停止,重新可见后按当前进度状态恢复。 - **用户关闭**:交接前用户关闭 Splash 视为取消启动并调用 `Shutdown()`。 ## 7. 已知问题 -- **交接瞬间的短暂停顿**:所有窗口共用一个渲染线程。主窗体首帧的栅格化(冷字体与字形缓存)占用渲染线程时,Splash 的 - 渲染线程动画随之暂停;Splash 关闭安排在主窗体首帧之后,最多看到一次短暂停顿。 +- **交接瞬间的短暂停顿**:所有窗口共用一个渲染线程。Splash 保持可见直到主窗体首帧完成,随后立即隐藏,避免出现长时间空档。 - **macOS 主窗体上屏前的渲染线程阻塞**:AtomUI 的 `WithAtomUIDefaultOptions()` 在 macOS 上 Metal 优先,Metal 在渲染线程 直接呈现,提交占用 UI 线程时 Splash 动画照常上屏(OpenGL 与 Software 经 IOSurface 呈现,需要主线程设置图层内容,提交 期间帧送不上屏)。但 Avalonia 的 `Window.Show` 先开始渲染、后让窗口上屏;UI 线程在主窗体上屏前仍忙时,主窗体的 `-[CAMetalLayer nextDrawable]` 可阻塞渲染线程约 1 s(drawable 池耗尽后的系统超时),Splash 在此期间停顿。这是 Avalonia - 多窗口共用渲染线程的行为,尚未处理。 + 多窗口共用渲染线程的行为;Splash 会保持可见直到主窗体首帧完成,不会留下提前隐藏后的长时间空档。 - **Wayland**:不允许应用定位窗口,Splash 的位置由合成器决定。 diff --git a/docs/architecture/systems/startup/verification.md b/docs/architecture/systems/startup/verification.md index a9ba3091c..11058208a 100644 --- a/docs/architecture/systems/startup/verification.md +++ b/docs/architecture/systems/startup/verification.md @@ -94,4 +94,5 @@ macOS、Windows、Linux X11 与 Wayland 各执行一次,检查 Gallery 与一 ## 5. 已知不一致 -- macOS 主窗体上屏前约 1 s 的渲染线程阻塞会让 Splash 停顿,见 [Splash 已知问题](splash.md#7-已知问题)。 +- 启动交接须验证主窗体首个合成批次完成后 Splash 才隐藏;呈现失败需恢复可见错误态。 +- macOS 主窗体首帧前的渲染线程阻塞可能让 Splash 动画停顿,但不得造成 Splash 提前消失后的长时间空档,见 [Splash 已知问题](splash.md#7-已知问题)。 diff --git a/docs/guides/startup/overview.md b/docs/guides/startup/overview.md index 1baede918..2b66b1a93 100644 --- a/docs/guides/startup/overview.md +++ b/docs/guides/startup/overview.md @@ -163,6 +163,7 @@ Splash 在内核提交之前显示,自定义主题必须遵守: - 汇报的 `Fraction`(0–1)使 Splash 切换为确定进度;`Message` 为 null 时不改消息。 - 用户在交接前关闭 Splash 时 `context.CancellationToken` 取消,应用随即退出。 +- 工厂返回后显示主窗体,等待其首个渲染批次完成,再立即隐藏并关闭 Splash,避免 Splash 消失后主窗体长时间未出现。 - 使用 `UseMainWindow` 时不要再自行给 `desktop.MainWindow` 赋值;它只适用于经典桌面生命周期。 ## 5. Prism diff --git a/src/AtomUI.Controls/Splash/SplashPresenter.cs b/src/AtomUI.Controls/Splash/SplashPresenter.cs index e0475d8fd..7ef8f3cff 100644 --- a/src/AtomUI.Controls/Splash/SplashPresenter.cs +++ b/src/AtomUI.Controls/Splash/SplashPresenter.cs @@ -4,13 +4,11 @@ using Avalonia.Rendering.Composition; namespace AtomUI.Controls; /// -/// Splash 呈现器:创建并显示 SplashWindow,汇报启动进度,错误态等待用户确认,交接时淡出关闭。 +/// Splash 呈现器:创建并显示 SplashWindow,汇报启动进度,错误态等待用户确认,交接时先隐藏、后关闭。 /// 全部成员在 UI 线程调用(由 Core 的启动编排保证)。 /// internal sealed class SplashPresenter : IAtomUISplashPresenter { - private static readonly TimeSpan FadeDuration = TimeSpan.FromMilliseconds(150); - private readonly TaskCompletionSource _firstFrame = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _errorAcknowledged = new(TaskCreationOptions.RunContinuationsAsynchronously); private SplashWindow? _window; @@ -69,25 +67,39 @@ internal sealed class SplashPresenter : IAtomUISplashPresenter } splash.Status = SplashStatus.Error; splash.Message = exception.GetBaseException().Message; + // 主窗体呈现失败可能发生在 Splash 已透明之后;错误态必须重新可见、可操作。 + if (_window is { } window) + { + window.Opacity = 1; + window.IsHitTestVisible = true; + window.Topmost = true; + if (ElementComposition.GetElementVisual(window) is { } visual) + { + visual.StopAnimation("Opacity"); + visual.Opacity = 1; + } + if (!window.IsVisible) + { + window.Show(); + } + } } - public async Task FadeOutAndCloseAsync() + public Task HideAsync() { if (_window is not { } window) { - return; + return Task.CompletedTask; } - if (ElementComposition.GetElementVisual(window) is { } visual) + if (!ReferenceEquals(_window, window)) { - var fade = visual.Compositor.CreateScalarKeyFrameAnimation(); - fade.Target = "Opacity"; - fade.InsertKeyFrame(0f, 1f); - fade.InsertKeyFrame(1f, 0f); - fade.Duration = FadeDuration; - visual.StartAnimation("Opacity", fade); - await Task.Delay(FadeDuration + TimeSpan.FromMilliseconds(10)); + return Task.CompletedTask; } - Close(); + // 主窗体首帧已经完成,直接隐藏 Splash,避免透明度合成和主窗体上屏之间形成空档。 + window.IsHitTestVisible = false; + window.Topmost = false; + window.Hide(); + return Task.CompletedTask; } public void Close() diff --git a/src/AtomUI.Core/Startup/AtomUIStartupRun.cs b/src/AtomUI.Core/Startup/AtomUIStartupRun.cs index e58bc2541..7244749a3 100644 --- a/src/AtomUI.Core/Startup/AtomUIStartupRun.cs +++ b/src/AtomUI.Core/Startup/AtomUIStartupRun.cs @@ -246,13 +246,12 @@ internal sealed class AtomUIStartupRun _process.MoveTo(AtomUIStartupState.Presenting); // 主窗体在首帧前被关闭视为用户取消:不再等待首帧,也不再走正常交接。 - var cancelledDuringPresentation = false; void HandleMainWindowClosed(object? sender, EventArgs args) { if (_process.State == AtomUIStartupState.Presenting) { - cancelledDuringPresentation = true; _process.Cancel(); + _cancellation.Cancel(); } } @@ -261,23 +260,17 @@ internal sealed class AtomUIStartupRun { if (builder.MainWindowFactory is not null) { + _cancellation.Token.ThrowIfCancellationRequested(); mainWindow.Show(); } - if (!cancelledDuringPresentation) - { - await WaitForFirstFrameAsync(mainWindow, _cancellation.Token); - } + await WaitForFirstFrameAsync(mainWindow, _cancellation.Token); } finally { mainWindow.Closed -= HandleMainWindowClosed; } - if (cancelledDuringPresentation) - { - await CloseSplashAsync(); - return; - } AtomUIStartupEventSource.Log.MainWindowFirstFrame(); + // 主窗体已经完成首个合成批次后才隐藏 Splash,避免 Splash 提前消失后主窗体长时间尚未上屏。 await CloseSplashAsync(); _process.CompleteReady(); } @@ -337,8 +330,10 @@ internal sealed class AtomUIStartupRun } try { - await visual.Compositor.RequestCompositionBatchCommitAsync().Rendered - .WaitAsync(MainWindowFirstFrameTimeout, closedOrCanceled.Token); + await AwaitCompositionAsync( + visual.Compositor.RequestCompositionBatchCommitAsync().Rendered, + MainWindowFirstFrameTimeout, + closedOrCanceled.Token); } catch (TimeoutException) { @@ -351,14 +346,29 @@ internal sealed class AtomUIStartupRun } } - private Task CloseSplashAsync() + private static Task AwaitCompositionAsync(Task composition, TimeSpan timeout, CancellationToken cancellationToken) + { + // CompositionBatch completes its TCS with synchronous continuations. Keep startup + // continuations off the render thread; the caller resumes on the UI dispatcher. + return composition.WaitAsync(timeout, cancellationToken).ContinueWith( + static completed => completed.GetAwaiter().GetResult(), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private async Task CloseSplashAsync() { if (_splash is not { } splash) { - return Task.CompletedTask; + return; } + await splash.HideAsync(); splash.ClosedByUser -= HandleSplashClosedByUser; _splash = null; - return splash.FadeOutAndCloseAsync(); + // Window.Close synchronously tears down its composition target. Defer it until + // the current render/native callback has returned; otherwise Avalonia can enter + // ServerCompositor.Render again from SyncDisposeCompositionTarget. + Dispatcher.UIThread.Post(splash.Close, DispatcherPriority.Background); } } diff --git a/src/AtomUI.Core/Startup/IAtomUISplashPresenter.cs b/src/AtomUI.Core/Startup/IAtomUISplashPresenter.cs index 7676370c4..45e861330 100644 --- a/src/AtomUI.Core/Startup/IAtomUISplashPresenter.cs +++ b/src/AtomUI.Core/Startup/IAtomUISplashPresenter.cs @@ -28,8 +28,8 @@ internal interface IAtomUISplashPresenter Task ErrorAcknowledged { get; } - /// 淡出并关闭(交接)。 - Task FadeOutAndCloseAsync(); + /// 主窗体首帧完成后隐藏 Splash;窗口随后由启动编排安全关闭。 + Task HideAsync(); /// 不等待动画立即关闭;用于启动取消或呈现失败时释放窗口。 void Close(); diff --git a/tests/AtomUI.Core.Tests/Startup/AtomUIStartupContractTests.cs b/tests/AtomUI.Core.Tests/Startup/AtomUIStartupContractTests.cs index 8f7266914..a32e37290 100644 --- a/tests/AtomUI.Core.Tests/Startup/AtomUIStartupContractTests.cs +++ b/tests/AtomUI.Core.Tests/Startup/AtomUIStartupContractTests.cs @@ -13,16 +13,23 @@ public class AtomUIStartupContractTests { // headless 会话的 dispatch 完成回调内联执行:必须阻塞式 dispatch,否则会话线程会在 Dispose 中等待自身。 [Fact] - public void Generated_hook_commits_before_Initialize_and_reaches_Ready() + public async Task Generated_hook_commits_before_Initialize_and_reaches_Ready() { - using var session = HeadlessUnitTestSession.StartNew(typeof(HookedEntry), AvaloniaTestIsolationLevel.PerTest); - session.Dispatch(() => + var session = HeadlessUnitTestSession.StartNew(typeof(HookedEntry), AvaloniaTestIsolationLevel.PerTest); + try { - var application = (TestApplication)Application.Current!; - application.ThemeManagerSeenInInitialize.ShouldBeTrue(); - application.GetStartup().State.ShouldBe(AtomUIStartupState.Ready); - application.GetStartup().Completion.IsCompletedSuccessfully.ShouldBeTrue(); - }, CancellationToken.None).GetAwaiter().GetResult(); + await session.Dispatch(() => + { + var application = (TestApplication)Application.Current!; + application.ThemeManagerSeenInInitialize.ShouldBeTrue(); + application.GetStartup().State.ShouldBe(AtomUIStartupState.Ready); + application.GetStartup().Completion.IsCompletedSuccessfully.ShouldBeTrue(); + }, TestContext.Current.CancellationToken); + } + finally + { + await Task.Run(session.Dispose, TestContext.Current.CancellationToken); + } } public static class HookedEntry