简介:这是一份围绕MFC对话框编程的图形界面开发实例,面向初学Windows桌面应用或正在学习C++类库封装原理的开发者。资源以ShowDialog工程为核心,完整呈现从CDialog派生窗口类、重写OnInitDialog完成控件初始化,到通过DoModal启动模态对话框、借助消息映射处理按钮事件的实现链路,适合对照源码理解MFC事件驱动模型与对话框生命周期。压缩包内共54个文件,主要包含7个cpp源文件、8个h头文件、1个rc资源脚本及可执行exe文件,另有obj、pch、pdb等编译中间文件,便于查看完整工程结构;包体整体约3.34MB。这份实例已吸引1657人学习浏览,下载后可获得包括登录信息对话框、主框架及文档视图在内的多模块源码与工程配置,对系统掌握对话框控件操作、消息响应及界面资源管理具有直接参考价值。
1. MFC 图形及界面编程实例:为什么一个绘图课设会把界面做成黑匣子
做 MFC 图形及界面编程实例,最常见的起点不是“我要学 GDI”,而是“我要交一个能画图的 Windows 程序”。课设通常是:做一个图形编辑器,能画直线、矩形、椭圆,能选颜色线宽,窗口拉伸不闪烁。但很多人一上手就发现,画好的图形一刷新就没了,窗口一拉就花屏。问题不是不会画线,而是把设备上下文、消息映射、双缓冲这些机制当成了黑匣子。
这套方案就是把“MFC 图形 + 界面编程”做成一个能交、能跑、能答辩的完整实例:从需求拆解讲到 GDI 绘图,从消息映射讲到控件联动,再到验收和答辩,每一步都给可照抄的结构和代码。适合正在做 MFC 课设或毕设的学生,也适合给团队出 Windows 绘图客户端方案的从业者。下面按方案结构逐层展开。
2. 需求分析与总体设计:图元数据、绘制引擎、交互层、界面层四块怎么切
2.1 需求拆解:这个 MFC 实例到底要交付什么
功能上,一个能上台面的 MFC 图形绘图实例通常包含这些点:
- 基本图元:直线、矩形、椭圆、圆角矩形,这是验收的底线;
- 属性设置:画笔颜色、线宽、填充画刷,能通过工具栏或参数面板修改;
- 鼠标交互:按住拖拽绘制,拖动过程中实时预览,松开鼠标落定;
- 文本输出:能在绘图区写字,答辩里很加分的功能;
- 重绘表现:窗口拉伸、最小化还原、被遮挡后再显示都不能闪烁;
- 保存扩展:图元数据结构化管理,为以后的序列化保存留接口。
这里有一个关键的判断:很多同学把“绘图”理解成“在屏幕上画一笔是一笔”,这是最大的误解。真正的绘图程序必须把绘制结果和绘制数据分开。屏幕上的像素只是数据的一次渲染,图元对象 CShape 才是程序维护的核心,OnDraw 每次执行都是把这份数据重新画一遍。做到这个分离,刷新丢失、闪烁、保存扩展三个问题会同时变得清晰。
非功能需求上,我额外盯两条:GDI 句柄数不能随操作持续增长;窗口在任意尺寸下重绘正常。这两条是“能跑”和“能交付”的分界线,也是答辩时导师最容易动手试的两个地方。把它们写进需求清单,后面验收就有依据。
2.2 应用框架选型:为什么选 SDI 加 CView 而不是对话框
MFC 里做界面程序一般有三个选择:对话框程序(CDialog)、单文档程序(SDI,CView)、多文档程序(MDI)。我的建议是直接选 SDI + CView,理由有三个。
第一,CView 框架天然把数据与显示分开:数据放在 CMyDoc 里,CMyView::OnDraw 只负责把文档数据画出来,正好落在上一节说的“绘制数据与绘制分离”上。第二,框架已经处理了 WM_PAINT 的触发时机,窗口无效时框架自动调用 OnDraw,不需要自己写消息循环,代码量小很多。第三,把基类从 CView 换成 CScrollView,可以免费获得滚动条支持,画布超出窗口时能滚动查看,是很实用的加分项。
对话框程序也不是不能用,但客户区绘图要自己响应 WM_PAINT、自己管理无效区域,代码量反而更大;界面一复杂,控件和绘图区挤在一起,维护成本高。对图形编辑器这种以绘图区为主体的程序,SDI 是更顺的选择,也符合 MFC 文档视图架构的经典用法。
2.3 类结构与资源设计:从 MainFrame 到 Shape 的职责划分
整体架构按四层划分,每一层只做自己的事:
| 层次 | 类 | 职责 |
|---|---|---|
| 界面层 | CMainFrame | 菜单、工具栏、状态栏,参数面板承载 |
| 文档层 | CMyDoc | 持有图元容器,提供图元增删改查 |
| 视图层 | CMyView | 鼠标交互、OnDraw 重绘、双缓冲 |
| 图元层 | CShape 及其派生类 | 各自实现 Draw 和 HitTest |
文档层的核心容器是 CArray<CShape*, CShape*>,视图通过 GetShapeCount 和 GetShapeAt 遍历它。图元层先定义一个抽象基类 CShape,派生 CLineShape、CRectShape、CEllipseShape、CTextShape,每种图元把“怎么画自己”封装在自己的 Draw 里。基类还要一个 HitTest 虚函数,用来做选中和删除,这个接口现在用不上,但加上它,整个类设计才完整。
资源设计上用资源编辑器做三样东西:一个 IDR_MAINFRAME 菜单,包含文件、编辑、图形三个菜单项;一个工具栏,放图形类型按钮和颜色按钮;一个设置对话框,放线宽滑块、颜色下拉、填充选项。资源 ID 建议按前缀规范:IDM_ 表示菜单项,IDTB_ 表示工具栏按钮,IDD_ 表示对话框,资源多了也不会乱。
这个结构最大的好处是扩展性强:要新增一种图元,只需要加一个 CShape 派生类,实现 Draw 和 HitTest,再在视图的鼠标逻辑里加一个分支,其余代码不动。答辩时讲“我设计了图元基类,新增图形不修改原有绘制流程”,这句话的分量比贴十行代码都重。
3. 图形绘制核心实现:GDI 设备上下文与双缓冲绘图
3.1 设备上下文:CPaintDC、CClientDC、CWindowDC 怎么选
设备上下文(DC)是 Windows GDI 绘图的中转站,所有绘图函数都是往 DC 上画,MFC 把它封装成 CDC 及其子类。最常用的三个是 CPaintDC、CClientDC、CWindowDC,选择依据是“在哪里画、什么时候画”,用错场景会出很诡异的现象。
| DC 类型 | 合法场景 | 典型用途 |
|---|---|---|
| CPaintDC | 只在 WM_PAINT 处理函数内 | OnDraw 里的被动重绘 |
| CClientDC | 输入消息处理函数内 | 鼠标拖动时主动绘制预览 |
| CWindowDC | 任何场景 | 在非客户区绘制,一般用不到 |
CPaintDC 的构造和析构会分别调用 BeginPaint 和 EndPaint,告诉系统“无效区域我已经重新画过了”。如果 OnDraw 里不用 CPaintDC 而用 CClientDC,会出现一个经典翻车现象:窗口明明重绘了,系统却认为无效区域还没被验证,于是一遍遍发送 WM_PAINT,CPU 直接飙高,窗口像抽风一样闪。反过来,在鼠标点击这种主动绘图场景里用 CPaintDC 也是错的,它只在 WM_PAINT 上下文里合法。
我的习惯是:被动重绘用 CPaintDC,主动绘制用 CClientDC,绝不混用。这个判断标准很简单,写代码前先问一句“这次绘制是被系统要求的,还是我主动发起的”。
3.2 GDI 对象生命周期:CPen、CBrush、CFont 的创建与释放
GDI 对象是系统资源,不是普通内存,new 出来不删、SelectObject 之后不恢复,时间一长句柄就泄漏。以画笔为例:
void CMyView::DrawWithPen(CDC* pDC) { int nSavedDC = pDC->SaveDC(); // 保存当前 DC 全部状态 CPen pen(PS_SOLID, 2, RGB(255, 0, 0)); // 栈对象,退出作用域自动析构 CPen* pOldPen = pDC->SelectObject(&pen); // 选入新画笔,保存旧画笔 pDC->MoveTo(10, 10); pDC->LineTo(200, 10); pDC->SelectObject(pOldPen); // 恢复旧画笔 pDC->RestoreDC(nSavedDC); // 恢复 DC 其他状态 }这里的两个设计是刻意的。一是用栈对象 CPen pen 而不是 new,CPen 的析构函数会调用 DeleteObject 释放系统句柄,栈对象保证函数无论从哪条路径退出都会析构,这是防泄漏的第一道保险。二是 SaveDC / RestoreDC 包裹整段绘制逻辑,DC 里选入的画笔、画刷、字体、位图、裁剪区都是状态,SaveDC 一次性记住,RestoreDC 全部还原,比逐个恢复旧对象省事,也不容易漏。
CBrush、CFont、CBitmap 的生命周期管理逻辑和 CPen 完全一样。特别提醒 CBitmap:如果位图还选在 DC 里就执行 DeleteObject,释放可能失败或留下隐患,必须先 SelectObject 换出再释放。凡是选入 DC 的对象,释放顺序永远是“先换出,再删除”。
3.3 图元绘制实例:从 Shape 基类的 Draw 到视图 OnDraw 的调用链
图元层先定义抽象基类,再派生具体图形:
class CShape { public: CShape() {} virtual ~CShape() {} virtual void Draw(CDC* pDC) const = 0; // 纯虚函数,每种图元自己实现 virtual BOOL HitTest(CPoint pt) const = 0; protected: CPoint m_ptStart; CPoint m_ptEnd; }; class CLineShape : public CShape { public: void Draw(CDC* pDC) const override { pDC->MoveTo(m_ptStart); pDC->LineTo(m_ptEnd); } BOOL HitTest(CPoint pt) const override { // 计算点到线段距离,小于阈值视为命中 // 直线命中阈值一般取 5 个像素 return DistanceToSegment(pt, m_ptStart, m_ptEnd) < 5; } };视图的 OnDraw 不关心具体是什么图形,只负责遍历文档数据:
void CMyView::OnDraw(CDC* pDC) { CMyDoc* pDoc = GetDocument(); for (int i = 0; i < pDoc->GetShapeCount(); i++) { pDoc->GetShapeAt(i)->Draw(pDC); } }这条调用链很关键:OnDraw 不知道也不关心具体画的是什么,它只负责“把文档数据完整渲染一遍”。新增一个椭圆图元,OnDraw 一行不用改。图元类里存的是起点和终点这样的逻辑坐标,而不是屏幕像素,Draw 的时候由 pDC 完成逻辑坐标到设备坐标的映射,窗口大小怎么变,图形比例都不会乱。
3.4 双缓冲绘制:消除窗口闪烁的标准做法
闪烁的根源是“擦背景”和“画前景”分成了两步。窗口无效时,系统先用背景色刷一遍客户区,这一步发生在 OnEraseBkgnd;然后才调 OnDraw 画内容。两次操作之间的间隙,用户看到的是一块空白,窗口越小、重绘越频繁,闪得越明显。
标准解法是双缓冲:先在内存里把背景和前景完整画好,再用 BitBlt 一次性整体拷贝到屏幕,用户只看到最终结果,看不到中间过程。
void CMyView::OnDraw(CDC* pDC) { CRect rcClient; GetClientRect(&rcClient); // 当前客户区尺寸 CDC memDC; memDC.CreateCompatibleDC(pDC); // 与屏幕 DC 兼容的内存 DC CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); memDC.FillSolidRect(rcClient, RGB(255, 255, 255)); // 先铺背景 CMyDoc* pDoc = GetDocument(); for (int i = 0; i < pDoc->GetShapeCount(); i++) pDoc->GetShapeAt(i)->Draw(&memDC); // 再画图元 pDC->BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); // 一次上屏 memDC.SelectObject(pOldBmp); // 换出位图再析构,顺序不能反 }注意:内存位图尺寸必须跟随客户区变化,通常在 WM_SIZE 里重建,否则拉伸后 BitBlt 会被裁剪。
这段代码最容易翻车的两处:一是位图宽高必须和客户区一致,窗口拉伸后如果位图还是旧尺寸,BitBlt 出来的画面会被裁剪,解决办法是把位图重建放到 WM_SIZE 响应里;二是配合滚动视图时,BitBlt 的源坐标要考虑文档滚动偏移,否则画面和鼠标位置对不上。另外,让 OnEraseBkgnd 直接返回 TRUE,表示背景已经由双缓冲处理,可以少一次擦除动作,配合双缓冲效果最好。
4. 界面交互编程:消息映射、鼠标拖拽与控件联动
4.1 消息映射机制:MFC 怎么把 WM_ 消息送到处理函数
MFC 程序的界面响应全靠消息映射,几个宏把 Windows 消息和处理函数绑定起来,分发逻辑交给框架处理。视图类最常用的一组映射是鼠标消息和重绘消息:
BEGIN_MESSAGE_MAP(CMyView, CView) ON_WM_LBUTTONDOWN() // 左键按下 ON_WM_MOUSEMOVE() // 鼠标移动 ON_WM_LBUTTONUP() // 左键抬起 ON_WM_ERASEBKGND() // 擦除背景 ON_WM_SIZE() // 窗口尺寸变化 END_MESSAGE_MAP()对应到类里,每个宏绑定一个 afx_msg 成员函数,比如 ON_WM_LBUTTONDOWN() 对应 void OnLButtonDown(UINT nFlags, CPoint point)。写代码时容易犯的错是“声明了函数但忘了在映射表里加宏”,结果鼠标怎么点都没反应,还以为是消息没发出来。这类问题用调试器看不到常规调用栈,属于典型的黑匣子问题,排查习惯是先查映射表,再查函数签名是否和框架要求完全一致,最后才怀疑消息本身。
4.2 鼠标拖拽绘制:按下、移动、抬起三段式状态机
图形绘制交互适合用一个三段式状态机描述:按下左键记录起点并创建当前图元,移动鼠标更新终点触发预览重绘,抬起左键把图元落定进文档。
void CMyView::OnLButtonDown(UINT nFlags, CPoint point) { m_bDrawing = TRUE; m_ptStart = point; m_ptEnd = point; m_pCurrentShape = CreateCurrentShape(); // 按工具栏选中的类型创建 CView::OnLButtonDown(nFlags, point); } void CMyView::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDrawing) { m_ptEnd = point; // 只更新终点 InvalidateRect(NULL, FALSE); // 请求重绘预览,不擦背景 } CView::OnMouseMove(nFlags, point); } void CMyView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bDrawing) { m_ptEnd = point; m_pCurrentShape->SetEndPoint(m_ptEnd); GetDocument()->AddShape(m_pCurrentShape); // 落定为正式图元 m_bDrawing = FALSE; } CView::OnLButtonUp(nFlags, point); }这里有个细节值得多说一句:预览阶段不要让图元进入文档数组,否则每移动一次鼠标,数组里就多一个半成品,撤销、保存、重绘全乱。正确做法是 OnDraw 里单独加一段预览绘制逻辑:
if (m_bDrawing) { DrawPreviewShape(pDC); // 用 m_ptStart 和 m_ptEnd 临时画一个 }提示:预览图元不进文档数组,这是拖拽绘制不产生垃圾数据的关键,务必在代码审查里检查这一条。
移动鼠标触发预览重绘时,我习惯用 InvalidateRect(NULL, FALSE)。第一个参数表示整个客户区无效,第二个 FALSE 表示不要擦背景。如果不传 FALSE,WM_ERASEBKGND 会先把客户区刷白,预览图形就会一下一下闪。配合上一章的双缓冲,这个重绘流程是平滑的。
4.3 控件联动:工具栏、颜色对话框与参数面板
界面联动是 MFC 图形实例里最能体现工程感的部分。工具栏放图形选择按钮,点“直线”“矩形”时调用视图的 SetDrawType 切换绘制模式;颜色按钮弹 CColorDialog,选完颜色后把 RGB 值存下来,绘制时设置画笔。
void CMyView::OnColorPick() { CColorDialog dlg(m_crPen); // 以当前颜色作为初始颜色 if (dlg.DoModal() == IDOK) { m_crPen = dlg.GetColor(); // 保存画笔颜色 } }如果参数面板是对话框,我一般不直接共享成员变量,而是让视图提供 SetPenWidth、SetFillMode 这类对外接口,对话框持有视图指针,控件通知消息到来时调用接口,再由视图触发重绘。这样视图内部怎么组织数据,对话框不关心,耦合降到最低。状态栏显示当前鼠标坐标和图形类型,在 OnMouseMove 里用 SetWindowText 更新文本即可,开销很小,不需要触发重绘。
4.4 刷新策略:InvalidateRect 与 UpdateWindow 的使用边界
界面编程最后一个容易混淆的点是刷新函数。InvalidateRect 只是“标记无效”,不立即重绘,要等当前消息处理完、消息循环空闲后统一重绘;UpdateWindow 则是“立刻发送 WM_PAINT”,区域无效马上重绘。
通常只有两种场景用 UpdateWindow:一是在一次消息处理里“改了数据、立刻要看结果”;二是窗口创建完成后主动刷首帧。绝大多数场景用 InvalidateRect 就够了,它会把多次无效合并成一次重绘,性能更好。如果拖动预览时每帧都用 UpdateWindow,会强制每一帧同步绘制,和双缓冲的思路互相打架,画面反而更卡。记住一个原则:“批量标记、统一重绘”,大多数 MFC 界面刷新问题都是这么解决的。
5. 避坑指南:MFC 图形编程最容易翻车的 5 个地方
这几条不是理论推演,是我在类似项目里真踩过的坑,每条都按“现象→原因→解决”讲,你对照自己的代码排查就行。
5.1 窗口一拉伸就花屏、拖出残影
现象:拖动窗口边缘改变大小时,客户区出现大块白斑或黑块,上一帧的画面残留在新区域,图元像被撕碎了。
原因:窗口尺寸变化后,新增的客户区没有被完整重绘。如果 OnDraw 只画图元而没有先填充背景,新增区域就是未初始化内容;如果双缓冲里的位图尺寸没有跟随窗口更新,BitBlt 只能覆盖旧区域,残影就留下了。两个原因常常同时存在。
解决:第一,OnDraw 第一行用 FillSolidRect 或 FillRect 把背景色铺满整个客户区;第二,双缓冲的内存位图在 WM_SIZE 里重新创建,宽高取新的客户区尺寸;第三,OnEraseBkgnd 返回 TRUE,避免系统再擦一遍背景。按这个顺序处理,拉伸花屏基本绝迹。排查时可以临时加一个 GetLastError 输出,看 BitBlt 是否失败。
5.2 画好的图刷新后就消失
现象:画完一个矩形,窗口一被遮挡或最小化,再回来图形没了;或者重绘后只剩下最后画的一个图形。
原因:图形只被画在了 DC 上,没有存进文档数据。OnDraw 是“重放”逻辑,它本身不保存任何像素状态,窗口每次无效都必须靠图元数据重建画面。只画不存,刷新自然丢失。只留下最后一个图形的情况,多半是把当前图元加进了文档但没存起点终点,或者每次重绘前清了文档。
解决:严格遵守“数据与绘制分离”。鼠标抬起时把图元 AddShape 进 CMyDoc,OnDraw 遍历数组重画。任何图形的产生路径必须走“创建对象→加入文档→触发重绘”三步,不允许只画不加文档。这条应该作为代码审查的固定检查项,我每次写完交互逻辑都会对着文档里图元数量核对一遍。
5.3 GDI 句柄泄漏,程序越跑越卡
现象:程序运行十几分钟后绘图卡顿,窗口按钮变灰;任务管理器里进程句柄数持续上涨不回落。
原因:典型的有两个。一是用 new CPen、new CBrush 创建堆对象,用完后没有 delete;二是在 DC 里 SelectObject 了新的 GDI 对象,用完没有恢复旧对象,对象无法被安全释放。GDI 句柄是系统级资源,进程默认上限大约一万个,泄漏到上限,整个程序的绘制就废了。
解决:我个人的习惯是全程不用 new 创建 GDI 对象,一律用局部栈对象,函数结束自动析构。必须用堆对象的地方,用智能指针或者确保 delete 和 SelectObject 恢复成对出现。再配合 SaveDC / RestoreDC 包裹一段绘制逻辑,对象的选入与恢复不会乱。验收时打开任务管理器观察句柄数,反复拖拽、改色、清空 50 次,句柄数应该基本持平,这是最直观的泄漏测试。
5.4 DrawText 输出文本带着一块不透明背景
现象:在绘图区输出文字,文字后面压着一个白色或黑色的矩形块,把图元盖住。
原因:设备上下文默认的背景模式是 OPAQUE,文字输出时会把文字矩形框的背景色填出来。如果当前 DC 背景色和画布底色不一致,这块色块就非常扎眼。尤其是用双缓冲后,内存 DC 的背景色经常和屏幕底色不同,更容易暴露这个问题。
解决:在文字绘制前调用 pDC->SetBkMode(TRANSPARENT),让背景透明,只画字形。再配合 SetTextColor 设置文字颜色,这两个函数配合到位,文本显示就干净了。如果确实要做带背景的文本标签,那就自己画一个填充矩形再写字,不要依赖默认背景模式,显式控制总比让系统猜好。
5.5 双缓冲内存位图尺寸和客户区对不上
现象:加上双缓冲后,窗口拉伸,画面只更新了一部分,或者 BitBlt 的区域明显小于客户区,边缘是花屏。
原因:内存位图在某个时刻按当时的客户区尺寸创建,之后窗口拉大,位图没有跟着重建,BitBlt 只能按旧尺寸拷贝,多出来的区域没有内容可拷。还有一种情况是 WM_SIZE 里重建位图时,旧位图还选在 DC 里没有先换出,导致重建失败。
解决:把“创建兼容位图”这个动作从 OnDraw 里挪到 WM_SIZE 响应里,或者在 OnDraw 里每次绘制前检查位图尺寸,发现不等于当前客户区就删除重建。重建时注意先 SelectObject 换出旧位图再删除,顺序反了会释放失败。窗口创建的消息顺序是 WM_CREATE 先于 WM_SIZE,所以首次 OnDraw 前一定有一次 WM_SIZE 先执行,位图尺寸不会是零,这一点可以放心。
6. 验收与答辩:怎样证明这套 MFC 图形实例真的能交付
验收不能等到答辩前一晚才开始,我一般拆成功能、稳定性、代码三个清单。功能清单按需求逐条点:直线、矩形、椭圆、圆角矩形、文本五种图元都能画;颜色、线宽、填充切换后新图元立即生效;拖拽预览流畅,无闪烁无残影;文本背景透明;清空功能正常。稳定性清单对应避坑章节:窗口拉伸、最小化还原、快速切换图形类型各操作 20 次,确认没有花屏;任务管理器句柄数在连续操作 50 次后没有明显上涨;窗口从 300x200 拉回最大,画面完整。代码清单主要是确认图元数据与绘制分离、GDI 对象全部为栈对象或正确释放。
答辩环节导师大概率问三个问题。第一个:MFC 里的设备上下文是什么?回答要点是“DC 是一张虚拟画布,GDI 对象是画笔和颜料,绘图函数是在画布上落笔,MFC 用 CDC 封装了这套机制”。第二个:画面为什么闪、你是怎么解决的?回答要点是“擦背景和画前景分两步,双缓冲把两步合到内存里完成,一次 BitBlt 上屏,用户只看到最终结果”。第三个:新增一种图形要改哪些地方?回答要点是“新建 CShape 派生类实现 Draw 和 HitTest,视图里加一个类型分支,OnDraw 不用动”。三个问题答顺,整个方案的可信度就立住了。
这套方案对应的完整代码和文档,按这个结构整理好就是一份可以直接提交的课设或毕设附件。我给自己定的收尾动作是强制走一遍“最小化→拉伸→遮挡→连续绘制”四步操作,加一句“句柄数观察”,跑两遍没异常才敢说交付。后来这个动作被我带进了每个界面项目,成了固定流程,省过不少临场翻车的尴尬。希望帮到你。
本文还有配套的精品资源,点击获取