fix(Startup): hand off Splash after main window first frame

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