简介:本资源是一份面向C++初学者与Windows桌面开发入门者的MFC图形绘制实践项目,聚焦于使用Microsoft Foundation Class库在视图中实现直线、矩形和椭圆的动态绘制,解决GUI程序中基础GDI绘图能力落地难的问题。压缩包共51个文件,包含7个核心.cpp源文件(如作业三View.cpp、Graphic.cpp)、8个.h头文件(定义视图、文档及辅助类)、1个可执行exe文件(直接运行验证效果),以及编译生成的obj、pdb、sbr等调试与中间文件,整体3.69MB,结构完整,适合作为VC6.0环境下MFC单文档绘图实验的标准工程模板。已有396人学习下载,读者可直接导入Visual C++ 6.0工程(含.dsw/.dsp文件),复现OnDraw()重写、CDC绘图函数调用(LineTo/DrawRect/Ellipse)、鼠标消息响应(WM_LBUTTONDOWN)及笔刷颜色设置等关键环节,掌握MFC视图绘图全流程与典型目录组织方式。
1. MFC绘图课设不翻车:从“点一下没反应”到“拖拽实时预览”的完整闭环
很多同学拿到“MFC绘制直线、矩形、椭圆”这个题目时,第一反应是:不就是调几个 CDC::MoveTo/LineTo、Rectangle、Ellipse 吗?抄个例程改改颜色就交了。结果一调试——鼠标左键按下去没反应,松开才画出来;画完一个再画第二个,前一个就没了;想拖拽调整大小?直接崩溃。这不是代码写错了,是根本没理解 MFC 绘图的底层契约:它不是“画完就扔”的一次性操作,而是一套依赖消息循环、设备上下文生命周期、客户区重绘机制的完整状态系统。这套方案专为需要真正交付、能答辩演示、能应对导师追问细节的课程设计或毕业设计准备。它覆盖从原始需求拆解(为什么必须用 OnLButtonDown/OnMouseMove/OnLButtonUp 三段式?)、CDC 获取时机(为什么不能在 OnPaint 外随便 GetDC?)、图形暂存策略(内存DC+位图缓冲怎么防闪烁),到最终支持撤销、颜色线宽配置、坐标实时显示等加分项。如果你正卡在“画不出来”“闪得看不清”“画完就丢”,或者导师问“为什么不用 GDI+?”“重绘时怎么保证图形不重叠?”,这份方案就是为你写的血泪经验沉淀。
2. 总体架构设计:基于消息驱动的四层状态机与双缓冲绘图引擎
MFC 绘图绝不是把 API 调用堆在一起就能跑通的。我带过三届学生做类似课设,90% 的翻车都源于架构失焦:要么把所有逻辑塞进 OnPaint,导致重绘失控;要么在鼠标消息里直接 CDC::Rectangle,结果窗口最小化再恢复就全黑。这套方案采用清晰分层,把“用户意图”“图形状态”“渲染输出”彻底解耦,让每一行代码都有明确归属。
2.1 四层职责划分:从输入到呈现的严格流水线
- 输入层(Input Layer):只负责捕获原始鼠标事件(WM_LBUTTONDOWN/WM_MOUSEMOVE/WM_LBUTTONUP),不做任何绘图操作,仅记录坐标、更新当前操作状态(如
m_nDrawState = DRAW_RECT)。关键点:所有坐标统一转换为客户区逻辑坐标(ClientToScreen → ScreenToClient),避免 DPI 缩放错位。 - 状态层(State Layer):维护所有已绘制图形的完整描述数据(CShape 类集合),每个图形对象封装类型、起始/结束点、颜色、线宽、是否填充等属性。新增图形时不触发重绘,只追加到
m_shapeList。 - 渲染层(Render Layer):在 OnPaint 中统一执行。先创建兼容内存DC(CreateCompatibleDC),再选入位图(CreateCompatibleBitmap),最后遍历
m_shapeList逐个绘制到位图上。绝不直接在屏幕DC上画——这是防闪烁的铁律。 - 输出层(Output Layer):OnPaint 最后一步,用 BitBlt 将内存位图一次性拷贝到屏幕DC。整个过程无闪烁、无残留、可精确控制刷新区域。
提示:很多同学在 OnMouseMove 里直接 CDC::Rectangle,看似“实时”,实则每次移动都触发一次屏幕重绘,CPU 占用飙升且极易撕裂。正确做法是:OnMouseMove 只更新临时图形(如
m_tempRect),并在 OnPaint 中与正式图形一起绘制——这样既保证视觉实时性,又守住重绘边界。
2.2 双缓冲核心实现:内存DC + 位图缓冲的完整代码链
// CMyView.h 中声明关键成员 private: CBitmap m_bmpBuffer; // 内存位图 CDC* m_pMemDC; // 内存DC指针(需在OnPaint中创建/销毁) CRect m_rectClient; // 当前客户区大小,用于位图尺寸校准 // CMyView.cpp 中 OnPaint 实现 void CMyView::OnPaint() { CPaintDC dc(this); // 屏幕DC,仅用于最终输出 CRect rectClient; GetClientRect(&rectClient); // 1. 检查位图尺寸是否匹配客户区,不匹配则重建 if (m_bmpBuffer.GetSafeHandle() == NULL || rectClient.Width() != m_rectClient.Width() || rectClient.Height() != m_rectClient.Height()) { if (m_bmpBuffer.GetSafeHandle()) m_bmpBuffer.DeleteObject(); m_bmpBuffer.CreateCompatibleBitmap(&dc, rectClient.Width(), rectClient.Height()); m_rectClient = rectClient; } // 2. 创建/复用内存DC if (m_pMemDC == nullptr) { m_pMemDC = new CDC(); m_pMemDC->CreateCompatibleDC(&dc); } m_pMemDC->SelectObject(&m_bmpBuffer); // 3. 清空内存位图背景(关键!否则旧图形残留) CBrush brush(RGB(255, 255, 255)); m_pMemDC->FillRect(&rectClient, &brush); // 4. 绘制所有已保存图形(来自 m_shapeList) for (int i = 0; i < m_shapeList.GetSize(); i++) { m_shapeList[i]->Draw(*m_pMemDC); } // 5. 绘制临时图形(如鼠标拖拽中的矩形预览) if (m_nDrawState != DRAW_NONE && m_bDrawTemp) { DrawTemporaryShape(*m_pMemDC); } // 6. 一次性输出到位图到屏幕 dc.BitBlt(0, 0, rectClient.Width(), rectClient.Height(), m_pMemDC, 0, 0, SRCCOPY); }这段代码的核心设计逻辑在于:位图生命周期与客户区强绑定。每次窗口大小变化,位图必须重建,否则 BitBlt 会越界或拉伸失真。m_pMemDC作为类成员指针而非栈变量,是为了避免频繁创建销毁开销——但必须在析构函数中手动释放(见第 4 章)。FillRect清空背景是防残留的关键,漏掉这句,每次重绘都会叠加旧图,几秒钟后界面就糊成一片。
2.3 图形对象抽象:CShape 基类与具体派生类的设计契约
所有图形(直线、矩形、椭圆)必须继承自统一基类CShape,强制约定三个接口:
// CShape.h class CShape { public: virtual ~CShape() {} virtual void Draw(CDC& dc) = 0; // 核心绘制方法 virtual bool IsPointInShape(CPoint pt) = 0; // 用于后续选中/删除功能 virtual void GetBoundingRect(CRect& rect) = 0; // 获取包围盒,用于重绘优化 protected: COLORREF m_color; int m_nPenWidth; bool m_bFilled; };以CEllipse为例,其Draw实现必须严格遵循 GDI 坐标规则:
// CEllipse.cpp void CEllipse::Draw(CDC& dc) { CPen pen(PS_SOLID, m_nPenWidth, m_color); CBrush brush(m_bFilled ? m_color : RGB(255,255,255)); CPen* pOldPen = dc.SelectObject(&pen); CBrush* pOldBrush = dc.SelectObject(&brush); // GDI 椭圆:左上角(x1,y1) 到 右下角(x2,y2) 的外接矩形 dc.Ellipse(m_ptStart.x, m_ptStart.y, m_ptEnd.x, m_ptEnd.y); dc.SelectObject(pOldPen); dc.SelectObject(pOldBrush); }注意:Ellipse参数是外接矩形的两个对角点,不是中心点+半径。很多同学传参写成Ellipse(center.x-r, center.y-r, center.x+r, center.y+r)是对的,但若误写为Ellipse(center.x, center.y, r, r)就完全失效。这种细节,正是答辩时导师最爱问的“为什么这么传参”。
3. 核心模块实现:三段式鼠标交互与图形状态管理
绘图交互的本质,是把离散的鼠标事件流,转化为连续的图形状态变更。MFC 不提供“画布”概念,我们必须自己构建状态机。本模块解决最痛的三个问题:如何让拖拽实时预览?如何保证画完即存?如何支持多图形叠加不覆盖?
3.1 三段式交互状态机:DRAW_NONE → DRAW_START → DRAW_DRAG → DRAW_END
状态流转不是靠全局变量硬编码,而是用枚举+清晰注释定义契约:
// CMyView.h 中定义 enum DRAW_STATE { DRAW_NONE, // 闲置状态 DRAW_START, // 已按下,记录起点,等待移动 DRAW_DRAG, // 正在拖拽,需持续更新终点 DRAW_END // 松开完成,生成正式图形 };对应消息处理逻辑:
// OnLButtonDown:只记录起点,不画任何东西 void CMyView::OnLButtonDown(UINT nFlags, CPoint point) { SetCapture(); // 捕获鼠标,防止移出窗口丢失事件 m_ptStart = point; m_ptEnd = point; m_nDrawState = DRAW_START; m_bDrawTemp = TRUE; // 允许OnPaint绘制临时图形 Invalidate(); // 强制重绘,触发OnPaint中临时图形显示 CView::OnLButtonDown(nFlags, point); } // OnMouseMove:仅更新终点,不重绘(由Invalidate触发) void CMyView::OnMouseMove(UINT nFlags, CPoint point) { if (m_nDrawState == DRAW_START || m_nDrawState == DRAW_DRAG) { m_ptEnd = point; m_nDrawState = DRAW_DRAG; Invalidate(); // 触发重绘,显示新位置的临时图形 } CView::OnMouseMove(nFlags, point); } // OnLButtonUp:生成正式图形,存入列表,清空临时状态 void CMyView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_nDrawState == DRAW_DRAG) { // 创建新图形对象(根据当前工具类型) CShape* pShape = nullptr; switch (m_nCurrentTool) { case TOOL_LINE: pShape = new CLine(m_ptStart, m_ptEnd, m_color, m_nPenWidth); break; case TOOL_RECT: pShape = new CRectShape(m_ptStart, m_ptEnd, m_color, m_nPenWidth, m_bFill); break; case TOOL_ELLIPSE: pShape = new CEllipse(m_ptStart, m_ptEnd, m_color, m_nPenWidth, m_bFill); break; } if (pShape) { m_shapeList.Add(pShape); // 存入正式列表 } } ReleaseCapture(); // 释放鼠标捕获 m_nDrawState = DRAW_NONE; m_bDrawTemp = FALSE; Invalidate(); // 清除临时图形,只显示正式图形 CView::OnLButtonUp(nFlags, point); }这里的关键设计是:所有图形生成只发生在 OnLButtonUp。OnMouseMove 只改坐标,不创建对象——否则拖拽过程中会疯狂 new 对象,内存泄漏且逻辑混乱。Invalidate()的调用时机也极讲究:OnLButtonDown 和 OnMouseMove 后都调用,确保临时图形实时更新;OnLButtonUp 后再调一次,清除临时态,只留正式图形。
3.2 图形列表管理:动态内存安全与重绘优化
m_shapeList是CPtrArray类型,存储CShape*指针。必须严格遵守 RAII 原则:
// CMyView 析构函数中释放所有图形 CMyView::~CMyView() { // 释放所有图形对象 for (int i = 0; i < m_shapeList.GetSize(); i++) { delete m_shapeList[i]; } m_shapeList.RemoveAll(); // 释放内存DC和位图 if (m_pMemDC) { m_pMemDC->DeleteDC(); delete m_pMemDC; m_pMemDC = nullptr; } m_bmpBuffer.DeleteObject(); }更进一步,为提升重绘效率,可在OnPaint中只重绘脏区域(Dirty Rect):
// 在 OnLButtonUp 中记录本次绘制的包围盒 CRect rectDirty; pShape->GetBoundingRect(rectDirty); InvalidateRect(&rectDirty, FALSE); // 只重绘该区域,FALSE表示不擦除背景CShape::GetBoundingRect在各派生类中实现,例如CEllipse:
void CEllipse::GetBoundingRect(CRect& rect) { rect.SetRect( min(m_ptStart.x, m_ptEnd.x), min(m_ptStart.y, m_ptEnd.y), max(m_ptStart.x, m_ptEnd.x), max(m_ptStart.y, m_ptEnd.y) ); }这样,画一个小椭圆时,只有那个小矩形区域被重绘,而不是整个窗口——对性能敏感的场景(如高分辨率屏)至关重要。
3.3 颜色与线宽配置:对话框驱动的参数持久化
用户需要随时切换颜色和线宽,不能写死在代码里。我们用标准 MFC 对话框CColorDialog和自定义CLineWidthDlg:
// OnToolsColor 菜单项响应 void CMyView::OnToolsColor() { CColorDialog dlg(m_color, CC_FULLOPEN); if (dlg.DoModal() == IDOK) { m_color = dlg.GetColor(); } } // 自定义线宽对话框(CLineWidthDlg.h) class CLineWidthDlg : public CDialog { DECLARE_DYNAMIC(CLineWidthDlg) public: CLineWidthDlg(int& nWidth, CWnd* pParent = nullptr); int m_nWidth; // 引用传入,修改后直接生效 protected: virtual BOOL OnInitDialog() override; afx_msg void OnBnClickedOk(); DECLARE_MESSAGE_MAP() }; // CLineWidthDlg.cpp 中 CLineWidthDlg::CLineWidthDlg(int& nWidth, CWnd* pParent) : CDialog(IDD_LINEWIDTH_DIALOG, pParent), m_nWidth(nWidth) {} BOOL CLineWidthDlg::OnInitDialog() { CDialog::OnInitDialog(); SetDlgItemInt(IDC_EDIT_WIDTH, m_nWidth); return TRUE; } void CLineWidthDlg::OnBnClickedOk() { m_nWidth = GetDlgItemInt(IDC_EDIT_WIDTH); CDialog::OnOK(); }关键点:m_nWidth是引用传入,对话框关闭后,视图类的m_nPenWidth成员自动更新。这种设计比全局变量安全,比消息传递简洁,是 MFC 课设中推荐的参数传递范式。
4. 常见问题排查:那些让导师皱眉的5个典型翻车点
做 MFC 绘图,80% 的问题有固定模式。以下是我在指导过程中记录的最高频、最易被答辩追问的 5 个坑,每一条都附真实现象、根因分析和可立即验证的修复步骤。
4.1 现象:窗口最小化再恢复,图形全部消失
原因:位图缓冲未与客户区尺寸联动,最小化后GetClientRect返回 (0,0),位图重建失败,BitBlt拷贝零尺寸区域。
解决:在OnSize消息中强制触发位图重建,并调用Invalidate():
void CMyView::OnSize(UINT nType, int cx, int cy) { CView::OnSize(nType, cx, cy); if (m_bmpBuffer.GetSafeHandle()) { m_bmpBuffer.DeleteObject(); } Invalidate(); // 强制重绘,OnPaint中会重建位图 }4.2 现象:拖拽矩形时,鼠标移动慢图形就卡顿,快反而流畅
原因:Invalidate()调用过于频繁,导致OnPaint被洪水般触发,CPU 占满。
解决:改用InvalidateRect()限定区域,并添加简单节流:
// 在 OnMouseMove 中 static DWORD lastTick = 0; if (GetTickCount() - lastTick > 30) { // 30ms 间隔限制 InvalidateRect(&m_rectLastTemp, FALSE); m_rectLastTemp = GetTempRect(); // 计算本次临时图形包围盒 lastTick = GetTickCount(); }4.3 现象:画完一个椭圆,再画直线,椭圆颜色变成直线的颜色
原因:CPen和CBrush未正确 SelectObject 恢复,GDI 对象句柄被覆盖。
解决:所有SelectObject必须配对SelectObject(pOldXXX),且顺序不能颠倒:
CPen pen(PS_SOLID, width, color); CBrush brush(fill ? color : RGB(255,255,255)); CPen* pOldPen = dc.SelectObject(&pen); CBrush* pOldBrush = dc.SelectObject(&brush); // ... 绘图 ... dc.SelectObject(pOldBrush); // 先恢复刷子 dc.SelectObject(pOldPen); // 再恢复画笔4.4 现象:在高 DPI 显示器上,图形严重缩放变形,坐标错位
原因:未启用 DPI 感知,GetClientRect返回物理像素,而CDC::MoveTo等默认使用逻辑单位。
解决:在CMyApp::InitInstance()中添加 DPI 感知声明:
// 在 InitInstance 开头 SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_SYSTEM_AWARE); // 并在资源文件中添加 manifest,声明支持 per-monitor DPI同时,所有坐标计算前用ScreenToClient转换,避免直接使用GetMessagePos()。
4.5 现象:程序退出时崩溃,报错 “invalid handle” 或 “access violation”
原因:m_pMemDC或m_bmpBuffer在析构时已被释放,但OnPaint可能因消息队列残留仍在调用。
解决:在OnPaint开头加双重检查,并在析构中置空指针:
void CMyView::OnPaint() { if (m_pMemDC == nullptr || m_bmpBuffer.GetSafeHandle() == NULL) { CPaintDC dc(this); return; // 安全退出 } // ... 正常逻辑 ... } // 析构中 if (m_pMemDC) { m_pMemDC->DeleteDC(); delete m_pMemDC; m_pMemDC = nullptr; // 置空防野指针 } m_bmpBuffer.DeleteObject(); // DeleteObject 对 NULL 安全5. 验收与答辩技巧:让导师眼前一亮的3个关键动作
验收不是“能运行就行”,而是证明你真正吃透了 MFC 绘图的内在逻辑。我带过的项目里,能顺利通过答辩的,往往在三个细节上做了超出要求的动作。这些不是炫技,而是工程素养的自然体现。
5.1 动态坐标与尺寸实时显示:嵌入状态栏的“所见即所得”
导师最想确认你是否理解“坐标系”这个基础概念。在状态栏显示当前鼠标位置(逻辑坐标)和临时图形尺寸,是最直观的证明:
// 在 OnMouseMove 中更新状态栏 CString strStatus; strStatus.Format(_T("X:%d Y:%d | W:%d H:%d"), point.x, point.y, abs(m_ptEnd.x - m_ptStart.x), abs(m_ptEnd.y - m_ptStart.y)); m_wndStatusBar.SetPaneText(0, strStatus);注意:
SetPaneText的 pane index 必须与indicators[]数组定义一致。很多同学复制代码后忘记改索引,状态栏一直空白——这恰恰暴露了对 MFC 框架初始化流程的陌生。从那以后我每次教课,都强制要求学生打开MainFrm.cpp找到indicators数组,亲手数一遍索引。
5.2 撤销功能(Undo)的轻量级实现:用 CPtrArray 管理历史快照
“能画”只是及格线,“能撤回”才是加分项。无需复杂命令模式,用图形列表快照即可:
// CMyView.h 新增 CPtrArray m_undoStack; // 存储 CPtrArray* 的数组(每个是 m_shapeList 快照) // OnLButtonUp 中保存快照 CPtrArray* pSnapshot = new CPtrArray(); for (int i = 0; i < m_shapeList.GetSize(); i++) { pSnapshot->Add(m_shapeList[i]->Clone()); // Clone 是 CShape 虚函数,返回新对象 } m_undoStack.Add(pSnapshot); // OnEditUndo 菜单项 void CMyView::OnEditUndo() { if (m_undoStack.GetSize() > 0) { // 清空当前列表 for (int i = 0; i < m_shapeList.GetSize(); i++) { delete m_shapeList[i]; } m_shapeList.RemoveAll(); // 加载最新快照 CPtrArray* pLast = (CPtrArray*)m_undoStack.GetAt(m_undoStack.GetSize()-1); for (int i = 0; i < pLast->GetSize(); i++) { m_shapeList.Add(pLast->GetAt(i)); } m_undoStack.RemoveAt(m_undoStack.GetSize()-1); delete pLast; Invalidate(); } }Clone()在CShape中声明为纯虚函数,各派生类实现深拷贝。这个实现不到 30 行,却能让导师立刻判断:“这学生懂对象生命周期,不是只会抄 API”。
5.3 答辩演示脚本:用3分钟讲清“为什么这样设计”
答辩不是复述代码,而是讲清设计权衡。我建议用以下结构组织你的 3 分钟陈述:
| 时间 | 内容 | 关键话术 |
|---|---|---|
| 0:00–0:45 | 问题定位 | “老师好,这个课设表面是画三个图形,实际要解决三个本质问题:一是鼠标拖拽的实时性与重绘性能的矛盾;二是图形状态的持久化与内存安全的平衡;三是多 DPI 场景下的坐标一致性。我们没有用 GDI+,因为 MFC 课设要求深入理解原生 GDI 消息机制。” |
| 0:46–1:50 | 方案亮点 | “第一,双缓冲位图与客户区尺寸强绑定,解决了最小化后图形丢失;第二,三段式状态机把鼠标事件流转化为确定图形状态,避免了 OnMouseMove 中创建对象的内存泄漏风险;第三,所有图形抽象为 CShape 接口,为后续扩展(如多边形、贝塞尔曲线)预留了干净的扩展点。” |
| 1:51–3:00 | 验证证据 | “请看演示:拖拽时状态栏实时显示坐标和尺寸(指向状态栏);撤销功能可连续回退(演示两次 Undo);在 200% DPI 设置下,图形比例依然精准(切换 DPI 演示)。所有代码均通过静态分析,无内存泄漏(展示 Visual Studio Diagnostic Tools 截图)。” |
最后收尾那句,我每次都会说:“从那以后我每次写 MFC 绘图,都强制走一遍 OnPaint 的位图重建逻辑,再检查三处 SelectObject 是否配对——这成了我的肌肉记忆。希望帮到你。”
本文还有配套的精品资源,点击获取