基于MFC MDI架构的矢量图形绘制程序设计与实现

发布时间:2026/7/23 3:28:02
基于MFC MDI架构的矢量图形绘制程序设计与实现 1. 项目概述从零构建一个专业的MDI画图程序最近在整理硬盘翻出来一个十多年前用VC 6.0写的多文档画图程序源码。现在看界面是有点“复古”但里面的设计思路和实现技巧对于理解Windows桌面程序开发、GDI绘图以及经典的文档/视图架构依然非常有价值。很多朋友在入门VC时总觉得MFC庞大复杂一个MDI程序更是让人望而生畏。其实拆开来看它就是一个经典的“文档-视图-框架”协作模型加上一套图形绘制与管理的逻辑。这个项目本质上是一个基于MFC多文档界面MDI的矢量图形绘制程序。你可以把它想象成一个简化版的Visio或早期版本的绘图工具。它的核心能力是在一个主窗口内同时打开多个绘图文档每个文档独立编辑支持绘制直线、矩形、圆形、曲线等基本图元并能进行选择、移动、修改属性等操作。通过解析这个程序的源码我们不仅能学会如何搭建一个MDI应用程序骨架更能深入理解如何在Windows环境下高效、正确地处理图形绘制、消息路由、对象序列化保存/打开文件等一系列关键问题。无论你是想重温经典技术栈还是希望深入理解桌面GUI程序的底层运作机制这个实战解析都能提供一条清晰的路径。2. 核心架构与设计思路拆解2.1 为什么选择MFC MDI在当今Python、Electron、Qt横行的时代再谈MFC似乎有些过时。但选择解析它恰恰是因为其经典性和代表性。MFC封装了原始的Win32 API其文档/视图架构是早期面向对象设计模式在GUI框架中的一次成功实践。MDIMultiple Document Interface作为其标准模板之一完美诠释了“一个主窗口管理多个子窗口”的复杂应用场景。通过学习它你能深刻理解应用、文档、视图、框架的关系这是理解任何现代GUI框架如Qt的QMainWindow、QWidget、Model/View的基础。消息映射机制MFC将Windows消息封装成更易用的消息映射宏这是事件驱动编程的雏形。资源管理菜单、工具栏、图标、字符串表等如何与代码绑定。GDI绘图基础虽然现在有Direct2D、OpenGL但GDI的基本概念设备上下文DC、画笔、画刷、坐标映射仍是图形学的入门必修课。这个画图程序采用MDI是因为绘图工具天然需要多任务处理——用户可能同时草图A、参考图B、修改图C。MDI提供了原生的窗口管理层叠、平铺、图标排列省去了大量自行管理子窗口的复杂度。2.2 程序骨架MFC AppWizard生成了什么用VC 6.0或更高版本的MFC AppWizard创建一个MDI项目假设项目名为DrawDemo你会得到一组核心类CWinApp的派生类CDrawDemoApp应用程序类全局唯一。InitInstance是入口负责注册文档模板。CDocument的派生类CDrawDemoDoc文档类。负责存储数据模型即我们绘制的所有图形对象线、圆、矩形等的列表。它关心的是“数据是什么”而不是“数据怎么画”。CView的派生类CDrawDemoView视图类。负责显示文档数据并处理用户交互鼠标点击、移动、绘图。它关心的是“数据怎么画”和“用户怎么操作”。CMDIFrameWnd的派生类CMainFrame主框架窗口。承载菜单栏、工具栏、状态栏并管理所有的子框架窗口。CMDIChildWnd的派生类CChildFrame子框架窗口。每个打开的绘图文档都对应一个子框架它内部包含一个CDrawDemoView视图。它们的协作关系是App启动创建DocTemplate将DocViewChildFrame绑定。用户点击“新建”App根据模板创建一个新的ChildFrame其内包含一个View和一个Doc。View向Doc请求数据并渲染用户操作通过View修改Doc中的数据Doc通知所有关联的View更新。注意很多新手混淆View和Frame。简单记Frame是窗户框带标题栏、最小化按钮View是窗户玻璃显示内容。MDI中主框架MainFrame管理多个子框架ChildFrame每个ChildFrame里嵌着一块View玻璃。3. 核心数据结构与图形对象建模3.1 图形基类设计一个专业的绘图程序图形对象的管理是核心。我们不能用一堆零散的坐标变量而需要面向对象的设计。首先定义一个所有图形的抽象基类。// DrawObject.h class CDrawObject : public CObject { DECLARE_SERIAL(CDrawObject) // 支持序列化宏 public: CDrawObject(); virtual ~CDrawObject(); // 序列化存盘/读盘 virtual void Serialize(CArchive ar); // 绘制虚函数 virtual void Draw(CDC* pDC) 0; // 判断点是否在图形上用于选择 virtual BOOL PointInObject(const CPoint point) 0; // 移动图形 virtual void Move(const CSize sizeOffset); // 获取图形外接矩形用于重绘区域计算 virtual CRect GetBounds() 0; // 图形属性 COLORREF m_colorLine; // 线条颜色 int m_nLineWidth; // 线宽 // ... 可能还有填充色、线型等 protected: CPoint m_ptStart; // 图形起始点 CPoint m_ptEnd; // 图形结束点 // 注意对于不同图形起点终点意义不同。矩形是左上和右下直线是两端点。 };设计理由继承自CObject是为了利用MFC的运行时类信息和序列化机制。纯虚函数Draw和PointInObject强制子类实现保证了多态性。将公共属性颜色、线宽和基本操作移动、序列化放在基类避免代码重复。3.2 具体图形子类实现以直线和矩形为例展示如何实现基类接口。// DrawObject.cpp - 直线类 IMPLEMENT_SERIAL(CDrawLine, CDrawObject, 1) void CDrawLine::Draw(CDC* pDC) { CPen pen(PS_SOLID, m_nLineWidth, m_colorLine); CPen* pOldPen pDC-SelectObject(pen); pDC-MoveTo(m_ptStart); pDC-LineTo(m_ptEnd); pDC-SelectObject(pOldPen); // 恢复旧画笔重要 } BOOL CDrawLine::PointInObject(const CPoint point) { // 简化判断计算点到直线的距离。实际应用可能需设置一个“感应”阈值。 // 这里使用一个简单的矩形区域近似判断 CRect rect(m_ptStart, m_ptEnd); rect.NormalizeRect(); // 确保左上右下正确 rect.InflateRect(5, 5); // 扩大5个像素的感应范围 return rect.PtInRect(point); } CRect CDrawLine::GetBounds() { return CRect(m_ptStart, m_ptEnd); }// DrawObject.cpp - 矩形类 IMPLEMENT_SERIAL(CDrawRect, CDrawObject, 1) void CDrawRect::Draw(CDC* pDC) { CPen pen(PS_SOLID, m_nLineWidth, m_colorLine); CBrush brush(HS_DIAGCROSS, RGB(255, 0, 0)); // 示例红色交叉画刷 CPen* pOldPen pDC-SelectObject(pen); CBrush* pOldBrush pDC-SelectObject(brush); pDC-Rectangle(m_ptStart.x, m_ptStart.y, m_ptEnd.x, m_ptEnd.y); pDC-SelectObject(pOldBrush); pDC-SelectObject(pOldPen); } BOOL CDrawRect::PointInObject(const CPoint point) { CRect rect(m_ptStart, m_ptEnd); rect.NormalizeRect(); return rect.PtInRect(point); // 矩形判断更直接 } CRect CDrawRect::GetBounds() { return CRect(m_ptStart, m_ptEnd); }关键点解析IMPLEMENT_SERIAL这个宏与DECLARE_SERIAL对应为类添加序列化支持。数字1是版本号如果后续类结构改了如新增成员变量可以升级版本号并在Serialize函数中做兼容处理。设备上下文DC与GDI对象管理这是GDI编程的核心。CDC是设备上下文类你可以把它想象成一张画布和一套绘图工具的集合。每次绘图前我们需要将自定义的画笔CPen、画刷CBrush等GDI对象选入DCSelectObject用完后必须选回旧对象。如果忘记会导致资源泄露或后续绘图混乱。这是一个非常经典的坑。NormalizeRect因为用户拖动鼠标方向不确定m_ptStart和m_ptEnd可能并不代表左上和右下。这个函数能确保矩形坐标是规范的。选择判断的优化PointInObject的实现决定了程序交互的灵敏度。示例中的直线判断过于简化用外接矩形实际应计算点到直线的真实距离。对于曲线如贝塞尔曲线判断逻辑会更复杂。3.3 文档类如何管理图形对象列表文档类CDrawDemoDoc的核心职责是持有并管理所有图形对象。// DrawDemoDoc.h class CDrawDemoDoc : public CDocument { protected: CTypedPtrListCObList, CDrawObject* m_objectsList; // 图形对象链表 CDrawObject* m_pObjTemp; // 临时对象正在绘制中的对象 int m_nDrawType; // 当前绘制类型直线、矩形、圆等 // ... 其他状态如当前颜色、线宽 public: void AddObject(CDrawObject* pObj); // 添加对象到列表 void RemoveObject(CDrawObject* pObj); // 从列表移除 POSITION GetFirstObjectPos() const; // 获取迭代起始位置 CDrawObject* GetNextObject(POSITION pos) const; // 获取下一个对象 // ... 其他操作列表的函数 };为什么用CTypedPtrListCObList, CDrawObject*CObList是MFC提供的可序列化的对象指针链表。CTypedPtrList是类型安全的包装它使得操作CDrawObject*的链表时不需要进行强制类型转换更安全、方便。文档的Serialize函数会遍历这个链表调用每个图形对象的Serialize方法从而完成整个绘图数据的保存和加载。4. 视图类交互与绘制的核心枢纽视图类是程序与用户交互的前线所有鼠标键盘事件都在这里处理。4.1 绘图状态机与鼠标消息处理绘图过程是一个典型的状态机空闲 - 开始绘制 - 绘制中 - 结束绘制。// DrawDemoView.h class CDrawDemoView : public CView { protected: CPoint m_ptStart; // 鼠标按下起点 CPoint m_ptOld; // 上一次鼠标位置用于橡皮筋效果 BOOL m_bDrawing; // 是否正在绘制 int m_nDrawType; // 当前绘制类型从文档或UI获取 // ... };// DrawDemoView.cpp - 鼠标消息处理 void CDrawDemoView::OnLButtonDown(UINT nFlags, CPoint point) { CDrawDemoDoc* pDoc GetDocument(); if (pDoc-m_nDrawType ! DRAW_SELECT) { // 进入绘制模式 m_bDrawing TRUE; m_ptStart point; m_ptOld point; SetCapture(); // 捕获鼠标确保即使移出窗口也能收到消息 } else { // 进入选择模式遍历文档对象列表找到被点击的对象 // ... 选择逻辑 } CView::OnLButtonDown(nFlags, point); } void CDrawDemoView::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDrawing) { CDrawDemoDoc* pDoc GetDocument(); CDC* pDC GetDC(); // 获取设备上下文 // 1. 擦除旧的临时图形橡皮筋效果 DrawTempObject(pDC, TRUE); // TRUE 表示以异或模式R2_NOT绘制即擦除 // 2. 更新临时图形的终点 pDoc-m_pObjTemp-m_ptEnd point; // 3. 绘制新的临时图形 DrawTempObject(pDC, TRUE); ReleaseDC(pDC); // 释放DC m_ptOld point; // 更新旧点 } CView::OnMouseMove(nFlags, point); } void CDrawDemoView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bDrawing) { m_bDrawing FALSE; ReleaseCapture(); // 释放鼠标捕获 CDrawDemoDoc* pDoc GetDocument(); CDC* pDC GetDC(); // 擦除最后的橡皮筋 DrawTempObject(pDC, TRUE); ReleaseDC(pDC); // 将临时对象正式加入文档列表 if (pDoc-m_pObjTemp) { pDoc-m_pObjTemp-m_ptEnd point; pDoc-AddObject(pDoc-m_pObjTemp); pDoc-m_pObjTemp NULL; pDoc-SetModifiedFlag(TRUE); // 标记文档已修改 pDoc-UpdateAllViews(this); // 通知所有视图重绘 } } CView::OnLButtonUp(nFlags, point); }关键技巧与避坑指南SetCapture/ReleaseCapture在开始绘制时捕获鼠标结束绘制时释放。这保证了鼠标移动事件的连续性即使鼠标快速移出视图窗口我们依然能收到OnMouseMove消息避免绘制中断。这是实现流畅绘图交互的关键。橡皮筋效果通过异或模式R2_NOT绘制临时图形。R2_NOT的特点是在同一位置画两次相当于擦除且与背景色无关。在OnMouseMove中我们先在旧位置用R2_NOT画一次擦除更新坐标再在新位置用R2_NOT画一次显示就形成了图形跟随鼠标移动的效果。这比每次都重绘整个视图区域要高效得多。GetDC/ReleaseDC必须成对出现。在MFC中CClientDC或CPaintDC是更常用的RAII资源获取即初始化风格类它们在构造函数中获取DC在析构函数中释放更安全。例如在OnMouseMove中可以使用CClientDC dc(this);。UpdateAllViews这是文档/视图架构的精髓。当文档数据改变如添加了新图形文档调用此函数通知所有与之关联的视图。视图收到通知后会触发OnUpdate或直接Invalidate标记窗口区域无效引发重绘从而更新显示。这实现了数据与显示的分离。4.2 视图重绘 (OnDraw)当窗口需要重绘如被遮挡后显示、滚动、调用Invalidate时框架会调用视图的OnDraw函数。void CDrawDemoView::OnDraw(CDC* pDC) { CDrawDemoDoc* pDoc GetDocument(); ASSERT_VALID(pDoc); // 遍历文档中的所有图形对象依次绘制 POSITION pos pDoc-GetFirstObjectPos(); while (pos ! NULL) { CDrawObject* pObj pDoc-GetNextObject(pos); if (pObj) { pObj-Draw(pDC); } } // 如果有正在绘制的临时对象也绘制出来 if (pDoc-m_pObjTemp) { pDoc-m_pObjTemp-Draw(pDC); } }重要原则OnDraw函数必须能够仅根据文档数据完整地重现当前视图应有的样子。这意味着所有绘图逻辑都应集中在这里或通过CDrawObject::Draw调用。避免在其他地方如鼠标消息处理函数中进行持久的、非临时的绘图操作。临时性的反馈如橡皮筋是例外。5. 序列化实现文件的保存与打开序列化是MFC提供的一种方便的对象持久化机制。它允许你将对象的状态保存到文件序列化或从文件恢复反序列化。5.1 文档类的序列化void CDrawDemoDoc::Serialize(CArchive ar) { m_objectsList.Serialize(ar); // 序列化对象链表 if (ar.IsStoring()) { // 保存可以写入一些文档级的额外信息 // ar m_nDocWidth m_nDocHeight; } else { // 加载读取额外信息 // ar m_nDocWidth m_nDocHeight; } }CObList以及其类型安全包装CTypedPtrList本身支持序列化它会遍历链表对每个元素即CDrawObject*调用其Serialize函数。这就要求CDrawObject及其子类也必须正确实现序列化。5.2 图形对象类的序列化void CDrawObject::Serialize(CArchive ar) { CObject::Serialize(ar); // 调用基类序列化 if (ar.IsStoring()) { ar m_ptStart m_ptEnd m_colorLine m_nLineWidth; } else { ar m_ptStart m_ptEnd m_colorLine m_nLineWidth; } } // 在子类中通常也需要调用基类的Serialize void CDrawLine::Serialize(CArchive ar) { CDrawObject::Serialize(ar); // 先序列化基类成员 // 如果CDrawLine有自己独有的成员在这里序列化 // if (ar.IsStoring()) { ar m_mySpecialData; } // else { ar m_mySpecialData; } }序列化的工作流程用户点击“保存”框架弹出对话框获取文件名。框架创建一个CFile文件对象和一个关联的CArchive归档对象模式为存储。框架调用文档的Serialize(ar)。文档的Serialize调用m_objectsList.Serialize(ar)。链表序列化时会先写入对象计数然后对每个CDrawObject*先写入其类信息通过CRuntimeClass再调用该对象的Serialize方法写入具体数据。打开文件时过程相反。归档对象模式为加载先读取类信息动态创建对应类的对象这就是为什么需要CRuntimeClass和DECLARE_SERIAL/IMPLEMENT_SERIAL然后调用该对象的Serialize读取数据最后将对象指针加入链表。避坑提示序列化版本号。IMPLEMENT_SERIAL(CDrawLine, CDrawObject, 1)中的1是版本号。如果你后续为CDrawLine类增加了新成员变量必须将版本号改为2并在CDrawLine::Serialize中处理版本兼容。读取旧文件版本1时不会读取新增的成员你可能需要设置默认值。这是一个容易忽略的兼容性问题。6. 高级功能实现与优化6.1 图形选择与编辑实现选择功能后程序才从一个“画图板”升级为一个“编辑器”。选择逻辑在OnLButtonDown的选择模式下需要遍历文档对象列表通常从后往前遍历因为后画的图形在上层调用每个对象的PointInObject方法找到第一个被点击的对象将其设置为“选中状态”。void CDrawDemoView::OnLButtonDown(UINT nFlags, CPoint point) { CDrawDemoDoc* pDoc GetDocument(); if (pDoc-m_nDrawType DRAW_SELECT) { CDrawObject* pSelected NULL; POSITION pos pDoc-GetLastObjectPos(); // 从后往前找 while (pos ! NULL) { CDrawObject* pObj pDoc-GetPrevObject(pos); if (pObj pObj-PointInObject(point)) { pSelected pObj; break; } } pDoc-SetSelectedObject(pSelected); // 文档记录选中的对象 // 更新UI如属性工具栏显示选中对象的颜色、线宽 // 可能需要重绘以高亮显示选中对象如绘制控制点 Invalidate(); } // ... 其他模式 }图形编辑选中对象后常见的编辑操作有移动、改变大小、修改属性。移动在OnMouseMove中计算鼠标位移偏移量调用选中对象的Move方法并实时重绘。Move方法实现简单就是修改图形的起点和终点坐标。改变大小更复杂一些。需要在图形周围绘制控制点小矩形。OnLButtonDown时判断点击的是哪个控制点OnMouseMove时根据控制点类型左上、右下等调整对应的图形坐标。修改属性弹出对话框或通过工具栏修改颜色、线宽等直接修改选中对象的成员变量然后重绘。重绘优化当只移动或编辑一个图形时重绘整个视图是浪费的。可以使用InvalidateRect函数只标记需要更新的区域通常是图形旧位置和新位置的并集区域系统会只重绘该区域提高效率。// 移动图形时 CRect rectOld pSelectedObj-GetBounds(); pSelectedObj-Move(sizeOffset); CRect rectNew pSelectedObj-GetBounds(); CRect rectUpdate; rectUpdate.UnionRect(rectOld, rectNew); rectUpdate.InflateRect(2, 2); // 扩大一点边界避免边缘残留 InvalidateRect(rectUpdate, FALSE); // FALSE表示不擦除背景由OnDraw处理6.2 撤销/重做 (Undo/Redo) 实现思路撤销/重做是专业编辑器的标配。一个经典的实现是命令模式。抽象命令基类定义一个CCommand类有Execute执行、Unexecute撤销纯虚函数。具体命令为每个操作添加图形、删除图形、移动图形、修改属性派生一个命令类如CAddObjectCommand。命令对象内部保存操作前后的状态如对于移动命令保存图形指针和移动偏移量。命令历史管理在文档类或一个单独的管理器中维护两个栈undoStack和redoStack。执行操作任何改变文档数据的操作都不直接执行而是创建一个对应的命令对象调用其Execute方法然后将命令压入undoStack并清空redoStack。撤销从undoStack弹出顶部命令调用其Unexecute然后将该命令压入redoStack。重做从redoStack弹出顶部命令调用其Execute再压入undoStack。这需要将之前直接操作文档的代码重构为命令对象增加了架构复杂度但带来了强大的撤销重做能力是软件设计模式的一个很好实践。6.3 滚动视图与坐标映射当绘制的图形超出视图窗口大小时就需要支持滚动。MFC提供了CScrollView类。继承自CScrollView将CDrawDemoView的基类改为CScrollView。设置滚动范围在文档或视图初始化时根据所有图形的总外接矩形调用SetScrollSizes设置滚动逻辑范围。坐标转换CScrollView会自动处理滚动条消息和视口原点。但在鼠标事件中传入的point是设备坐标相对于窗口客户区。我们需要使用OnPrepareDC或CScrollView::OnScroll相关的函数或者手动调用DeviceToDevice实际上是先DeviceToLogical进行坐标转换才能得到正确的逻辑坐标与图形数据一致的坐标系。OnDraw中的处理CScrollView传递到OnDraw的pDC已经设置了合适的视口原点因此直接用逻辑坐标绘图即可。使用CScrollView后OnDraw中的绘图逻辑基本不变但鼠标事件处理中的坐标必须转换这是滚动视图开发中最容易出错的地方。7. 常见问题与调试技巧实录7.1 图形闪烁问题这是GDI绘图中最常见的问题。闪烁是由于OnDraw中绘制复杂图形时屏幕更新擦除背景-绘制图形不同步造成的。解决方案双缓冲这是最根本的解决方法。原理是在内存中创建一个与屏幕兼容的位图CBitmap先在这个内存DC上绘制所有图形绘制完成后一次性将整个位图拷贝到屏幕DC上。void CDrawDemoView::OnDraw(CDC* pDC) { CDrawDemoDoc* pDoc GetDocument(); CRect rectClient; GetClientRect(rectClient); CDC dcMem; CBitmap bitmapMem; dcMem.CreateCompatibleDC(pDC); bitmapMem.CreateCompatibleBitmap(pDC, rectClient.Width(), rectClient.Height()); CBitmap* pOldBitmap dcMem.SelectObject(bitmapMem); // 先用背景色填充内存位图 dcMem.FillSolidRect(rectClient, RGB(255, 255, 255)); // 在内存DC上绘制所有图形调用原有的绘制逻辑 POSITION pos pDoc-GetFirstObjectPos(); while (pos ! NULL) { CDrawObject* pObj pDoc-GetNextObject(pos); pObj-Draw(dcMem); } // 一次性拷贝到屏幕 pDC-BitBlt(0, 0, rectClient.Width(), rectClient.Height(), dcMem, 0, 0, SRCCOPY); dcMem.SelectObject(pOldBitmap); }禁止背景擦除在视图类中重写OnEraseBkgnd函数直接返回TRUE告诉Windows不要擦除背景由OnDraw完全负责绘制。这可以避免一次全屏清空造成的闪烁。但必须和双缓冲配合使用否则会有残影。BOOL CDrawDemoView::OnEraseBkgnd(CDC* pDC) { return TRUE; // 我们已经自己处理了整个客户区的绘制 }7.2 内存泄漏排查GDI对象和C对象都需要妥善管理。GDI对象泄漏确保每个SelectObject调用都保存了旧对象句柄并在使用后恢复。使用CClientDC、CPaintDC等RAII类可以简化DC管理。在Debug模式下VC会在输出窗口报告GDI对象泄漏这是重要的调试信息。C对象泄漏确保new的每个图形对象在文档销毁或从列表中移除时都被delete。使用MFC集合类如CTypedPtrList时需要手动遍历列表删除对象或者在列表析构前调用RemoveAll并删除。更现代的做法是使用智能指针但在MFC环境中需要小心处理与序列化的兼容性。7.3 程序崩溃访问无效指针或数组越界序列化后指针失效序列化读入时对象是动态创建的。确保文档的DeleteContents函数在新建或打开文档前被调用正确清空了旧列表并删除了所有对象。迭代器失效在遍历图形列表如绘制或选择的过程中如果另一个线程虽然MFC GUI单线程或消息处理函数修改了列表如删除元素会导致迭代器POSITION失效。因此应避免在遍历过程中直接修改列表。如果需要可以先收集要操作的对象指针遍历完成后再进行操作。使用ASSERT和TRACE在Debug版本中大量使用ASSERT_VALID(pDoc),ASSERT(pObj ! NULL)等断言可以及早发现逻辑错误。使用TRACE宏输出调试信息跟踪程序流程和变量状态。7.4 界面与业务逻辑耦合过紧在简单的Demo中绘图类型m_nDrawType、当前颜色等状态可能直接放在文档或视图里。但在稍大的项目中这会造成混乱。一个更好的模式是引入一个应用程序状态管理器或工具管理器。创建一个CToolManager单例类管理当前激活的工具选择工具、直线工具、矩形工具、当前颜色、线宽等全局状态。视图和文档通过这个管理器获取状态而不是直接互相传递或持有。当用户从工具栏或菜单改变工具时更新管理器的状态。这样界面逻辑工具选择与核心数据模型文档和视图逻辑绘图交互就解耦了。回顾整个项目从搭建MDI框架到设计图形对象体系再到实现交互、序列化、高级编辑每一步都踩在Windows桌面程序开发的经典路径上。虽然MFC已不再是主流但其中蕴含的消息循环、对象封装、文档视图分离、资源管理、GDI绘图等思想是GUI编程的基石。通过亲手实现这样一个功能相对完整的画图程序你对这些概念的理解将不再是浮于书本而是有了扎实的、可运行的代码作为支撑。当你再去学习Qt、WinForms乃至WPF时会发现很多概念是相通的只是换了一套更现代的API和语法糖而已。