☰
MFC绘图入门:从OnDraw到鼠标拖拽画椭圆
2026/11/4 14:12:40 网站建设 项目流程

简介:本资源是一份面向C++初学者与Windows桌面开发入门者的MFC图形绘制实践项目,聚焦于使用MFC框架在视图中动态绘制直线、矩形和椭圆三大基础图形,解决GUI程序中GDI绘图逻辑实现与用户交互响应的核心问题。压缩包共51个文件,包含8个头文件(.h)定义类结构、7个源码文件(.cpp)实现OnDraw、鼠标消息处理及图形绘制逻辑、2个图标(.ico)与位图(.bmp)资源,以及编译生成的可执行文件(.exe)、调试符号(.pdb)和工程配置文件(.dsw/.dsp),整体大小3.69MB,完整呈现VC6.0环境下MFC单文档应用的标准目录结构与构建流程。已有396人学习下载,读者可直接运行exe体验交互式绘图效果,复现从CView继承、CDC设备上下文调用、LineTo/DrawRect/Ellipse绘图函数使用到WM_LBUTTONDOWN消息映射的全流程代码,掌握颜色设置、坐标变换等进阶绘图控制要点。

1. 这不是“画图软件”,而是 Windows 桌面程序的“手绘底层”:MFC 绘制直线、矩形、椭圆,到底在干啥?

你有没有试过点开一个老式 Windows 软件——比如某个设备控制面板、实验室数据采集工具,或者十年前的工业配置界面——里面按钮是灰蓝色的,窗口边框带阴影,菜单栏下拉时有淡入效果,而界面上还实时画着一条随传感器跳动的曲线?那条线,很可能就是用 MFC(Microsoft Foundation Classes)一笔一笔“画”出来的。
它不是 Photoshop 里拖个椭圆工具就完事,也不是网页上写个<canvas>就能跑;它是让程序员像“手艺人”一样,直接调用 Windows 系统最底层的绘图能力,在窗口客户区里一像素一像素地安排线条走向、填充颜色、响应鼠标拖拽。你今天看到的“绘制直线、矩形、椭圆”,表面是三个几何图形,背后其实是整套 Windows 桌面程序图形交互的最小可行入口——没有窗体框架、没有 UI 库封装、没有跨平台抽象,只有你和 GDI(Graphics Device Interface)之间的一张画布、一支笔、一个坐标系。
适合谁?刚学完 C++ 基础、对“程序怎么在屏幕上显示东西”还停留在cout阶段的同学;想补全传统桌面开发逻辑、看懂遗留工业软件源码的转岗工程师;或者单纯想亲手造一个“能画、能擦、能存”的迷你绘图板,不靠 Electron、不靠 Qt,就用原生 Windows 的方式。这不是玄学,是看得见、改得动、断点跟得进的实在功夫。

2. 先搞懂三件事:为什么非得用 MFC?为什么非得重写 OnDraw?为什么坐标系总“反着来”?

2.1 MFC 不是语言,是“Windows 图形世界的翻译官”

很多新手第一反应是:“C++ 我会,Python 我会,为啥还要学 MFC?”
因为 C++ 本身不会画图——它连“窗口”都没有概念。Windows 系统提供了一套叫 Win32 API 的底层函数(比如CreateWindowEx、BeginPaint、LineTo),但全是 C 风格的指针、句柄、结构体,写十行代码光声明变量就得五种类型。MFC 就是微软给这套“天书”配的中文说明书+自动翻译器:它把窗口封装成CWnd类,把画布封装成CDC(Device Context)类,把鼠标消息封装成OnLButtonDown这样的成员函数。你不用再手动管理HDC句柄生命周期,也不用每次画线前都调BeginPaint/EndPaint——MFC 在背后帮你扛了。
打个比方:Win32 是你要亲自拧螺丝、接电线、读电路图才能点亮一盏灯;MFC 是给你一个带开关、插头、LED 指示灯的台灯套装——你只管按开关,它自动完成所有底层动作。学 MFC,本质是学“怎么用面向对象的方式,安全、可维护地调用 Windows 原生绘图能力”。

2.2 OnDraw 不是“画一次”,而是“系统喊你重画时才动笔”

新手常犯的错:在OnInitDialog()里写pDC->Rectangle(10,10,100,100),结果窗口一缩放、一遮挡再露出来,矩形就消失了。
原因很简单:OnDraw(CDC* pDC)不是你“想画就画”的画布,而是 Windows 的“重绘委托协议”。当系统发现某块窗口区域变脏(比如被别的窗口盖住后又暴露、用户拖动了窗口边框),它会主动调用你的OnDraw,并传给你一块“干净画布”(pDC)。你只负责在这块画布上把当前该显示的内容画全;画完,系统收走画布,下次再脏,再给你一块新的。
所以,所有绘制逻辑必须塞进OnDraw,而不是按钮点击事件里直接画。按钮点击该做的事是:记录“用户想画一个矩形”,存下起点、终点坐标,然后调用Invalidate()(告诉系统“我这块区域脏了,请重绘”)——接下来,系统自然会调OnDraw,你再从内存里取出坐标,画出来。这是 MFC 绘图的铁律:数据驱动视图,而非操作直接绘图。

2.3 坐标系不是数学课本里的,是 Windows 的“左上原点世界”

你在数学课上学的坐标系:原点在中心,X 向右为正,Y 向上为正。
Windows GDI 的默认坐标系:原点在左上角,X 向右为正,Y向下为正。这意味着:

  • Rectangle(0,0,100,100)画的是从左上角开始、宽 100 像素、高 100 像素的矩形;
  • Ellipse(50,50,150,150)画的是以 (50,50) 为左上角、(150,150) 为右下角的外切矩形内的椭圆;
  • 如果你想让 Y 轴“向上为正”,得手动调SetMapMode(MM_LOENGLISH)或SetWindowExtEx,但绝大多数项目不这么干——因为所有 Windows 窗口、控件、字体度量都基于这个左上原点体系,强行改反而容易翻车。
    记住这个口诀:“Windows 的 Y 轴,是重力方向”。你画的每一条线、每一个点,都要先在脑子里把坐标系“倒过来”想一遍。这是新手最常卡壳的“认知摩擦点”,不是你算错了,是坐标系默认就和你直觉相反。

3. 第一个能跑通的例子:从新建工程到画出第一个红色矩形(含完整步骤与逐行解释)

3.1 新建 MFC 应用程序:选“单文档”,关掉所有高级选项

打开 Visual Studio(2019 或更新版本),点击【创建新项目】→ 搜索 “MFC 应用程序” → 选择模板 → 输入项目名(如MyFirstDrawer)→ 点击【下一步】。
关键设置:

  • 应用程序类型:选单文档(Single document);
  • 高级功能:全部取消勾选(不选数据库、不选 ActiveX、不选打印预览);
  • 用户界面功能:只保留工具栏和状态栏(其他如“拆分窗口”“OLE 容器”全关);
  • 最后一步:确认“使用 Unicode 库”已勾选(现代 Windows 开发标准)。

提示:选“单文档”是因为它自动生成了标准的CView视图类,而绘图逻辑就写在视图里;如果选“基于对话框”,你会被困在对话框资源编辑器里,找不到OnDraw入口,新手极易迷失。

3.2 找到并打开CMyFirstDrawerView.cpp:你的画布就在这个文件里

项目生成后,在【解决方案资源管理器】中展开Source Files→ 找到MyFirstDrawerView.cpp(类名会匹配你项目名,如CMyFirstDrawerView)。双击打开。
滚动到文件末尾,找到这个函数:

void CMyFirstDrawerView::OnDraw(CDC* pDC) { CMyFirstDrawerDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); if (!pDoc) return; // TODO: add draw code for native data here }

这就是你的“法定画布”。现在,我们往// TODO后面加第一行真正绘图代码。

3.3 写下第一行绘图代码:画一个红色矩形,并理解每个参数含义

在// TODO下方,插入以下代码(注意:不要删掉原有ASSERT和if):

void CMyFirstDrawerView::OnDraw(CDC* pDC) { CMyFirstDrawerDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); if (!pDoc) return; // ★★★ 第一行真正绘图代码 ★★★ CRect rect(50, 50, 200, 150); // 定义矩形区域:左=50, 上=50, 右=200, 下=150 CPen pen(PS_SOLID, 2, RGB(255, 0, 0)); // 创建一支红色实线笔,粗2像素 CBrush brush(RGB(255, 220, 220)); // 创建一个浅红色填充刷 CPen* pOldPen = pDC->SelectObject(&pen); // 把笔“装上”画布 CBrush* pOldBrush = pDC->SelectObject(&brush); // 把刷“装上”画布 pDC->Rectangle(rect); // 真正画矩形! pDC->SelectObject(pOldPen); // 画完,换回原来的笔(重要!) pDC->SelectObject(pOldBrush); // 画完,换回原来的刷(重要!) }
逐行白话解释:
  • CRect rect(50, 50, 200, 150);:定义一个矩形区域。注意顺序是(left, top, right, bottom),不是(x, y, width, height)。这里画的是从 X=50、Y=50 开始,到 X=200、Y=150 结束的矩形,实际宽=150 像素,高=100 像素。
  • CPen pen(PS_SOLID, 2, RGB(255, 0, 0));:创建一支笔。“PS_SOLID”表示实线,“2”是线宽(像素),“RGB(255,0,0)”是纯红(R=255, G=0, B=0)。RGB 值范围都是 0~255。
  • CBrush brush(RGB(255, 220, 220));:创建一个填充刷。这里用浅红色(粉红),让矩形内部不是空心。如果想画空心矩形,这行可以删,但下面SelectObject(&brush)也要删。
  • pDC->SelectObject(&pen);:把这支笔“安装”到画布上。就像你换钢笔写字前,得先把旧笔拔下来、新笔插进去。SelectObject返回旧笔指针,我们存到pOldPen里,就是为了后面换回去。
  • pDC->Rectangle(rect);:核心命令!告诉画布:“按我给的矩形区域,用当前笔和刷,画一个矩形”。它自动画边框 + 填充。
  • pDC->SelectObject(pOldPen);:画完立刻换回原来的笔。这步绝不能省!因为pDC是系统共享资源,如果你不换回去,后续系统画菜单、画按钮时可能用你的粗红线,整个界面就乱了——这是新手最典型的“界面崩溃”原因。

3.4 编译运行:看到红色矩形,才算真正入门

按Ctrl + F5(不调试直接运行),等待几秒,窗口弹出。
你将看到一个标准 Windows 单文档窗口:标题栏、菜单栏、工具栏、状态栏,以及——在客户区中央,一个清晰的红色矩形(边框粗、内部浅红)。
如果没看到:

  • 检查是否在OnDraw里写了代码(不是在OnInitialUpdate或按钮函数里);
  • 检查CRect四个值是否合理(比如right < left会导致矩形消失);
  • 检查RGB参数是否写反(RGB(0,0,255)是蓝,不是红)。
    看到它的那一刻,你就跨过了 MFC 绘图的第一道门槛:你亲手把代码变成了屏幕上的图形。这不是 Demo,是真实 Windows 程序的毛细血管级操作。

4. 从矩形到椭圆:三步扩展法,让第一个例子活起来(支持鼠标拖拽画图)

4.1 思路拆解:把“固定坐标”变成“鼠标动态坐标”

刚才的矩形是写死的(50,50,200,150)。要让它随鼠标画,就得:

  1. 记录鼠标按下时的起点(OnLButtonDown);
  2. 记录鼠标移动时的临时终点(OnMouseMove),并实时重绘(Invalidate());
  3. 鼠标松开时,把最终坐标存下来,作为永久图形(OnLButtonUp)。
    我们先实现“鼠标按下→移动→松开,画一个椭圆”,再逐步加入直线、矩形切换。

4.2 在视图类中添加成员变量:存起点、终点、当前绘图模式

打开MyFirstDrawerView.h(头文件),在public:区域下方、protected:上方,添加如下成员变量:

public: enum DrawMode { MODE_NONE, MODE_ELLIPSE, MODE_RECTANGLE, MODE_LINE }; DrawMode m_drawMode; // 当前绘图模式 CPoint m_ptStart; // 鼠标按下时的起点 CPoint m_ptEnd; // 鼠标移动/松开时的终点 bool m_bDrawing; // 是否正在拖拽中(用于 OnMouseMove 判断)

然后回到MyFirstDrawerView.cpp,在构造函数CMyFirstDrawerView::CMyFirstDrawerView()里初始化它们:

CMyFirstDrawerView::CMyFirstDrawerView() { m_drawMode = MODE_NONE; m_bDrawing = FALSE; }

4.3 实现鼠标消息响应:三步钩子,让图形跟着鼠标跑

在MyFirstDrawerView.cpp中,右键 → 【类向导】→ 选择CMyFirstDrawerView→ 左侧选Message→ 找到WM_LBUTTONDOWN→ 双击添加OnLButtonDown;同理添加OnMouseMove和OnLButtonUp。
然后填入以下代码:

void CMyFirstDrawerView::OnLButtonDown(UINT nFlags, CPoint point) { m_ptStart = point; // 记下起点 m_ptEnd = point; // 初值设为起点 m_bDrawing = TRUE; // 标记开始拖拽 m_drawMode = MODE_ELLIPSE; // 默认画椭圆(可后续改成按钮切换) CView::OnLButtonDown(nFlags, point); } void CMyFirstDrawerView::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDrawing && (nFlags & MK_LBUTTON)) { m_ptEnd = point; // 更新终点为当前鼠标位置 Invalidate(); // 告诉系统:客户区脏了,快重绘! } CView::OnMouseMove(nFlags, point); } void CMyFirstDrawerView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bDrawing) { m_ptEnd = point; // 最终确定终点 m_bDrawing = FALSE; // 结束拖拽 // 此处可添加“保存到图形列表”逻辑,本例暂不存,只画一次 } CView::OnLButtonUp(nFlags, point); }

4.4 改写 OnDraw:根据模式画不同图形,支持“橡皮筋”效果

回到OnDraw函数,把原来固定的矩形代码,替换成动态分支:

void CMyFirstDrawerView::OnDraw(CDC* pDC) { CMyFirstDrawerDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); if (!pDoc) return; // ★★ 动态绘图主逻辑 ★★ if (m_bDrawing) { // 正在拖拽时,画“橡皮筋”效果(虚线预览) CPen pen(PS_DOT, 1, RGB(0, 0, 255)); // 蓝色虚线 CPen* pOldPen = pDC->SelectObject(&pen); CRect rect(m_ptStart, m_ptEnd); // 自动按左上/右下排序 switch (m_drawMode) { case MODE_ELLIPSE: pDC->Ellipse(&rect); break; case MODE_RECTANGLE: pDC->Rectangle(&rect); break; case MODE_LINE: pDC->MoveTo(m_ptStart); pDC->LineTo(m_ptEnd); break; } pDC->SelectObject(pOldPen); } // ★★ 此处可添加“已保存图形”的循环绘制(后续扩展)★★ }

注意:CRect(m_ptStart, m_ptEnd)构造函数会自动处理坐标大小关系(比如m_ptStart.y > m_ptEnd.y时,它会把top设为m_ptEnd.y),所以你无需手动min/max,放心传。

编译运行,点击窗口任意位置并拖动鼠标——你会看到一条蓝色虚线椭圆,随着鼠标移动实时变化大小。松开鼠标,虚线消失,图形定格。这就是最基础的“所见即所得”绘图体验。你已经把静态示例,升级成了可交互的原型。

5. 新手必踩的五大坑:从黑屏、闪退到坐标错乱,血泪经验总结

5.1 现象:窗口一片空白,或只显示灰色背景,什么图形都不见

原因:OnDraw函数里忘了调CView::OnDraw(pDC)父类实现,或误删了ASSERT_VALID(pDoc)后的return语句,导致pDC为空指针。
解决:检查OnDraw开头三行是否完整存在;确保if (!pDoc) return;没被注释或删除;在pDC->Rectangle(...)前加一句if (!pDC) return;做双重保险。

5.2 现象:画完图形,窗口一最小化再恢复,图形就消失了

原因:把绘图代码写在了OnInitialUpdate、OnCreate或按钮响应函数里,而不是OnDraw。这些函数只执行一次,系统重绘时不调用。
解决:所有绘图逻辑必须在OnDraw内;按钮函数只负责修改数据(如m_drawMode = MODE_LINE)和触发重绘(Invalidate())。

5.3 现象:图形位置完全不对,比如想画在中间,结果跑到右上角去了

原因:混淆了CRect的(left, top, right, bottom)和(x, y, width, height)。例如写了CRect(100, 100, 50, 50),由于right < left,矩形面积为负,GDI 直接忽略绘制。
解决:永远用CRect(CPoint pt1, CPoint pt2)构造,让 MFC 自动排序;或手动确保left < right && top < bottom,用min/max包裹:CRect(min(x1,x2), min(y1,y2), max(x1,x2), max(y1,y2))。

5.4 现象:画线时出现“残影”,鼠标移动留下多条线,像鬼画符

原因:在OnMouseMove里直接调用了pDC->LineTo(),而pDC在OnMouseMove中不可用(它只在OnDraw中有效)。OnMouseMove里只能更新坐标、调Invalidate(),不能直接绘图。
解决:删掉OnMouseMove里所有pDC->xxx代码;确保绘图只发生在OnDraw;Invalidate()后系统会自动调OnDraw,这才是唯一合法绘图时机。

5.5 现象:程序运行几秒后崩溃,报错Access violation reading location 0x00000000

原因:SelectObject后没换回原对象,且CPen/CBrush对象是栈变量(如CPen pen(...)),函数退出后自动析构,但画布pDC还拿着已销毁对象的指针。下次OnDraw再用,就访问野指针。
解决:两种方案任选其一:

  • 方案 A(推荐):用new创建堆对象,并在类中声明为成员变量(如CPen* m_pPen;),在OnDraw中pDC->SelectObject(m_pPen),不换回(因为成员变量一直有效);
  • 方案 B:严格遵守“先存旧、再换新、最后换回”三步,且CPen/CBrush必须是局部变量(栈上),确保SelectObject(pOldPen)在函数结束前执行。

6. 下一步学什么:从“画三个图形”到“做一个能存能开的绘图板”,我的实战路径建议

6.1 第二阶段:让图形真正“留下来”,不再只是橡皮筋

你现在画的椭圆,松开鼠标就没了。要让它成为永久图形,需要引入“图形对象列表”。这不是加几行代码的事,而是一次小架构升级:

  • 定义一个基类CGraphicObject,含纯虚函数Draw(CDC* pDC);
  • 派生CEllipseObject、CRectangleObject、CLineObject,各自实现Draw;
  • 在视图类中加std::vector<std::unique_ptr<CGraphicObject>> m_objects;
  • OnLButtonUp时,根据m_drawMode创建对应对象,push_back进列表;
  • OnDraw中遍历m_objects,对每个对象调obj->Draw(pDC)。
    这样,你就能无限添加图形,关闭再打开也不会丢——因为数据存在内存里了。这是从“Demo”迈向“可用工具”的关键跃迁。

6.2 第三阶段:支持撤销、保存、打开,补齐生产级功能

有了图形列表,下一步就是持久化:

功能关键技术点新手友好度
撤销(Undo)用std::stack<std::vector<std::unique_ptr<CGraphicObject>>> m_undoStack,每次操作前push当前状态;点击撤销时pop并替换m_objects⭐⭐⭐⭐
保存为 .draw 文件用CFile二进制写入:先写图形数量,再对每个图形写type(int)、points(4个 int,存矩形/椭圆的LTRB,或线的2个点)⭐⭐⭐
打开 .draw 文件用CFile读取,按相同格式解析,重建m_objects列表,最后Invalidate()⭐⭐⭐

提示:.draw是自定义后缀,不用注册关联,纯文本或二进制都行。我第一次做时,先用文本格式(每行ELLIPSE 50 50 200 150),方便调试;稳定后再切二进制提速。

6.3 第四阶段:加入实用细节,告别“玩具感”

真实绘图工具的质感,藏在细节里:

  • 抗锯齿:在OnDraw开头加pDC->SetStretchBltMode(COLORONCOLOR); pDC->SetGraphicsMode(GM_ADVANCED);,再用Gdiplus::Graphics替代CDC(需初始化 GDI+);
  • 颜色/线宽选择:拖一个CColorDialog出来,让用户点选;用CComboBox下拉选线宽(1,2,3,5,8);
  • 缩放和平移:监听Ctrl+MouseWheel触发SetWindowExt/SetViewportExt,配合ScrollWindow实现平移;
  • 图层概念:把m_objects拆成m_backgroundObjects和m_foregroundObjects,支持“置顶”“置底”右键菜单。

这些不是炫技,而是当你把程序给同事或老师看时,他们脱口而出“这真能用啊”的底气来源。

从那以后我每次教新人,都强制他们做完“鼠标拖拽画椭圆”后,立刻动手加一个“保存按钮”。不是为了功能完整,而是为了建立一种肌肉记忆:图形是数据,不是画面;保存的本质,是把内存里的对象序列化成文件字节。只要这个闭环打通了,后面学 Qt、学 WPF、甚至学 WebGL,你都会下意识先问:“它的图形对象模型长什么样?数据怎么存?”——而不是盯着 API 文档发呆。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询