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 设为None,Title Bar 设为False,Style 保持Popup,这样它天生就是无边框弹窗。然后在对话框上拖一个Picture Control,Type 选Bitmap,ID 设为IDC_SPLASH_BMP;再拖一个Progress Control,ID 设为IDC_SPLASH_PROGRESS。位图资源自己导入一张,比如 480x320 的 PNG 转成 BMP,ID 用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的事,体验差别很大。