资讯动态

Visual C++习题7-12深度拆解:MFC文档视图与鼠标交互绘图实战

发布时间:2026/10/13 6:07:35 来源:尧图企业网站定制
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需要自己写循环。这道题如果不要求存盘用哪个都行。对比项CArraystd::vector与MFC序列化集成原生支持需手动实现内存连续性连续连续迭代器支持有限完整现代C兼容性一般优秀教材匹配度高低3. 核心实现从消息响应到重绘的完整链路3.1 文档类的数据设计先在文档类头文件里定义图形元素的结构。假设这道题要求画线段那么一个线段需要起点和终点两个CPoint。我习惯把它定义成一个简单的structstruct CLineSegment { CPoint ptStart; CPoint ptEnd; CLineSegment() {} CLineSegment(CPoint s, CPoint e) : ptStart(s), ptEnd(e) {} };然后在文档类里加一个容器std::vectorCLineSegment m_segments;如果你用CArray写法是CArrayCLineSegment, 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::paintEventInvalidate对应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_LOMETRICOnDraw里拿到的坐标含义就变了。这道题不需要改映射模式但如果你发现画出来的线位置不对检查一下是不是不小心改了SetMapMode。鼠标消息里的point参数是客户区坐标和设备坐标一致在MM_TEXT下。但如果你在OnDraw里用了SetWindowOrg或SetViewportOrg做了平移鼠标坐标和绘制坐标就对不上了。这时候需要做坐标转换用DPtoLP或LPtoDP。这道题用不上但知道有这回事以后遇到不会慌。6.3 内存DC的清理如果用双缓冲内存DC和位图用完后必须删除否则GDI泄漏。CreateCompatibleDC创建的DC要DeleteDCCreateCompatibleBitmap创建的位图要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或者加上键盘方向键微调选中线段的位置练习键盘消息和命中测试。每一个小扩展都能带出一串新知识点比单纯刷题有意思得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑