1. 从Win32 API到MFC:为什么还要学这套"老框架"
很多人第一次接触Windows桌面开发,都是从WinMain、WndProc、RegisterClass这一套Win32 API开始的。窗口类注册、消息循环、窗口过程函数,每一步都要自己写,一个最简单的"Hello World"窗口,代码量轻松超过一百行。写完之后你会发现,光是处理WM_PAINT、WM_DESTROY、WM_SIZE这些基础消息,就已经占了大半篇幅,真正跟业务逻辑相关的代码反而没几行。
MFC(Microsoft Foundation Classes)就是在这个背景下出现的。它本质上是对Win32 API的一套C++封装,把窗口、消息、设备上下文、GDI对象这些底层概念,包装成了CWnd、CWinApp、CDC、CGdiObject等类。你不再需要手动写WndProc里的switch-case,而是通过消息映射宏把消息和成员函数绑定起来。框架帮你处理了消息循环、窗口注册、默认消息响应这些重复劳动,你只需要关注"我的窗口要做什么"。
但这里有个常见的误解:很多人以为学MFC就是学拖控件、学向导生成代码。实际上,MFC的价值在于它展示了一套完整的应用程序框架设计思想——文档/视图架构、运行时类型识别、动态创建、序列化机制。这些设计模式即使放到今天,对理解大型C++框架的组织方式依然很有帮助。你会在很多老牌工业软件、医疗设备上位机、金融交易终端里看到MFC的身影,它们至今仍在稳定运行。
这篇文章面向的是有一定C++基础、想理解Windows桌面程序骨架是怎么搭起来的开发者。我会从应用程序框架的角度切入,把CWinApp、CFrameWnd、CDocument、CView这几个核心类的职责和协作关系讲清楚,再配合实际代码说明消息是怎么流转的。不会停留在"点下一步生成项目"的层面,而是把框架背后的运行机制拆开来看。
提示:如果你完全没接触过Win32 API,建议先花半天时间写一个纯API的窗口程序,理解消息循环和窗口过程的基本概念,再回来看MFC会顺畅很多。
2. MFC应用程序框架的四大核心角色
2.1 CWinApp:整个程序的"发动机"
CWinApp代表一个应用程序对象,全局有且只有一个实例。它继承自CWinThread,所以本身也是一个线程对象。框架的入口点WinMain其实是被MFC自己实现的,它在内部调用了AfxWinMain,然后通过theApp这个全局对象来驱动整个程序。
你写的代码里通常长这样:
class CMyApp : public CWinApp { public: virtual BOOL InitInstance(); }; CMyApp theApp;theApp这个全局变量在main之前就已经构造完成,MFC的入口函数会通过AfxGetApp()拿到它。InitInstance()是你必须重写的虚函数,框架在这里给你一个机会去创建主窗口、初始化数据、显示界面。如果返回FALSE,程序就直接退出,不会进入消息循环。
这里有个容易踩的坑:很多人把耗时的初始化操作(比如读大文件、连数据库)直接写在InitInstance()里,导致程序启动时界面迟迟不出现。正确的做法是先把主窗口显示出来,耗时的初始化放到OnIdle或者单独的线程里去做。InitInstance()的职责应该尽量轻量,只做框架层面的搭建。
CWinApp还管理着消息循环。Run()函数里是一个标准的PeekMessage/DispatchMessage循环,但它比纯Win32多了一层OnIdle处理。当消息队列为空时,框架会调用OnIdle,你可以在这里做空闲时的后台任务。这个机制在早期单线程程序里很有用,现在更多是用工作线程来替代。
2.2 CFrameWnd:主窗口的"骨架"
CFrameWnd是主框架窗口类,它负责创建和管理那个带标题栏、菜单栏、工具栏、状态栏的主窗口。在单文档(SDI)程序里,CFrameWnd直接作为主窗口;在多文档(MDI)程序里,CMDIFrameWnd作为主框架,CMDIChildWnd作为子窗口。
框架窗口的核心职责是管理客户区。客户区里放什么?在文档/视图架构里,放的是视图窗口(CView的派生类)。CFrameWnd通过CreateView在OnCreateClient阶段创建视图,并把视图嵌入到客户区中。视图的大小会随着框架窗口的缩放自动调整,这个是通过WM_SIZE消息和RecalcLayout机制实现的。
BOOL CMainFrame::OnCreateClient(LPCREATESTRUCT lpcs, CCreateContext* pContext) { return m_wndSplitter.Create(this, 2, 2, CSize(100, 100), pContext); }上面这段代码展示了用CSplitterWnd把客户区分成2x2的窗格,每个窗格可以放不同的视图。这是MFC框架很典型的一个用法:框架窗口负责布局,视图负责内容。
CFrameWnd还管理着控制条(CControlBar),包括工具栏(CToolBar)、状态栏(CStatusBar)、对话条(CDialogBar)。这些控制条的位置和显示状态由框架统一调度,你不需要手动去计算它们的坐标。框架会在RecalcLayout里根据控制条的可见性和大小,重新计算客户区的剩余空间。
2.3 CDocument与CView:数据与表现的分离
文档/视图架构是MFC框架里最核心的设计模式。CDocument代表数据模型,CView代表数据的可视化呈现。一个文档可以对应多个视图,比如同一个数据表格,可以同时用表格视图和图表视图来展示。
CDocument的主要职责包括:
- 持有数据成员(你的业务数据放在这里)
- 提供
Serialize()函数用于持久化 - 管理所有关联的视图列表
- 处理"新建""打开""保存""另存为"等命令
CView则负责:
- 在
OnDraw()里绘制内容 - 处理用户输入(鼠标、键盘)
- 通过
GetDocument()获取关联的文档数据
void CMyView::OnDraw(CDC* pDC) { CMyDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); if (!pDoc) return; for (int i = 0; i < pDoc->m_data.GetSize(); i++) { pDC->TextOut(10, 10 + i * 20, pDoc->m_data[i]); } }这种分离带来的好处是:当数据变化时,文档调用UpdateAllViews()通知所有视图刷新;当用户通过视图修改数据时,视图调用GetDocument()->SetModifiedFlag()标记文档已修改。框架会在关闭程序时提示用户保存未保存的修改。
但这里有个实际开发中经常遇到的问题:文档和视图的职责边界容易模糊。有些开发者把大量业务逻辑塞进视图类,导致视图变得臃肿,文档反而成了空壳。我的经验是,凡是跟数据本身相关的操作(增删改查、计算、校验)都放在文档里,视图只负责"把数据画出来"和"把用户操作翻译成对文档的调用"。这样当你要换一种展示方式时,只需要写一个新的视图类,文档完全不用动。
2.4 消息映射:MFC如何把Windows消息路由到你的函数
纯Win32里,消息处理是一个巨大的switch-case:
LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_PAINT: // ... break; case WM_LBUTTONDOWN: // ... break; // 还有几十个case } return DefWindowProc(hWnd, msg, wParam, lParam); }MFC用消息映射宏替代了这个switch-case。你在类声明里写DECLARE_MESSAGE_MAP(),在实现文件里写BEGIN_MESSAGE_MAP和END_MESSAGE_MAP,中间用ON_WM_PAINT()、ON_WM_LBUTTONDOWN()这样的宏把消息和成员函数关联起来。
BEGIN_MESSAGE_MAP(CMyView, CView) ON_WM_PAINT() ON_WM_LBUTTONDOWN() ON_COMMAND(ID_FILE_SAVE, &CMyView::OnFileSave) END_MESSAGE_MAP()框架内部维护了一张消息映射表,当窗口收到消息时,CWnd::WindowProc会调用OnWndMsg,在消息映射表里查找对应的处理函数。如果找到了就调用,找不到就交给基类的消息映射表继续找,最终到CWnd的默认处理。
这套机制的好处是:消息处理函数可以分散在类的继承链上,每个类只处理自己关心的消息,不需要在一个巨大的switch里堆砌。而且消息映射表是静态的,查找效率很高,不会因为消息处理函数多而变慢。
注意:消息映射宏必须写在类的实现文件里,不能写在头文件里。
BEGIN_MESSAGE_MAP的第一个参数是当前类名,第二个参数是基类名,这个顺序不能错,否则消息会路由到错误的基类。
3. 一个最小MFC程序的完整生命周期
3.1 从theApp构造到InitInstance调用
程序启动时,全局对象theApp首先被构造。此时CWinApp的构造函数会做一些初始化工作,比如设置m_hInstance、初始化一些全局状态。但这个时候Windows窗口还没有创建,你也不能做任何跟界面相关的操作。
构造完成后,MFC的入口函数AfxWinMain被调用。它做了几件事:
- 调用
AfxWinInit初始化MFC内部状态 - 调用
theApp->InitApplication()(通常用默认实现) - 调用
theApp->InitInstance(),这是你重写的函数 - 如果
InitInstance返回TRUE,调用theApp->Run()进入消息循环 - 消息循环结束后,调用
theApp->ExitInstance()清理
InitInstance()是整个程序的"总装配线"。在这里你要:
- 创建主框架窗口对象(
new CMainFrame) - 调用
LoadFrame或Create创建实际的Windows窗口 - 设置主窗口指针
m_pMainWnd - 调用
ShowWindow和UpdateWindow显示窗口
BOOL CMyApp::InitInstance() { CWinApp::InitInstance(); CMainFrame* pFrame = new CMainFrame; if (!pFrame->LoadFrame(IDR_MAINFRAME)) return FALSE; m_pMainWnd = pFrame; pFrame->ShowWindow(SW_SHOW); pFrame->UpdateWindow(); return TRUE; }LoadFrame会根据资源ID加载菜单、图标、加速键表等资源,然后创建窗口。这一步如果失败,通常是因为资源ID不对或者资源文件没有正确编译进去。
3.2 消息循环里的PeekMessage与OnIdle
CWinApp::Run()里的消息循环跟纯Win32的GetMessage循环有一个关键区别:它用的是PeekMessage而不是GetMessage。
while (TRUE) { if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) { if (msg.message == WM_QUIT) break; TranslateMessage(&msg); DispatchMessage(&msg); } else { // 消息队列为空,调用OnIdle if (!OnIdle(lCount++)) WaitMessage(); } }PeekMessage不会阻塞,如果没有消息就立即返回FALSE,然后框架调用OnIdle。OnIdle的默认实现会做一些清理工作(比如删除临时对象、更新工具栏按钮状态),你也可以重写它来做后台任务。
但这里有个性能陷阱:不要在OnIdle里做耗时操作。因为OnIdle是在UI线程里执行的,如果它占用太长时间,界面就会卡住。我见过有人在OnIdle里做文件扫描,结果程序一启动就"未响应"。正确的做法是把耗时任务放到工作线程里,通过PostMessage通知UI线程更新。
OnIdle返回TRUE表示还有空闲任务要做,框架会继续调用;返回FALSE表示空闲任务做完了,框架会调用WaitMessage进入等待状态,直到有新消息到来。
3.3 窗口销毁与ExitInstance的清理时机
当用户关闭主窗口时,WM_CLOSE消息被发送到框架窗口。默认处理是调用DestroyWindow,这会触发WM_DESTROY。在WM_DESTROY的默认处理里,框架会调用PostQuitMessage,往消息队列里投递一个WM_QUIT消息。
消息循环收到WM_QUIT后跳出循环,Run()返回。然后AfxWinMain调用ExitInstance(),你可以在这里做清理工作,比如保存配置、释放全局资源。
int CMyApp::ExitInstance() { // 保存窗口位置到注册表或配置文件 SaveWindowPlacement(); return CWinApp::ExitInstance(); }需要注意的是,ExitInstance返回后,theApp对象才会被析构。所以如果你在ExitInstance里访问了某些全局资源,要确保它们在theApp析构之前仍然有效。
还有一个容易忽略的点:如果InitInstance返回FALSE,ExitInstance仍然会被调用。所以不要在ExitInstance里假设InitInstance一定成功执行完了。我通常会在InitInstance里用一个成员变量记录初始化进度,ExitInstance根据这个变量决定清理哪些资源。
4. 文档/视图架构的落地细节与常见误区
4.1 文档模板如何把三者串起来
在SDI或MDI程序里,CSingleDocTemplate或CMultiDocTemplate负责把文档、框架、视图三者关联起来。你在InitInstance里创建文档模板:
CSingleDocTemplate* pDocTemplate = new CSingleDocTemplate( IDR_MAINFRAME, RUNTIME_CLASS(CMyDoc), RUNTIME_CLASS(CMainFrame), RUNTIME_CLASS(CMyView)); AddDocTemplate(pDocTemplate);然后调用ProcessShellCommand处理命令行参数,如果是"新建"就创建新文档,如果是"打开"就加载文件。
文档模板的核心作用是定义了"文档-框架-视图"的三角关系。当用户选择"文件-新建"时,框架通过文档模板创建文档对象、框架窗口、视图窗口,并把它们关联起来。这个过程中,RUNTIME_CLASS宏和DECLARE_DYNCREATE/IMPLEMENT_DYNCREATE宏配合,实现了运行时动态创建。
class CMyDoc : public CDocument { DECLARE_DYNCREATE(CMyDoc) // ... }; IMPLEMENT_DYNCREATE(CMyDoc, CDocument)DECLARE_DYNCREATE声明了动态创建所需的静态成员函数,IMPLEMENT_DYNCREATE实现了它们。如果没有这两个宏,文档模板就无法在运行时创建文档对象。
4.2 Serialize函数与持久化的正确写法
CDocument::Serialize是MFC提供的序列化机制。你可以重写它来实现数据的保存和加载:
void CMyDoc::Serialize(CArchive& ar) { if (ar.IsStoring()) { ar << m_nCount; for (int i = 0; i < m_nCount; i++) ar << m_data[i]; } else { ar >> m_nCount; m_data.SetSize(m_nCount); for (int i = 0; i < m_nCount; i++) ar >> m_data[i]; } }CArchive封装了文件读写,支持基本类型和MFC容器类的序列化。但这里有几个坑:
第一个坑:版本兼容。如果你在后续版本里增加了新的数据成员,旧版本保存的文件在加载时会出错。正确的做法是在序列化开头写一个版本号:
void CMyDoc::Serialize(CArchive& ar) { const int nVersion = 2; if (ar.IsStoring()) { ar << nVersion; ar << m_nCount; // ... } else { int nFileVersion; ar >> nFileVersion; ar >> m_nCount; if (nFileVersion >= 2) ar >> m_newField; // 新版本才有的字段 } }第二个坑:指针序列化。CArchive不能直接序列化指针,因为指针地址在每次运行时都不同。如果你需要保存对象之间的引用关系,要用索引或者ID来代替指针。
第三个坑:大文件性能。CArchive默认使用缓冲,但对于超大文件,频繁的<<和>>操作仍然可能成为瓶颈。我通常会在序列化之前先把数据整理成连续的内存块,然后一次性写入。
4.3 视图刷新:UpdateAllViews与Invalidate的配合
当文档数据变化时,需要通知所有视图刷新。CDocument::UpdateAllViews就是干这个的:
void CMyDoc::AddItem(LPCTSTR szText) { m_data.Add(szText); SetModifiedFlag(TRUE); UpdateAllViews(NULL); }UpdateAllViews会遍历所有关联的视图,调用它们的OnUpdate函数。OnUpdate的默认实现是调用Invalidate,触发WM_PAINT消息,最终调用OnDraw重绘。
但Invalidate有一个问题:它只是标记窗口区域为"脏",实际重绘要等到消息队列空闲时才会发生。如果你在循环里连续调用UpdateAllViews,会导致多次无效的重绘请求。更好的做法是批量修改数据后只调用一次UpdateAllViews。
// 不推荐:每次添加都刷新 for (int i = 0; i < 1000; i++) { pDoc->AddItem(data[i]); // 内部调用了UpdateAllViews } // 推荐:批量添加后统一刷新 pDoc->BeginBulkUpdate(); for (int i = 0; i < 1000; i++) { pDoc->AddItemNoUpdate(data[i]); } pDoc->EndBulkUpdate(); // 这里调用一次UpdateAllViewsOnUpdate的lHint参数可以用来传递额外的信息,告诉视图"是哪部分数据变了",这样视图可以只重绘变化的部分,而不是整个客户区。对于复杂界面,这个优化效果很明显。
4.4 多视图场景下的数据同步问题
当一个文档关联多个视图时,数据同步就变得重要了。比如一个视图修改了数据,其他视图需要及时更新。UpdateAllViews默认会通知所有视图,但有时候你只想通知特定的视图。
// 只通知除了pSender之外的所有视图 UpdateAllViews(pSender, lHint, pHint); // 只通知特定类型的视图 POSITION pos = GetFirstViewPosition(); while (pos != NULL) { CView* pView = GetNextView(pos); if (pView->IsKindOf(RUNTIME_CLASS(CMyChartView))) { pView->OnUpdate(pSender, lHint, pHint); } }这里有个实际开发中的经验:如果多个视图之间需要频繁同步,考虑用一个共享的数据模型。MFC的文档/视图架构里,文档就是那个共享模型。但如果你觉得CDocument太重(比如你不需要序列化功能),也可以自己写一个轻量的数据类,让多个视图持有它的指针。
还有一个常见问题是视图之间的通信。比如点击表格视图的某一行,图表视图要高亮对应的数据点。这种场景下,我通常会在文档里加一个"当前选中项"的成员变量,表格视图修改它之后调用UpdateAllViews,图表视图在OnUpdate里读取这个变量并刷新。
5. 框架扩展:从默认骨架到实际可用的程序
5.1 工具栏、状态栏与菜单的命令路由
MFC的命令路由机制比消息映射更复杂一层。当用户点击菜单项或工具栏按钮时,框架会发送WM_COMMAND消息,然后沿着"视图 -> 文档 -> 框架 -> 应用"的顺序查找命令处理函数。
// 在视图类里处理 ON_COMMAND(ID_EDIT_COPY, &CMyView::OnEditCopy) // 在文档类里处理 ON_COMMAND(ID_FILE_SAVE, &CMyDoc::OnFileSave) // 在框架类里处理 ON_COMMAND(ID_VIEW_TOOLBAR, &CMainFrame::OnViewToolbar)这个路由顺序意味着:如果多个类都处理同一个命令,只有最靠近"视图"的那个会被调用。比如视图和文档都处理ID_FILE_SAVE,那么视图的处理函数会先被调用,文档的不会被执行。
命令UI更新也是类似的机制。ON_UPDATE_COMMAND_UI宏用来控制菜单项和工具栏按钮的启用/禁用状态:
void CMyView::OnUpdateEditCopy(CCmdUI* pCmdUI) { pCmdUI->Enable(!m_selectedText.IsEmpty()); }框架在空闲时会遍历所有命令UI,调用对应的更新函数。这就是为什么工具栏按钮的禁用状态能自动更新的原因。
注意:
ON_UPDATE_COMMAND_UI的处理函数不要做耗时操作,因为它会在每次空闲时被调用。如果状态计算很复杂,考虑加缓存或者用定时器定期更新。
5.2 自定义消息与线程间通信
除了标准的Windows消息,MFC也支持自定义消息。定义自定义消息:
#define WM_MY_CUSTOM_MSG (WM_USER + 100) // 消息映射 ON_MESSAGE(WM_MY_CUSTOM_MSG, &CMyView::OnMyCustomMsg) // 处理函数 LRESULT CMyView::OnMyCustomMsg(WPARAM wParam, LPARAM lParam) { // 处理消息 return 0; }工作线程向UI线程发送消息时,要用PostMessage而不是SendMessage。SendMessage是同步的,会阻塞工作线程直到UI线程处理完消息;PostMessage是异步的,把消息投递到队列就返回。
// 工作线程中 void CWorkerThread::Run() { // 做耗时计算 int nResult = DoHeavyWork(); // 通知UI线程 ::PostMessage(pMainWnd->GetSafeHwnd(), WM_MY_CUSTOM_MSG, (WPARAM)nResult, 0); }这里有个经典陷阱:不要在工作线程里直接访问MFC对象。MFC对象大多不是线程安全的,跨线程访问可能导致断言失败或崩溃。正确的做法是把数据打包成消息参数,通过PostMessage传给UI线程,让UI线程去更新界面。
如果需要在工作线程里调用MFC函数,要确保线程是通过AfxBeginThread创建的,这样MFC会为线程维护一个模块状态(module state)。但即便如此,跨线程访问同一个MFC对象仍然是不安全的。
5.3 对话框与模态/非模态的选择
MFC里对话框分模态和非模态两种。模态对话框用DoModal(),会阻塞调用者直到对话框关闭;非模态对话框用Create()+ShowWindow(),不阻塞。
// 模态对话框 CMyDialog dlg; if (dlg.DoModal() == IDOK) { // 用户点击了确定 } // 非模态对话框 CMyDialog* pDlg = new CMyDialog; pDlg->Create(IDD_MY_DIALOG, this); pDlg->ShowWindow(SW_SHOW);选择哪种取决于交互需求。如果对话框是"必须处理完才能继续"的(比如登录框、设置框),用模态。如果对话框是"辅助工具"性质的(比如查找替换框、属性面板),用非模态。
非模态对话框有个内存管理问题:如果你用new创建,必须在对话框关闭时delete。通常的做法是在对话框类的PostNcDestroy里delete this:
void CMyDialog::PostNcDestroy() { CDialog::PostNcDestroy(); delete this; }但这样又带来一个问题:如果你在栈上创建了非模态对话框,delete this会导致崩溃。所以非模态对话框一定要用new创建,并且确保只delete一次。
5.4 资源管理与GDI对象泄漏排查
MFC里GDI对象(CPen、CBrush、CFont等)是有限资源,每个进程默认最多只能创建10000个GDI对象。如果泄漏,程序会逐渐变慢,最终无法绘制任何界面。
常见的GDI泄漏场景:
- 创建了
CPen但没有DeleteObject - 把临时GDI对象选入DC后没有恢复原来的对象
- 在
OnDraw里频繁创建GDI对象但没有释放
// 错误写法:每次OnDraw都创建新Pen,旧Pen没有删除 void CMyView::OnDraw(CDC* pDC) { CPen pen(PS_SOLID, 1, RGB(255, 0, 0)); pDC->SelectObject(&pen); // 绘制... // pen离开作用域时析构,但已经选入DC的Pen不会被自动删除 } // 正确写法:恢复原对象 void CMyView::OnDraw(CDC* pDC) { CPen pen(PS_SOLID, 1, RGB(255, 0, 0)); CPen* pOldPen = pDC->SelectObject(&pen); // 绘制... pDC->SelectObject(pOldPen); // 恢复 }排查GDI泄漏可以用任务管理器查看"GDI对象"列,如果数字持续增长不下降,就说明有泄漏。更精确的方法是使用GetGuiResources函数定期打印当前GDI对象数量。
还有一个容易忽略的点:CDC对象的DeleteDC和ReleaseDC。如果你用CreateCompatibleDC创建了内存DC,必须调用DeleteDC;如果你用GetDC获取了窗口DC,必须调用ReleaseDC。MFC的CDC类在析构时会自动处理这些,但如果你手动调用了Detach,就要自己负责清理。
6. 调试与排错:那些年我们踩过的MFC坑
6.1 断言失败:ASSERT_VALID与AfxAssertValidObject
MFC在Debug模式下有大量的断言检查。最常见的断言失败是ASSERT_VALID(pDoc),通常是因为GetDocument()返回了空指针。这往往发生在视图还没有完全初始化的时候,或者文档已经被销毁但视图还在。
void CMyView::OnDraw(CDC* pDC) { CMyDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); // 如果pDoc为空,这里会断言失败 if (!pDoc) return; // Release模式下要加这个检查 // ... }ASSERT_VALID在Release模式下会被编译掉,所以不能依赖它来做空指针检查。正确的做法是同时写ASSERT_VALID和if (!pDoc) return;,Debug模式下用断言快速定位问题,Release模式下用条件判断保证不崩溃。
另一个常见的断言是AfxAssertValidObject,通常是因为你传递了一个无效的MFC对象指针。比如把一个已经delete的对象指针传给了某个函数。这种问题在Debug模式下会立即断言,在Release模式下可能表现为随机崩溃。
6.2 内存DC与双缓冲绘制的正确姿势
闪烁是GDI绘制的常见问题。当OnDraw里绘制内容较多时,窗口重绘会先擦除背景再绘制,导致闪烁。解决方案是双缓冲:先在内存DC里绘制完整画面,然后一次性拷贝到屏幕DC。
void CMyView::OnDraw(CDC* pDC) { CRect rect; GetClientRect(&rect); CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bitmap; bitmap.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); CBitmap* pOldBitmap = memDC.SelectObject(&bitmap); // 在memDC里绘制所有内容 memDC.FillSolidRect(rect, RGB(255, 255, 255)); // ... 绘制其他内容 // 一次性拷贝到屏幕 pDC->BitBlt(0, 0, rect.Width(), rect.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBitmap); }双缓冲的关键是内存DC和位图要在绘制结束后正确释放。memDC和bitmap都是局部对象,离开作用域会自动析构,但SelectObject选入的位图必须恢复,否则位图不会被正确释放。
还有一个优化点:如果窗口大小不变,可以缓存内存DC和位图,避免每次OnDraw都重新创建。但要注意在WM_SIZE时重新创建,否则位图大小不对。
6.3 消息映射宏写错导致的"命令无响应"
消息映射宏写错是新手最常见的问题。比如把ON_COMMAND写成了ON_MESSAGE,或者类名和基类名写反了,都会导致命令没有响应。
// 错误:基类名写成了CView,但实际基类是CScrollView BEGIN_MESSAGE_MAP(CMyView, CView) ON_WM_PAINT() END_MESSAGE_MAP() // 正确 BEGIN_MESSAGE_MAP(CMyView, CScrollView) ON_WM_PAINT() END_MESSAGE_MAP()如果基类名写错,消息映射链会断裂,基类的消息处理函数不会被调用。表现就是某些消息(比如WM_VSCROLL)没有响应。
另一个常见错误是忘记在类声明里加DECLARE_MESSAGE_MAP()。没有这个宏,BEGIN_MESSAGE_MAP就无法编译通过。
排查消息映射问题的方法:在OnWndMsg里下断点,看消息是否到达了消息映射函数。如果到达了但没找到处理函数,检查宏的拼写和参数顺序。
6.4 程序退出时的资源释放顺序问题
程序退出时的崩溃往往比运行时崩溃更难排查,因为涉及到多个对象的析构顺序。MFC里,theApp是全局对象,它的析构发生在main返回之后。如果theApp的析构函数里访问了已经销毁的窗口对象,就会崩溃。
常见的退出崩溃场景:
- 在
ExitInstance里访问了已经delete的框架窗口 - 在文档析构时访问了已经销毁的视图
- 在全局对象的析构函数里调用了MFC函数
我的经验是:在ExitInstance里只做跟全局状态相关的清理,比如保存配置、关闭日志文件。跟窗口相关的清理放在OnClose或OnDestroy里做。文档和视图的清理交给框架自动处理,不要手动delete。
如果确实需要在退出时做复杂清理,可以用atexit注册清理函数,但要确保清理函数不依赖任何MFC对象。
7. 从MFC框架里能学到什么
MFC的文档/视图架构、消息映射、运行时类型识别这些机制,本质上是在解决C++里"如何组织大型应用程序"的问题。虽然现在做Windows桌面开发有了更多选择,但MFC里的一些设计思路依然值得借鉴。
比如消息映射的"链式查找"思想,在很多事件驱动框架里都能看到影子。文档/视图的"数据与表现分离",跟后来MVC、MVVM的核心思想是一致的。运行时类型识别和动态创建,在需要根据配置动态生成对象的场景里非常有用。
我在实际项目里遇到过这样的情况:一个工业控制软件需要支持多种设备,每种设备有不同的数据格式和展示方式。用MFC的文档/视图架构,我把每种设备做成一个文档类,每种展示方式做成一个视图类,通过文档模板动态组合。新增一种设备只需要加一个文档类和一个视图类,框架代码完全不用改。这种扩展性正是框架设计的价值所在。
当然,MFC也有它的历史局限性。比如消息映射宏的语法比较晦涩,调试时不容易跟踪;文档/视图架构对于简单程序来说过于重量级;GDI绘图在复杂图形场景下性能不足。但这些不影响它作为一个学习Windows程序框架设计的优秀样本。
如果你正在维护一个MFC项目,或者需要理解老代码的架构,建议从CWinApp::InitInstance开始,沿着"应用 -> 框架 -> 文档 -> 视图"的路径把整个启动流程走一遍。然后在OnDraw里下断点,看消息是怎么从Windows消息队列一路路由到你的绘制函数的。把这个流程搞清楚了,MFC的框架机制也就理解得差不多了。