启动画面与加载动画
大约 10 分钟约 3058 字
启动画面与加载动画
简介
启动画面(Splash Screen)是应用加载时显示的过渡界面,可以提升用户体验感知。WPF 提供了 SplashScreen 类(静态图片)和自定义窗口两种方式。配合加载动画、进度提示和后台初始化,可以构建专业的启动体验。在工业上位机中,启动画面常用于显示品牌标识、加载进度和初始化状态,让用户了解应用正在做什么。
启动流程分析
应用启动 → Splash Screen → 后台初始化 → 主窗口显示 → Splash 关闭
↓ ↓ ↓ ↓ ↓
App.xaml 显示Logo/进度 连接DB/设备 MainWindow 淡出动画
OnStartup 初始化服务 加载配置/插件 Show()两种启动画面方式对比
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| SplashScreen(内置) | 代码简单,自动淡出 | 只能显示静态图片,无交互 | 简单应用 |
| 自定义窗口 | 支持动画、进度条、自定义布局 | 需要额外代码 | 专业应用 |
特点
内置 SplashScreen
静态图片启动
// ========== 内置 SplashScreen 使用 ==========
/// <summary>
/// App.xaml.cs — 使用 WPF 内置 SplashScreen
/// 最简单的启动画面实现
/// </summary>
public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
// 方式 1:自动显示和关闭(最简单)
// SplashScreen 会自动在 300ms 后淡出
// splash.Show(autoClose: true, topMost: true);
// 方式 2:手动控制关闭时机
var splash = new SplashScreen("Resources/splash.png");
splash.Show(autoClose: false); // 不自动关闭,手动控制
splash.TopMost = true;
// 模拟初始化(实际项目中替换为真实初始化逻辑)
InitializeServices();
// 显示主窗口
var mainWindow = new MainWindow();
mainWindow.Loaded += (s, args) =>
{
// 主窗口加载完成后关闭启动画面(带淡出动画)
splash.Close(TimeSpan.FromMilliseconds(500));
};
mainWindow.Show();
base.OnStartup(e);
}
private void InitializeServices()
{
// 在这里执行初始化操作
// 注意:SplashScreen.Show 不会阻塞线程
// 所以初始化操作仍然是同步的
Thread.Sleep(1000); // 模拟耗时操作
}
}build.csproj 配置
<!-- 内置 SplashScreen 可以在 csproj 中配置 -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<StartupObject>MyApp.App</StartupObject>
</PropertyGroup>
<ItemGroup>
<!-- 设置为 SplashScreen 资源 -->
<Resource Include="Resources\splash.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Resource>
</ItemGroup>
</Project>自定义启动窗口
带进度条的启动画面
<!-- ========== SplashWindow.xaml — 自定义启动窗口 ========== -->
<Window x:Class="MyApp.Views.SplashWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
WindowStartupLocation="CenterScreen"
Width="520" Height="360"
ResizeMode="NoResize"
ShowInTaskbar="False"
Topmost="True">
<Border Background="White" CornerRadius="12"
BorderBrush="#E0E0E0" BorderThickness="1">
<Border.Effect>
<DropShadowEffect BlurRadius="20" ShadowDepth="2" Opacity="0.3"/>
</Border.Effect>
<Grid Margin="30">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Logo 和标题 -->
<StackPanel Grid.Row="0" HorizontalAlignment="Center" Margin="0,10,0,20">
<Image Source="pack://application:,,,/Resources/logo.png"
Width="80" Height="80"
RenderOptions.BitmapScalingMode="HighQuality"/>
<TextBlock Text="设备监控系统" FontSize="26" FontWeight="Bold"
HorizontalAlignment="Center" Margin="0,12,0,0"
Foreground="#333333"/>
<TextBlock x:Name="VersionText" Text="v2.1.0" FontSize="12"
Foreground="Gray" HorizontalAlignment="Center"/>
</StackPanel>
<!-- 加载动画区域 -->
<StackPanel Grid.Row="2" VerticalAlignment="Center"
HorizontalAlignment="Center">
<!-- 进度条 -->
<ProgressBar x:Name="LoadingProgress"
Width="320" Height="4"
Minimum="0" Maximum="100"
Foreground="#0078D4" Background="#E8E8E8"
BorderThickness="0"/>
<!-- 状态文字 -->
<TextBlock x:Name="StatusText"
Text="正在初始化..."
FontSize="12" Foreground="Gray"
HorizontalAlignment="Center" Margin="0,10,0,0"/>
</StackPanel>
<!-- 底部版权信息 -->
<TextBlock Grid.Row="4" Text="Copyright 2024 MyApp Team"
FontSize="10" Foreground="#CCCCCC"
HorizontalAlignment="Center" Margin="0,10,0,0"/>
</Grid>
</Border>
</Window>// ========== 启动流程控制 ==========
/// <summary>
/// App.xaml.cs — 自定义启动画面流程
/// </summary>
public partial class App : Application
{
private SplashWindow? _splash;
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// 步骤 1:显示启动画面
_splash = new SplashWindow();
_splash.Show();
// 步骤 2:使用 Progress 报告初始化进度
var progress = new Progress<(int percent, string message)>(p =>
{
_splash.LoadingProgress.Value = p.percent;
_splash.StatusText.Text = p.message;
});
try
{
// 步骤 3:后台初始化所有服务
await InitializeAsync(progress);
}
catch (Exception ex)
{
_splash.StatusText.Text = $"初始化失败: {ex.Message}";
await Task.Delay(2000);
Shutdown(1);
return;
}
// 步骤 4:显示主窗口
var mainWindow = new MainWindow();
mainWindow.Show();
// 步骤 5:关闭启动画面
_splash.Close();
_splash = null;
}
/// <summary>
/// 初始化所有服务和模块
/// </summary>
private async Task InitializeAsync(IProgress<(int percent, string message)> progress)
{
// 1. 加载配置
progress.Report((5, "加载配置文件..."));
await LoadConfigurationAsync();
// 2. 初始化日志
progress.Report((10, "初始化日志系统..."));
InitializeLogging();
// 3. 配置依赖注入
progress.Report((15, "配置服务容器..."));
ConfigureServices();
// 4. 初始化数据库
progress.Report((25, "连接数据库..."));
await InitializeDatabaseAsync();
// 5. 加载用户设置
progress.Report((40, "加载用户设置..."));
LoadUserSettings();
// 6. 连接设备服务
progress.Report((55, "连接设备服务..."));
await ConnectDeviceServicesAsync();
// 7. 加载插件
progress.Report((70, "加载插件..."));
await LoadPluginsAsync();
// 8. 准备 UI 数据
progress.Report((85, "准备界面数据..."));
await PrepareUIDataAsync();
// 9. 初始化主题
progress.Report((95, "初始化主题..."));
InitializeTheme();
// 10. 完成
progress.Report((100, "启动完成"));
await Task.Delay(300); // 短暂停顿让用户看到 100%
}
// 以下为各初始化步骤的具体实现(示例)
private async Task LoadConfigurationAsync() => await Task.Delay(100);
private void InitializeLogging() => Task.Delay(50).Wait();
private void ConfigureServices() => Task.Delay(50).Wait();
private async Task InitializeDatabaseAsync() => await Task.Delay(300);
private void LoadUserSettings() => Task.Delay(50).Wait();
private async Task ConnectDeviceServicesAsync() => await Task.Delay(200);
private async Task LoadPluginsAsync() => await Task.Delay(200);
private async Task PrepareUIDataAsync() => await Task.Delay(150);
private void InitializeTheme() => Task.Delay(50).Wait();
}带异常处理的初始化
// ========== 带重试和超时的初始化 ==========
/// <summary>
/// 初始化步骤模型
/// </summary>
public record InitializationStep(
string Name, Func<Task> Action, int Weight = 1, int TimeoutMs = 30000);
/// <summary>
/// 初始化管理器 — 管理启动步骤的执行和进度报告
/// </summary>
public class InitializationManager
{
private readonly List<InitializationStep> _steps = new();
private readonly ILogger<InitializationManager> _logger;
public InitializationManager(ILogger<InitializationManager> logger)
{
_logger = logger;
}
/// <summary>
/// 添加初始化步骤
/// </summary>
public InitializationManager AddStep(string name, Func<Task> action,
int weight = 1, int timeoutMs = 30000)
{
_steps.Add(new InitializationStep(name, action, weight, timeoutMs));
return this;
}
/// <summary>
/// 执行所有初始化步骤
/// </summary>
public async Task RunAllAsync(IProgress<(int percent, string message)> progress)
{
int totalWeight = _steps.Sum(s => s.Weight);
int completedWeight = 0;
var stopwatch = Stopwatch.StartNew();
foreach (var step in _steps)
{
progress.Report((
(int)(completedWeight * 100.0 / totalWeight),
step.Name));
var stepStopwatch = Stopwatch.StartNew();
try
{
// 带超时的执行
using var cts = new CancellationTokenSource(step.TimeoutMs);
await step.Action().WaitAsync(cts.Token);
_logger.LogInformation(
"初始化步骤完成: {Step},耗时 {ElapsedMs}ms",
step.Name, stepStopwatch.ElapsedMilliseconds);
}
catch (OperationCanceledException)
{
_logger.LogError("初始化步骤超时: {Step}({TimeoutMs}ms)",
step.Name, step.TimeoutMs);
throw new TimeoutException($"初始化超时: {step.Name}");
}
catch (Exception ex)
{
_logger.LogError(ex, "初始化步骤失败: {Step}", step.Name);
throw;
}
completedWeight += step.Weight;
}
stopwatch.Stop();
_logger.LogInformation("全部初始化完成,总耗时 {TotalMs}ms",
stopwatch.ElapsedMilliseconds);
progress.Report((100, "启动完成"));
}
}
// 使用示例
public partial class App : Application
{
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
_splash = new SplashWindow();
_splash.Show();
var progress = new Progress<(int percent, string message)>(p =>
{
_splash.LoadingProgress.Value = p.percent;
_splash.StatusText.Text = p.message;
});
try
{
var manager = new InitializationManager(_logger)
.AddStep("加载配置", () => LoadConfigurationAsync(), weight: 1)
.AddStep("初始化日志", () => Task.Run(InitializeLogging), weight: 1)
.AddStep("连接数据库", () => InitializeDatabaseAsync(), weight: 2, timeoutMs: 10000)
.AddStep("连接设备", () => ConnectDevicesAsync(), weight: 3, timeoutMs: 15000)
.AddStep("加载插件", () => LoadPluginsAsync(), weight: 1)
.AddStep("准备界面", () => PrepareUIAsync(), weight: 1);
await manager.RunAllAsync(progress);
var mainWindow = new MainWindow();
mainWindow.Show();
_splash.Close();
}
catch (Exception ex)
{
_splash.StatusText.Text = $"启动失败: {ex.Message}";
_logger.LogCritical(ex, "应用启动失败");
await Task.Delay(3000);
Shutdown(1);
}
}
}加载动画
圆形旋转动画
<!-- ========== CircularLoading.xaml — 自定义加载控件 ========== -->
<UserControl x:Class="MyApp.Controls.CircularLoading"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid Width="40" Height="40">
<!-- 背景圆环 -->
<Ellipse Width="36" Height="36"
Stroke="#E0E0E0" StrokeThickness="3"
StrokeDashCap="Round"/>
<!-- 旋转圆弧 -->
<Ellipse Width="36" Height="36"
Stroke="#0078D4" StrokeThickness="3"
StrokeDashArray="20 80"
StrokeDashCap="Round"
RenderTransformOrigin="0.5,0.5">
<Ellipse.RenderTransform>
<RotateTransform x:Name="RotateTransform"/>
</Ellipse.RenderTransform>
<Ellipse.Triggers>
<EventTrigger RoutedEvent="Loaded">
<BeginStoryboard>
<Storyboard RepeatBehavior="Forever">
<DoubleAnimation
Storyboard.TargetName="RotateTransform"
Storyboard.TargetProperty="Angle"
From="0" To="360" Duration="0:0:1"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Ellipse.Triggers>
</Ellipse>
</Grid>
</UserControl>脉冲点动画
<!-- ========== PulseDots.xaml — 脉冲点加载动画 ========== -->
<UserControl x:Class="MyApp.Controls.PulseDots"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<StackPanel Orientation="Horizontal" Height="20">
<Ellipse x:Name="Dot1" Width="8" Height="8" Fill="#0078D4" Margin="3"/>
<Ellipse x:Name="Dot2" Width="8" Height="8" Fill="#0078D4" Margin="3" Opacity="0.4"/>
<Ellipse x:Name="Dot3" Width="8" Height="8" Fill="#0078D4" Margin="3" Opacity="0.4"/>
<StackPanel.Triggers>
<EventTrigger RoutedEvent="Loaded">
<BeginStoryboard>
<Storyboard RepeatBehavior="Forever">
<!-- 点 1 脉冲 -->
<DoubleAnimation Storyboard.TargetName="Dot1"
Storyboard.TargetProperty="Opacity"
Values="1;0.3;1" Duration="0:0:1.2"/>
<!-- 点 2 延迟脉冲 -->
<DoubleAnimation Storyboard.TargetName="Dot2"
Storyboard.TargetProperty="Opacity"
Values="0.3;1;0.3" Duration="0:0:1.2"
BeginTime="0:0:0.2"/>
<!-- 点 3 延迟脉冲 -->
<DoubleAnimation Storyboard.TargetName="Dot3"
Storyboard.TargetProperty="Opacity"
Values="0.3;1;0.3" Duration="0:0:1.2"
BeginTime="0:0:0.4"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</StackPanel.Triggers>
</StackPanel>
</UserControl>加载遮罩层
<!-- ========== 加载遮罩层 — 页面级加载指示 ========== -->
<Grid>
<ContentControl x:Name="MainContent"/>
<!-- 加载遮罩 -->
<Grid x:Name="LoadingOverlay"
Background="#80000000"
Visibility="Collapsed">
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
<local:CircularLoading/>
<TextBlock x:Name="OverlayText" Text="加载中..."
Foreground="White" FontSize="14"
HorizontalAlignment="Center" Margin="0,12,0,0"/>
</StackPanel>
</Grid>
</Grid>// ========== 加载状态管理 ==========
public partial class MainWindow : Window
{
/// <summary>
/// 带遮罩的异步加载
/// </summary>
public async Task LoadDataWithOverlayAsync()
{
LoadingOverlay.Visibility = Visibility.Visible;
OverlayText.Text = "正在加载数据...";
try
{
await Task.Run(() =>
{
// 模拟耗时操作
Thread.Sleep(2000);
});
}
finally
{
LoadingOverlay.Visibility = Visibility.Collapsed;
}
}
}平滑过渡动画
<!-- ========== 启动画面淡出、主窗口淡入 ========== -->
<!-- 启动窗口淡出 -->
<Window.Triggers>
<EventTrigger RoutedEvent="Loaded">
<EventTrigger.Actions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetProperty="Opacity"
From="1" To="0" Duration="0:0:0.5"
Completed="OnFadeOutCompleted"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger.Actions>
</EventTrigger>
</Window.Triggers>// ========== 主窗口淡入 ==========
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
Opacity = 0; // 初始透明
Loaded += (s, e) =>
{
var fadeIn = new DoubleAnimation
{
From = 0,
To = 1,
Duration = TimeSpan.FromMilliseconds(300),
EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }
};
BeginAnimation(OpacityProperty, fadeIn);
};
}
}优点
缺点
总结
启动画面推荐使用自定义窗口方式(支持进度和动画)。核心流程:先显示 SplashWindow → 后台初始化 → 进度更新 → 初始化完成显示主窗口 → 关闭 Splash。内置 SplashScreen 适合简单场景。加载动画建议做成独立控件,方便在多个页面复用。初始化步骤使用 InitializationManager 统一管理,支持超时和重试。
关键知识点
- SplashScreen.Show(autoClose: false) 允许手动控制关闭时机。
- 自定义启动窗口设置 WindowStyle=None + AllowsTransparency=True 实现无边框窗口。
Progress<T>自动将回调调度到创建它的线程(UI 线程)。- 初始化步骤应该支持超时和异常处理,避免无限等待。
- 加载动画做成独立 UserControl,可在多处复用。
项目落地视角
- 初始化步骤拆分为独立方法,方便单独测试和超时控制。
- 为每个初始化步骤添加日志记录。
- 初始化失败时提供明确的错误提示和重试选项。
- 加载动画做成可复用的控件库。
常见误区
- 在 OnStartup 中执行同步耗时操作阻塞启动。
- 启动画面关闭后才初始化服务,导致主窗口加载慢。
- 忘记处理初始化异常,导致应用静默崩溃。
- 启动画面在多显示器环境下位置不居中。
进阶路线
- 实现 SplashScreen 的骨架屏(Skeleton Screen)效果。
- 研究启动性能优化(延迟加载、按需初始化)。
- 实现启动画面到主窗口的 3D 翻转过渡动画。
适用场景
- 应用启动时间超过 1 秒时显示启动画面。
- 需要执行数据库连接、设备初始化等耗时操作。
- 需要展示品牌形象和加载进度。
落地建议
- 启动画面显示时间不超过 5 秒(用户体验最佳实践)。
- 初始化步骤按优先级排序,先显示界面再后台加载。
- 加载动画做成独立控件,方便在多个页面复用。
排错清单
- 确认启动画面在所有分辨率下正确显示。
- 检查初始化步骤的超时设置是否合理。
- 确认 Progress 回调是否在 UI 线程执行。
- 检查启动画面关闭后是否正确释放资源。
复盘问题
- 如果把《启动画面与加载动画》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《启动画面与加载动画》最容易在什么规模、什么边界条件下暴露问题?
- 相比默认实现或替代方案,采用《启动画面与加载动画》最大的收益和代价分别是什么?
