
简介这是一份面向WinForm开发者的启动画面Splash动画源码项目适合希望提升桌面应用启动体验、学习GDI绘图与动画编程的初中级开发者。项目围绕自定义控件绘制、Graphics与Pen/Brush绘图、Timer驱动动画、图像淡入淡出与透明度处理、多线程加载等知识点展开可作为WinForm动画技术的实战参考。压缩包共26个文件约310KB以cs源码文件为主配合resx/resx资源、exe可执行程序、pdb调试符号、txt说明文档及jpg图片素材结构完整便于直接运行与二次修改。已有191人学习关注。通过阅读SplashForm与MainForm相关代码读者可掌握启动画面从绘制、动画驱动到平滑过渡主界面的完整实现思路理解事件驱动与后台线程配合的写法并可将这套动画方案迁移到自己的WinForm项目中快速搭建专业感更强的开机启动效果。1. 从一张“假死”的启动图说起WinFormSplashForm 到底解决什么问题很多 WinForm 项目第一次被用户吐槽不是功能不行而是双击图标之后“半天没反应”。实际上主窗体已经在后台加载数据库连接、读取配置、初始化 Dapper 容器、拉取远程菜单但屏幕上什么都没有Windows 甚至可能给标题栏加上“未响应”。用户此时会再点一次、再点一次于是你收到三条“打不开”的工单。WinFormSplashForm 就是为这个场景存在的在主窗体完成初始化之前先弹一个轻量、无边框、带 winform 动画的启动窗体把“正在加载配置”“正在连接数据库”这类状态用进度条和文字反馈出去让等待变得可见。它适合谁适合所有还在用 WinForm 做内部管理系统、工控上位机、医疗/收银客户端的人。这类项目往往启动时要连数据库、读本地缓存、校验授权耗时从几百毫秒到几秒不等。SplashForm 不是装饰品它是启动阶段的“黑匣子”把原本不可见的初始化过程暴露出来。这一篇就按“先讲清原理和选型再给能直接抄的代码最后说坑”的顺序把 WinFormSplashForm 从零搭到能用。2. WinFormSplashForm 的三种实现路线与选型理由2.1 为什么不用 Thread.Sleep 硬等而要用消息泵新手最容易写出的启动逻辑是这样的在 Program.cs 里先new SplashForm().Show()然后紧接着跑一堆初始化代码最后Application.Run(new MainForm())。跑起来你会发现启动窗体要么白屏要么干脆不显示等主窗体出来时它才闪一下。原因在于 WinForm 是单线程的 STA 模型UI 绘制依赖消息循环你在主线程里连续执行耗时操作消息泵根本没机会处理 WM_PAINT窗体自然画不出来。正确做法有两种。第一种是把初始化放到Application.Run(splashForm)之后用splashForm自己的消息循环驱动绘制初始化完成后关闭 splash 再打开主窗体。第二种是保留Application.Run(mainForm)但在主窗体 Load 之前用ShowDialog显示 splash并在 splash 内部用Application.DoEvents()或后台线程 Invoke 更新进度。前者更干净后者更贴近现有项目改造。我一般推荐第一种因为它不依赖 DoEvents 这种容易引发重入的写法。2.2 三种路线对比Timer 动画、后台线程、异步 Task路线动画驱动方式初始化执行位置适用场景主要风险Timer 动画System.Windows.Forms.Timer 定时刷新主线程分步执行初始化步骤少、耗时短步骤多时仍会卡后台线程独立线程跑初始化Invoke 回 UIThread 或 Task.Run数据库、网络等 IO 密集跨线程访问控件异步 Taskasync/await IProgressTask.Run 内执行现代 C# 项目需注意 UI 上下文Timer 路线适合“加载 3 个配置项”这种轻量场景动画和初始化都在主线程代码最简单。后台线程路线是工控和医疗客户端的常见选择因为初始化里往往有串口枚举、数据库连接这类阻塞操作。异步 Task 路线是 .NET Framework 4.5 之后的主流写法配合IProgressT更新进度代码可读性最好。选哪条取决于你的初始化里有没有真正的 IO 阻塞。2.3 最小可运行骨架Program.cs 的启动顺序先给一个不依赖任何第三方库的最小骨架把启动顺序固定下来。这段代码是整个方案的入口后面所有动画和进度都挂在这上面。// Program.cs using System; using System.Windows.Forms; namespace SplashDemo { internal static class Program { [STAThread] static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); // 1. 先创建启动窗体但不立即 Run using (var splash new SplashForm()) { // 2. 用 splash 自己的消息循环驱动绘制 // Shown 事件里启动初始化完成后关闭自己 Application.Run(splash); } // 3. splash 关闭后再进入主窗体 Application.Run(new MainForm()); } } }逻辑说明Application.Run(splash)会为 splash 建立消息循环这样窗体上的动画 Timer 才有机会触发重绘。初始化任务在SplashForm_Shown里启动完成后调用this.Close()Application.Run返回再进入主窗体。参数上EnableVisualStyles必须在创建任何控件之前调用否则启动窗体的圆角和自绘按钮会退化成经典样式。SetCompatibleTextRenderingDefault(false)让文本用 GDI 渲染中文小字号更清晰。提示如果你的项目已经在Application.Run(new MainForm())里不要硬改成上面的结构可以在 MainForm 构造函数最前面new SplashForm().ShowDialog()但要注意 ShowDialog 会阻塞初始化必须放在 splash 内部完成。3. 把 winform 动画做进 SplashForm自绘、Timer 与进度联动3.1 无边框窗体的圆角与透明背景怎么设SplashForm 的第一印象来自外形。默认的 Form 有标题栏和边框看起来像“另一个窗口”不像启动画面。要做出干净的启动图需要设置FormBorderStyle None、StartPosition CenterScreen、ShowInTaskbar false。圆角则通过Region配合GraphicsPath实现透明背景用TransparencyKey或Opacity。// SplashForm.cs 构造函数片段 public SplashForm() { InitializeComponent(); this.FormBorderStyle FormBorderStyle.None; this.StartPosition FormStartPosition.CenterScreen; this.ShowInTaskbar false; this.DoubleBuffered true; // 减少自绘闪烁 this.BackColor Color.FromArgb(30, 30, 30); // 圆角区域半径 12 using (var path new GraphicsPath()) { int r 12; path.AddArc(0, 0, r, r, 180, 90); path.AddArc(this.Width - r, 0, r, r, 270, 90); path.AddArc(this.Width - r, this.Height - r, r, r, 0, 90); path.AddArc(0, this.Height - r, r, r, 90, 90); path.CloseFigure(); this.Region new Region(path); } }逻辑说明DoubleBuffered true是自绘动画不闪的关键它把绘制放到后台缓冲区再一次性提交。Region用四段圆弧拼出圆角矩形半径 12 是视觉上比较克制的值太大像气泡太小看不出圆角。注意Region在窗体尺寸变化时不会自动更新如果你的 splash 允许缩放需要在Resize事件里重算。3.2 用 Timer 驱动一个旋转 Loading 动画启动画面最常见的动画是旋转的圆环或跳动的点。用System.Windows.Forms.Timer每 30 毫秒改变一次角度在OnPaint里重绘。30 毫秒约等于 33 帧对启动画面足够且不会明显占用 CPU。// SplashForm.cs 动画部分 private readonly Timer _animTimer new Timer(); private float _angle 0f; private void InitAnimation() { _animTimer.Interval 30; // 约 33 FPS _animTimer.Tick (s, e) { _angle (_angle 6f) % 360f; // 每帧转 6 度 this.Invalidate(); // 触发重绘 }; _animTimer.Start(); } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); var g e.Graphics; g.SmoothingMode SmoothingMode.AntiAlias; // 画一个旋转的圆弧 using (var pen new Pen(Color.FromArgb(0, 120, 215), 4f)) { pen.StartCap LineCap.Round; pen.EndCap LineCap.Round; g.DrawArc(pen, 20, 20, 40, 40, _angle, 270); } }逻辑说明Invalidate()只是标记区域无效真正的重绘发生在消息循环空闲时所以 Timer 的 Tick 不会阻塞初始化。_angle每次加 6 度60 帧转一圈视觉上接近匀速。SmoothingMode.AntiAlias让圆弧边缘平滑代价是少量 CPU启动阶段可以接受。如果你的动画更复杂比如播放视频或 GIF建议改用ImageAnimator或 Direct2DTimer 自绘只适合几何图形。3.3 进度条与状态文字让用户知道卡在哪一步动画只是“还活着”的信号真正让用户安心的是进度和文字。进度条可以用自绘也可以用标准ProgressBar。标准控件在深色背景上不好看我一般自绘一个圆角进度条配合Label显示当前步骤。// 更新进度的方法供初始化线程调用 public void ReportProgress(int percent, string message) { if (this.InvokeRequired) { this.Invoke(new Actionint, string(ReportProgress), percent, message); return; } _progress Math.Max(0, Math.Min(100, percent)); _statusText message; this.Invalidate(); // 重绘进度条和文字 } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); // ... 前面的圆弧绘制 ... // 进度条背景 var bgRect new Rectangle(20, 80, 260, 8); using (var bgBrush new SolidBrush(Color.FromArgb(60, 60, 60))) e.Graphics.FillRectangle(bgBrush, bgRect); // 进度条前景 var fgRect new Rectangle(20, 80, (int)(260 * _progress / 100f), 8); using (var fgBrush new SolidBrush(Color.FromArgb(0, 120, 215))) e.Graphics.FillRectangle(fgBrush, fgRect); // 状态文字 using (var font new Font(微软雅黑, 9f)) using (var brush new SolidBrush(Color.FromArgb(200, 200, 200))) e.Graphics.DrawString(_statusText, font, brush, 20, 100); }逻辑说明InvokeRequired判断是跨线程更新的关键。初始化跑在后台线程时直接改_progress会抛跨线程异常必须 Invoke 回 UI 线程。Math.Max/Min把进度钳在 0 到 100防止调用方传错值导致进度条画出边界。文字用DrawString而不是 Label是为了和自绘进度条保持同一套坐标避免布局错位。注意Invoke是同步的如果初始化线程频繁调用 ReportProgress会拖慢初始化。高频更新建议用BeginInvoke或者把进度合并成每 100 毫秒一次。4. 初始化任务怎么拆后台线程、IProgress 与主窗体交接4.1 用 Task.Run IProgress 组织初始化步骤初始化任务通常包含读配置、连数据库、初始化 Dapper、加载菜单、校验授权。把这些步骤写成一个方法用Task.Run丢到后台用IProgressT回报进度。IProgressT的好处是它自动把回调 marshal 回创建它的线程这里是 UI 线程不用自己写 Invoke。// SplashForm.cs 中启动初始化 private async void SplashForm_Shown(object sender, EventArgs e) { var progress new ProgressProgressInfo(p { ReportProgress(p.Percent, p.Message); }); try { await Task.Run(() InitializeApp(progress)); this.DialogResult DialogResult.OK; } catch (Exception ex) { MessageBox.Show(初始化失败 ex.Message); this.DialogResult DialogResult.Abort; } finally { this.Close(); } } private void InitializeApp(IProgressProgressInfo progress) { progress.Report(new ProgressInfo(10, 读取配置文件...)); ConfigManager.Load(); progress.Report(new ProgressInfo(30, 连接数据库...)); DbInitializer.Connect(); progress.Report(new ProgressInfo(60, 初始化依赖注入...)); ServiceLocator.Build(); progress.Report(new ProgressInfo(85, 加载菜单...)); MenuLoader.Load(); progress.Report(new ProgressInfo(100, 启动完成)); } private class ProgressInfo { public int Percent { get; } public string Message { get; } public ProgressInfo(int percent, string message) { Percent percent; Message message; } }逻辑说明ProgressT在构造时捕获当前 SynchronizationContextReport调用会自动 post 回 UI 线程所以ReportProgress里的 InvokeRequired 判断实际上不会触发但保留它作为防御性代码没坏处。Task.Run把整个初始化放到线程池UI 线程只负责绘制。DialogResult用来告诉 Program.cs 初始化是否成功失败时不要进主窗体。4.2 主窗体如何拿到初始化结果初始化产生的对象数据库连接、配置、服务容器需要传给主窗体。常见做法是放在一个静态的AppContext或依赖注入容器里主窗体构造函数从容器取。不要用全局变量满天飞但启动阶段一个轻量的静态入口是务实的。// AppContext.cs public static class AppContext { public static AppConfig Config { get; set; } public static IServiceProvider Services { get; set; } public static bool IsInitialized { get; set; } } // Program.cs 中根据 splash 结果决定是否进主窗体 using (var splash new SplashForm()) { Application.Run(splash); if (splash.DialogResult ! DialogResult.OK) return; // 初始化失败直接退出 } Application.Run(new MainForm());逻辑说明DialogResult是 splash 和 Program 之间的契约OK 表示初始化成功Abort 表示失败。AppContext里的Services可以是一个简单的ServiceCollection.BuildServiceProvider()主窗体通过AppContext.Services.GetServiceT()取依赖。这样主窗体的构造函数保持干净也方便后续替换成完整的 DI 框架。4.3 启动耗时的测量与阈值判断不是所有项目都需要 splash。如果初始化在 200 毫秒内完成加 splash 反而让启动变慢因为多了一次窗体创建和消息循环。判断标准很简单在Main开头记一个Stopwatch在初始化结束时打印耗时。超过 500 毫秒才值得做 splash超过 2 秒必须做并且要有进度反馈。var sw Stopwatch.StartNew(); // ... 初始化 ... sw.Stop(); Debug.WriteLine($初始化耗时{sw.ElapsedMilliseconds} ms);逻辑说明Stopwatch用ElapsedMilliseconds而不是DateTime差值精度更高且不受系统时间调整影响。这个数字应该写进日志方便后续优化。如果发现某一步特别慢比如数据库连接占了 1.5 秒那优化连接字符串比美化 splash 更值得做。5. WinFormSplashForm 避坑与常见问题排查5.1 启动窗体一闪而过或根本不显示现象双击程序后直接进主窗体splash 只闪了一下甚至完全看不到。原因通常是初始化太快或者 splash 的Shown事件里同步执行了耗时操作导致窗体还没来得及绘制就被关闭。解决在Shown里先await Task.Delay(300)给绘制留时间或者把初始化改成异步。如果初始化确实很快那就不需要 splash直接去掉。5.2 跨线程更新进度条抛 InvalidOperationException现象调试时提示“线程间操作无效从不是创建控件的线程访问它”。原因是后台线程直接改了 Label 的 Text 或 ProgressBar 的 Value。解决所有 UI 更新走IProgressT或Control.Invoke。如果用的是BackgroundWorker在ProgressChanged事件里更新那个事件本身就在 UI 线程。5.3 自绘动画闪烁严重现象旋转圆弧或进度条在刷新时明显闪烁。原因是没有开双缓冲或者OnPaintBackground也在绘制背景。解决设置DoubleBuffered true并重写OnPaintBackground为空方法把所有绘制集中到OnPaint。如果用的是 Panel 承载动画同样要设DoubleBufferedPanel 默认不开。5.4 初始化失败后主窗体仍然打开现象数据库连不上splash 报错关闭但主窗体还是弹出来了然后一堆空引用异常。原因是Application.Run(splash)返回后没有检查DialogResult直接进了Application.Run(new MainForm())。解决在 Program.cs 里判断splash.DialogResult ! DialogResult.OK就return并且确保 splash 在异常路径上也设置了DialogResult Abort。5.5 高 DPI 下 splash 变形或模糊现象在 125% 或 150% 缩放的屏幕上启动窗体的圆角和文字位置错乱。原因是 WinForm 默认的 DPI 感知是系统级自绘坐标没有按缩放比例换算。解决在 app.manifest 里开启 PerMonitorV2 DPI 感知并在自绘时用e.Graphics.DpiX / 96f作为缩放因子。如果不想改 manifest至少把AutoScaleMode设为Dpi让控件自动缩放。6. 让 SplashForm 支持换肤与多语言的一个小技巧做到这里SplashForm 已经能跑、能看、能报错。最后一个值得投入的点是换肤和多语言因为内部系统经常要求“同一个程序给不同客户换一套颜色和文案”。我的习惯是把 splash 的所有视觉参数抽到一个SplashTheme类里包括背景色、强调色、字体、圆角半径、动画速度甚至状态文案的 key。这样换肤就是换一个配置对象不用改绘制代码。public class SplashTheme { public Color Background { get; set; } Color.FromArgb(30, 30, 30); public Color Accent { get; set; } Color.FromArgb(0, 120, 215); public Color TextColor { get; set; } Color.FromArgb(200, 200, 200); public string FontFamily { get; set; } 微软雅黑; public float FontSize { get; set; } 9f; public int CornerRadius { get; set; } 12; public int AnimationInterval { get; set; } 30; public float AngleStep { get; set; } 6f; }绘制代码里所有硬编码的颜色和尺寸都改成从_theme取。多语言则把读取配置文件...这类字符串换成资源文件的 keyReportProgress接收 key 而不是最终文案在 UI 线程里查表。这样新增一种语言只需要加一个 resx不用动逻辑。验证换肤是否生效最直接的办法是写一个SplashTheme的单元测试断言CornerRadius改变后Region的边界矩形宽度变化符合预期。不过 WinForm 的Region不好直接断言我一般用截图对比在 CI 里跑一个隐藏的 splash用DrawToBitmap导出图片和基准图做像素差异比较。这个做法有点重但对“换肤后按钮位置错位”这类问题非常有效。最后说一个我踩过的坑曾经把动画 Timer 的 Interval 设成 10 毫秒追求丝滑结果在低配工控机上 CPU 占用冲到 15%启动反而更慢。后来改成 30 毫秒肉眼几乎看不出差别CPU 降到 2% 以下。启动画面的动画克制比炫技重要。希望帮到你。本文还有配套的精品资源点击获取