ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MFC绘图实战:DrawGraph源码解析GDI与CDC图形绘制技巧

MFC绘图实战:DrawGraph源码解析GDI与CDC图形绘制技巧 简介这是基于MFC框架实现数据可视化的完整源码工程面向需要学习Windows图形编程或快速实现统计图表的C开发者。资源以Visual C工程形式组织包含自定义视图类、文档类及图形渲染封装等模块着重演示如何通过CDC设备上下文绘制曲线图、饼状图与柱状图曲线图以MoveTo与LineTo连接数据点饼状图通过Pie函数生成扇形柱状图则以Rectangle完成矩形绘制并覆盖坐标换算、图例更新与用户交互等关键细节。压缩包共37个文件以9个头文件和8个C源文件为核心另含图标、位图、工程配置及说明文档整体大小约26.51MB结构清晰便于直接打开和二次改造。该资源已有857人学习下载适合用于课程设计、毕业设计或项目原型验证阅读源码可快速掌握MFC绘图流程与交互式图表实现思路。1. 谁还在写 MFC 画图DrawGraph 源码里的工程答案当 Web 可视化大行其道时MFC 绘图依然活跃在工控上位机、设备调试工具、老项目维护和课程设计里。DrawGraph 这个项目把曲线图、饼状图、柱状图三件事放在同一个 Doc/View 框架下核心不是“如何画一个 Pie”而是坐标系处理、数据比例映射和视图刷新的配合方式。适合两类人需要维护 MFC 项目的工程师以及想通过源码理解 CDC 底层原理的学习者。项目不大但没有把绘图逻辑全塞进 OnDraw而是拆成 Graph、GraphSeries、GraphLegend 三层这个拆分方式本身比画图更有看头。接下来我会沿着 GDI 的内存机制、三类图表的 CDC 实现、源码模块划分、以及实际会踩到的坑逐层拆开。2. GDI 与 CDC 的内存机制绘图前必须建立的三个坐标系认知2.1 设备上下文不是画布是状态机很多初学者把 CDC 理解成一张画布其实它是 GDI 对象的集合体当前画笔、画刷、字体、背景模式、映射模式、剪裁区全部挂在 CDC 上。在 DrawGraph 里Graph::Draw(CDC* pDC)接收的 CDC 指针实际上是一个已经配置好默认状态的对象。绘图时你改的是它的属性绘图结束必须恢复原状否则会影响视图其他区域的重绘。常见做法是先保存旧状态再设置新状态用完立刻还原CDC* pDC GetDC(); int nOldMode pDC-SetBkMode(TRANSPARENT); COLORREF clrOldText pDC-SetTextColor(RGB(0, 0, 0)); // 绘图逻辑... pDC-SetBkMode(nOldMode); pDC-SetTextColor(clrOldText); ReleaseDC(pDC);这段代码的逻辑是SetBkMode(TRANSPARENT)让文字和图形背景不再填充白色避免遮挡已有线条SetTextColor设置绘图文字颜色。真正关键的是后半段——恢复旧状态。MFC 的OnDraw里如果改坏了文本颜色或背景模式下一次重绘时整个视图的显示都会异常这是 GDI 状态机的典型陷阱。项目中 GraphLegend 绘制图例文字时一定会先保存再恢复原因就在这里。2.2 从逻辑坐标到设备坐标的映射模式MFC 绘图默认使用MM_TEXT映射模式逻辑坐标以像素为单位X 轴向右Y 轴向下。也就是说屏幕左上角是 (0,0)Y 坐标越大越靠下。这对数学系出身的开发者是反直觉的数据点越大在屏幕上反而越低。DrawGraph 里画曲线图时数据源是用户输入的数值序列而屏幕坐标需要把数值转换成像素。转换公式是int screenX marginLeft (int)((valueX - minX) * plotWidth / (maxX - minX)); int screenY plotTop (int)((maxY - valueY) * plotHeight / (maxY - minY));参数含义marginLeft是图表左边留白给 Y 轴刻度留位置plotWidth和plotHeight是绘图区宽高minX/maxX是 X 轴数据范围valueY是当前数据点的值。注意screenY的写法(maxY - valueY)把数据值翻转了一次这样数值大的点才会出现在屏幕上方。这个公式几乎贯穿了 Graph.cpp 中所有图形类型的坐标计算逻辑。不切换映射模式始终用MM_TEXT自己算是多数 MFC 图表的实际做法。优点是可控性强缺点是缩放、平移都得自己处理。DrawGraph 源码中 Graph.cpp 里的 SetRange 方法本质上就是维护 minX/maxX/minY/maxY 四个成员变量为坐标换算提供边界。2.3 双缓冲为什么 DrawGraph 必须自己做内存 DC直接往窗口 DC 上画图数据量一大会闪烁。原因在于每次Invalidate()触发OnDraw时窗口先擦除背景发送WM_ERASEBKGND再执行绘制。擦除和绘制之间有一个空白窗口期屏幕就闪了。MFC 默认背景擦除用的是白色画刷图表数据点多时每一次重绘都相当于先白屏再画内容视觉上就是闪烁。DrawGraph 源码里如果只是静态显示三张图闪烁问题不明显但如果接上定时器刷新曲线数据没有双缓冲根本没法看。标准做法是在内存 DC 里先画好再一次BitBlt拷贝到屏幕CRect rcClient; GetClientRect(rcClient); CDC memDC; CBitmap memBitmap; memDC.CreateCompatibleDC(pDC); memBitmap.CreateCompatibleBitmap(pDC, rcClient.Width(), rcClient.Height()); CBitmap* pOldBitmap memDC.SelectObject(memBitmap); // 在 memDC 上执行所有绘图操作 DrawGraph(memDC); // 一次性拷贝到屏幕 pDC-BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBitmap);这里的关键动作有三个CreateCompatibleDC创建与屏幕兼容的内存设备上下文CreateCompatibleBitmap分配一块内存位图作为绘图表面SelectObject把位图选入内存 DC之后的绘图操作实际上是在位图上进行。最后BitBlt用SRCCOPY光栅操作把整块位图一次性复制到窗口。整个过程中屏幕只被更新一次闪烁自然消失。我一般建议把这个逻辑封装成一个DrawGraphWithBuffer(CDC* pDC)函数内部先判断绘图区是否为空再决定是否启用双缓冲这样在打印预览时可以直接关闭缓冲避免打印出内存位图的低分辨率效果。3. DrawGraph 三大图表的 CDC 实现拆解3.1 曲线图MoveTo/LineTo 与数据归一化曲线图的核心是把数据点依次连线。原理不复杂但有两个细节决定最终效果数据归一化方式和折线的步进粒度。归一化在GraphSeries::GetNormalizedPoint中完成逻辑是先把原始数据映射到 0.0 ~ 1.0 区间再乘上绘图区像素尺寸。这样做的好处是当窗口尺寸变化时不需要重新计算数据只需重新乘一次缩放因子。实际画线代码CPen pen(PS_SOLID, 2, RGB(0, 120, 215)); CPen* pOldPen pDC-SelectObject(pen); pDC-MoveTo(nStartX, nStartY); for (int i 1; i m_nDataCount; i) { int x nStartX (int)(m_pDataX[i] * nPlotWidth); int y nStartY - (int)(m_pDataY[i] * nPlotHeight); pDC-LineTo(x, y); } pDC-SelectObject(pOldPen);参数说明nStartX/nStartY是图表左下角的像素坐标m_pDataX/m_pDataY是归一化后的数据数组nPlotWidth/nPlotHeight是绘图区像素尺寸。LineTo从当前点连线到新点连完当前点自动更新为终点所以循环里不需要再调MoveTo。这里有个很多人忽略的点折线图的数据点间距是均匀的如果原始数据的时间间隔不均匀直接按索引连线会造成视觉误导。DrawGraph 的处理方式是额外存储每个点的实际 X 轴数值在归一化时按(valueX - minX) / (maxX - minX)映射保证 X 轴间距反映真实数据间隔。如果你的数据是等间隔采样省略这一步也没问题如果不是务必按这个思路处理。3.2 饼状图Pie 函数的扇形角度计算与起点饼状图在 GDI 里对应的是CDC::Pie函数签名如下BOOL Pie(int x1, int y1, int x2, int y2, int xStart, int yStart, int xEnd, int yEnd);前四个参数是椭圆外接矩形的左上角和右下角坐标。如果矩形为正方形画出的就是正圆否则是椭圆。后四个参数定义扇形的起始点和终点GDI 会从椭圆中心到这两个点连线沿椭圆弧线形成闭合扇形。写入时最常出错的点GDI 的角度坐标系和数学坐标系不同。数学系习惯逆时针为正GDI 是顺时针为正且 0 度在 3 点钟方向。DrawGraph 里封装了一个DrawPieSegment函数void DrawPieSegment(CDC* pDC, CRect rcBound, double dStartAngle, double dEndAngle, COLORREF clrFill) { double dStartRad (dStartAngle - 90.0) * PI / 180.0; double dEndRad (dEndAngle - 90.0) * PI / 180.0; CPoint ptCenter(rcBound.CenterPoint()); int nRadius min(rcBound.Width(), rcBound.Height()) / 2; CPoint ptStart( ptCenter.x (int)(nRadius * cos(dStartRad)), ptCenter.y (int)(nRadius * sin(dStartRad)) ); CPoint ptEnd( ptCenter.x (int)(nRadius * cos(dEndRad)), ptCenter.y (int)(nRadius * sin(dEndRad)) ); CBrush brFill(clrFill); CBrush* pOldBrush pDC-SelectObject(brFill); pDC-Pie(rcBound, ptStart, ptEnd); pDC-SelectObject(pOldBrush); }这里的关键逻辑是(dStartAngle - 90.0)的偏移。为什么要减 90 度因为数据语义中第一个扇区通常从 12 点钟方向开始而 GDI 角度从 3 点钟方向起算减 90 度完成了一次坐标系旋转。cos/sin计算得到圆周上的点坐标Pie函数会自动连接圆心形成封闭扇形。调用时角度累计方式也要注意上一个扇区的结束角是下一个扇区的开始角。用浮点数累计不要用整数累加后取整否则所有扇区加起来会明显小于或大于 360 度导致饼状图出现缺口或重叠。3.3 柱状图Rectangle 绘制与柱宽分配策略柱状图在三者里最简单但“柱宽怎么算”决定了图表是否协调。DrawGraph 的做法是绘图区宽度除以柱子总数得到理论柱宽再乘以 0.8 作为实际柱宽剩余的 0.2 作为柱间距。这样当柱子数量变化时间距比例恒定视觉上不会出现柱子时宽时窄。int nBarWidth (nPlotWidth / m_nDataCount) * 8 / 10; for (int i 0; i m_nDataCount; i) { int xLeft nStartX i * (nPlotWidth / m_nDataCount); int xRight xLeft nBarWidth; int yTop nStartY - (int)(m_pDataY[i] * nPlotHeight); int yBottom nStartY; CBrush brFill(m_clrBars[i % m_nMaxColors]); CBrush* pOldBrush pDC-SelectObject(brFill); pDC-Rectangle(xLeft, yTop, xRight, yBottom); pDC-SelectObject(pOldBrush); }参数含义nPlotWidth / m_nDataCount是每个柱子的“槽位”宽度乘以 8/10 得到实际柱宽保证柱间距 槽位宽度的 20%。m_clrBars是预定义的颜色数组i % m_nMaxColors让柱子循环使用颜色数据量超过颜色数时不会数组越界。yTop是柱子高度对应的像素 Y 值yBottom是 X 轴基线位置两者构成矩形的上下边界。需要特别提醒的是nStartY此时代表 X 轴所在的 Y 坐标不是窗口底部。如果直接把rcClient.bottom作为基线柱子会顶满整个窗口底部看不到 X 轴刻度线。这是柱状图实现里最容易被忽略的边界问题。4. 源码模块划分与数据流Graph / GraphSeries / GraphLegend 各管什么4.1 三个核心类为什么这么拆先看文件名就能猜出分工Graph.cpp是图表宿主负责整体坐标范围、绘制调度GraphSeries.cpp是单个数据序列维护数据数组、归一化结果和画笔样式GraphLegend.cpp是图例负责把每条序列的颜色、线型和名称画在图表一角。这个拆分的核心价值是应对多序列场景。Graph对象里持有GraphSeries的数组源码中用CObArray或CArrayGraphSeries*, GraphSeries*增加一条曲线只需要AddSeries不需要改绘制代码。没有这种拆分三条曲线就得复制三份绘制逻辑改一处漏两处是常态。GraphLegend单独成类的意义在于图例的位置、文字大小、背景透明度是独立的展示逻辑和数据绘制没有依赖关系。把它剥离出来后Graph::Draw的顺序是先画网格和坐标轴再遍历所有GraphSeries画数据最后调GraphLegend::Draw画图例。这个顺序是固定的图例永远在最上层不会被数据遮挡。4.2 数据更新与视图刷新的完整链路MFC Doc/View 框架下的数据流DrawGraph 遵循的是标准模式。数据变更路径是这样的视图类里某个消息处理函数比如工具栏按钮触发的添加数据调用文档类的更新接口文档类修改数据后调用UpdateAllViews(NULL)框架最终触发所有视图的OnUpdate进而引发OnDraw重绘。// 在 View 的菜单处理函数里 void CMyDrawView::OnAddData() { CMyDrawDoc* pDoc GetDocument(); pDoc-AddDataPoint(m_dNewValue); } // 在 Doc 里 void CMyDrawDoc::AddDataPoint(double dValue) { m_graph.GetSeries(0)-AddPoint(dValue); m_graph.GetSeries(0)-RecalcRange(); UpdateAllViews(NULL); } // 回到 View void CMyDrawView::OnUpdate(CView* pSender, LPARAM lHint, CObject* pHint) { Invalidate(FALSE); // FALSE 表示不清除背景配合双缓冲减少闪烁 }这段链路里有两个值得注意的细节RecalcRange()在数据变化后重算归一化范围如果不调用新数据超出旧范围时会被截断Invalidate(FALSE)的参数是bErase设为 FALSE 可以跳过WM_ERASEBKGND的白色填充配合上一章的双缓冲闪烁基本消除。4.3 工具栏与预设数据如何串联Toolbar.bmp和resource.h的存在说明项目带有工具栏资源。MFC 工具栏按钮通过ON_COMMAND宏绑定命令 IDDrawGraph 里对应的是切换图表类型。实现上工具栏按钮点击后设置视图的一个枚举成员m_nChartType然后Invalidate(FALSE)触发重绘OnDraw根据枚举值分派到不同绘制函数switch (m_nChartType) { case CHART_LINE: m_graph.DrawLineChart(pDC); break; case CHART_PIE: m_graph.DrawPieChart(pDC); break; case CHART_BAR: m_graph.DrawBarChart(pDC); break; }这里有个工程上的好习惯枚举值定义在Graph.h而不是视图类里因为图表类型是绘图模块的概念视图只是转发用户的意图。如果把枚举定义在 View 类未来想从非视图代码触发绘图时就得包含 View 头文件模块依赖关系就会变复杂。DrawGraph 源码的模块分界在这个细节上是站得住脚的。5. 从能画到顺手图例绘制、坐标轴自适应与三个排错技巧5.1 图例绘制布局计算优先于文字输出图例的实现难点不在 GDI 文字而在布局。DrawGraph 的GraphLegend.cpp里图例按行排列每行包含一个颜色块和一行文字。计算方式是先算出所有条目中文字宽度最大的值加上颜色块宽度和间距后作为图例总宽度总高度等于行高乘以条目数。然后根据预设的对齐方式右上角 / 右下角等反向算出起始坐标。文字宽度要用GetTextExtent获取不能拍脑袋定死宽度。CDC::GetTextExtent返回字符串在当前设备上下文下的像素宽度依赖当前字体所以必须在设置好自定义字体之后再测量。如果不需要自定义字体就按默认字体测但测完所有条目后要用最大宽度统一对齐否则中文长度不一致时图例会参差不齐。5.2 坐标轴自适应标尺刻度动态计算一个图表能画不等于能画好。坐标轴刻度是大多数半成品源码最敷衍的部分。DrawGraph 里如果做到了自适应刻度它的计算思路通常是把数据范围除以期望的刻度数比如 5 或 6得到一个原始步长然后取整到“好看”的步长。好看的意思是步长为 1、2、5 或 10 的倍数。我给出的简化实现如下int CalcNiceStep(double dRange, int nTargetTicks) { double dRawStep dRange / nTargetTicks; double dMagnitude pow(10.0, floor(log10(dRawStep))); double dNormalized dRawStep / dMagnitude; double dNiceStep; if (dNormalized 1.5) dNiceStep 1.0; else if (dNormalized 3.0) dNiceStep 2.0; else if (dNormalized 7.0) dNiceStep 5.0; else dNiceStep 10.0; return (int)(dNiceStep * dMagnitude); }逻辑说明log10得到原始步长的数量级floor后取整得到该数量级对应的 10 的幂dNormalized是去掉数量级后的归一化系数落在 1~10 之间。后面的 if 分支把归一化系数归入 1、2、5、10 四个档位这是刻度美化的核心思想。最终返回值是“好看”的步长乘以数量级还原成实际刻度间隔。时间序列数据的刻度可以相同思路只需把dMagnitude替换成时间单位比如 15 分钟、1 小时、1 天。5.3 三个高频坑颜色泄漏、绘制时序、DPI 缩放颜色泄漏是新手最容易犯的错。每次循环里CreateSolidBrush创建新画刷必须在循环末尾DeleteObject或SelectObject回旧画刷后调用DeleteObject。注意正在被选入 DC 的画刷不能删除必须先选出旧画刷再删新画刷。DrawGraph 的柱状图代码里频繁进行SelectObject和SelectObject(pOldBrush)的配对本质就是为了安全删除画刷。绘制时序问题出现在图表叠加场景。先画填充扇形再画边框线条顺序颠倒会导致边框被填充覆盖一半。正确顺序是先画填充画刷再选入一个PS_SOLID风格的边框画笔用Pie或Ellipse同时画出边框或者FrameRgn手动补边框。DPI 缩放在高分辨率屏幕上是个硬伤。MFC 默认不支持 DPI 感知GetClientRect返回的像素值在 150% 缩放下会偏小导致图表字迹模糊。处理方式是在main或应用初始化时调用SetProcessDPIAware()或者在.manifest文件里声明dpiAwaretrue/pm/dpiAware让系统按物理像素布局。如果你的 MFC 版本较新可以直接调用EnableDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)。DrawGraph 源码如果未处理这一点在 4K 屏上做课程展示时会明显出现文字发虚加上这一句即可解决。这也是“源码能编译过”和“实际能用”之间的典型差距。本文还有配套的精品资源点击获取
返回列表