Visual C++习题7-12拆解:MFC文档视图与图形绘制实战
1. 从一道课本习题说起为什么值得认真拆解1.1 这道题到底在练什么看到“Visual C——黄维通《Visual C面向对象与可视化程序设计》——习题7-12”这个标题很多人的第一反应是这不就是一本经典教材里的一道课后题吗没错但如果你只把它当成一道“做完就扔”的作业那就太可惜了。这道题出现在“面向对象与可视化程序设计”这本教材的第七章通常对应的是文档/视图架构下的图形绘制与交互相关内容。换句话说它练的不是简单的语法填空而是让你把C的面向对象思想真正落到一个可视化的Windows程序里。我自己当年第一次做这道题的时候也是照着书上的示例代码敲了一遍编译通过、窗口弹出来、图形画上去就觉得“我会了”。结果后来真正接手一个需要自绘控件的项目才发现当时根本没理解文档与视图之间数据怎么同步、重绘消息什么时候触发、坐标映射到底怎么算。所以这道题的价值不在于它本身有多难而在于它是一个最小可运行的完整闭环从数据存储到界面呈现从消息响应到重绘刷新全都能在一个小工程里跑通。这道题适合谁来参考如果你是正在学Visual C的学生它能帮你把零散的API串成一条线如果你是从其他语言转过来、想理解Windows原生开发的老手它能让你快速摸清MFC那套消息机制的脾气如果你只是好奇“可视化程序设计”到底怎么个可视化法它也是一个很好的切入点。下面我就按自己实际复现这道题的过程把里面的门道一层层拆开。1.2 为什么选文档/视图架构而不是对话框这里要先说一个关键选择。教材第七章的习题通常默认使用单文档SDI或多文档MDI的应用程序框架而不是基于对话框的程序。为什么因为对话框程序虽然上手快但它的消息循环和绘制逻辑是扁平的所有东西都堆在一个类里很难体现“面向对象”的分工。而文档/视图架构把数据文档类和显示视图类拆开天然适合演示数据与界面分离这个核心思想。我实测下来用单文档模板生成的项目默认会给你四个核心类应用类、主框架类、文档类、视图类。习题7-12往往要求你在视图类里响应鼠标消息把用户点击的坐标记录下来存到文档类里然后在OnDraw函数里根据这些数据画图。这个流程走一遍你就明白了为什么MFC要把事情搞得这么“绕”——因为一旦数据变了你只需要调用UpdateAllViews所有视图都会自动重绘不用你手动去刷新每一个窗口。这种设计在小型程序里显得多余但在需要多视图同步、或者需要撤销重做、序列化保存的场景下就是救命稻草。注意如果你用的是较新版本的Visual Studio创建MFC项目时可能需要先在安装器里勾选“适用于最新v143生成工具的C MFC”组件否则模板列表里根本找不到单文档选项。这个坑我踩过折腾了半小时才发现是组件没装。2. 核心细节拆解消息、坐标与重绘2.1 鼠标消息的捕获与坐标转换习题7-12通常要求实现类似“点击鼠标画点”或“拖拽画线”的功能。这里第一个要搞清楚的就是鼠标消息的捕获。在视图类里你需要用类向导添加WM_LBUTTONDOWN、WM_MOUSEMOVE、WM_LBUTTONUP这几个消息的处理函数。但这里有个细节OnLButtonDown拿到的坐标是客户区坐标单位是像素原点在客户区左上角。如果你直接把这个坐标存起来在OnDraw里用dc.MoveTo画图没问题。但如果你后续要做缩放、滚动就必须考虑逻辑坐标与设备坐标的转换。我当时的做法是在OnLButtonDown里用CPoint point point;拿到坐标后先调用CView::OnLButtonDown(nFlags, point);让基类处理默认行为然后再把自己的逻辑加进去。为什么要先调基类因为如果你不调某些默认的焦点切换、滚动条处理可能会失效。这个顺序问题教材上不一定写但实际调试时如果发现点击后窗口不响应键盘了多半就是这里漏了。坐标存储我建议用一个CArrayCPoint, CPoint或者std::vectorCPoint放在文档类里。为什么放文档类因为视图类可能被销毁重建比如窗口最小化再恢复但文档类的数据是持久的。如果你把点存在视图类的成员变量里窗口一重绘数据可能就丢了。这个坑我在早期写绘图板的时候踩得结结实实画了一屏幕的点最小化再打开全没了。2.2 OnDraw与双缓冲避免闪烁的关键OnDraw是视图类的绘制入口每次窗口需要重绘时都会被调用。习题7-12的图形通常比较简单直接画就行。但如果你画的点或线比较多或者有拖拽预览就会看到明显的闪烁。这时候就需要双缓冲。原理很简单先在内存里创建一个兼容DC和兼容位图把所有图形画到内存DC上最后一次性BitBlt到屏幕DC。这样屏幕只刷新一次人眼就感觉不到闪烁了。具体代码大概长这样void CMyView::OnDraw(CDC* pDC) { CMyDoc* pDoc GetDocument(); ASSERT_VALID(pDoc); if (!pDoc) return; CRect rect; GetClientRect(rect); CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap memBitmap; memBitmap.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); CBitmap* pOldBitmap memDC.SelectObject(memBitmap); memDC.FillSolidRect(rect, RGB(255, 255, 255)); for (int i 0; i pDoc-m_points.GetSize(); i) { memDC.SetPixel(pDoc-m_points[i], RGB(0, 0, 0)); } pDC-BitBlt(0, 0, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBitmap); }这里有个性能细节SetPixel画单个点非常慢如果点数上万建议改用SetPixelV或者直接操作DIB位图内存。我实测过画一万个点SetPixel要几百毫秒而直接写内存不到十毫秒。当然习题阶段不用这么较真但知道这个差异以后做真实项目就不会抓瞎。提示双缓冲的兼容位图一定要在每次OnDraw里根据当前客户区大小重新创建或者至少在OnSize里更新。如果你在构造函数里创建一次就不管了窗口拉大后右边和下边会画不上去。2.3 文档与视图的通信UpdateAllViews的正确用法当你在视图里捕获了鼠标点击把点加到文档类的数组后必须通知视图重绘。标准做法是调用pDoc-UpdateAllViews(NULL);。这个函数会遍历所有关联的视图调用它们的OnUpdate最终触发OnDraw。但这里有个容易忽略的点UpdateAllViews的第二个参数lHint可以用来传递提示信息第三个参数pHint可以传指针。如果你只想更新某个矩形区域可以在OnUpdate里调用InvalidateRect而不是Invalidate这样能减少重绘量。我见过有人在OnLButtonDown里直接调用Invalidate()然后指望OnDraw能拿到最新数据。这在小程序里能跑但逻辑上是错的——因为Invalidate只是标记区域无效真正的重绘发生在消息队列空闲时。如果你在Invalidate之后立刻去读文档数据可能读到的还是旧的。正确的顺序永远是先改数据再通知更新。3. 完整实操流程从新建项目到跑通3.1 项目创建与类向导配置打开Visual Studio新建项目选择“MFC应用程序”名称随便起一个比如“DrawDemo”。在向导里应用程序类型选“单文档”项目样式可以勾选“MFC标准”。其他保持默认点完成。生成后解决方案资源管理器里会有五个项目文件夹头文件、源文件、资源文件等。你需要关注的是DrawDemoDoc.h/cpp和DrawDemoView.h/cpp。接下来添加鼠标消息处理。在类视图中右键点击CDrawDemoView选择“类向导”在消息列表里找到WM_LBUTTONDOWN、WM_MOUSEMOVE、WM_LBUTTONUP分别添加处理程序。VS会自动在头文件里生成afx_msg void OnLButtonDown(UINT nFlags, CPoint point);这样的声明并在cpp里生成空实现。然后在文档类里添加数据成员。打开DrawDemoDoc.h在public区域加一行CArrayCPoint, CPoint m_points;。注意CArray需要包含afxtempl.h不过MFC项目通常已经预编译了。如果你用std::vector记得在文档类头文件里#include vector。3.2 消息处理函数的编写细节OnLButtonDown的实现void CDrawDemoView::OnLButtonDown(UINT nFlags, CPoint point) { CDrawDemoDoc* pDoc GetDocument(); ASSERT_VALID(pDoc); if (!pDoc) return; pDoc-m_points.Add(point); pDoc-UpdateAllViews(NULL); CView::OnLButtonDown(nFlags, point); }OnMouseMove如果你要做拖拽画线需要判断nFlags MK_LBUTTON表示左键按下状态。这时候可以记录一个临时终点在OnDraw里画一条从上一个点到当前点的线。但注意OnMouseMove触发非常频繁如果每次都UpdateAllViews程序会卡。优化方法是只在必要时更新或者用InvalidateRect只刷新变化区域。OnLButtonUp里通常做收尾工作比如把临时线固化到文档数据里然后清空临时状态。3.3 编译运行与常见报错处理第一次编译大概率会遇到这几个问题报错信息原因解决方法error C2065: “CArray”: 未声明的标识符缺少模板头文件在文档类cpp里#include afxtempl.herror LNK2019: 无法解析的外部符号MFC库链接配置不对项目属性→常规→使用MFC→在共享DLL中使用MFC窗口弹出但点击无反应消息映射缺失检查cpp里是否有ON_WM_LBUTTONDOWN()宏画点后最小化再恢复点消失数据存在视图类把数据移到文档类我印象最深的一次是消息映射忘了加编译链接都通过运行就是没反应。查了半天才发现类向导生成的BEGIN_MESSAGE_MAP里少了一行。所以每次添加消息处理程序后最好扫一眼那个宏块。注意如果你在OnDraw里用了GetDocument()一定要加ASSERT_VALID(pDoc)和空指针判断。在程序关闭时视图可能先于文档销毁这时候GetDocument()返回NULL不判断就会崩。4. 常见问题与排查技巧实录4.1 图形闪烁、残影、刷新不全这是可视化程序设计里最经典的三连问。闪烁的根源是背景擦除和前景绘制不同步解决方案就是前面说的双缓冲。残影通常是因为你只画了新图形没擦掉旧图形。比如拖拽画线时鼠标移动过程中画了临时线但下一次移动前没把上一条临时线擦掉。解决办法是在OnDraw里先FillSolidRect用背景色清空或者用InvalidateRect精确刷新旧线所在矩形。刷新不全多半是Invalidate的矩形算错了或者双缓冲位图尺寸没跟上窗口变化。我自己的排查顺序是先看OnDraw里有没有清空背景再看双缓冲位图是不是每次重绘都重建最后检查UpdateAllViews有没有被正确调用。这三步能解决九成以上的显示问题。4.2 坐标偏移与滚动视图的坑如果你给视图加了滚动条在OnInitialUpdate里调用SetScrollSizes那么鼠标坐标和绘图坐标就不在同一个坐标系里了。OnLButtonDown拿到的point是设备坐标而OnDraw里的pDC默认是逻辑坐标。你需要用CPoint logicalPoint point;然后CClientDC dc(this); dc.DPtoLP(logicalPoint);转换。或者更简单在OnDraw里用pDC-SetMapMode(MM_TEXT)保持默认然后统一用设备坐标。但滚动之后设备坐标的原点会变所以还是得转。这个坑我在做一个可滚动的画布时卡了一整天画出来的点总是偏一个滚动偏移量。后来发现是OnPrepareDC里MFC已经帮你调整了视口原点但鼠标消息里的坐标没调。解决办法是在OnLButtonDown里先调用CView::OnLButtonDown然后手动减去GetScrollPos的值。4.3 内存泄漏与GDI对象管理MFC程序里CDC、CBitmap、CPen这些GDI对象如果创建了不释放会导致内存泄漏和GDI句柄耗尽。双缓冲里创建的memDC和memBitmap是局部变量析构时会自动释放但SelectObject选入的pOldBitmap必须记得选回来。我见过有人忘了选回程序跑一会儿就画不出东西了因为GDI句柄用完了。排查内存泄漏可以用VS自带的调试工具在程序退出时如果输出窗口有Detected memory leaks!就说明有new没delete。MFC对象一般不用手动delete但如果你用了new CPoint这种就必须自己管。4.4 常见问题速查表现象可能原因快速修复点击无反应消息映射缺失或基类未调用检查ON_WM_LBUTTONDOWN和CView::OnLButtonDown画完不显示没调UpdateAllViews在数据修改后立即调用窗口缩放后图形错位坐标未随窗口调整在OnSize里重新计算或使用相对坐标程序退出报错文档指针为空加ASSERT_VALID和空判断图形颜色不对画笔/画刷未选入检查SelectObject的返回值5. 从习题到项目还能怎么扩展5.1 加入序列化保存图形数据教材里的习题通常不要求保存但实际项目里数据持久化是刚需。MFC的文档类自带Serialize函数你只需要在m_points的存取上加上ar m_points.GetSize();和循环读写每个点。然后在CDrawDemoDoc::Serialize里调用。这样文件菜单里的“保存”和“打开”就能直接用了。我试过不到二十行代码就能让画好的图存成二进制文件下次打开原样恢复。5.2 支持撤销与重做撤销重做是绘图程序的标配。实现思路是用一个命令栈每次操作画点、画线、清空都封装成一个命令对象压入栈中。撤销时弹出命令并执行反向操作。MFC没有内置的命令栈但你可以用std::stack自己搭。关键是文档数据要能被命令对象修改所以命令类需要持有文档指针。这个扩展能把习题的难度提升一个档次但做完之后你对面向对象的理解会深很多。5.3 多视图同步与拆分窗口单文档程序默认只有一个视图但你可以通过CSplitterWnd把窗口拆成左右或上下两块每块显示同一个文档的不同部分。这时候UpdateAllViews的威力就体现出来了你在左边画一个点右边自动同步显示。这个功能在对比查看、多角度编辑的场景下非常实用。实现步骤稍微复杂需要在主框架类里重写OnCreateClient创建拆分窗口并指定视图类。我个人在实际操作中的体会是这道习题就像一把钥匙它本身只是一道题但背后连着的是整个MFC文档/视图架构的设计哲学。你把它跑通只是知道了“怎么做”你把它拆开再组装一遍才明白“为什么这么做”。后面那些扩展每一个都能单独写一篇长文但核心思路都是一样的数据归文档显示归视图消息驱动更新。把这个原则刻在脑子里以后不管是用Qt还是WPF甚至是Web前端的数据绑定你都能看到相似的影子。最后再分享一个小技巧调试绘图程序时把OnDraw里的绘制代码先注释掉只画一个背景色确认刷新逻辑没问题再逐步加图形。这样能把显示问题和数据问题分开排查省下大量瞎猜的时间。