1. 从一道课本习题说起:为什么值得认真拆解
1.1 这道习题到底在练什么
看到“Visual C++——黄维通《Visual C++面向对象与可视化程序设计》——习题7-12”这个标题,很多人第一反应是:不就是课本上一道课后题吗,有什么好讲的。但我实际把这道题翻出来重新做了一遍之后,发现它其实是一道非常典型的“承上启下”型题目——它卡在MFC文档视图框架和对话框编程的交界处,既要求你理解面向对象的封装思路,又要求你动手把可视化控件和底层数据打通。
教材里第七章讲的是文档与视图,第十二题通常落在“在视图类中响应鼠标或键盘消息,动态修改数据并触发重绘”这一类需求上。换句话说,它逼着你把“数据从哪来、存在哪、怎么显示、怎么刷新”这条链路完整走一遍。这条链路恰恰是后来做任何Windows桌面程序都绕不开的基本功。
我见过太多人学VC++的时候,前面几章还能跟着敲,一到文档视图就懵了,因为MFC把很多细节藏在了框架代码里,你不主动去扒,就永远不知道OnDraw是谁调的、Invalidate到底触发了什么。这道习题的价值就在于:它足够小,小到你可以完整跟踪一遍消息流向;又足够完整,完整到能覆盖“输入—处理—存储—渲染”四个环节。
1.2 适合谁来啃这道题
如果你是正在上这门课的学生,这道题值得你花两三个小时认真做,而不是抄一份代码交差。如果你已经工作但当年MFC学得稀里糊涂,想找个由头把文档视图重新理一遍,这道题也是个很好的切入点。甚至如果你现在主要写Qt或者WPF,回头看看MFC怎么处理重绘和消息映射,对理解现代框架的设计取舍也有帮助——你会发现很多概念是一脉相承的,只是换了层皮。
需要的基础不多:会建MFC单文档工程,知道怎么用ClassWizard加消息处理函数,能看懂简单的C++类继承关系。就这些。剩下的边做边查完全来得及。
2. 动手之前:把题目需求翻译成技术语言
2.1 习题7-12的常见形态与需求拆解
不同印次的教材习题编号可能略有出入,但第七章第十二题通常围绕“交互式绘图”或“数据动态更新”展开。我手上这份题目的大意是:在单文档应用中,通过鼠标操作在客户区绘制或修改图形,并且要求数据变化后视图能够正确刷新,同时保证窗口被遮挡再恢复后图形不丢失。
这句话里藏着三个关键技术要求。第一,“通过鼠标操作”意味着你要处理WM_LBUTTONDOWN、WM_MOUSEMOVE、WM_LBUTTONUP这一组消息,而且要注意鼠标捕获的问题。第二,“数据变化后视图正确刷新”意味着你不能把图形直接画在DC上就完事,必须把数据存到文档类里,让视图根据数据重绘。第三,“遮挡恢复后不丢失”直接点明了必须走OnDraw这条路,因为只有OnDraw会在WM_PAINT时被框架调用。
很多人第一次做的时候会把图形直接画在鼠标消息处理函数里的DC上,运行起来看着没问题,一最小化再恢复,图形全没了。这就是没理解文档视图分离的典型症状。所以这道题真正的考点不是“怎么画线”,而是“数据该放在哪一层”。
2.2 为什么必须走文档视图分离这条路
MFC的文档视图架构本质上是一个简化版的MVC。CDocument负责持有数据,CView负责呈现数据,框架负责在合适的时候调用OnDraw。你可能会觉得这套东西啰嗦——我就画个图,为什么要分两个类?但当你需要支持多视图、需要做撤销重做、需要把数据序列化到文件时,这套分离的价值就出来了。
具体到这道题,正确的做法是:在文档类里定义一个容器(比如CArray或std::vector)存放图形元素,鼠标消息在视图类里处理,但处理的结果是往文档的数据容器里增删改,然后调用UpdateAllViews或Invalidate触发重绘。OnDraw里遍历容器,把每个元素画出来。这样无论窗口怎么遮挡、怎么缩放,只要数据在,画面就能重建。
注意:
Invalidate只是标记客户区无效,真正重绘发生在消息队列空闲时。如果你在循环里反复调用Invalidate,不会立即重绘,而是合并成一次WM_PAINT。这个机制既提高了效率,也意味着你不能依赖Invalidate之后立刻读取绘制结果。
2.3 方案选型:用CArray还是std::vector
教材年代通常推荐MFC自己的CArray、CObArray这类容器,因为它们和MFC的序列化机制配合得比较好。但现在实际开发中,std::vector更通用,性能也更好。我的建议是:如果这道题只是交作业,用CArray没问题,和教材一致,老师看着顺眼;如果你想顺便练一下现代C++写法,用std::vector完全可行,只要注意在文档类析构时清理干净就行。
两者在用法上的主要差异是:CArray的Add和GetAt,对应std::vector的push_back和operator[]。序列化方面,CArray有现成的Serialize支持,std::vector需要自己写循环。这道题如果不要求存盘,用哪个都行。
| 对比项 | CArray | std::vector |
|---|---|---|
| 与MFC序列化集成 | 原生支持 | 需手动实现 |
| 内存连续性 | 连续 | 连续 |
| 迭代器支持 | 有限 | 完整 |
| 现代C++兼容性 | 一般 | 优秀 |
| 教材匹配度 | 高 | 低 |
3. 核心实现:从消息响应到重绘的完整链路
3.1 文档类的数据设计
先在文档类头文件里定义图形元素的结构。假设这道题要求画线段,那么一个线段需要起点和终点两个CPoint。我习惯把它定义成一个简单的struct:
struct CLineSegment { CPoint ptStart; CPoint ptEnd; CLineSegment() {} CLineSegment(CPoint s, CPoint e) : ptStart(s), ptEnd(e) {} };然后在文档类里加一个容器:
std::vector<CLineSegment> m_segments;如果你用CArray,写法是CArray<CLineSegment, CLineSegment&> m_segments;。注意CArray的第二个模板参数是引用类型,这个细节容易写错。
文档类还需要提供一个公开接口供视图类调用,比如AddSegment、ClearAll、GetSegmentCount、GetSegmentAt。这样视图类不需要直接访问容器,降低耦合。这也是面向对象封装的基本要求——数据成员私有,通过方法访问。
实操心得:很多同学图省事把容器设成public,视图类直接操作。小作业这样写能跑,但一旦题目要求“支持多种图形”或者“支持撤销”,你就会发现改起来非常痛苦。养成封装的习惯,从这道题开始。
3.2 视图类的鼠标消息处理
视图类需要响应三个鼠标消息。在WM_LBUTTONDOWN里记录起点并设置一个标志位表示正在拖拽;在WM_MOUSEMOVE里如果标志位为真,就更新临时终点并触发重绘以显示橡皮筋效果;在WM_LBUTTONUP里把最终线段写入文档并清除标志位。
这里有个关键细节:鼠标捕获。如果不调用SetCapture,当鼠标移出客户区时,视图就收不到WM_MOUSEMOVE和WM_LBUTTONUP了,拖拽会“断掉”。正确做法是在OnLButtonDown里调用SetCapture(),在OnLButtonUp里调用ReleaseCapture()。
void CMyView::OnLButtonDown(UINT nFlags, CPoint point) { m_bDragging = TRUE; m_ptStart = point; m_ptCurrent = point; SetCapture(); CView::OnLButtonDown(nFlags, point); } void CMyView::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDragging) { m_ptCurrent = point; Invalidate(); } CView::OnMouseMove(nFlags, point); } void CMyView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bDragging) { m_bDragging = FALSE; ReleaseCapture(); CMyDoc* pDoc = GetDocument(); pDoc->AddSegment(CLineSegment(m_ptStart, point)); Invalidate(); } CView::OnLButtonUp(nFlags, point); }注意GetDocument()返回的是CDocument*,需要强制转换成你的文档类指针。MFC提供了GetDocument()的内联版本,在视图类里通常已经帮你做好转换,如果没有就自己写一个。
3.3 OnDraw里的绘制逻辑
OnDraw是重绘的核心。它接收一个CDC*指针,你所有的绘制都通过这个DC进行。绘制顺序很重要:先画已经确定的线段,再画正在拖拽的临时线段。
void CMyView::OnDraw(CDC* pDC) { CMyDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); // 画已确定的线段 for (size_t i = 0; i < pDoc->GetSegmentCount(); i++) { CLineSegment seg = pDoc->GetSegmentAt(i); pDC->MoveTo(seg.ptStart); pDC->LineTo(seg.ptEnd); } // 画拖拽中的临时线段 if (m_bDragging) { CPen pen(PS_DOT, 1, RGB(128, 128, 128)); CPen* pOldPen = pDC->SelectObject(&pen); pDC->MoveTo(m_ptStart); pDC->LineTo(m_ptCurrent); pDC->SelectObject(pOldPen); } }这里用虚线笔画临时线段,是为了和已确定的实线区分开,视觉上更像“橡皮筋”。CPen的创建和选入选出一对操作要成对出现,否则GDI对象会泄漏。虽然现代Windows对GDI对象泄漏有一定的容忍度,但养成好习惯没坏处。
注意:
OnDraw可能被调用非常频繁(比如窗口拖动时),所以里面的代码要尽量高效。不要在OnDraw里做耗时计算或频繁的内存分配。像上面每次画临时线段都创建CPen,在拖拽场景下其实可以优化成成员变量,但作为习题这样写足够清晰。
3.4 消息映射的添加方式
如果你用Visual Studio的类向导添加消息处理函数,它会自动帮你在头文件里加声明、在cpp里加BEGIN_MESSAGE_MAP条目。但手工添加也不难,关键是三处要对应:头文件里的afx_msg声明、cpp里的消息映射宏、以及函数实现。
// 头文件 afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg void OnMouseMove(UINT nFlags, CPoint point); afx_msg void OnLButtonUp(UINT nFlags, CPoint point); // cpp消息映射 BEGIN_MESSAGE_MAP(CMyView, CView) ON_WM_LBUTTONDOWN() ON_WM_MOUSEMOVE() ON_WM_LBUTTONUP() END_MESSAGE_MAP()这三处任何一处写错,消息都不会被响应,而且编译器不一定报错,运行时就是没反应。这是初学者最容易踩的坑之一。我的排查方法是:在消息处理函数第一行打个断点,如果断点不进,就是消息映射没配对。
4. 常见问题与排查技巧实录
4.1 图形画不出来或画出来立刻消失
这是最高频的问题。原因通常有三种:一是数据没存进文档类,只存在了视图的局部变量里;二是存进去了但没调用Invalidate,视图不知道要重绘;三是OnDraw里遍历容器的逻辑写错了,比如循环边界不对。
排查顺序建议从后往前:先在OnDraw里打个断点,看容器里到底有没有数据。如果有数据但没画出来,检查MoveTo/LineTo的坐标是不是在客户区外。如果没数据,回到鼠标消息处理函数,看AddSegment有没有被调用。如果调用了但容器还是空,检查GetDocument()返回的指针是不是空,或者是不是转换错了类型。
4.2 拖拽时鼠标移出窗口就断了
前面提过,这是没有SetCapture的典型症状。但还有一种情况是调用了SetCapture却忘了ReleaseCapture,导致鼠标被“锁死”,其他程序收不到鼠标消息。所以这两个调用必须成对出现,而且ReleaseCapture要保证在所有退出路径上都被执行到。
更稳妥的写法是用一个小的RAII包装,或者至少在OnLButtonUp和OnCancelMode里都处理。OnCancelMode会在一些意外情况下被调用(比如弹出模态对话框),这时候也应该释放捕获并取消拖拽状态。
4.3 重绘时闪烁严重
当图形比较多或者拖拽频繁时,Invalidate会导致整个客户区重绘,闪烁明显。解决办法是只 invalidate 需要更新的区域,比如InvalidateRect传入一个包含旧临时线段和新临时线段的矩形。更彻底的办法是使用双缓冲:在内存DC里画好整幅图,再一次性BitBlt到屏幕DC。
双缓冲的典型写法是在OnDraw里创建兼容DC和兼容位图,所有绘制先画到内存DC,最后拷贝。这样虽然多占一点内存,但闪烁基本消除。对于这道习题,如果老师不要求,可以不做双缓冲;但如果你想做得漂亮一点,加上双缓冲是加分项。
| 问题现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 图形不显示 | 数据未存入文档 | 在OnDraw断点查看容器 | 确保AddSegment被调用 |
| 遮挡后图形丢失 | 直接画在DC未走OnDraw | 检查绘制代码位置 | 移到OnDraw中 |
| 拖拽中断 | 未SetCapture | 检查鼠标消息处理 | 成对调用Capture/Release |
| 重绘闪烁 | 全屏Invalidate | 观察闪烁范围 | 用InvalidateRect或双缓冲 |
| 消息无响应 | 消息映射缺失 | 断点不进函数 | 检查三处映射是否对应 |
4.4 编译通过但运行崩溃
MFC程序崩溃常见于空指针解引用。比如GetDocument()返回空,或者容器越界访问。GetDocument()在文档未创建时可能返回空,虽然正常流程下不会,但如果你在构造函数里调用它就会出问题。容器越界通常是循环条件写成了<=而不是<。
另一个隐蔽的崩溃点是GDI对象泄漏累积到一定数量后系统拒绝创建新对象。虽然单次运行不一定触发,但反复拖拽画线几百次后可能出问题。用任务管理器看GDI对象数,如果只增不减,就是泄漏了。检查所有SelectObject是否都有对应的恢复操作。
实操心得:我习惯在调试版本里加
ASSERT_VALID(pDoc)和ASSERT(i < count)这类断言,Release版本里它们会被自动去掉,不影响性能,但调试时能提前暴露很多问题。这个习惯从做课本习题时就可以开始培养。
5. 从这道题延伸出去:还能练什么
5.1 加上撤销与重做
如果这道题做完了觉得不过瘾,可以试着加撤销功能。最简单的实现是在文档类里维护一个操作栈,每次AddSegment时把操作压栈,撤销时弹出并反向操作。但线段的“反向操作”就是删除,所以栈里存的应该是“添加了第几个元素”这样的信息,撤销时删掉对应元素即可。
更通用的做法是命令模式,每个操作封装成一个对象,支持Do和Undo。这个模式在MFC里没有现成支持,需要自己写,但写一遍之后对面向对象的理解会深很多。教材后面章节如果讲到序列化,还可以把操作栈也存盘,实现跨会话的撤销。
5.2 支持多种图形
把CLineSegment抽象成一个基类CShape,派生CLine、CRectangle、CEllipse等。文档类里存CShape指针的容器,OnDraw里调用虚函数Draw。这样加新图形不需要改OnDraw,只需要加一个新类。这就是多态的典型应用,也是教材强调面向对象的原因。
不过要注意,存指针的话需要管理生命周期,文档类析构时要遍历删除。用std::unique_ptr或者MFC的CObArray(它负责删除对象)可以简化管理。这道题如果只画线,用不上多态;但作为延伸练习,值得一试。
5.3 把数据存成文件
MFC的序列化机制用起来很简单:在文档类里重写Serialize,在里面依次序列化每个成员。对于std::vector,需要自己写循环;对于CArray,可以直接调用它的Serialize。存盘后再打开,图形应该原样恢复。
序列化时要注意版本兼容。如果以后加了新字段,旧文件读进来会出问题。可以在文件头写一个版本号,读取时根据版本号决定怎么解析。这个技巧在实际项目中非常常用,课本上不一定讲,但早点知道没坏处。
5.4 迁移到现代框架的思路
如果你已经熟悉了MFC这套,想试试Qt,会发现概念对应关系很清晰:CDocument对应数据模型,CView::OnDraw对应QWidget::paintEvent,Invalidate对应update(),鼠标消息对应mousePressEvent等。Qt的信号槽机制比MFC的消息映射更灵活,但底层的“数据变化触发重绘”思路是一样的。
甚至在前端开发里,React的“状态驱动视图”也是同一个思想:你不直接操作DOM,而是改状态,框架根据状态重新渲染。MFC的文档视图虽然老,但它把这个思想用C++的方式表达得很清楚。把这道习题做透,以后再学其他框架,看到“数据绑定”“响应式更新”这些词,你会有一种“哦,这不就是当年那个OnDraw吗”的感觉。
6. 一些容易被忽略的细节
6.1 坐标系的坑
MFC默认的坐标原点在客户区左上角,x向右,y向下。这和数学里的笛卡尔坐标系y轴方向相反。如果你要做图表类程序,需要自己做转换。这道题画线不涉及坐标转换,但如果你在OnDraw里算坐标时用了数学公式,记得y要取反或者调整。
另外,CPoint的x和y是LONG类型,不是int。在32位和64位下LONG都是32位,所以问题不大,但和std::vector的size_t混用时要注意符号问题。循环变量用size_t,和GetSegmentCount的返回类型保持一致,避免编译器警告。
6.2 设备坐标与逻辑坐标
默认情况下,MFC的DC使用MM_TEXT映射模式,逻辑坐标和设备坐标一致,单位是像素。如果你改了映射模式(比如MM_LOMETRIC),OnDraw里拿到的坐标含义就变了。这道题不需要改映射模式,但如果你发现画出来的线位置不对,检查一下是不是不小心改了SetMapMode。
鼠标消息里的point参数是客户区坐标,和设备坐标一致(在MM_TEXT下)。但如果你在OnDraw里用了SetWindowOrg或SetViewportOrg做了平移,鼠标坐标和绘制坐标就对不上了。这时候需要做坐标转换,用DPtoLP或LPtoDP。这道题用不上,但知道有这回事,以后遇到不会慌。
6.3 内存DC的清理
如果用双缓冲,内存DC和位图用完后必须删除,否则GDI泄漏。CreateCompatibleDC创建的DC要DeleteDC,CreateCompatibleBitmap创建的位图要DeleteObject。而且位图选入DC后,必须先选回原 bitmap 再删除,否则删不掉。
CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // ... 在memDC上绘制 ... pDC->BitBlt(0, 0, rect.Width(), rect.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); // bmp和memDC析构时自动清理用栈对象的好处是析构自动调用,不用手动DeleteDC。但SelectObject的恢复还是要手动做,因为析构不会帮你恢复。这个细节在MSDN上有说明,但很多人不看,结果就是泄漏。
6.4 调试技巧:用TRACE输出中间状态
MFC的TRACE宏在调试输出窗口打印信息,比断点更轻量。比如在AddSegment里TRACE("Added segment, count=%d\n", m_segments.size());,运行的时候在输出窗口就能看到每次添加。Release版本里TRACE会被编译掉,不影响性能。
配合ASSERT使用,可以在关键路径上做检查。比如ASSERT(point.x >= 0 && point.y >= 0)确保鼠标坐标有效。这些调试手段在课本上往往一笔带过,但实际做项目时能省很多时间。从做习题时就开始用,养成习惯。
7. 最后聊几句实在的
这道习题本身不难,代码量也不大,但它像一面镜子,照出你对MFC文档视图的理解是浮在表面还是真的通了。我见过有人把代码敲完运行成功就扔了,也见过有人把消息流向画成图、把每个函数的调用时机标清楚。后者后来做Windows开发明显更顺,因为底层的东西一旦通了,上层怎么变都不慌。
如果你现在正在做这道题,我的建议是:别急着找现成代码抄。先自己画一张消息流向图,标出鼠标按下、移动、抬起分别触发什么、数据怎么流动、什么时候重绘。画完再动手写,写不出来再查。这样一遍下来,比抄十遍都管用。
另外,Visual Studio的MFC向导生成的代码里有很多注释和宏,别跳过它们。那些//{{AFX_...}}和//}}AFX_...标记是ClassWizard用来定位代码的,手工改的时候别删。虽然现在新版本VS对MFC的支持不如从前,但基本功能都在,遇到问题查MSDN或者社区老帖,大部分都能解决。
这个内容后续还可以这样扩展:把线段换成贝塞尔曲线,练习PolyBezier;或者加上键盘方向键微调选中线段的位置,练习键盘消息和命中测试。每一个小扩展都能带出一串新知识点,比单纯刷题有意思得多。