简介:《精通MFC(光盘)源代码》面向已具备C++基础、希望系统掌握MFC与Windows桌面开发的中高级读者,围绕图书章节提供可编译示例源码,用于理解框架运行机制并对照动手实践。压缩包共1114个文件、约8.01MB,以h头文件与cpp源文件为核心,辅以rc/rc2资源脚本、vcproj/sln工程文件、ico/bmp图像及少量dll、lib、idl等,工程组织较完整,便于直接打开、编译和调试。内容覆盖面向对象与UML、窗口与消息循环、CObject运行时信息与序列化、应用框架、消息映射宏、对话框数据交换、文档/视图与打印预览、GDI/GDI+绘图、进程线程与线程安全、动态链接库资源、COM接口及.NET托管扩展等16章主题。已有516人学习,适合课程设计、桌面项目开发及MFC进阶阶段查阅类库用法、拆解消息处理流程、积累工程结构参考以及查漏补缺。其中示例按章节编号组织,便于配合教材逐章阅读与复现实验现象。
1. 一份带光盘的 MFC 源码,为什么还值得现在拆一遍
上个月接了个老工业软件的维护活,界面是 MFC 的,代码从上一任手里传下来,压缩包里还留着刻录盘的内容清单。新框架当然更舒服,可现场那台机器上跑的仍然是这一套消息循环。真正能救命的不是教程,而是能被编译、能单步、能对着跑的一套完整例子源码。这份「精通MFC」配套光盘源码就属于这类东西:它把窗口创建、消息映射、对话框数据交换、GDI 自绘这几条主干,拆成几十个能独立编译的小工程,一个工程解决一个具体界面问题,而不是丢给你一个跑不起来的大系统。它适合两类人——被老项目按在地上摩擦、需要快速摸清 Win32 与 MFC 边界的维护者,以及语法过关但写不出像样桌面界面的新手。下面按「先看骨架、再跑起来、最后抄骨架」的顺序拆,跑不通的地方和参数开关都写清楚。
2. 先看清这套源码的骨架:DSP 工程、文档视图与 GDI 绘制的三层结构
光盘源码第一眼容易被吓退:几十个文件夹,每个里面塞一个工程,文件名还是十几年前的拼音缩写。但它的组织逻辑其实很固定——最外层按功能主题分组(对话框、菜单、绘图、文件、进程线程),每个主题下是若干条可独立编译的完整例子,而不是一个互相依赖的大系统。这个结构对学习特别友好,因为每条例子只引入一个新的 API 面,其余的窗口框架是重复的。你要做的第一件事不是打开代码,而是先搞清楚哪个文件是工程入口、哪个是资源、哪个可以放心删掉,否则后面升级工程时会在一堆中间文件里原地打转。
2.1 老工程文件怎么看:.dsp/.dsw 与 .vcxproj 的对应关系
老一代 MFC 教学光盘基本都是 VC6 时代建的工程,后来有人补过 VS2008、VS2010 的版本,文件后缀五花八门。先认文件,再动手:
| 旧文件 | 作用 | 新 IDE 里的对应物 | 处理方式 |
|---|---|---|---|
.dsw | 工作区,相当于解决方案 | .sln | 升级向导会生成,旧的可弃用 |
.dsp | 工程文件,含源文件清单和编译选项 | .vcxproj | 同上 |
.rc+resource.h | 资源脚本与 ID 宏定义 | 同名保留 | 绝对不能删,删了界面全没 |
.clw | 类向导的元数据数据库 | 无对应 | 可以删,新 IDE 直接解析源码 |
.aps | 资源编辑器的符号缓存 | 自动重建 | 可以删 |
StdAfx.h/.cpp | 预编译头 | pch.h/pch.cpp(旧名也能用) | 不能删,改名要同步工程设置 |
Debug/、Release/ | 中间产物与旧 exe | 同名 | 升级前先删干净 |
具体动作用四步,别跳:
第一步,把整个光盘目录复制到本地工作盘,光盘内容默认只读,直接在上面升级会写不进去。复制完先把所有Debug、Release、*.ncb、*.opt、*.aps删掉,这些东西跟当前机器的编译器版本毫无关系,留着只会在升级时被当成源文件报错。
第二步,用新 IDE 的「文件 → 打开 → 项目/解决方案」,文件类型选「所有文件」,直接点.dsp。会弹升级向导,一路下一步即可,向导生成.vcxproj和.sln,.dsp保留原地不做修改。
第三步,打开「解决方案资源管理器」,右键工程 → 属性,把「平台工具集」改成当前安装的版本(v143 / v142 都行),「Windows SDK 版本」保持「随系统继承」。
第四步,检查配置下拉框里是不是只有Win32。很多老工程根本没建x64平台,如果你需要 64 位,在配置管理器里新建一个x64平台,并把源平台选成Win32,让它继承设置。
提示:升级向导对
resource.h是只读处理的,如果原工程的资源 ID 有重复定义,升级时不会报错,编译时才炸。升级完先单独打开.rc看一眼有没有红色波浪线。
2.2 从 CWinApp 到 CFrameWnd:一条消息要走几道手
这是整套源码里最值得先读懂的一层。MFC 没有魔法,它只是把 Win32 的WNDCLASS注册、CreateWindowEx、消息循环用 C++ 类包了起来。理解这条链路,你后面抄任何一段源码都知道该往哪塞。
最核心的事实是:一个 MFC 程序有且只有一个全局CWinApp派生对象,main/WinMain被 MFC 藏在库里,程序启动时先构造这个全局对象,再调用它的InitInstance。
// 全局唯一实例。MFC 在库内部靠它找到程序入口,不要写成局部变量 CMyApp theApp; class CMyApp : public CWinApp { public: virtual BOOL InitInstance(); // 唯一必须重写的虚函数 }; BOOL CMyApp::InitInstance() { // 第二步才轮到基类,它负责初始化 MFC 内部状态、加载资源句柄 if (!CWinApp::InitInstance()) return FALSE; // Create 的第一个参数传 NULL,表示使用 MFC 预注册的窗口类 AfxFrameOrView // 想自定义图标、光标、背景刷,就在这之前调用 AfxRegisterWndClass 自己注册 CFrameWnd* pFrame = new CFrameWnd; if (!pFrame->Create(NULL, _T("最小窗口"))) { delete pFrame; return FALSE; } m_pMainWnd = pFrame; // 必须挂上,CWinThread::Run 靠它判断何时退出 pFrame->ShowWindow(m_nCmdShow); // m_nCmdShow 来自启动参数,别硬写 SW_SHOW pFrame->UpdateWindow(); return TRUE; // 返回 FALSE 程序会立刻退出,且不报错 }InitInstance返回TRUE之后,MFC 进入消息泵。一条鼠标点击消息的完整路径大致是:系统消息队列 → 线程消息队列 →CWinThread::PumpMessage取出 → 先走CWnd::WalkPreTranslateTree做快捷键和PreTranslateMessage预处理 →::DispatchMessage交给窗口过程 → MFC 的统一窗口过程AfxWndProc→AfxCallWndProc找回对应的CWnd*→OnWndMsg查消息映射表 → 命中就调用你写的afx_msg函数 → 没命中就落到DefWindowProc。
理解这条链有三个直接收益。其一,PreTranslateMessage是所有键盘消息的总闸,想拦截回车、Tab、方向键,改这里最省事,不用给每个控件挂子类。其二,消息映射是一张静态表,BEGIN_MESSAGE_MAP里第二个参数必须是直接基类,写错了不会编译报错,只会在运行时不响应消息,这是最阴的一类 bug。其三,m_pMainWnd为空时程序会一闪而过,很多新手以为是自己代码崩了,其实是窗口没挂上主窗口指针。
2.3 按主题分层的阅读顺序
光盘里的工程量大,全看一遍不现实。按下面的优先级走,前面的看懂了,后面的基本是重复劳动加一个新 API:
| 优先级 | 主题分组 | 这一组真正要学的东西 | 建议投入 |
|---|---|---|---|
| 一 | 对话框与控件 | DoDataExchange、DDX/DDV、控件子类化 | 最值得花时间 |
| 二 | 菜单、工具栏、状态栏 | 命令路由、ON_UPDATE_COMMAND_UI | 中等 |
| 三 | GDI 绘图 | CDC、CPen、CBitmap、双缓冲 | 中等,坑最多 |
| 四 | 文档/视图 | CDocument、CView、序列化 | 看需要,做工具软件可跳 |
| 五 | 文件与进程线程 | CFile、CArchive、工作线程与 UI 线程通信 | 按项目需要 |
阅读方式是「横向对比」而不是「纵向通读」:把对话框这一组里的五六个工程同时打开,只看它们的DoDataExchange和OnInitDialog,你会发现差异只有控件类型和校验规则,骨架完全一样。这个对比过程比读十页书管用。
3. 把示例跑起来:工程升级、字符集与 MFC 运行库的三个开关
升级向导点完「完成」不等于能跑。老 MFC 工程在新编译器上翻车,九成集中在三个开关上:MFC 组件装没装、字符集选哪个、运行库用静态还是共享。这三个开关互相牵连,改错一个能连出十几个编译错误,看着像源码有问题,其实全是配置问题。这一章按「先保证能编过、再保证能跑对」的顺序走,每一步都给验证动作,别一口气全改完再编译。
3.1 装齐 MFC 组件与工具集
新版 IDE 的 C++ 工作负载默认不带MFC,只装「使用 C++ 的桌面开发」是不够的。缺组件时的典型症状是fatal error C1083: 无法打开包括文件: "afxwin.h",很多人第一反应是去改包含路径,改半天没用。
正确的做法是在安装器里点「修改」→「单个组件」,勾选「适用于最新 v143 生成工具的 C++ MFC(x86 和 x64)」。装完不用重启,但已经打开的 IDE 要关掉重开,否则环境变量还是旧的。
装完先验证,别急着开工程。打开「x64 Native Tools 命令提示符」,这是一套预置好环境变量的命令行,比在系统里手动配 PATH 靠谱:
:: 打印当前工具集根目录,正常形如 ...\VC\Tools\MSVC\14.4x.xxxxx\ echo %VCToolsInstallDir% :: 检查 MFC 头文件是否存在,存在会输出“已找到” if exist "%VCToolsInstallDir%atlmfc\include\afxwin.h" ( echo 已找到 MFC 头文件 ) else ( echo 缺少 MFC 组件,回到安装器勾选 )%VCToolsInstallDir%是这套命令行环境自动设置的变量,末尾自带反斜杠,所以拼接时不要再补一个。atlmfc是 MFC 与 ATL 共用目录,里面include放头文件、lib放静态库,两个子目录都得在,只装了一半的情况也遇到过。
命令行验证通过还不够,工程属性里还有一个独立开关。右键工程 → 属性 → 「配置属性 → 高级」→「使用 MFC」,这个值有三种:不使用、在共享 DLL 中使用 MFC、在静态库中使用 MFC。老工程从 VC6 升级上来时,这一项经常变成「不使用」,然后就报一堆CWinApp未定义的链接错误。先把它设成「在共享 DLL 中使用 MFC」,等编译过了再讨论要不要改静态。
3.2 字符集与运行库必须成对设置
这是升级过程中最容易反复的一个点,因为两个设置互相关联,改了一个另一个会跟着出问题。
先看字符集。老工程绝大多数建在 MBCS 上,而新版 IDE 的模板默认是 Unicode。这两个不是「换个编码」那么简单,它决定了TCHAR展开成char还是wchar_t,进而决定了所有字符串字面量、所有 Win32 API 调用的参数类型。
| 字符集 | TCHAR展开 | 字符串写法 | 典型症状 |
|---|---|---|---|
| 使用多字节字符集(MBCS) | char | "文本" | 新版 IDE 可能没装 MBCS 库,链接报错 |
| 使用 Unicode 字符集 | wchar_t | L"文本"或_T("文本") | 老代码里裸写const char*的地方全线C2664 |
我的建议是直接切 Unicode,别为了少改代码退回 MBCS。理由很实际:MBCS 库在新版 IDE 里是可选组件,目标机器上也不一定有,而 Unicode 是现在唯一稳妥的路径。切换的动作只有一步——工程属性 →「配置属性 → 高级」→「字符集」选「使用 Unicode 字符集」,剩下的错误一个个改。
改错误有个原则:别用强制类型转换糊过去。看到C2664,第一反应不该是加(LPCTSTR),而是判断这个参数到底该是什么类型。
// 老代码:MBCS 下能过,切 Unicode 后必然报 C2664 void ShowLog(const char* pszText); ShowLog("初始化完成"); // 字面量是 const char[],接口要 const char* // 改法一(推荐):接口本来就该用 TCHAR,一次性改到位 void ShowLog(LPCTSTR pszText); ShowLog(_T("初始化完成")); // _T 在 Unicode 下展开成 L"初始化完成" // 改法二:只在必须调用窄接口的边界处做显式转换,转换点集中、可搜索 CStringW strW = L"初始化完成"; CStringA strA(strW); // 显式走一次转换,意图清楚 ShowLog(strA.GetString());改法一的意义在于:LPCTSTR在 Unicode 下就是const wchar_t*,在 MBCS 下就是const char*,接口本身不用改两次。改法二只用在真正绕不开的地方,比如对接一个只接受const char*的第三方 SDK。最怕的是第三种——在整份代码里到处撒(LPCTSTR),编译能过,运行到一半字符串被截断成乱码,排查起来极其痛苦。
再看运行库。它和字符集是两个独立维度,但经常一起出问题:
| 使用 MFC 的方式 | 依赖 | 单文件部署 | 适用场景 |
|---|---|---|---|
| 在共享 DLL 中使用 MFC | mfc140u.dll等 | 否,目标机需要运行库 | 多模块共用,体积小,调试友好 |
| 在静态库中使用 MFC | 全部打进 exe | 是,但 exe 通常大好几 MB | 现场机器不让装运行库 |
调试阶段一律先用共享 DLL,静态链接会把编译时间拉长,而且一旦和第三方库的 CRT 冲突,报错信息极难读。等确认功能都对,再切静态重新编一遍验证。
3.3 第一次编译通过的验证动作
编译通过不等于跑对。每次升级完工程,我都会按固定顺序走一遍验证,及早发现配置类问题:
第一,看编译输出里的警告数量。老代码里的C4996(strcpy、sprintf被标记不安全)可以暂时用_CRT_SECURE_NO_WARNINGS压掉,但C4244(__int64转int,可能截断)和C4018(有符号无符号比较)要认真看,这两个在 64 位平台上会真的出问题。
第二,Debug配置下跑一遍,关窗口后看输出窗口有没有Detected memory leaks!。有的话先记下来,等第 5 章统一处理。
第三,把窗口拖到副屏或者缩放比例不同的显示器上,看布局有没有错位。老工程基本都没做 DPI 感知,这一项大概率不过,属于已知情况,不影响功能验证。
第四,打开任务管理器看进程的 GDI 对象数。窗口开开关关十几次,如果 GDI 对象数一直涨不回落,说明有 DC 或位图没释放,这是 MFC 项目最常见的资源泄漏形态。
在代码里加一句调试期的泄漏检测,能让上面的第二步从「凭感觉」变成「有数字」:
// 放在预编译头之后、所有 #include 之前,Debug 下把 new 重定向到带文件行号的版本 #ifdef _DEBUG #define new DEBUG_NEW #endifDEBUG_NEW宏在Debug配置下会把每次new的文件名和行号记录下来,泄漏报告里直接告诉你哪一行分配的内存没释放。注意它只在_DEBUG下生效,Release配置里展开成普通new,不会拖慢发布版本。还有一个副作用要记住:如果某个类重载了operator new,DEBUG_NEW会绕过它,这种情况要在这行宏之前#undef new。
4. 抄源码要抄骨架:消息映射、对话框数据交换与双缓冲自绘
光盘源码真正的价值不在「看懂」,在「能抄」。但抄要有选择——抄业务逻辑没意义,抄界面布局换个需求就废,真正值得抄的是三段骨架:消息映射的声明格式、对话框数据交换的绑定方式、GDI 双缓冲的绘制流程。这三段在几十个工程里反复出现,格式几乎一模一样,把它们固化下来,你新建任何 MFC 界面都能直接铺开写。
4.1 手写一份最小消息映射
MFC 的消息映射由三个宏配合完成,缺一个都编不过。很多人第一次抄源码时把宏的位置放错,报一堆看不出所以然的错,所以这里把完整结构写一遍:
class CMainFrame : public CFrameWnd { protected: // 三个消息处理函数,返回值和参数类型由对应的宏决定,不能随便写 afx_msg void OnPaint(); // 对应 ON_WM_PAINT afx_msg BOOL OnEraseBkgnd(CDC* pDC); // 对应 ON_WM_ERASEBKGND afx_msg LRESULT OnDataReady(WPARAM wParam, LPARAM lParam); // 对应 ON_MESSAGE afx_msg void OnFileOpen(); // 对应 ON_COMMAND DECLARE_MESSAGE_MAP() // 声明静态映射表,必须放在类体内,且不用加分号 }; // 第一个参数是本类,第二个必须是直接基类,写错基类不会编译报错但消息收不到 BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) ON_WM_PAINT() ON_WM_ERASEBKGND() ON_MESSAGE(WM_APP + 100, &CMainFrame::OnDataReady) // 自定义消息必须用 WM_APP 起步 ON_COMMAND(ID_FILE_OPEN, &CMainFrame::OnFileOpen) END_MESSAGE_MAP()几个必须记住的对应关系:ON_WM_PAINT()不带参数,因为它对应的处理函数签名是固定的;ON_COMMAND的处理函数没有参数,因为命令 ID 在宏里已经给了;ON_MESSAGE的处理函数必须返回LRESULT,返回void会编译报错,参数是(WPARAM, LPARAM)两个值。
还有一处极容易翻车的点:自定义消息的起始值。WM_USER以下的编号被系统占用,WM_USER到0x7FFF是窗口类私有范围,从WM_APP(0x8000)往上才是应用自己的。我在一个项目里见过两个模块都用WM_USER + 1,结果一个模块的刷新消息被另一个模块的处理函数接走了,现象是「点 A 按钮,B 面板乱跳」,查了整整两天。从那时候起,项目里所有自定义消息统一定义在一个头文件里,格式是WM_APP + 模块编号 * 100 + 序号。
4.2 DDX/DDV:对话框控件与成员变量的绑定
对话框是 MFC 里用得最多、也最容易写歪的部分。DoDataExchange这个函数看着像样板代码,其实它能决定控件和变量之间什么时候同步、什么时候不同步。
void CLoginDlg::DoDataExchange(CDataExchange* pDX) { CDialogEx::DoDataExchange(pDX); // 必须放第一行,放后面会覆盖基类的绑定 // DDX:建立成员变量与控件的双向通道,方向由 pDX 决定 DDX_Text(pDX, IDC_EDIT_USER, m_strUser); // DDV:紧跟对应的 DDX,校验不通过会弹提示并让 UpdateData 返回 FALSE DDV_MaxChars(pDX, m_strUser, 16); DDX_Text(pDX, IDC_EDIT_PWD, m_strPwd); DDV_MinChars(pDX, m_strPwd, 8); // DDX_Control 是把控件本身子类化后挂到成员变量,不是取控件的值 DDX_Control(pDX, IDC_LIST_LOG, m_listLog); }pDX这个指针是方向开关,它不需要你手动构造。调用UpdateData(TRUE)时,框架传进来一个方向为「存储」的CDataExchange,数据从控件流向成员变量;调用UpdateData(FALSE)时方向反过来,成员变量写回控件。CDialogEx::OnOK内部默认先调一次UpdateData(TRUE),返回FALSE就不关窗口,这就是为什么校验失败时对话框停着不动。
参数上还有两个细节值得留意。DDV_MaxChars的第三个参数是限制长度,它必须紧跟在对应的DDX_Text后面,顺序反了绑定就错位。DDX_Control与DDX_Text混用时,DDX_Control的调用时机更早——在OnInitDialog里访问控件成员变量时,它已经被绑定好了,因为DoDataExchange在OnInitDialog之前就已经跑过一次。
一个高频错误是:在OnInitDialog里直接给控件变量赋值,然后忘了调UpdateData(FALSE),界面纹丝不动。另一个是反过来,在按钮响应函数里改了成员变量,但没调UpdateData(FALSE)就以为界面会刷新。这两处都是「代码看着对、界面不动」的典型,血泪经验是:记住谁负责把值搬过去。
4.3 双缓冲:CPaintDC、内存 DC 与 BitBlt
绘图这一组是整个光盘里坑最多的部分。直接往CPaintDC上画的代码能跑,但一拖窗口就白花花地闪,原因是「擦背景」和「画内容」是两次独立的重绘,中间那一下被用户看见了。双缓冲的思路很朴素:先在内存里画完一张完整的图,再一次性贴到屏幕上。
void CBoardView::OnPaint() { // 必须用 CPaintDC,不能用 CDC。它构造时调 BeginPaint,析构时调 EndPaint, // 少了 EndPaint 会导致窗口区域一直处于无效状态,界面疯狂重绘 CPaintDC dc(this); CRect rc; GetClientRect(&rc); if (rc.IsRectEmpty()) return; // 窗口被最小化时宽高为 0,直接返回 CDC memDC; memDC.CreateCompatibleDC(&dc); // 兼容 DC 继承屏幕的色深和调色板 CBitmap bmp; // 关键:这里传的必须是屏幕 DC,传 memDC 会得到 1bpp 单色位图,画出来全是黑白 bmp.CreateCompatibleBitmap(&dc, rc.Width(), rc.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // 记下旧对象,析构前要还原 memDC.FillSolidRect(&rc, RGB(240, 240, 240)); DrawScene(&memDC); // 所有绘制操作都改到 memDC 上,不碰屏幕 // SRCCOPY 表示直接拷贝,不做任何混合。需要半透明叠加时改 SRCPAINT 或 AlphaBlend dc.BitBlt(0, 0, rc.Width(), rc.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); // 还原,否则 bmp 析构时还在 DC 里选着,GDI 句柄泄漏 }三个参数点值得单独说。CreateCompatibleDC传入的 DC 决定了内存 DC 的默认调色板,一般传屏幕 DC 就行。CreateCompatibleBitmap的第二个和第三个参数是像素宽高,不是逻辑单位,做缩放时要注意区分。BitBlt的最后一个参数是光栅操作码,SRCCOPY覆盖 95% 的场景,剩下 5% 是异或绘图(SRCINVERT,画橡皮筋选择框时用)。
配套还要处理背景擦除,否则双缓冲省下来的效果被背景刷吃掉:
BOOL CBoardView::OnEraseBkgnd(CDC* /*pDC*/) { // 返回 TRUE 表示“背景我已经处理好了”,框架不再调默认的清屏 // 双缓冲里已经用 FillSolidRect 铺满整个客户区,这里必须拦掉 return TRUE; }一定要把这件事交给双缓冲,不要在OnEraseBkgnd里调用FillSolidRect,那样等于画了两遍背景,闪烁会更严重。
4.4 自定义消息 ON_MESSAGE 的返回值和参数
工作线程把数据送到界面,最常用的就是自定义消息。这里有两个函数要分清:PostMessage把消息丢进队列立刻返回,SendMessage会阻塞等对方处理完。跨线程用SendMessage极易死锁——UI 线程正好在等这个工作线程,工作线程又在等 UI 线程处理消息。
// 发送方(工作线程里):堆上分配,所有权随消息一起转移 struct CWorkResult { int code; CString msg; }; ::PostMessage(m_hMainWnd, WM_APP + 100, (WPARAM)0, (LPARAM)new CWorkResult{ 0, _T("完成") }); // 接收方(UI 线程):用 unique_ptr 接管,异常路径也不会漏 LRESULT CMainFrame::OnDataReady(WPARAM wParam, LPARAM lParam) { std::unique_ptr<CWorkResult> p(reinterpret_cast<CWorkResult*>(lParam)); if (p && p->code == 0) { m_statusBar.SetPaneText(0, p->msg); // 现在在 UI 线程,可以直接碰控件 } return 0; // 返回值目前没被使用,但类型必须是 LRESULT }WPARAM和LPARAM这两个参数在 32 位下都是 4 字节,在 64 位下WPARAM是 8 字节、LPARAM也是 8 字节,所以指针放在LPARAM里是安全的。但仍然要避免把栈上的对象地址传过去——消息是异步的,等处理函数执行时,那个栈帧早就没了。
5. 避坑与排查:老 MFC 代码搬迁时最容易翻的五次车
前面几章按「应该怎么做」走,这一章反过来,按「实际会怎么错」写。下面五条都是从升级现场攒出来的,每条按现象、原因、解决三段走,症状描述尽量写成你屏幕上会看到的那句原文,方便对照搜索。
5.1 找不到 afxwin.h:组件与开关是两回事
现象:fatal error C1083: 无法打开包括文件: "afxwin.h": No such file or directory,但同一台机器上别的 C++ 工程能正常编译。
原因有两个,且互相独立。一是 IDE 安装时没勾选 MFC 组件,atlmfc目录根本不存在;二是组件装了,但工程属性里「使用 MFC」被设成了「不使用」,此时编译器不会自动把atlmfc\include加进包含路径。第二种情况最坑,因为你去「附加包含目录」里手动加上路径也能编过,但接下来会撞上一堆链接错误,绕了一大圈才发现根子上是那个开关。
解决:先用第 3.1 节的命令行验证afxwin.h是否存在,不存在就去安装器补组件。存在的话,直接去工程属性里把「使用 MFC」改成「在共享 DLL 中使用 MFC」,不要去手动改包含目录——那一项是给非 MFC 工程用的。
5.2 C2664 字符集报错:别用强制转换糊过去
现象:升级后一口气出现几十个error C2664: 无法将参数 1 从 "const char [5]" 转换为 "LPCTSTR",集中在字符串字面量和sprintf、strcpy这些老 API 上。
原因:工程字符集从 MBCS 切到了 Unicode,TCHAR从char变成了wchar_t,所有裸写的窄字符串都对不上了。
解决:先判断这个参数该不该是宽字符。如果是自己写的函数,直接把形参改成LPCTSTR,调用处加_T()或L前缀,一次改到位。如果是对接只接受窄字符的第三方接口,就在调用边界处用CStringA/CStringW做一次显式转换,转换点集中在一处,以后要改也好找。绝对不要全篇加(LPCTSTR)——那是把一个编译期能发现的问题,推迟成运行时的乱码。
顺带提醒:sprintf这类函数切到 Unicode 后要换成_stprintf_s或CString::Format,strcpy换成_tcscpy_s或直接用CString赋值。用_CRT_SECURE_NO_WARNINGS压掉警告只是掩盖,缓冲区溢出的风险一点没少。
5.3 界面闪烁与黑白位图:双缓冲只写对了一半
现象一:窗口拖动或改变大小时整个客户区白闪,画得越复杂闪得越厉害。现象二:加了双缓冲之后不闪了,但画出来的图全是黑白两色,颜色信息全丢。
原因:现象一是没做双缓冲,或者做了双缓冲但OnEraseBkgnd还在走默认的清屏,等于画了两遍。现象二是CreateCompatibleBitmap的第一个参数传成了内存 DC——内存 DC 在还没选入彩色位图时默认是 1bpp 单色,用它去创建兼容位图,得到的自然是单色图。
解决:按第 4.3 节的写法,CreateCompatibleBitmap第一个参数传屏幕 DC,OnEraseBkgnd直接返回TRUE。改完还闪的话,检查OnPaint里有没有在BitBlt之后又画东西,那等于把双缓冲结果盖掉了。
5.4 退出时报内存泄漏:多半是 GDI 对象没还原
现象:Debug版关掉程序后,输出窗口出现Detected memory leaks!并列出若干{123} normal block at 0x...,或者任务管理器里 GDI 对象数只涨不落。
原因:MFC 的CDC、CPen、CBrush这些对象都是包装类,真正的 GDI 句柄在析构时才释放,而SelectObject选进去的对象如果不还原,句柄就一直挂在 DC 上。另一种情况是GetDC()拿了 DC 忘了ReleaseDC(),或者CDC::FromHandle拿到的临时对象跨了消息循环还在用。
解决:凡是SelectObject就拿一个指针接住旧对象,离开作用域前还回去;拿GetDC()就配对ReleaseDC();绘图对象优先用栈上对象,让析构自动处理。FromHandle返回的CDC*是临时对象,只能在一个消息处理函数里用完就丢,绝不能存成成员变量。
5.5 跨线程操作界面:断言和随机卡死
现象:Debug版在工作线程里直接改控件文本时弹出ASSERT失败;Release版不弹框,但界面偶尔卡死,或者控件内容变成一堆乱码。
原因:MFC 的窗口对象有线程亲和性,只有创建窗口的那个线程才能安全地操作它。工作线程直接调用SetWindowText、UpdateData这类函数,实际上是在跨线程写 UI 线程的数据结构,Debug版有断言兜着,Release版直接把内存写乱。
解决:工作线程只负责算,算完用PostMessage把结果丢回 UI 线程,由 UI 线程自己去改控件。需要返回值就用自定义消息加一个事件对象,别图省事直接SendMessage,UI 线程一旦在等别的东西就会死锁。判断自己是否在 UI 线程,可以用::GetWindowThreadProcessId比对::GetCurrentThreadId,早期排查阶段加一句日志能省很多时间。
6. 把光盘源码变成自己的模板库:抽骨架 + 十分钟验证清单
源码看完就忘,是因为你把它们当成了「例子」,而不是「零件」。真正能让这套东西长期产生价值的做法是:从里面抽出三个骨架,固化成一个自己用的模板工程,以后新起界面直接复制,不再从空工程一步步搭。
我的抽法是这样:新建一个空的 MFC 工程,只保留三类文件——一个最小CWinApp派生类(带InitInstance里那七行)、一个CFrameWnd派生类(带消息映射三段宏和空的OnPaint)、一个CDialogEx派生类(带DoDataExchange和OnInitDialog)。然后把双缓冲的OnPaint和OnEraseBkgnd写成可直接改的版本,OnPaint里的DrawScene留成一个空函数当绘制入口。这套东西不到两百行,覆盖了九成界面需求。
抽完之后跑一遍验证清单,十分钟能走完,但能提前发现几乎所有配置类问题:
| 步骤 | 动作 | 通过标准 |
|---|---|---|
| 1 | Debug/Win32编译 | 零错误,警告不超过 5 条 |
| 2 | Release/x64编译 | 零错误,且不依赖Debug才有的宏 |
| 3 | 启动、拖动、最大化、最小化还原 | 不闪、不错位、不残留 |
| 4 | 打开关闭窗口 20 次 | 任务管理器 GDI 对象数回落 |
| 5 | 关掉程序看输出窗口 | 无Detected memory leaks! |
| 6 | 拷到一台没装开发环境的机器上跑 | 共享 DLL 方式下能正常启动 |
第 6 步是很多人会跳过的一步,也是最容易被现场打脸的一步。共享 DLL 方式依赖目标机上有对应版本的运行库,静态链接方式不依赖但体积大。这个取舍在项目开始时就要定下来,别等到交付前一天才发现现场机器装不了运行库。
最后一个技巧,关于 DPI。老 MFC 界面在高分屏上被系统拉伸得糊成一片,解决方式是在InitInstance的最开头加一句进程级 DPI 感知声明:
BOOL CMyApp::InitInstance() { // 必须在任何窗口创建之前调用,晚了不生效 ::SetProcessDPIAware(); if (!CWinApp::InitInstance()) return FALSE; // ... 后续创建窗口 return TRUE; }这是最省事的兜底写法,让进程按物理像素工作,界面不再被系统拉伸模糊,代价是控件尺寸不会自动放大,在高分屏上会显得偏小。要真正做到按比例缩放,得改成 per-monitor 感知并在OnInitDialog里按当前 DPI 重算一遍所有控件坐标,那是另一个话题了。先用这一行把「糊」的问题解决掉,剩下的慢慢调。
从那以后我每次升级老 MFC 工程,都强制按「先删中间产物 → 装组件验证 → 定字符集和运行库 → 编过 → 走一遍六项清单」这个顺序走,一步都不跳。跳过哪一步,后面就得花三倍时间回头找。希望帮到你。
本文还有配套的精品资源,点击获取