资讯动态

MFC创建一个程序启动画面:从零实现带进度条的Splash Window

发布时间:2026/10/9 19:26:28 来源:尧图企业网站定制
1. 为什么老 MFC 程序需要一个像样的启动画面如果你维护过十年以上的 MFC 工程大概率见过这种场景双击 exe 之后主窗口迟迟不出现任务栏里连个影子都没有用户以为程序没启动又点了一次结果开了两个实例。尤其是那些在InitInstance里要读配置、连数据库、加载几万行列表数据的程序冷启动三到五秒很正常。这时候一个带进度条的启动画面Splash Window就不是锦上添花而是刚需。启动画面本质上是一个无边框的弹出窗口在InitInstance真正创建主框架之前先显示出来配合定时器或者初始化进度回调让用户知道程序在干活。它和主窗口是两条独立的窗口生命周期Splash 先创建、先显示主窗口初始化完成后再把它销毁。MFC 里没有现成的 Splash 控件得自己从CWnd派生一个类手动处理位图绘制和进度条。这篇文章面向的是还在用 VS2010 到 VS2022 维护 MFC 桌面项目的开发者。我会从对话框资源创建讲起给出可直接复制的CSplashWnd类代码、定时器控制显示时长的写法以及进度条和真实初始化步骤联动的验证方法。整套流程在 Unicode 和多字节字符集下都验证过你照着改资源 ID 就能落地。需要说明的是启动画面解决的是感知性能不是真实性能。如果你的初始化逻辑本身要十秒Splash 只能让这十秒不那么难熬不能把它变成两秒。所以后面我会顺带讲怎么把耗时的初始化拆成可汇报进度的步骤让进度条不是摆设。2. 动手前的准备资源、类向导与 TaoToken 辅助排查先说工程侧的准备。打开你的 MFC 项目在资源视图里右键Dialog节点插入一个新的 Dialog 资源ID 改成IDD_SPLASH。把它的属性调成Border 设为NoneTitle Bar 设为FalseStyle 保持Popup这样它天生就是无边框弹窗。然后在对话框上拖一个Picture ControlType 选BitmapID 设为IDC_SPLASH_BMP再拖一个Progress ControlID 设为IDC_SPLASH_PROGRESS。位图资源自己导入一张比如 480x320 的 PNG 转成 BMPID 用IDB_SPLASH。这里有个新手常踩的坑MFC 的 Picture Control 加载位图时如果 BMP 是 24 位真彩色且没有调色板显示通常没问题但如果你用的是带透明通道的 PNG 直接改后缀会加载失败。老老实实用画图或 PS 导出 24 位 BMP。接下来是类。用类向导给IDD_SPLASH添加一个类基类选CWnd而不是默认的CDialogEx类名CSplashWnd。为什么不用 CDialog因为我们要精确控制PreCreateWindow里的窗口样式CWnd派生更干净少一层对话框消息处理。添加完之后头文件里补上成员// SplashWnd.h class CSplashWnd : public CWnd { DECLARE_DYNAMIC(CSplashWnd) public: CSplashWnd(); virtual ~CSplashWnd(); BOOL Create(CWnd* pParent NULL); void SetProgress(int nPercent); // 0-100 protected: CBitmap m_bmpBack; BITMAP m_bmInfo; CProgressCtrl m_progress; CFont m_fontTip; virtual BOOL PreCreateWindow(CREATESTRUCT cs); afx_msg int OnCreate(LPCREATESTRUCT lpCreateStruct); afx_msg void OnPaint(); afx_msg void OnTimer(UINT_PTR nIDEvent); DECLARE_MESSAGE_MAP() };关于 TaoToken它在整个流程里扮演的是辅助排查的角色不是必需依赖。比如你写OnPaint时对BitBlt的参数顺序记不清或者CreateCompatibleDC返回空不知道哪一步错了可以把报错和上下文贴到模型对话里让它帮你定位。它的 API 地址是https://taotoken.net/api兼容常见的对话补全格式你在自己的调试工具里配好 Base URL 和 Key 就能用。我一般是在遇到CreateCompatibleDC失败、SelectObject返回 NULL 这类 GDI 问题时把代码片段发过去问比翻 MSDN 快。如果你还没配过去控制台建一个 Key接入文档里有 curl 和 Python 的最小示例照着跑通一次就行。需要提醒的是TaoToken 只是帮你查资料、看报错的工具别把它当成运行时依赖塞进 MFC 工程里。启动画面本身是纯本地 GDI 绘制不联网。3. 可复制的 CSplashWnd 实现与定时器配置这一节是核心代码可以直接抄。先看PreCreateWindow和OnCreate// SplashWnd.cpp IMPLEMENT_DYNAMIC(CSplashWnd, CWnd) CSplashWnd::CSplashWnd() {} CSplashWnd::~CSplashWnd() {} BOOL CSplashWnd::PreCreateWindow(CREATESTRUCT cs) { if (!CWnd::PreCreateWindow(cs)) return FALSE; cs.style WS_POPUP; // 无边框弹出窗 cs.dwExStyle ~WS_EX_CLIENTEDGE; return TRUE; } int CSplashWnd::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CWnd::OnCreate(lpCreateStruct) -1) return -1; if (!m_bmpBack.LoadBitmap(IDB_SPLASH)) return -1; m_bmpBack.GetObject(sizeof(BITMAP), m_bmInfo); // 创建进度条子控件 CRect rcClient; GetClientRect(rcClient); int nBarH 14; m_progress.Create(WS_CHILD | WS_VISIBLE | PBS_SMOOTH, CRect(20, m_bmInfo.bmHeight - 40, m_bmInfo.bmWidth - 20, m_bmInfo.bmHeight - 40 nBarH), this, IDC_SPLASH_PROGRESS); m_progress.SetRange(0, 100); m_progress.SetPos(0); m_fontTip.CreatePointFont(110, _T(微软雅黑)); return 0; }注意进度条的位置是用位图高度算出来的这样换不同尺寸的启动图不用改坐标。PBS_SMOOTH让进度条是平滑填充而不是一格一格。OnPaint负责把位图画上去再叠一行提示文字void CSplashWnd::OnPaint() { CPaintDC dc(this); CDC dcMem; if (!dcMem.CreateCompatibleDC(dc)) return; CBitmap* pOld dcMem.SelectObject(m_bmpBack); dc.BitBlt(0, 0, m_bmInfo.bmWidth, m_bmInfo.bmHeight, dcMem, 0, 0, SRCCOPY); dc.SelectObject(m_fontTip); dc.SetBkMode(TRANSPARENT); dc.SetTextColor(RGB(255, 255, 0)); dc.TextOut(20, m_bmInfo.bmHeight - 62, _T(正在初始化请稍候...)); dcMem.SelectObject(pOld); // 恢复避免 GDI 对象泄漏 }最后那行dcMem.SelectObject(pOld)就是原帖作者没搞懂的地方。它的作用是把内存 DC 里原来选中的位图换回去。每个 DC 创建时默认选入一张 1x1 的单色位图你SelectObject(m_bmpBack)之后那张默认位图就被挤出来了。如果不换回去等dcMem析构时m_bmpBack还挂在 DC 上而m_bmpBack是成员变量、生命周期比 DC 长这本身不致命但如果你在别处重复 Select 同一个位图到多个 DC或者位图先于 DC 销毁就会出问题。养成选入什么就换出什么的习惯是 GDI 编程的基本功。SetProgress和定时器void CSplashWnd::SetProgress(int nPercent) { if (::IsWindow(m_progress.GetSafeHwnd())) m_progress.SetPos(nPercent); } void CSplashWnd::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1) { // 兜底最长显示 5 秒后自动关闭 KillTimer(1); DestroyWindow(); } CWnd::OnTimer(nIDEvent); }创建和显示 Splash 的入口放在InitInstance里主窗口创建之前BOOL CMyApp::InitInstance() { CWinApp::InitInstance(); CSplashWnd splash; CString strClass AfxRegisterWndClass( CS_HREDRAW | CS_VREDRAW, AfxGetApp()-LoadCursor(IDC_WAIT), (HBRUSH)(COLOR_WINDOW 1), NULL); splash.CreateEx(WS_EX_TOPMOST, strClass, _T(Splash), WS_POPUP, CRect(0, 0, 0, 0), NULL, NULL); splash.SetWindowPos(NULL, 0, 0, splash.m_bmInfo.bmWidth, splash.m_bmInfo.bmHeight, SWP_NOMOVE | SWP_NOZORDER); splash.CenterWindow(); splash.ShowWindow(SW_SHOW); splash.UpdateWindow(); // 立即触发一次 WM_PAINT splash.SetTimer(1, 5000, NULL); // 5 秒兜底 // 这里插入你的真实初始化并调用 splash.SetProgress(...) DoHeavyInit(splash); splash.DestroyWindow(); // ... 创建主框架 ... }UpdateWindow()这行很关键。ShowWindow只是把窗口标记为可见真正的WM_PAINT要等消息队列空闲才处理。如果紧接着就跑一个三秒的初始化循环消息泵没机会转用户看到的是一片白。手动UpdateWindow强制立刻重绘一次位图才会马上出现。4. 验证请求与成功结果进度条联动初始化光有画面不够进度条得真的动。做法是把耗时初始化拆成若干步每步完成后调一次SetProgress并且每步之间让消息泵转一下否则进度条不会刷新。写一个辅助函数static void PumpMessages() { MSG msg; while (::PeekMessage(msg, NULL, 0, 0, PM_REMOVE)) { if (msg.message WM_QUIT) break; ::TranslateMessage(msg); ::DispatchMessage(msg); } } void CMyApp::DoHeavyInit(CSplashWnd* pSplash) { struct Step { LPCTSTR name; int percent; }; Step steps[] { { _T(加载配置), 15 }, { _T(初始化数据库), 40 }, { _T(读取用户数据), 70 }, { _T(构建界面缓存), 90 }, { _T(完成), 100 }, }; for (int i 0; i _countof(steps); i) { // 真实工作放这里示例用 Sleep 模拟 ::Sleep(400); if (pSplash) pSplash-SetProgress(steps[i].percent); PumpMessages(); // 让进度条重绘 } }验证成功的标准很直观程序启动后启动画面立刻出现位图完整显示黄色提示文字在底部进度条从 0 平滑走到 100然后启动画面消失、主窗口出现。整个过程没有白屏、没有未响应。如果你想更严谨地验证可以在SetProgress里加一行TRACE(_T(progress%d\n), nPercent);在 VS 输出窗口看进度是否按预期递增。另外用 Spy 观察窗口句柄确认 Splash 窗口在主框架创建前存在、之后被销毁没有残留。一个容易忽略的点PumpMessages里如果收到WM_QUIT要 break否则用户在启动过程中点了关闭消息会被吞掉。还有如果你的初始化里创建了模态对话框消息泵会嵌套进度条可能卡住这种情况建议把模态操作挪到主窗口显示之后再做。实测下来把Sleep(400)换成真实的数据库连接和文件读取进度条联动的效果是可信的。关键是每一步的粒度别太粗一步干两秒进度条不动用户照样以为卡死。5. 本篇常见报错排查从 401 到 GDI 资源泄漏启动画面本身不联网但如果你在排查过程中用 TaoToken 的 API 做辅助可能会遇到几类典型报错这里一并说清楚。第一类是401 Unauthorized。这通常出现在你调https://taotoken.net/api的时候原因是请求头里的Authorization: Bearer key没带、带错或者 Key 已经被删除。检查方法去控制台的 API Keys 页面确认 Key 还在、复制完整注意别把首尾空格带进去。如果你用的是某个客户端确认 Base URL 填的是https://taotoken.net/api而不是带路径的完整 endpoint很多客户端会自己拼/v1/chat/completions。第二类是local proxy failed或连接被拒。这多半是你本地配了某个转发工具端口没起来或者配置指向了不存在的地址。把客户端的代理设置清空直连https://taotoken.net/api再试。注意这里说的是客户端自身的网络配置不是让你去搞什么网络工具就是检查一下有没有多余的本地转发项。第三类是解析响应时报reading choices或choices field missing。这说明返回的 JSON 结构和客户端预期的不一致常见于你把非对话补全的响应喂给了只认choices的解析器。用 curl 直接打一次看返回体里到底有没有choices数组curl https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_KEY \ -H Content-Type: application/json \ -d {model:claude-sonnet-4-5,messages:[{role:user,content:hi}]}如果返回正常但客户端还报错那就是客户端版本太老升级或换一个。第四类是 OAuth 相关的报错比如OAuth token expired。如果你用的是 Claude Code 这类工具它可能走的是 OAuth 流程而不是纯 API Key。这种情况去对应的凭证管理页面重新授权或者改用 API Key 方式接入。Claude Code 的接入配置里Base URL、Key、Model ID 三件套要写全缺一个都会报错。回到 MFC 本身最常见的报错其实是 GDI 资源泄漏导致的界面异常。症状是启动画面显示几次之后位图变成黑块或者程序直接崩。原因就是SelectObject之后没换回原对象或者CreateCompatibleDC创建的 DC 没DeleteDC。用任务管理器的 GDI 对象列观察正常程序这个数应该稳定如果每次启动都涨几十就是泄漏了。把OnPaint里的pOld换回、把临时 DC 及时释放问题就消失。还有一类是位图加载失败LoadBitmap返回 FALSE。检查资源 ID 是否拼错、BMP 是否是 MFC 支持的格式。用GetLastError()看具体错误码ERROR_RESOURCE_TYPE_NOT_FOUND就是 ID 对不上。6. 把启动画面接进你的工程下一步怎么做到这里一个带进度条的 MFC 启动画面已经能跑起来了。落地的时候有几个工程化建议。第一把CSplashWnd单独放一对 .h/.cpp别塞进主对话框文件方便复用。第二DoHeavyInit里的步骤数组最好和真实的初始化函数一一对应别为了好看硬凑进度。第三如果程序支持多语言提示文字走字符串表别硬编码。如果你在接入过程中需要查 API 用法、看报错含义TaoToken 的模型对话入口可以当随身文档用把代码和错误贴进去问就行。要长期在编码和 Agent 场景里用它Coding Plan 比按次调用更划算适合天天写 MFC 的人。Key 的管理在控制台接入细节看文档这两处配好之后剩下的就是把你自己的初始化逻辑填进DoHeavyInit。最后留一个实用技巧启动画面的显示时长不要写死。用GetTickCount记录开始时间在DoHeavyInit每步之后判断如果总耗时已经超过 800ms 就继续显示否则直接跳过 Splash 创建主窗口。这样在快机器上用户几乎看不到闪屏慢机器上又能起到安抚作用。这个判断放在InitInstance创建 Splash 之前一行if的事体验差别很大。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑