C#自制启动界面:GDI+实现半透明窗体、圆角进度条与旋转动画

发布时间:2026/9/7 8:19:59
C#自制启动界面:GDI+实现半透明窗体、圆角进度条与旋转动画 简介C# 自制精美程序启动界面压缩包提供了一套完整的 WinForms 启动窗口示例工程适合 C# 初学者以及想让自有桌面程序更有质感的开发者。示例围绕启动画面实现展开涵盖背景图片、进度条、文本标签等界面元素并给出 Timer 配合 AlphaBlend 实现渐显渐隐、BackgroundWorker 在后台加载资源并更新进度、加载完成后自动切换主窗体等关键代码最终形成一个可直接运行的 CSWinFormSplashScreen 解决方案。压缩包共 38 个文件大小仅 525KB包含 SplashScreen1.cs、SplashScreen2.cs、MainForm.cs 等多个窗体源码以及 .resx/.resources 资源文件、.exe 可执行程序、.png/.jpg 图片素材和 .sln/.csproj 工程文件目录结构清晰便于用 Visual Studio 直接打开分析与复用。当前已有 413 人学习下载对于需要快速给 C# 项目增加启动动画或学习 WinForms 界面交互的开发者来说是一份小而实用的参考代码包。 先说说我的习惯用C#写上位机也好做桌面工具也罢很多开发者会把精力全放在通讯、协议、数据处理上启动界面基本是随手一个默认窗体拖个Label就完事。但如果你做的程序要交付给现场操作员、要展示给客户或者你自己就是个对细节敏感的人启动界面其实非常值得较真。用户第一眼看到的不是你的串口收发有多稳而是这个界面有没有质感。这篇就聊聊我用C#自制程序启动界面的完整思路不依赖第三方皮肤库纯GDI绘制包含半透明窗体、圆角进度条、旋转Logo动画和后台数据加载适合有WinForms基础、想提升程序质感的开发者参考。1. 启动界面真的值得自己画吗先回答一个最常见的问题为什么不用现成库市面上的界面库确实很多有的甚至能一键生成闪屏但放在启动界面这个场景里引入一套几百MB的依赖库明显是杀鸡用牛刀。启动界面要的不过是一张好看的图和一根会动的进度条用GDI手绘完全够用而且有几个额外好处。第一是体积和启动速度。我自己做过对比同一个项目用第三方控件库和纯WinForms自绘启动项体积差了几十倍冷启动时间差了将近一秒。上位机程序很看中“点到就能开”一秒的差距在车间里就是实打实的效率。第二是可控性。自绘的每一个像素都是自己算出来的进度条想做成圆角还是渐变、Logo想转多快、文字想怎么浮现全部是代码说了算不用去翻控件文档找属性。第三是维护成本。项目交付之后现场提了个修改意见把启动背景换成厂区照片进度条颜色从蓝色改成企业VI色。用自绘方案改一个资源文件、改几个Brush参数就能交付连重新编译发布都很快。那具体要做一个什么效果呢我的目标很明确窗体无边框、整体半透明背景用一张位图铺底中间放一个带动画旋转的Logo标志底部是一条带圆角和渐变效果的进度条进度条上方显示版本号和版权信息同时程序在后台悄悄完成配置读取、硬件连接这些耗时操作。整个启动过程控制在3秒到5秒到达100%后平滑过渡到主界面。听起来不算复杂但真正动手时会发现窗体透明、绘制效率、多线程进度同步这几个点每个都有坑。下面按模块一步步拆。2. 窗体层半透明无边框窗体的核心实现启动界面的窗体不能是普通的矩形白底第一步就是要让窗体“消失”只留下我们画的内容。WinForms里做透明窗体有不少种手段我常用的组合是这样的。2.1 窗体属性和基础参数把Splash窗口的以下属性设置好这是所有效果的地基public partial class SplashForm : Form { public SplashForm() { InitializeComponent(); FormBorderStyle FormBorderStyle.None; // 去掉系统边框 StartPosition FormStartPosition.CenterScreen; // 默认居中 ShowInTaskbar false; // 不让启动页占任务栏 DoubleBuffered true; // 控件内层双缓冲 BackgroundImage Properties.Resources.SplashBg; BackgroundImageLayout ImageLayout.Stretch; ClientSize new Size(720, 420); // 设计分辨率 AutoScaleMode AutoScaleMode.Dpi; // 适配高DPI } }注意DoubleBuffered和AutoScaleMode这两个属性前者直接影响绘制闪烁后者直接影响高分屏下的模糊程度。很多初学者只问为什么进度条一帧一帧地闪其实就是因为没开双缓冲。这个后面在绘制层还会细说。真正让窗体“透明”有三个思路一是Opacity属性整体调节透明度所有控件和绘制内容都会一起变淡二是TransparencyKey指定一个颜色凡是这种颜色的区域会变成真透明可以直接允许桌面背景透过来三是调用UpdateLayeredWindow做分层窗口。启动界面场景里我推荐Opacity加自绘背景配合使用。背景图本身有透明度层次再用Opacity做整体淡入淡出动画过渡非常顺手。2.2 透明通道上的细节这里有个很容易被忽略的问题如果背景图是一张带圆角的PNG而窗体本身是矩形四个角会露出默认的窗体底色。处理办法是把窗体BackColor设成一个基本不会用到的颜色然后把这个颜色设为TransparencyKey。实操中我用的是Color.Magenta粉得那么彻底的绿色界面里几乎不可能用到。BackColor Color.Magenta; TransparencyKey Color.Magenta;设置后整个窗体层中所有被Magenta覆盖的区域都会变成真透明让桌面背景透出来这样一来无论背景图四个角怎么圆、怎么不规则都不会露出难看的底色。绘制的时候只要保证没有用Magenta当绘图色就行否则会被“挖”掉一块这种低级事故我在调试时确实踩过一次。另外ShowInTaskbar false看似小巧但一定要记得加。启动界面的生命周期很短如果它蹦到任务栏里又消失会在用户心里留下一种“程序卡死”的印象尤其在生产环境很多操作员就是通过任务栏判断程序是否正常的。2.3 多显示器和分辨率适配StartPosition.CenterScreen在只有一个屏幕时没问题但如果现场接了扩展屏或工控机用的是竖屏居中位置可能不是你想要的。我写项目时通常会再手动确认一下取主屏幕工作区来定位if (Screen.PrimaryScreen ! null) { Rectangle area Screen.PrimaryScreen.WorkingArea; Location new Point(area.X (area.Width - Width) / 2, area.Y (area.Height - Height) / 2); }这样程序无论接到哪台设备上启动页都会出现在主屏幕正中央不会被任务栏或副屏带偏。这个细节在现场部署时非常有用很多工控机插着触摸屏和显示器两个屏默认居中可能会落到奇怪的位置。3. 绘制层让进度条不再是丑矩形窗体搭好了接下来是重头戏用GDI把启动界面画出来。我选择在OnPaint事件里统一绘制而不是堆一堆WinForms控件原因很简单启动界面的刷新频率高控件多了每帧都要触发多次布局更新既卡又不灵活。统一自绘的话一次Invalidate()就搞定全画面。3.1 圆角进度条怎么来的系统自带的ProgressBar是直角矩形难看不说还无法和半透明背景融合。自绘进度条的底层逻辑也不复杂先画一个圆角矩形的底槽再按进度值在底槽内画一个圆角矩形的高亮区域。protected override void OnPaint(PaintEventArgs e) { Graphics g e.Graphics; g.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; g.InterpolationMode System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic; g.PixOffsetMode System.Drawing.Drawing2D.PixelOffsetMode.HighQuality; // 底槽区域 Rectangle barRect new Rectangle(60, 330, 600, 12); using (GraphicsPath path RoundedRect(barRect, 6)) { g.FillPath(new SolidBrush(Color.FromArgb(60, 255, 255, 255)), path); } // 当前进度区域宽度按比例计算 float ratio _progress / 100f; int fillWidth (int)(barRect.Width * ratio); if (fillWidth 0) { Rectangle fillRect new Rectangle(barRect.X, barRect.Y, fillWidth, barRect.Height); using (GraphicsPath fillPath RoundedRect(fillRect, 6)) using (LinearGradientBrush brush new LinearGradientBrush( fillRect, Color.FromArgb(255, 0, 158, 255), Color.FromArgb(255, 0, 224, 255), 0f)) { g.FillPath(brush, fillPath); } } base.OnPaint(e); }你可以看到渐变用的是LinearGradientBrush从深蓝到亮蓝视觉上有一种“发光填充”的效果。配合半透明白色底槽整根进度条看起来就像嵌在背景里一样。这里要说一个关键点进度条右侧的圆角在填充到一半时如果简单画一个圆角矩形会出现右侧弧线被“切掉”的观感。所以填充分段里用的RoundedRect函数要处理宽度小于高度的边界情况宽度极小的时候直接用普通矩形代替圆角否则会出现奇形怪状的圆弧。3.2 RoundedRect路径函数RoundedRect这种通用函数我直接放在项目里用以后任何窗体都能复用。思路是用GraphicsPath依次添加四段圆弧和四条边线private GraphicsPath RoundedRect(Rectangle bounds, int radius) { int d radius * 2; GraphicsPath path new GraphicsPath(); path.AddArc(bounds.X, bounds.Y, d, d, 180, 90); path.AddArc(bounds.Right - d, bounds.Y, d, d, 270, 90); path.AddArc(bounds.Right - d, bounds.Bottom - d, d, d, 0, 90); path.AddArc(bounds.X, bounds.Bottom - d, d, d, 90, 90); path.CloseFigure(); return path; }这个方法名叫RoundedRect里面没有直接调用CloseAllFigures而是用CloseFigure把首尾连接起来这样路径是闭合的FillPath才能正确填充。还有个小细节在循环里调用这个方法时用完的GraphicsPath必须Dispose否则高频绘制下GDI句柄会泄漏程序运行一会儿就开始卡顿。我第一次做这个功能时没有注意启动页跑了几十次后直接报内存不足排查了半天才意识到是GDI对象没释放。3.3 双缓冲与DPI的配合自绘界面的闪屏问题根因是GDI在绘制过程中系统默认先擦掉旧画面再画新画面这中间存在一个空白帧。解决办法就是双缓冲先在内存里建一块画布画完一整帧后再一次性复制到屏幕上。WinForms里最简单的办法是构造时设DoubleBuffered true或者更彻底一点SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true);OptimizedDoubleBuffer会告诉系统把绘制内容缓冲到后台避免闪烁。AllPaintingInWmPaint的作用则是让系统在WM_PAINT消息处理时不单独擦背景减少闪烁源。这三个标志位配合起来是自绘控件不闪的标准做法。DPI适配则是另一个容易被现场环境打脸的点。你的开发电脑可能是100%缩放但现场工控机可能是125%或150%Windows会对应用做位图拉伸结果启动页整体变糊文字边缘像蒙了一层纱。我现在的做法是窗体设为AutoScaleMode.Dpi所有坐标不要写死像素而是按设计宽度720进行比例换算。比如进度条横坐标x bounds.Width * 0.083f这样不同分辨率下布局是等比缩放不会被系统拉伸搞糊。4. 动画层定时器驱动的旋转Logo与平滑进度静态的启动界面只能算好看加一点动画才会有“高级感”。这个项目里我做了两处动画一是Logo绕中心缓慢旋转二是进度条数值平滑滚动。二者都由同一个System.Windows.Forms.Timer驱动。4.1 为什么用Timer而不是后台线程Sleep很多新人会写一个while循环里面Thread.Sleep(30)然后去刷新界面这其实是个大坑。WinForms界面的所有绘制都必须在UI线程消息循环里完成如果你在另一个线程里直接改UI状态要么跨线程异常要么界面极其不稳定。Timer的优势在于它本身就是消息循环的一部分Tick事件会安全地在UI线程触发不需要手动处理跨线程调度很适合做界面动画。_timer.Interval 30; // 约33fps _timer.Tick (s, e) { _rotateAngle (_rotateAngle 2f) % 360f; _currentProgress Lerp(_currentProgress, _targetProgress, 0.08f); Invalidate(); }; _timer.Start();30毫秒一帧的刷新率人眼看起来就是流畅动画CPU占用率也很低。做启动界面这种短生命周期的动画不必盲目追求60fps反而会增加开机时和其他启动任务抢CPU的几率。4.2 旋转Logo的仿射变换Logo旋转用GDI的仿射变换实现核心是TranslateTransform加RotateTransform。具体步骤是先把坐标原点平移到Logo中心然后旋转角度再画图最后还原坐标系private void DrawLogo(Graphics g) { const float logoSize 80f; float cx ClientSize.Width / 2f; float cy 150f; var oldState g.Save(); g.TranslateTransform(cx, cy); g.RotateTransform(_rotateAngle); g.TranslateTransform(-logoSize / 2f, -logoSize / 2f); var logoRect new RectangleF(0, 0, logoSize, logoSize); g.DrawImage(Properties.Resources.Logo, logoRect); g.Restore(oldState); }使用Save和Restore很重要如果不恢复坐标系后面的进度条和文字绘制都会跟着旋转和偏移。另外旋转是以TranslateTransform(cx, cy)为轴心进行的所以先平移到中心、旋转、再平移回左上角这样才能让图片绕自己的中心旋转而不是绕窗体左上角旋转。4.3 进度条缓动效果进度条如果直接跳跃到目标值会显得机械。我给进度加了一个插值过程_currentProgress每帧向_targetProgress靠近而不是直接等于。Lerp的实现很朴素private float Lerp(float a, float b, float t) { return a (b - a) * t; }每次Tick时当前进度往目标进度走8%的差额视觉上进度条先快后慢有一种“被吸入”的阻尼感。这种效果比分段跳变自然得多而且因为是参数化控制想调整灵敏度只改一个系数即可。需要注意的是百分比文字应该显示_currentProgress否则会出现进度条还没到头、数字已经到100%的尴尬。5. 数据加载层后台任务与跨线程更新的正确姿势启动界面不只是好看它还有个非常实际的功能掩护程序初始化。配置文件读取、数据库连接、PLC通信握手、插件加载这些操作放在UI线程里会让启动界面卡死进度条也会一抖一抖。正确做法是丢到后台线程用BackgroundWorker调度。5.1 初始化任务的拆分与进度上报BackgroundWorker是WinForms里最简单可靠的后台任务组件。它有三个核心事件DoWork执行耗时逻辑ProgressChanged汇报进度RunWorkerCompleted通知结束。我在Form_Load里把它串起来_backgroundWorker new BackgroundWorker { WorkerReportsProgress true, WorkerSupportsCancellation false }; _backgroundWorker.DoWork (s, e) { // 任务1读取配置文件 ConfigManager.Load(); _backgroundWorker.ReportProgress(20, 读取配置文件); // 任务2初始化PLC通信 PlcService.Connect(); _backgroundWorker.ReportProgress(50, 连接PLC); // 任务3加载主窗体所需的数据表 DataCache.Preload(); _backgroundWorker.ReportProgress(90, 加载数据缓存); // 任务4完成 _backgroundWorker.ReportProgress(100, 准备完成); }; _backgroundWorker.ProgressChanged (s, e) { _targetProgress e.ProgressPercentage; _statusText e.UserState as string ?? ; }; _backgroundWorker.RunWorkerCompleted (s, e) { if (e.Error ! null) { LogHelper.WriteException(e.Error); MessageBox.Show(程序初始化失败请查看日志: e.Error.Message); Application.Exit(); return; } OpenMainForm(); }; _backgroundWorker.RunWorkerAsync();ReportProgress的第二个参数可以携带业务状态我用它来传递当前正在执行的初始化任务描述然后显示在进度条上方。这样操作员能看到软件不是在白屏发呆而是在执行具体的启动步骤对排查问题也很有帮助。5.2 跨线程更新的安全红线BackgroundWorker的设计初衷就是帮你屏蔽跨线程地狱你只需要在DoWork里调ReportProgress系统会封送调用到UI线程触发ProgressChanged这时候才可以安全地修改UI控件的值。如果你在DoWork里直接访问进度条、Label轻则报“线程间操作无效”重则导致界面状态错乱。我在一开始不熟悉时会出于方便直接在后台线程里改Text后来被各种诡异闪烁教育了才老老实实遵守这条红线。WorkerReportsProgress true一定不能漏漏掉的话ReportProgress调用会被忽略。这个属性初学者很容易踩。还有一点BackgroundWorker在关闭启动窗体时如果有线程还没跑完可能会抛异常所以我在FormClosing事件里做了一个简单的状态判断确认任务已完成后才允许关闭。5.3 任务失败时的降级处理现场环境比开发环境恶劣得多PLC可能没上电数据库可能连不上配置文件可能被手滑改乱。如果这些异常直接抛出来启动界面就会卡死在半途用户只能强杀进程这是我最不想看到的场景。我的处理方式是在每个初始化任务里都用try-catch捕获把异常信息汇总到Liststring中最后在RunWorkerCompleted里检查有没有错误记录有的话弹一次明确的错误提示然后退出进程。不要把所有初始化都放到一个try-catch块里因为第一项失败就直接跳过了后续项你看到的错误永远只有最肤浅的那个。更合理的做法是逐项捕获尽量多收集几类错误一次反馈给用户。这个习惯帮我在现场少跑了好几趟。6. 把启动界面接进主程序前面所有代码都是启动窗体内部的工作最后还有两步至关重要一是让程序先启动启动页、再启动主界面二是打包发布。6.1 Program.Main里的启动编排WinForms默认生成的Program.cs里只有一个Application.Run(new MainForm())如果直接换成Application.Run(new SplashForm())主窗体的生命周期会乱。我通常这样写[STAThread] static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.SetHighDpiMode(HighDpiMode.SystemAware); using (SplashForm splash new SplashForm()) { Application.Run(splash); if (splash.IsInitFailed) return; Application.Run(new MainForm()); } }启动窗体在RunWorkerCompleted里执行this.Close()Application.Run(splash)会退出消息循环然后程序判断初始化状态再进入主窗体消息循环。这样两个窗口使用两个独立的消息循环互不干扰又显得很自然。注意一定要在启动窗体结束后再Run主窗体不要在启动页没关时就创建主窗体并在后台让它偷偷显示这是很多界面卡在一起的根源。如果想让两个窗体之间无缝过渡我试过在启动页关闭前把主窗体的Opacity从0快速调到1能做出一个淡入效果。但这个属于锦上添花先把生命周期理顺再说。6.2 发布和运行时的几个细节发布时如果用.NET Framework直接把Release目录打包就行注意目标机器有没有对应框架车间老电脑经常缺运行库建议用.NET 6的框架依赖模式或自包含模式发布。如果用.NET 6可以发布成单文件dotnet publish -c Release -r win-x64 --self-contained false -p:PublishSingleFiletrue自包含模式不带框架要求但体积会比较可观。如果你的启动界面要嵌背景图和Logo记得把图片文件设为Embedded Resource或Content否则换目录运行时背景图丢失窗体一片灰非常难看。现场部署时还要验证杀毒软件是否误杀单文件程序我在交付两个客户时都遇到过安全软件把自包含exe当可疑程序拦掉的情况后来换成框架依赖模式才消停。这种问题在开发环境根本不会暴露但一旦出现就很耽误验收值得提前测试。最后再分享一个我后来才意识到的小技巧启动界面关闭到主窗体打开之间可以保留一个全局的ManualResetEvent或静态标志用来记录初始化数据。比如PLC连接后拿到的设备参数配置里解析出的关键路径都放进静态类里主窗体直接读取避免主窗体再次耗时加载一遍。启动界面的价值不只是“好看”它把那些本来会让用户盯着空白主界面发呆的初始化工作全部转移到了后台并且用动画告诉用户“我还活着”。做好这一步整个程序的交付体验会提升一个档次。本文还有配套的精品资源点击获取

相关新闻