简介本资源是一份基于VC6.0平台的MFC图形绘制实践项目面向C初学者及Windows桌面开发入门者聚焦于掌握GDI绘图核心技能。项目完整实现了鼠标交互式绘制直线、矩形与椭圆三大基础图形涵盖CView类继承、OnDraw重写、CDC设备上下文调用、消息映射如WM_LBUTTONDOWN及绘图属性设置笔/刷对象等关键知识点可作为课程设计、实验作业或自学练手的典型范例。压缩包共51个文件含8个头文件.h、7个源码文件.cpp、7个编译中间文件.obj、2个图标资源.ico、1个可执行程序.exe及配套工程文件.dsw/.dsp/.rc等总大小3.69MB结构完整开箱即用。已有396人学习下载提供可直接运行的调试环境与清晰的模块划分如Graphic.cpp封装绘图逻辑、View类负责响应与渲染便于理解MFC文档/视图架构与GDI绘图流程。1. MFC 绘图不是“画布填色”用 CDC 类手撕直线/矩形/椭圆绕开 GDI 资源泄漏和坐标系翻车现场你是不是也试过在 MFC 对话框里OnPaint()里pDC-Rectangle()画个框结果窗口一缩放就消失、一拖动就重影、一连点三次按钮就程序卡死这不是你代码写错了——是没搞清 CDC 的生命周期和设备上下文的“借用规则”。这个资源包不是教你怎么拖控件而是把 MFC 绘图最常翻车的三类基础图形直线、矩形、椭圆拆成可复现、可调试、可嵌入真实项目的最小闭环。它包含完整 VS2019 工程含 Release/Debug 配置、带注释的CClientDC/CPaintDC切换逻辑、抗锯齿开关控制、以及一个被 90% 教程忽略的关键动作GDI 对象的 SelectObject 恢复链。适合正在做工业 HMI 界面、仪器波形显示、或需要在 CView/CDialog 上叠加动态标注的开发者。如果你的项目还卡在“能画出来就行”那这份资源就是帮你把绘图模块从“玄学稳定”推进到“上线敢改”的临界点。2. 为什么非得用 CDC 而不是直接调 Win32 API三张图讲清 MFC 绘图的三层封装与选型依据MFC 绘图不是对Gdi32.dll的简单封装而是一套带内存管理语义的 RAII 式包装。很多初学者一上来就GetDC()ReleaseDC()结果在多线程或快速重绘场景下触发 GDI 句柄耗尽——这恰恰是因为跳过了 CDC 这一层的自动清理机制。我们先厘清三个核心类的定位差异2.1 CPaintDC只用于 OnPaint() 的“一次性画布”强制绑定 WM_PAINTCPaintDC是 MFC 为响应WM_PAINT消息专门设计的 RAII 封装。它的构造函数内部调用BeginPaint()析构时自动EndPaint()且禁止在 OnPaint 外使用。一旦你在按钮点击事件里 new 一个CPaintDC编译器不会报错但运行时会返回 NULL DC后续所有绘图调用静默失败。// ❌ 错误示范在 OnBnClickedButton1() 中创建 CPaintDC void CDrawDemoDlg::OnBnClickedButton1() { CPaintDC dc(this); // 构造时调用 BeginPaint()但当前无 WM_PAINT 消息dc.m_hDC NULL dc.Rectangle(10, 10, 100, 100); // 实际不执行无报错画面无反应 }提示CPaintDC的构造函数会检查m_hWnd是否有效并验证当前消息队列是否有WM_PAINT。若不满足m_hDC初始化为NULL所有 CDC 成员函数内部有空指针防护直接 return。2.2 CClientDC对话框/视图客户区的“长期画布”需手动管理生存期当你需要在非OnPaint()场景如鼠标移动、定时器、按钮点击实时绘制时CClientDC是唯一安全选择。它在构造时调用GetDC()析构时调用ReleaseDC()但必须确保其作用域严格限定在单次函数调用内。常见错误是把它声明为类成员变量——这会导致ReleaseDC()在对象析构时才调用而此时窗口可能已销毁引发 GDI 句柄泄漏。// ✅ 正确用法作用域严格限制在函数内 void CDrawDemoDlg::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDrawing) { CClientDC dc(this); // 构造即 GetDC() dc.MoveTo(m_ptStart); dc.LineTo(point); // 实时绘制橡皮筋线 // 函数结束dc 析构自动 ReleaseDC() } CDialogEx::OnMouseMove(nFlags, point); }2.3 CDC* 原始指针仅用于性能敏感场景必须配对 SaveDC/RestoreDC某些高频绘制如波形滚动需避免 CDC 构造/析构开销此时可缓存CDC*指针但必须配合SaveDC()/RestoreDC()管理状态栈。本资源包中CWaveView示例展示了如何在OnDraw()中复用CDC*并防止画笔/画刷状态污染// 在 CView 派生类中缓存 CDC* class CWaveView : public CView { protected: CDC* m_pMemDC; // 内存 DC 指针 CBitmap* m_pMemBmp; // 对应位图 int m_nSaveDC; // SaveDC 返回的状态索引 public: void OnDraw(CDC* pDC) override { if (!m_pMemDC) { InitMemoryDC(); // 首次创建 } // 保存当前 DC 状态含画笔、字体、映射模式等 m_nSaveDC m_pMemDC-SaveDC(); // 在内存 DC 中绘制波形无闪烁 DrawWaveform(m_pMemDC); // 恢复 DC 状态防止影响后续绘制 m_pMemDC-RestoreDC(m_nSaveDC); // 将内存 DC 拷贝到屏幕 pDC-BitBlt(0, 0, m_rectClient.Width(), m_rectClient.Height(), m_pMemDC, 0, 0, SRCCOPY); } };注意SaveDC()返回整数索引RestoreDC(int)必须传入该索引。多次SaveDC()形成栈结构RestoreDC(-1)可恢复到栈底状态。未配对调用会导致 GDI 状态混乱表现为字体突变、线条变粗、颜色错乱。3. 直线/矩形/椭圆三类绘图的底层参数解析与坐标系陷阱排查MFC 的CDC::LineTo()、CDC::Rectangle()、CDC::Ellipse()看似简单但参数含义与 Windows GDI 坐标系深度耦合。很多“画不出来”的问题根源在于没理解LPtoDP()/DPtoLP()的必要性或混淆了逻辑坐标与设备坐标的边界。3.1 直线绘制LineTo() 的隐式 MoveTo() 与绝对坐标依赖CDC::LineTo(x, y)不是“画一条从 (0,0) 到 (x,y) 的线”而是“从当前画笔位置画线到 (x,y)”。它依赖CDC::MoveTo()设置起点且所有坐标均为设备坐标像素。若你在OnPaint()中连续调用LineTo()而未MoveTo()第二条线会从第一条线的终点开始画// ❌ 错误未初始化起点LineTo 依赖上一次 MoveTo 位置 void CDrawDemoDlg::OnPaint() { CPaintDC dc(this); dc.LineTo(100, 50); // 从 (0,0) 画到 (100,50) dc.LineTo(200, 100); // 从 (100,50) 画到 (200,100)形成折线 } // ✅ 正确每次 LineTo 前显式 MoveTo void CDrawDemoDlg::OnPaint() { CPaintDC dc(this); dc.MoveTo(10, 10); dc.LineTo(100, 50); dc.MoveTo(10, 60); // 新起点 dc.LineTo(100, 110); }3.2 矩形绘制Rectangle() 的“左上-右下”约定与边框像素归属CDC::Rectangle(left, top, right, bottom)的四个参数定义的是矩形外边界且right和bottom是不包含的像素坐标。这意味着Rectangle(0,0,10,10)实际绘制的是 10×10 像素区域x0~9, y0~9而非 11×11。更关键的是当使用实心画刷填充时边框像素由画笔决定而画笔宽度会影响实际占用空间画笔宽度边框是否覆盖填充区实际占用像素范围1否边框在填充区外侧left-0.5 ~ right0.5, top-0.5 ~ bottom0.53是边框中心对齐部分覆盖填充left-1 ~ right1, top-1 ~ bottom1本资源包中CDrawHelper::DrawRectWithBorder()函数封装了自动补偿逻辑// 自动补偿画笔宽度确保矩形尺寸精确 void CDrawHelper::DrawRectWithBorder(CDC* pDC, CRect rect, int penWidth, COLORREF penColor) { CPen pen(PS_SOLID, penWidth, penColor); CPen* pOldPen pDC-SelectObject(pen); // 补偿减去半宽使视觉尺寸等于 rect.Size() CRect adjusted rect; adjusted.DeflateRect(penWidth / 2, penWidth / 2); pDC-Rectangle(adjusted); pDC-SelectObject(pOldPen); }3.3 椭圆绘制Ellipse() 的 bounding box 本质与 DPI 缩放失真CDC::Ellipse()本质是绘制外切矩形内的椭圆参数与Rectangle()完全一致。这意味着Ellipse(0,0,100,50)绘制的是宽 100、高 50 的椭圆其长轴/短轴比例由 bounding box 决定。但在高 DPI 显示器如 125% 缩放下若未启用 DPI 感知GetClientRect()返回的CRect是逻辑尺寸而Ellipse()参数是设备像素导致椭圆被拉伸// ❌ 高 DPI 下失效GetClientRect 返回逻辑坐标Ellipse 期望设备坐标 CRect rect; GetClientRect(rect); dc.Ellipse(rect); // 在 125% DPI 下rect.width800 但实际像素1000椭圆被压扁 // ✅ 正确转换为设备坐标 CRect rectDpi; GetClientRect(rectDpi); dc.LPtoDP(rectDpi); // 将逻辑坐标转为设备坐标 dc.Ellipse(rectDpi);提示MFC 应用默认不启用 DPI 感知。需在manifest文件中添加dpiAwaretrue/PM/dpiAware并在InitInstance()中调用AfxEnableControlContainer()后插入SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);Windows 10 1703。4. 避坑GDI 资源泄漏、坐标系错乱、重绘闪烁的五条血泪经验绘图模块上线后最头疼的不是“画不出”而是“画着画着就卡死”“缩放后图形错位”“拖动窗口留下残影”。以下是本资源包实测中踩过的五个硬坑每条都附带现象、根因和可落地的修复代码。4.1 现象程序运行几小时后 CPU 占用飙升至 100%任务管理器显示 GDI 对象数超 10000原因CBrush/CPen创建后未释放或SelectObject()返回的旧对象未保存恢复。GDI 对象句柄是系统级资源每个进程默认上限约 10000耗尽后GetDC()返回 NULL后续绘图全部静默失败。解决所有 GDI 对象必须用栈对象或智能指针管理SelectObject()后必须SelectObject(oldObj)恢复// ✅ 使用栈对象 RAII 自动清理 void DrawFilledEllipse(CDC* pDC, CRect rect, COLORREF fillColor) { CBrush brush(fillColor); CBrush* pOldBrush pDC-SelectObject(brush); // 保存旧画刷 pDC-Ellipse(rect); pDC-SelectObject(pOldBrush); // 必须恢复否则下次绘图用错画刷 } // brush 析构自动 DeleteObject()4.2 现象对话框最大化后之前画的椭圆位置偏移 20 像素且大小缩放异常原因OnSize()中未重置CDC的映射模式MM_ANISOTROPIC和窗口/视口范围导致逻辑坐标与设备坐标比例失调。解决在OnSize()中重置映射模式并设置SetWindowExt()/SetViewportExt()void CDrawDemoDlg::OnSize(UINT nType, int cx, int cy) { CDialogEx::OnSize(nType, cx, cy); if (m_pDC nType ! SIZE_MINIMIZED) { // 重置为 MM_TEXT 模式1逻辑单位1像素避免缩放干扰 m_pDC-SetMapMode(MM_TEXT); // 若需逻辑坐标此处设置 SetWindowExt(1000,1000) 和 SetViewportExt(cx,cy) } }4.3 现象鼠标快速拖动画矩形时出现大量残影松开后只保留最后一帧原因未使用双缓冲每次InvalidateRect()触发重绘时OnPaint()先擦除背景FillRect()再绘制新图形造成闪烁和残影。解决禁用背景擦除 双缓冲位图绘制// 在对话框类中重载 BOOL CDrawDemoDlg::OnEraseBkgnd(CDC* pDC) { return TRUE; // 直接返回 TRUE禁止默认擦除 } // OnPaint 中使用内存 DC void CDrawDemoDlg::OnPaint() { CPaintDC dc(this); CRect rect; GetClientRect(rect); // 创建兼容内存 DC CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rect.Width(), rect.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 先用背景色填充内存 DC CBrush bgBrush(::GetSysColor(COLOR_WINDOW)); memDC.FillRect(rect, bgBrush); // 在内存 DC 中绘制所有图形 DrawAllShapes(memDC); // 一次性 BitBlt 到屏幕 dc.BitBlt(0, 0, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }4.4 现象在OnLButtonDown()中调用Invalidate()后OnPaint()里GetClientRect()返回的尺寸比实际窗口小 10 像素原因Invalidate()仅标记区域无效OnPaint()执行时机不确定若在OnLButtonDown()中立即调用GetClientRect()可能获取到窗口尚未完成布局的尺寸。解决在OnPaint()中获取尺寸或使用GetWindowRect()ScreenToClient()确保坐标一致性// ✅ 在 OnPaint() 中获取最终尺寸 void CDrawDemoDlg::OnPaint() { CPaintDC dc(this); CRect rect; GetClientRect(rect); // 此时 rect 为最终准确尺寸 // 绘制逻辑基于 rect DrawGrid(dc, rect); }4.5 现象启用 ClearType 字体后椭圆边缘出现彩色锯齿且与文字重叠时颜色混杂原因CDC::SetBkMode(TRANSPARENT)与 ClearType 渲染冲突GDI 在透明背景下无法正确计算亚像素灰度。解决绘制前关闭 ClearType或改用CDC::SetStretchBltMode(COLORONCOLOR)配合PatBlt()填充// ✅ 绘制前临时禁用 ClearType void DrawAntiAliasedEllipse(CDC* pDC, CRect rect, COLORREF color) { // 保存原渲染模式 int oldMode pDC-SetTextRenderingHint(TEXT_RENDERING_HINT_ANTIALIASED); // 绘制椭圆此时使用 GDI 风格抗锯齿 CPen pen(PS_SOLID, 2, color); CPen* pOldPen pDC-SelectObject(pen); pDC-Ellipse(rect); pDC-SelectObject(pOldPen); // 恢复原模式 pDC-SetTextRenderingHint(oldMode); }5. 进阶技巧用 Region 实现不规则裁剪、Hit-Test 精准判定与 DPI 自适应坐标转换绘图不只是“画出来”更是“画得准、判得清、缩得稳”。本章给出三个生产环境高频需求的落地方案如何让椭圆只在指定区域内显示比如遮罩层、如何判断鼠标是否点中了某条动态绘制的直线、以及如何在 100%-200% DPI 缩放间无缝切换坐标计算。5.1 用 CRgn 实现椭圆区域裁剪让绘制内容“只露一半”当需要实现遮罩效果如圆形头像裁剪、仪表盘指针限位时CRgn是比SetClipRgn()更可控的选择。关键点在于区域必须基于设备坐标创建且需在每次重绘前重新设置// 在对话框类中声明 CRgn m_rgnClip; // OnPaint() 中应用裁剪 void CDrawDemoDlg::OnPaint() { CPaintDC dc(this); // 创建椭圆裁剪区域设备坐标 CRect rectClip(50, 50, 250, 250); m_rgnClip.CreateEllipticRgnIndirect(rectClip); // 应用裁剪 dc.SelectClipRgn(m_rgnClip); // 此时所有绘图只在椭圆内生效 dc.Ellipse(0, 0, 300, 300); // 只显示中间椭圆部分 dc.Rectangle(100, 100, 200, 200); // 也只显示椭圆交集部分 // 移除裁剪重要否则影响后续绘图 dc.SelectClipRgn(NULL); }注意SelectClipRgn()的NULL参数表示移除裁剪。若忘记此步后续所有CDC绘图包括按钮、文本都会被裁剪导致界面大面积空白。5.2 Hit-Test 精准判定判断鼠标是否点中了动态绘制的直线CDC::PtInRect()只能判断点是否在矩形内而直线是无限细的。实际工程中需实现“容差判定”——以鼠标点为中心画一个半径为 5 像素的圆判断该圆是否与直线段相交// 计算点到线段的最短距离平方避免开方 bool IsPointNearLine(CPoint pt, CPoint pt1, CPoint pt2, int tolerance 5) { long dx pt2.x - pt1.x; long dy pt2.y - pt1.y; long lengthSq dx * dx dy * dy; if (lengthSq 0) return PtInRect(CRect(pt1, CSize(1,1)), pt); // 点重合 // 计算投影参数 t ((pt-pt1)·(pt2-pt1)) / |pt2-pt1|^2 long t ((pt.x - pt1.x) * dx (pt.y - pt1.y) * dy) / lengthSq; t max(0L, min(t, lengthSq)); // 限制在线段范围内 // 投影点坐标 long projX pt1.x (dx * t) / lengthSq; long projY pt1.y (dy * t) / lengthSq; // 距离平方 long distSq (pt.x - projX) * (pt.x - projX) (pt.y - projY) * (pt.y - projY); return distSq (long)(tolerance * tolerance); } // OnLButtonDown 中调用 void CDrawDemoDlg::OnLButtonDown(UINT nFlags, CPoint point) { if (IsPointNearLine(point, m_ptLineStart, m_ptLineEnd, 6)) { AfxMessageBox(_T(点击到了直线)); } CDialogEx::OnLButtonDown(nFlags, point); }5.3 DPI 自适应坐标转换表一份覆盖 100%-225% 缩放的映射速查不同 DPI 缩放下GetSystemMetrics(SM_CXSCREEN)返回的逻辑分辨率与实际像素比不同。本资源包内置CDpiHelper类提供静态方法自动适配DPI 缩放GetDeviceCaps(LOGPIXELSX)逻辑像素:设备像素推荐处理方式100%961:1直接使用GetClientRect()125%1204:5ScaleRect(rect, 4, 5)150%1442:3ScaleRect(rect, 2, 3)175%1684:7ScaleRect(rect, 4, 7)200%1921:2ScaleRect(rect, 1, 2)CDpiHelper核心方法class CDpiHelper { public: static void GetDpiScale(int xScale, int yScale) { HDC hdc GetDC(NULL); int dpiX GetDeviceCaps(hdc, LOGPIXELSX); int dpiY GetDeviceCaps(hdc, LOGPIXELSY); ReleaseDC(NULL, hdc); // 标准 DPI 为 96计算缩放因子四舍五入到最简分数 xScale dpiX / 96; yScale dpiY / 96; // 简化分数125% - 120/96 5/4故 xScale5, yScale4 int g Gcd(xScale, yScale); xScale / g; yScale / g; } static void ScaleRect(CRect rect, int num, int den) { rect.left MulDiv(rect.left, num, den); rect.top MulDiv(rect.top, num, den); rect.right MulDiv(rect.right, num, den); rect.bottom MulDiv(rect.bottom, num, den); } private: static int Gcd(int a, int b) { return b 0 ? a : Gcd(b, a % b); } }; // 使用示例 void CDrawDemoDlg::OnPaint() { CPaintDC dc(this); CRect rect; GetClientRect(rect); int xScale, yScale; CDpiHelper::GetDpiScale(xScale, yScale); CDpiHelper::ScaleRect(rect, xScale, yScale); // 转换为设备坐标 dc.Ellipse(rect); }从那以后我每次写 MFC 绘图模块都会在OnInitDialog()里强制走一遍CDpiHelper::GetDpiScale()测试当前环境并在OnPaint()开头加断点验证GetClientRect()返回值是否与GetDeviceCaps(LOGPIXELSX)匹配。这套流程帮我避开了至少三次因 DPI 导致的客户现场返工。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?