简介:这是《精通MFC》一书的配套光盘源代码,适合希望系统掌握MFC框架、Windows界面编程与组件开发的C++开发者。资源按章节组织,覆盖面向对象基础、窗口与消息映射、对话框、文档/视图结构、GDI/GDI+绘图、进程线程、动态链接库、COM组件乃至.NET托管扩展等主题,对应书中大量可运行示例,便于读者边读边练。压缩包共1114个文件,以h头文件、cpp源文件、rc资源脚本和vcproj工程文件为主,另有少量ico、bmp、jpg等素材与项目配置文件,包体约8.01MB,可直接配合Visual Studio等环境查看编译。目前已有513人学习下载。对正在研读该书的读者来说,这些源代码能帮助理解MFC底层机制、掌握类库调用方式,并可作为实际项目开发的参考模板,节省从零搭建环境的时间。
1. 精通MFC光盘源代码:从“能编译”到“能看懂”,中间隔着一整条调试链路
拿到《精通MFC》光盘源代码,第一件要做的事不是打开书,而是按F7编译。至少一半人会在这里第一次翻车:afxwin.h打不开、char*转CString报错、_WIN32_WINNT不满足。这套源码是学MFC绕不开的经典资源,但它的价值不在于把示例编译出来,而在于你知道每一行代码为什么存在。它适合已经掌握C++基础、想在Windows桌面软件开发里真正用MFC干活的人。如果你还在纠结桌面软件开发用MFC还是Qt,先把这套源码里的对话框、消息映射、状态栏跑通,再谈选型。
2. 读懂这套源码前,先看MFC程序的骨架:破解WinMain、消息映射和消息路由
2.1 先判断源码属于哪个年代:目录结构、工程后缀与源代码管理
我拿到任何一套旧光盘源码,第一件事不是双击工程文件,而是先看根目录。旧书配套光盘通常按章铺目录,每章一个文件夹,下面挂着一个或多个独立工程;公共类被单独放在Common或Include目录里,多个工程往上两级引用它。如果根目录里躺着ReadMe或安装说明,先读它,里面往往写着“需要Visual Studio 6.0”这类运行前提。
判断年代最直接的方式是看后缀。VC6时代的工作区是.dsw,工程是.dsp;从VS2002到VS2008,工程变成.sln加.vcproj;到VS2010以后,工程又变成.sln加.vcxproj。后缀决定了你能用什么IDE顺利打开它。即使是.dsw格式,也不用急着找VC6虚拟机,拿VS2022直接打开,它会进入一次工程转换向导,转换后另存为新工程。转换有概率失败,所以动手之前,先把整张光盘复制到本地。
复制不是简单拖一遍文件夹,而是要过滤掉生成目录。以前的光盘里经常带着编译好的Debug和Release目录,体积大且含旧路径,放进自己机器会干扰排查。“源代码管理”对老光盘源码尤其重要:先初始化一个git仓库,把原始文件原封不动提交一次,之后每一步改动都留痕。命令行做这一套最干净:
robocopy E:\光盘\Source D:\MFC_Legacy /E /XD Debug Release /XF *.pdb *.obj cd D:\MFC_Legacy git init git add . git commit -m "initial import: legacy MFC samples before migration"robocopy的/E复制所有子目录,/XD排除Debug和Release,/XF排除中间产物,这样导入git的才是真正的源代码。git提交记录就是你的“后悔药”:迁移改坏了大不了回滚。别信光盘自带的一键安装器,那些程序多半是十多年前写的,会在当前系统里干出预期之外的活。
注意:替换路径前一定提交一次git,这能让你在改坏路径后一分钟内回到原始状态。
2.2 MFC程序的最小可运行单元:从theApp到InitInstance
说回代码本身。一个MFC程序无论界面多复杂,入口都长得很像。老光盘示例里最常见的结构:一个从CWinApp派生的App类、一个从CDialog或CFrameWnd派生的主窗口类,外加一个全局的App对象。全局对象会在main函数之前构造,MFC框架里对应的入口是WinMain,而你实际写的第一个被调函数,是App类的InitInstance。
// 常见MFC应用入口写法,老示例几乎都长这个骨架 #include "stdafx.h" #include "ExampleApp.h" #include "ExampleDlg.h" BEGIN_MESSAGE_MAP(CExampleApp, CWinApp) ON_COMMAND(ID_HELP, &CWinApp::OnHelp) END_MESSAGE_MAP() // 全局对象,程序启动时第一个被构造 CExampleApp theApp; BOOL CExampleApp::InitInstance() { // 对话框应用的经典启动路径 CExampleDlg dlg; m_pMainWnd = &dlg; dlg.DoModal(); return TRUE; }InitInstance里做了三件事:构造对话框对象、把对话框指针交给m_pMainWnd、再调用DoModal进入模态循环。DoModal返回之后,程序就准备退出。所以阅读源码时如果找不到“从哪开始”,先搜全局对象theApp,顺着InitInstance往下走,整条启动链就通了。
SDI和MDI程序会稍有不同,主窗口是CFrameWnd,Document/View架构还会加上CDocument和CView,但万变不离其宗:CWinApp负责应用生命周期,窗口类负责界面。学习时不要一上来钻进复杂的View类,先把对话框这个最小闭环吃透。所谓“精通MFC”,真正值钱的不是某段奇技淫巧,而是把这条启动链刻进脑子里。
2.3 消息映射表:看懂宏展开,就看清了MFC的调度逻辑
MFC和普通Win32程序最大的差异,是把“switch(message) case WM_XXX”这堆重复代码折叠成了消息映射宏。每个窗口类里都有一张BEGIN_MESSAGE_MAP到END_MESSAGE_MAP之间的表,这张表就是该类的消息路由表。阅读源码时,我一般先在类定义里搜索BEGIN_MESSAGE_MAP,把这张表单独列出来,再去看处理函数,比顺着控件事件翻代码高效得多。
// 对话框类里的消息映射,常见的三种条目 BEGIN_MESSAGE_MAP(CExampleDlg, CDialogEx) ON_WM_LBUTTONDOWN() // 窗口消息:鼠标左键按下 ON_BN_CLICKED(IDC_BTN_START, &CExampleDlg::OnBnClickedStart) // 控件通知:按钮被点击 ON_MESSAGE(WM_USER + 1, OnMyMessage) // 自定义消息 END_MESSAGE_MAP()ON_WM_LBUTTONDOWN展开后,在消息映射表结构里登记“WM_LBUTTONDOWN由哪个成员函数处理”。控件通知的ON_BN_CLICKED绑定了控件ID和点击事件。自定义消息一定要用ON_MESSAGE手动绑定,这是新手最容易漏的:消息发出去了,窗口却毫无反应。
看这张表要同时理解两点。第一,消息从上往下找,前面的条目优先,同一个消息不要重复登记两个处理函数。第二,子类没处理的消息会往基类的消息映射继续传,这也是为什么宏的第二行要写基类名CDialogEx。如果一条命令注册了但触发不了,先查IDC常量是否写对,再查控件是否真的属于这个对话框,最后查是不是被父窗口的WM_COMMAND拦截了。能按这个顺序排查,基本就摸到MFC调度的骨架了。
3. 在Visual Studio里把光盘旧工程跑起来:从.dsw到.vcxproj的迁移实战
3.1 打开.dsw与.sln:工程转换的正确姿势与路径替换
把光盘源码复制到本地并提交到git后,就可以打开工程了。VS2022可以直接打开.dsw或.dsp,但严格来说它做的是“转换”而不是“打开”:转换向导会生成新的.sln和.vcxproj,原文件保持不动。这里有个细节,转换完成后VS会提示是否保存新工程,如果选否,下次打开还是走老文件,等于没迁移。我的习惯是转换成功后马上“另存为”到新目录,让老工程彻底留作对比。
转换过程中最常见的失败提示是“Project 'xxx' could not be loaded”。原因通常是光盘源码里把某些头文件或库写成了硬盘绝对路径,例如C:\MFC\Include,而当前机器没有这个目录。遇到这种提示,先用记事本打开对应.vcxproj或.dsp,搜索盘符和反斜杠路径,把绝对路径改成相对路径再重新加载。老光盘代码对开发目录要求很高,有的甚至要求必须解压到C盘根目录,这是当年简化代码的土办法。
# 批量把源码中的旧绝对路径替换为当前目录(先备份,再执行,不要全盘替换) $root = "D:\MFC_Legacy" Get-ChildItem $root -Recurse -Include *.dsp,*.dsw,*.vcproj,*.vcxproj | ForEach-Object { (Get-Content $_.FullName -Raw) -replace 'C:\\LegacySource', $root | Set-Content $_.FullName -NoNewline }替换路径的前提是目录层级一致。VS对工程文件和源文件的相对引用以.vcxproj所在目录为基准,源码里如果写的是....\Common,复制后只要维持同样的相对层级就不会断。这里最忌讳一键全盘替换,因为.dsp和.vcxproj的路径写法不同,前者用正斜杠也能用,后者对转义更敏感。先挑一两个工程替换、跑通,再批量处理。
3.2 迁移必调的四个工程设置:字符集、工具集、MFC用法、SDK版本
打开工程后先别急着按F7,把四个关键设置统一检查一遍。它们在旧代码里几乎都不是当前系统需要的值。
第一,平台工具集。老工程默认为v60或v100,当前机器装了哪个版本的VS就选哪个,VS2019对应v142,VS2022对应v143。第二,Windows SDK版本。本机装了更高SDK就选本机版本,老代码在Win10/Win11下编译,SDK版本太低会触发一堆_WIN32_WINNT不满足的警告。第三,MFC的使用方式,是“在共享dll中使用MFC”还是“在静态库中使用MFC”。学习用途建议选共享,静态库会让每个exe膨胀到几十MB,排查问题也更麻烦。第四,字符集。这是老代码翻车最密集的地方,下一节具体说。
四个设置都在“项目属性”里,但不同VS版本入口标签有差异,VS2022是“配置属性->常规”。我一般先把配置切到“所有配置”,把Debug和Release一次性改完,避免以后在两种配置间反复对比。改完工具集和SDK后,VS会提示需要重新加载项目,让它重载,然后立刻编译一次,记录第一批错误。迁移的耐心这时候最关键:不要试图一次性消灭所有错误,而是每修一类就编译一次,让错误列表自己收敛。
老光盘里一个.dsw可能挂着十几个工程,直接按F7会把它们全部编译,报错信息滚屏。我一般先右键具体示例工程,选“生成”,让其它工程靠边站。如果某个工程依赖Common项目,先编译Common生成的静态库,再把依赖路径配好。这一条能省下你在日志海洋里找第一根刺的时间。
3.3 批量修复编译错误:字符集、C4996与CString转换
老代码在VS2022下编译,前三类错误基本固定:C4996、C2664、以及一串“xxx is not a member of xxx”。C4996是微软把strcpy、sprintf等函数标记为不安全导致的,新工程默认报错。最快的手法是加预处理器定义,源码不用动:
// 项目属性 -> C/C++ -> 预处理器 -> 预处理器定义,追加: _CRT_SECURE_NO_WARNINGS加了之后strcpy这类函数不再报C4996。短期用没问题,但血泪经验是:这只解决了“能编译”,没解决“能运行”。如果代码里用sprintf往固定char缓冲区写内容,超长数据照样踩坏栈。我一般会在迁移时顺手把明显有风险的sprintf换成CString::Format:
// 旧写法,在Unicode工程里可能报错或隐含截断 char buf[256]; sprintf(buf, "value=%d", nValue); CString strInfo = buf; // 推荐的新写法,类型安全且适配Unicode CString strInfo; strInfo.Format(_T("value=%d"), nValue); SetDlgItemText(IDC_STATIC_INFO, strInfo);第二类C2664多半是字符串类型不匹配。旧代码用char和LPCSTR,新工程默认Unicode,函数实际要LPCWSTR。修这类错误有个通用口诀:源码里的字符串字面量都套上_T(),需要传出char的地方改CStringA,需要接收宽字符的地方改CStringW。切忌用强制类型转换硬生生把报错压下去,那会让运行期得到乱码或直接崩溃。
第三类“is not a member”是API改名,比较典型的有GetWindowTextLengthA、SetWindowTextA这类带A/W后缀的旧API,随着SDK更新很多别名没了。处理办法是全局搜索带A或W后缀的调用,按所在工程的字符集去掉后缀或改成CString接口。如果工程暂时不想全面换Unicode,也可以先把项目字符集改回“多字节字符集”跑通,但必须说清楚:Win11环境下多字节字符集的UI中文显示和某些新控件兼容性都在变差,这只能作为临时过渡,不是长期出路。
4. MFC代码里最值得精读的三个模块:状态栏、浏览器控件、自绘界面
4.1 状态栏显示信息:CStatusBar的Pane更新与刷新时机
MFC状态栏怎么显示,是几乎所有老示例都会碰到的题。很多初学者直接调用SetWindowText,但那是改标题栏。真正要在窗口底部灰色条上显示文字,得走CStatusBar。状态栏由若干Pane组成,通常第一个Pane显示菜单提示,后几个显示CapsLock、NumLock等指示器。想在第一个Pane里显示自己的文字,用SetPaneText,它接受Pane下标。
// CMainFrame::OnCreate里的状态栏初始化 static UINT indicators[] = { ID_SEPARATOR, // 0号Pane:留给动态文字 ID_INDICATOR_CAPS, ID_INDICATOR_NUM, }; if (!m_wndStatusBar.Create(this) || !m_wndStatusBar.SetIndicators(indicators, sizeof(indicators) / sizeof(UINT))) { TRACE0("Failed to create status bar\n"); return -1; } // 某按钮点击后更新状态栏 void CMainFrame::OnUpdateStatusText() { m_wndStatusBar.SetPaneText(0, _T("正在处理第2步")); // 需要时强制刷新,避免文字残留 m_wndStatusBar.Invalidate(); }SetPaneText不会自动清空上一次显示的内容,如果前后文字长度不一,短文字会留下旧尾巴,所以要么每次把Pane清空,要么调用Invalidate重绘。需要频繁刷新时,不要在每次循环里直接SetPaneText,那会造成界面闪烁。常见做法是开一个定时器,每300毫秒读一次进度变量再更新状态栏,把刷新频率降下来。这个“把刷新频率降下来”的细节,就是示例源码和可交付代码之间的差距。
更进一步,状态栏指示器还可以配合字符串表:在资源里定义ID_INDICATOR_RECORD等字符串,然后把它加进indicators数组,MFC会自动把字符串显示到对应Pane。这种写法比SetPaneText更符合MFC的资源驱动习惯,也方便做多语言版本。
4.2 IWebBrowser2.CreateEx:在对话框里嵌入网页的正确姿势
MFC iwebbrowser2 createex是一组出现频率很高的检索词,对应的是老版MFC里把IE嵌入对话框的需求。这套代码现在仍能跑,但坑比想象中多。先看典型场景:在对话框的OnInitDialog里创建一个WebBrowser子窗口。常见做法是先用ClassWizard给对话框添加ActiveX控件,VS会生成一个CWebBrowser2包装类;如果要从纯代码创建,则走CreateEx。
// 在对话框类里声明:CWebBrowser2 m_web; BOOL CMyDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 1.得到对话框上的占位区域 CRect rc; GetDlgItem(IDC_STATIC_PLACEHOLDER)->GetWindowRect(&rc); ScreenToClient(&rc); // 2.创建浏览器子窗口 m_web.CreateEx(WS_CHILD | WS_VISIBLE | WS_BORDER, rc, this, 0); // 3.导航,注意别在CreateEx同一条调用链里立即Navigate m_web.Navigate(_T("about:blank"), nullptr, nullptr, nullptr, nullptr); return TRUE; }CreateEx的第一个参数是窗口样式,子窗口至少要WS_CHILD和WS_VISIBLE,否则控件创建了也不显示。第三步特别容易翻车:把Navigate紧跟在CreateEx后面,结果窗口出来了但白屏。原因在于ActiveX控件从CreateEx到内部接口就绪是异步的,立即Navigate时浏览器的容器还没完全连接。稳妥写法是PostMessage到本窗口,让Navigate延后到下一轮消息循环再执行。
线程问题是另一个重灾区。IWebBrowser2的Navigate和内部事件回调都要求在同一线程,也就是创建该控件的UI线程。如果你在后台工作线程里调用Navigate,轻则崩溃,重则整个消息泵被锁死。我一般会在工作线程里只更新一个“待访问地址”的成员变量,然后PostMessage给UI线程执行实际导航。这套路同样适用于Print、ShowBrowserBar等其它接口。
4.3 自绘控件与双缓冲:DrawItem里最容易被忽略的GDI细节
老光盘源码里特别喜欢展示自绘按钮、自绘列表框,因为那个年代UI美化的技术含量就在这里。自绘的本质是重写DrawItem或OnPaint,把所有绘制搬进自己的代码。初学者照抄最容易犯的错,是直接在窗口的HDC上画,于是“按下”“抬起”的切换过程全是闪烁。原因很直接:每次状态变化都会做一次背景擦除和重绘,画在屏幕上的中间态一眨眼就过去,肉眼却看得到。
双缓冲就是“先在内存画完,再一次贴到屏幕”。思路是创建兼容DC和兼容位图,把全部绘制动作落到内存DC,最后用BitBlt一次性输出。这比用FillSolidRect一点点涂要稳定得多,代码量也不大:
void CMyButton::DrawItem(LPDRAWITEMSTRUCT lpDIS) { CDC* pDC = CDC::FromHandle(lpDIS->hDC); CRect rc = lpDIS->rcItem; CDC memDC; // 内存画布 CBitmap bmp, *pOld; memDC.CreateCompatibleDC(pDC); bmp.CreateCompatibleBitmap(pDC, rc.Width(), rc.Height()); pOld = memDC.SelectObject(&bmp); // 所有绘制都在memDC上进行 memDC.FillSolidRect(rc, GetSysColor(COLOR_BTNFACE)); memDC.SetBkMode(TRANSPARENT); memDC.DrawText(_T("确定"), rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); // 一次性贴到屏幕 pDC->BitBlt(rc.left, rc.top, rc.Width(), rc.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); // 恢复旧位图 // bmp和memDC析构时自动释放GDI对象 }这里有几个GDI细节很容易踩坑:DrawText的rc会被原地修改,如果后面还用rc做命中和布局计算,下次拿到的是被压缩后的矩形,所以每次绘制都要从GetWindowRect重新取;CreateCompatibleBitmap创建的是内存位图,画文字前记得SetBkMode(TRANSPARENT);最后SelectObject回旧位图这一步不能省,否则位图对象析构时报告资源泄漏,Debug窗口会刷出一堆GDI警告。自绘本身不算多高深的技术,但它把GDI选择、状态保存、资源释放全部串在一起,是检验MFC基本功的好模块。
5. 避坑:编译光盘源码最常遇到的翻车现场,以及排查顺序
先说排查顺序,这是无数条血泪经验换来的:先环境后代码,先单个工程后整个工作区,先Debug后Release。按这个顺序走,能省下一半试错时间;反过来,一上来就盯着某行代码看,大概率看错方向。
5.1 现象:fatal error C1010,预编译头找不到
现象是编译某个.cpp时报fatal error C1010:在查找预编译头时遇到意外的文件结尾。原因不外乎两种:这个.cpp文件第一行没有包含stdafx.h,或者工程属性里“预编译头”选的是“使用”,而文件本身不符合要求。老光盘示例里许多文件默认依赖stdafx.h,一旦工程迁移后预编译头设置丢失,整批.cpp都会挂。解决方案在“项目属性->C/C++->预编译头”里统一处理:绝大多数.cpp应该用“使用/创建”,个别不需要的源文件单独设成“未使用”,同时保证每个设为使用的cpp都#include "stdafx.h"。批量检查时,我会先搜索工程里所有.cpp文件头部,看是不是每个文件都带同一行包含。
5.2 现象:C2664,字符串类型在char*和CString之间互相打架
现象是编译报错,提示无法将参数从const char转换为LPCTSTR,或CString无法转换为LPCSTR。原因是工程字符集从MBCS变成了Unicode,而你还用char写字符串。解决时先看报错密集的文件,把字符串字面量统一改成_T("..."),改用CString和_tcscpy这类双字符版本函数。临时方案是把“字符集”改回“使用多字节字符集”,能压掉一大批报错,但会让新的Windows API和部分资源编译器产生兼容副作用,所以只在判断“代码量过大、需要分几天迁移”时才用它过渡,最终还是要回到Unicode。
5.3 现象:Debug能跑,Release一编译就崩
现象是Debug配置下运行正常,切到Release后崩溃或出现明显逻辑错乱。原因有两类:ASSERT和其它调试宏在Release下被编译器删除,某些代码路径连着走两条完全不同的流程;第二类是Release开启优化后,老代码里没初始化的局部变量或被忽略的返回值开始“现原形”。排查时先切到Release并关闭优化,如果崩溃消失,基本可以断定是未初始化变量问题,逐个检查指针和BOOL成员变量并赋初值。如果关闭优化仍然复现,导出调用栈,看看哪个回调函数在Release里被内联掉了。这步有时会追到MFC源码内部,别紧张,重点看栈顶是不是有自己写的函数。
5.4 现象:IWebBrowser2的网页窗口白屏或干脆不显示
现象是对话框上有一块占位,但创建的浏览器控件既不显示,Navigate也没反应。原因来自三个方向:控件创建时样式不对,缺了WS_CHILD或WS_VISIBLE;创建和Navigate在同一个调用链里连续执行,COM接口还没就绪;或者是ActiveX控件在机器上未注册。排查顺序:先确认工具箱里拖WebBrowser到对话框能正常显示,能显示说明组件可用;再检查代码里CreateEx的返回值,S_OK不代表窗口立刻可见,需要再调用ShowWindow;白屏再往后走,用Navigate(_T("about:blank"))做最小验证,如果about:blank可以显示而目标站点不行,局域网、代理设置与IE内核的安全配置各占一半原因。
5.5 现象:LNK2001 unresolved external symbol,链接阶段全军覆没
现象是编译全过,链接时刷出一串无法解析的外部符号,通常是WinMain、AfxWinMain或某个MFC类。原因多半是工程设置里MFC使用方式选错了,或链接器没有找到MFC库。新工程默认在“项目属性->常规->使用MFC”选“使用标准Windows库”,老示例要改成“在共享dll中使用MFC”才能对上内部的模块定义。另一类原因比较隐蔽:老代码把函数定义放在#ifdef _DEBUG的块里,Release下那些符号整段消失了。遇到链接错误先查工程设置,再查条件编译宏,这是唯一能系统化收敛的排查路径。
这四条之外,还有一个频率高到值得写进习惯的坑:目录路径和文件名。老光盘里工程名和文件名经常带空格或中文,VS新版本偶尔会触发一些看似玄学的编译错误。我的习惯是把整个源码复制到D:\Work\Legacy这种纯英文、无空格的路径下再迁移。很多“换台机器就编译不过”的怪事,最后都能追溯到路径差异。
6. 把光盘源码变成自己的代码:三个值得长期保持的阅读与改写习惯
先回应一个当下流行的问题:AI工具能不能帮我读这套源码?能用,但别全信。AI对MFC老API的掌握容易带幻觉,比如把CWnd和CWinApp的职责搞混,或生成一个根本不存在的消息映射参数。我的做法是把AI当检索加速器,每个结论都在MSDN和代码里核对一遍,尤其涉及消息映射和GDI时更要多留个心眼。
6.1 每读一个示例,在代码里留三个注释
问自己三个问题:这段代码在演示哪个消息?它封装的类解决什么通用问题?删掉这段代码程序会怎样?把答案写成注释放在代码后面。一百个示例读下来,这些注释本身就是你的MFC教程笔记,而且比任何别人的笔记都贴合你的知识缺口。
6.2 用git diff回放理解过程
对任何一段想彻底弄明白的代码,先测试原始行为,再做一处改动,用git diff看这次改动影响的范围。如果程序崩溃,二分回滚到最近一次稳定提交。这套习惯能把你从“看起来懂了”推进到“改得出来”,也让“源代码管理”真正成为学习工具,而不只是备份工具。
6.3 花一晚上把示例程序改名重写
不要满足于跑通。把CDialog派生类改名、消息处理函数改名、按钮ID改掉,再添加一个自己的控件。抄一万行不如自己改通一百行。我早年学MFC,有一次为了在自己的程序里显示状态栏,翻遍书也没找到直接能抄的例子,最后照着SetPaneText的签名一点点试,试出来之后那个函数我再没忘过。放心大胆地改,git里存着原始版本,崩了也能回去。希望帮到你。
本文还有配套的精品资源,点击获取