尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MFC绘图程序设计:GDI图形绘制与双缓冲重绘实战

MFC绘图程序设计:GDI图形绘制与双缓冲重绘实战 简介这是一份基于MFC框架的C图形绘制工具源码包面向具备一定Visual C与Windows编程基础的学习者或开发者适合用于课程设计、图形学入门练习以及MFC界面编程的参考项目。资源共50个文件压缩包约23.26MB主要包含9个C源文件、9个头文件、6个对象文件以及工程配置、资源脚本、编译日志和可直接运行的exe文件从源码、界面资源到生成产物均有覆盖便于直接运行并对照阅读。项目提供了直线、曲线、矩形、圆角矩形、圆形、椭圆、圆弧、扇形、多边形等常见图形绘制能力并集成画笔、画刷与橡皮擦工具包含线宽设置对话框整体代码按MFC标准工程结构组织模块划分较清晰。目前已有99人学习参考适合作为MFC绘图程序设计的学习与二次开发素材。1. MFC 单文档视图架构从 Graphics_Draw 的框架文件说起拿到这套Graphics_Draw.sln的时候第一反应不是去看绘图算法而是先看它是不是标准的 MFC SDI单文档界面工程。打开文件列表MainFrm.cpp、Graphics_DrawDoc.cpp、Graphics_DrawView.cpp、CSetPenSizeDialog.cpp各自独立Graphics_Draw.rc管理菜单和对话框资源resource.h管资源 ID这基本坐实了是 Document/View 架构。很多人做绘图工具习惯把逻辑全塞进 View 的鼠标消息里文档类形同虚设结果一拖窗口就丢画面根本原因就是把“临时绘制状态”和“持久化图形数据”混在了一起。这套工程的价值在于它用 MFC 的文档视图分离把图形的产生、存储和重绘拆成了三条清晰的链路。适合正在学 MFC 的人拆开来读也适合做小型 CAD 或白板工具时拿来改框架。2. CDC 与 GDI 对象直线、矩形、圆角的绘制逻辑2.1 设备上下文与 OnDraw 的关系MFC 里所有绘制最终都落在CDC上Graphics_DrawView的OnDraw函数接收一个CDC* pDC这个指针在窗口需要重绘时由框架自动传入。常见的错误是直接在OnLButtonDown里用CClientDC画完就结束这样画面只在鼠标消息那一下存在一旦窗口遮挡恢复、最小化再打开内容就灰飞烟灭。正确做法是消息处理里只改变文档数据然后调用Invalidate()让框架触发OnDraw统一重画OnDraw永远从文档数据重建画面。void CGraphics_DrawView::OnDraw(CDC* pDC) { CGraphics_DrawDoc* pDoc GetDocument(); if (!pDoc) return; for (INT_PTR i 0; i pDoc-m_shapes.GetSize(); i) { CShape* pShape pDoc-m_shapes[i]; pShape-Draw(pDC); // 多态调用每个图形自己负责绘制 } }GetDocument()拿到文档指针m_shapes是文档里保存图形对象的数组CShape::Draw是虚函数直线、矩形、圆弧各自实现。这里最关键的是pDC的生命周期由框架管理不要在OnDraw外部保存这个指针。GetSize()返回INT_PTR循环变量用INT_PTR而不是int在 64 位编译下避免符号扩展问题这是 MFC 工程里容易被忽略的细节。2.2 CPen 和 CBrush 的选入与恢复画矩形时边框由画笔决定填充由画刷决定。MFC 的CPen和CBrush只是 GDI 对象的封装必须用SelectObject选入设备上下文才生效用完要及时选回旧对象否则会造成 GDI 句柄泄漏。一个 Windows 进程默认的 GDI 句柄上限是 10000 个长时间运行的工具类程序一旦泄漏界面会逐渐出现绘制异常甚至花屏。void CShapeRect::Draw(CDC* pDC) { CPen pen(m_nPenStyle, m_nPenWidth, m_clrLine); CBrush brush(m_clrFill); 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(pOldPen); pDC-SelectObject(pOldBrush); }CPen的第一个参数是线型PS_SOLID、PS_DASH、PS_DOT分别对应实线、虚线和点线第二个参数是线宽单位是逻辑单位默认映射模式下等于像素。Rectangle的四个参数分别是左上角和右下角的坐标注意 MFC 的矩形是左开右闭还是全闭取决于具体版本一般填坐标即可。SelectObject返回值必须保存并在用完恢复这条规则比具体画什么图形更重要。2.3 圆角矩形 RoundRect 的椭圆参数RoundRect比Rectangle多了两个参数用于指定圆角处椭圆的宽度和高度。很多人直接填固定值 20看起来没问题但窗口缩放后圆角比例会很难看。// 圆角半径取矩形短边的 1/8保证不同尺寸下视觉一致 int nEllipseWidth min(dx, dy) / 8; int nEllipseHeight min(dx, dy) / 8; pDC-RoundRect(m_ptStart.x, m_ptStart.y, m_ptEnd.x, m_ptEnd.y, nEllipseWidth, nEllipseHeight);dx和dy是鼠标拖拽形成的矩形宽高。这里的椭圆宽度和高度是指圆角处虚拟椭圆的长轴和短轴不是圆角半径的一半。min宏在 Windows 头文件里默认可用但更稳妥的是用std::min并包含algorithm避免与windows.h里的min宏冲突。2.4 表格GDI 绘制函数与参数速查函数作用关键参数常见误用MoveToExLineTo画直线起点、终点坐标忘记MoveToEx导致线条从上次位置连过来Rectangle画矩形左上、右下坐标坐标反了画面出现负宽矩形RoundRect画圆角矩形4 角坐标 椭圆宽高椭圆宽高填成半径形状怪异Ellipse画椭圆/圆边界矩形想画圆时不调整宽高一致Pie画扇形边界矩形 起始/结束点起始点不在椭圆轨迹上角度判断错误Polygon画多边形点数组 点数量点少于 3 个导致绘制失败绘制圆形时Ellipse接受的是外接矩形的四个参数想让鼠标拖出正圆需要在OnMouseMove里根据起点和当前点计算边长取宽高的最小值作为正方形边长再重新推算左上角坐标。如果直接Ellipse(start.x, start.y, end.x, end.y)画出来一定是椭圆。3. 曲线、圆弧、扇形与多边形GDI 的几何计算与参数陷阱3.1 曲线通过 Polyline 多点逼近MFC 没有直接画任意曲线的函数常见的做法是在鼠标移动过程中持续收集采样点最后用Polyline一次性绘制。采样点越密曲线越平滑但点太多会拖慢重绘速度。一般鼠标移动消息的频率在 125Hz 左右每次移动产生的像素距离足以支撑平滑曲线。void CShapeCurve::AddPoint(CPoint pt) { m_points.Add(pt); // CArrayCPoint, CPoint } void CShapeCurve::Draw(CDC* pDC) { if (m_points.GetSize() 2) return; pDC-Polyline(m_points.GetData(), (int)m_points.GetSize()); }Polyline的第一个参数是LPPOINT数组指针CArray::GetData()返回内部缓冲区首地址可以直接传给 GDI 函数。这里要注意GetSize()返回INT_PTRGDI 函数需要int必须显式强转。画曲线时不要每收到一个WM_MOUSEMOVE就局部重绘整条曲线而是先画一条从上一个点到当前点的线段作为预览鼠标松开后再把完整采样点存入文档这样交互流畅度和最终效果都能兼顾。3.2 Arc 与 Pie 的边界矩形和角度语义Arc和Pie是 GDI 里最容易搞错的函数。它们都用外接矩形限定椭圆形状然后需要指定起点和终点。这两个点不是角度值而是坐标值而且系统会从椭圆中心向这两个点做射线射线与椭圆的交点才是实际绘制的起点和终点。// 画 90 度圆弧从 3 点钟方向到 6 点钟方向 CRect rc(m_ptStart, m_ptEnd); CPoint ptCenter rc.CenterPoint(); CPoint ptStart(rc.right, ptCenter.y); // 3 点钟方向 CPoint ptEnd(ptCenter.x, rc.bottom); // 6 点钟方向 pDC-Arc(rc, ptStart, ptEnd);Arc从起点逆时针画到终点Pie除了圆弧还会连接圆心和两个端点形成扇形。如果想按角度精确控制需要用三角函数计算坐标double rad nAngle * 3.14159 / 180.0; CPoint pt; pt.x (LONG)(ptCenter.x nRadius * cos(rad)); pt.y (LONG)(ptCenter.y nRadius * sin(rad));这里的nRadius是椭圆短半轴和长半轴之间的某个值如果外接矩形是正方形半径统一如果外接矩形是长方形角度和坐标的换算要分别按半宽和半高计算直接用一个半径会导致角度偏移。这也是 MFC 绘图工具栏里画圆弧时最隐蔽的 bug。扇形用Pie弦形用Chord两者参数完全一样只是渲染效果不同。3.3 多边形 Polygon 与填充规则多边形是所有图形里对输入点顺序最敏感的。Polygon会自动闭合首尾但每三个点之间不能出现自交否则填充区域会按照ALTERNATE规则产生空洞。MFC 默认的填充模式是ALTERNATE即每条扫描线遇到第一条边开始填充第二条边停止第三条边再开始。对自交多边形这套规则会产生奇怪效果。void CShapePolygon::Draw(CDC* pDC) { pDC-SetPolyFillMode(WINDING); // 改用 WINDING 模式 pDC-Polygon(m_points.GetData(), (int)m_points.GetPoints()); }WINDING模式根据环绕次数决定是否填充对凹多边形和自交多边形的处理更符合直觉。画多边形时建议限制最小点数为 3并在鼠标双击时结束当前多边形否则用户很容易画出只有两个点的“多边形”Polygon会直接失败。3.4 图形对象的保存与序列化Graphics_DrawDoc里用一个CObArray保存所有图形对象每个对象继承自CShape基类。基类里存起点、终点、画笔色、画刷色和线宽派生类存各自的额外参数比如曲线存CArrayCPoint多边形存点数组。序列化时需要对每个对象写类型标记再按类型分别读写。void CShape::Serialize(CArchive ar) { CObject::Serialize(ar); if (ar.IsStoring()) { ar m_ptStart m_ptEnd; ar m_clrLine m_clrFill m_nPenWidth; } else { ar m_ptStart m_ptEnd; ar m_clrLine m_clrFill m_nPenWidth; } }CArchive的和操作符支持CPoint和COLORREF但COLORREF本质是DWORD直接读写没有问题。派生类必须实现Serialize并先调用基类版本否则加载工程文件时数据错位。这是 C 覆盖override的典型应用场景基类虚函数在派生类里重新实现调用时通过基类指针分派到正确的版本。4. 画笔、画刷、橡皮擦与交互状态从鼠标消息到对话框4.1 工具状态枚举与 UI 命令路由绘图工具需要维护一个当前工具状态告诉鼠标消息处理函数这次拖拽要生成什么形状。用枚举类型管理避免魔法数字散落在各个消息处理函数里。enum DrawType { DRAW_NONE, DRAW_LINE, DRAW_CURVE, DRAW_RECT, DRAW_ROUNDRECT, DRAW_CIRCLE, DRAW_ELLIPSE, DRAW_ARC, DRAW_PIE, DRAW_POLYGON, DRAW_ERASER };菜单和工具栏的点击事件通过ON_COMMAND宏映射到视图类的处理函数处理函数只修改m_nDrawType和m_nPenSize不直接进行绘制。这样做的优势在于用户切换工具时不需要重建任何 GDI 对象下一次鼠标按下时使用最新状态。ON_UPDATE_COMMAND_UI宏可以实时更新菜单项的勾选状态比如当前选中“圆形”工具时“圆形”菜单项自动打勾。4.2 鼠标消息的三段式绘制流程画直线、矩形、椭圆这类图形交互流程统一为左键按下记录起点和当前工具鼠标移动更新终点并预览左键抬起把形状写入文档。预览阶段使用CClientDC直接绘制同时调用GetDocument()-UpdateAllViews(this)让视图重绘已有的图形避免预览图形和已有图形重叠时出现残留。void CGraphics_DrawView::OnMouseMove(UINT nFlags, CPoint point) { if ((nFlags MK_LBUTTON) 0) return; CClientDC dc(this); dc.SetROP2(R2_NOT); // 异或模式第二次画同一条线可恢复背景 // 第一次进入时 m_ptPrev 为无效值先画一次起点标记 if (m_bTracking) { dc.MoveTo(m_ptStart); dc.LineTo(m_ptPrev); } dc.MoveTo(m_ptStart); dc.LineTo(point); m_ptPrev point; m_bTracking TRUE; }SetROP2(R2_NOT)把绘图模式设置为异或屏幕上已有内容时第一次画线是反色显示第二次在同一条线上再画一次会恢复原样。这样预览图形不需要知道背景内容就能实现“橡皮筋”效果。直线预览用这种方式够用但矩形和椭圆预览如果只用MoveTo/LineTo就得画四条边代码冗余。更优雅的做法是保存上一次预览的矩形区域并重绘该区域。void CGraphics_DrawView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_nDrawType DRAW_NONE || !m_bTracking) return; CShape* pShape CreateShape(m_nDrawType, m_ptStart, point); CGraphics_DrawDoc* pDoc GetDocument(); pDoc-m_shapes.Add(pShape); pDoc-SetModifiedFlag(TRUE); m_bTracking FALSE; Invalidate(); // 触发 OnDraw 从文档数据重绘 }CreateShape是一个工厂函数根据m_nDrawType创建不同的CShape派生类对象并把起点终点和当前画笔状态拷贝进去。SetModifiedFlag告诉文档内容已被修改关闭工程时系统会提示保存。4.3 画笔粗细对话框 CSetPenSizeDialog 的实现CSetPenSizeDialog在资源文件里有一个IDD对话框模板上面放一个编辑框类成员m_nPenSize通过DDX_Text与控件绑定。调用时用DoModal()阻塞等待用户输入返回 IDOK 后读取成员变量。void CGraphics_DrawView::OnPenSize() { CSetPenSizeDialog dlg; dlg.m_nPenSize m_nPenWidth; // 初始化显示当前值 if (dlg.DoModal() IDOK) { m_nPenWidth dlg.m_nPenSize; if (m_nPenWidth 1) m_nPenWidth 1; } }对话框类头文件里enum { IDD IDD_SET_PEN_SIZE };把资源模板和类关联起来DoModal内部会加载IDD对应的对话框资源。DDX_Text在DoDataExchange里调用负责把m_nPenSize的值同步到编辑框或反写回来。注意对用户输入的合法性校验必须在DoModal返回之后做DDX_Text只负责数据类型转换不负责范围检查。4.4 橡皮擦的两种实现路径橡皮擦在 GDI 里有两种做法。第一种是真正意义上的擦除即把擦除区域的图形数据从文档中删除。第二种是视觉上的擦除用背景色覆盖指定区域。第一种做法的难度在于需要命中检测判断鼠标划过的路径与哪些已有图形相交实现复杂但数据干净。第二种做法只需要把画刷设成窗口背景色画一条粗线即可。void CShapeEraser::Draw(CDC* pDC) { CPen pen(PS_SOLID, m_nPenWidth, pDC-GetBkColor()); CPen* pOldPen pDC-SelectObject(pen); pDC-MoveTo(m_ptStart); pDC-LineTo(m_ptEnd); pDC-SelectObject(pOldPen); }GetBkColor()返回设备上下文的背景色默认是白色如果视图的背景色改过就需要保持一致。这里有个隐患用背景色覆盖只是视觉欺骗图形数据还留在文档里保存工程再打开时擦除的图形会原样出现。真正的橡皮擦应该定位到文档数组里删除对应图形或者记录擦除路径并用ExcludeClipRect在处理OnDraw时跳过被擦除区域。5. 双缓冲重绘与命中测试让图形编辑器更顺手图形数量一多OnDraw里逐个重绘会出现明显闪烁。闪烁的根源是窗口先擦除背景再用新内容绘制交替过程暴露了背景色。解决方案是双缓冲先在内存位图上画完整内容再一次BitBlt到窗口。void CGraphics_DrawView::OnDraw(CDC* pDC) { CRect rcClient; GetClientRect(rcClient); CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 填充背景 memDC.FillSolidRect(rcClient, RGB(255, 255, 255)); // 逐个绘制图形 CGraphics_DrawDoc* pDoc GetDocument(); for (INT_PTR i 0; i pDoc-m_shapes.GetSize(); i) { pDoc-m_shapes[i]-Draw(memDC); } // 一次性拷贝到窗口 pDC-BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }BitBlt的最后一个参数SRCCOPY是三元光栅操作码表示直接把源位图复制到目标区域。内存 DC 创建后必须用SelectObject把位图选进去否则内存 DC 默认只有 1x1 像素的位图画出来的内容裁剪得只剩一个点。双缓冲的代价是每次重绘都要多一次全屏内存拷贝对普通绘图工具体量完全可接受。命中测试是让已有图形可以被选中、移动、删除的基础操作。最简单的方式是为每个图形维护一个包围盒CRect鼠标点击时判断点是否落在包围盒内。对直线这类细长图形直接判断点距线段的最短距离更准确用点到直线的向量投影计算。double CShapeLine::DistToPoint(CPoint pt) { double dx m_ptEnd.x - m_ptStart.x; double dy m_ptEnd.y - m_ptStart.y; double len2 dx * dx dy * dy; if (len2 0) return 0; double t ((pt.x - m_ptStart.x) * dx (pt.y - m_ptStart.y) * dy) / len2; t max(0.0, min(1.0, t)); // 限制在投影点在线段范围内 double nearestX m_ptStart.x t * dx; double nearestY m_ptStart.y t * dy; return sqrt((pt.x - nearestX) * (pt.y - nearestX) * (pt.x - nearestX) * (pt.y - nearestX)); }t是投影参数范围限制在 0 到 1 之间避免点到线段延长线的距离干扰判断。命中阈值一般设为 5 像素小于该值视为选中。最后一步是给选中的图形画选中框用SetROP2(R2_NOT)绘制虚线矩形既不污染图形数据又能直观指示当前操作对象。这套流程做完整个绘图工具从绘制到编辑的闭环就完整了。本文还有配套的精品资源点击获取
返回列表