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

资讯详情

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

VC++ GDI图表绘制实战:从零构建轻量级饼图、柱状图与折线图

VC++ GDI图表绘制实战:从零构建轻量级饼图、柱状图与折线图 1. 项目概述为什么要在VC里“重造轮子”最近在整理一个老项目的代码里面有个需求是要在MFC对话框里动态展示一些统计图表。项目组里有人提议直接用现成的图表控件比如MSChart或者找个第三方库。但我琢磨了一下这个项目本身不大界面风格又比较特殊需要高度定制化从配色、动画到交互都得按我们的来。更重要的是它是个历史悠久的VC项目引入一堆复杂的依赖后续维护和部署都是麻烦事。所以我决定基于最原始的GDI图形设备接口自己动手实现一套轻量、可定制、且足够“炫酷”的图表绘制模块核心就三样饼图、柱状图和折线图。你可能觉得这年头还用GDI画图是不是有点“复古”确实Direct2D、OpenGL甚至各种Web图表库像ECharts功能更强大、效果更华丽。但GDI有它不可替代的优势零依赖、极致轻量、完全可控。它就像一把瑞士军刀虽然不如专业工具锋利但胜在随手可得、用起来心里有底。尤其是在一些对安装包体积敏感、或者运行环境受限比如某些工业控制上位机的场景下一个纯GDI实现的图表模块能让你彻底摆脱“DLL地狱”和运行时库版本的困扰。而且通过一些技巧GDI也能做出渐变、阴影、平滑动画这些看起来挺“现代”的效果。这篇文章我就把自己从设计思路、核心实现到各种调试坑点的心得完整地梳理一遍适合那些还在维护或开发VC桌面应用又不想被复杂图表库绑架的朋友。2. 核心架构设计如何用面向对象的思想组织绘图直接上手就画线画矩形代码很快就会变成一锅粥。好的架构是成功的一半。我的设计核心是分层与职责分离。2.1 核心类职责划分我设计了三个核心类构成了图表库的骨架CGraph图表基类/控制器这是总指挥。它不负责具体画什么而是管理整个图表的生命周期和公共属性。比如图表的位置、尺寸m_rect、标题、图例显示与否、背景色、边框样式等。它还持有一个或多个CGraphSeries数据系列的集合。它的Draw(CDC* pDC)方法是个虚函数由具体的子类饼图、柱状图、折线图去实现具体的绘制逻辑。CGraphSeries数据系列类数据是图表的灵魂。这个类专门管理一组相关的数据点。对于折线图它存储一系列的(x, y)坐标对于柱状图是一组值和对应的分类标签对于饼图是一组数值和对应的标签。它还管理这个系列的视觉属性线条颜色、粗细、线型实线、虚线、数据点标记形状、填充颜色对柱状图和饼图扇形等。一个图表CGraph可以包含多个CGraphSeries从而实现多系列对比比如多条折线、多组柱状图。具体图表子类如CLineGraph,CBarGraph,CPieGraph它们继承自CGraph并重写Draw方法。这里是GDI绘图代码的聚集地。它们需要根据CGraphSeries中的数据计算坐标映射调用GDI函数进行绘制。同时它们也负责实现各自特有的逻辑比如折线图的平滑曲线、柱状图的并列或堆叠显示、饼图的扇形角度计算和分离效果。2.2 坐标映射与数据转换连接数据与像素的桥梁这是所有图表绘制的核心算法也是最容易出bug的地方。GDI绘图是在设备坐标像素里进行的而我们的数据是业务逻辑值比如销售额、温度、时间。我们需要一个映射函数。以折线图为例假设我们有一个数据系列X轴是时间范围[minX, maxX]Y轴是温度范围[minY, maxY]。图表的绘图区域是一个矩形plotRect排除了标题、图例、坐标轴标签等区域后的内部矩形。那么将一个数据点(dataX, dataY)转换为设备坐标(screenX, screenY)的公式是// 假设 plotRect 是绘图区矩形left, top, right, bottom 已知 int screenX plotRect.left (int)((dataX - minX) / (maxX - minX) * (plotRect.Width() - 1)); // 注意屏幕坐标系Y轴向下为正而通常图表Y轴向上为正所以需要翻转 int screenY plotRect.bottom - 1 - (int)((dataY - minY) / (maxY - minY) * (plotRect.Height() - 1));这里有几个关键点-1的调整plotRect.Width()返回的是宽度像素数例如从left10到right110宽度是100。但有效的像素索引是从10到109共100个点。直接用宽度乘以比例可能会得到110超出范围。所以用Width()-1作为映射范围确保结果在[left, right-1]之内。Y轴同理。Y轴翻转这是新手最容易忽略的。GDI的坐标系原点在左上角Y值向下增长。而数学图表通常原点在左下角Y值向上增长。所以转换时需要做bottom - 1 - mappedY这个减法操作。除零保护务必检查(maxX - minX)或(maxY - minY)是否为0。如果数据全一样可以将其视为一个非常小的范围比如max min 1e-10或者特殊处理比如在绘图区中央画一条线或一个点。实操心得我将坐标映射封装成一个独立的CPoint CGraph::DataToPoint(double dataX, double dataY)函数。在具体图表子类的Draw函数中只需遍历数据系列调用这个函数转换坐标然后进行绘制。这样逻辑清晰也便于未来支持对数坐标等更复杂的映射关系。3. 三大图表类型的GDI实现精讲有了架构和坐标映射的基础我们来看看每种图表具体怎么画。3.1 折线图平滑曲线与数据标记折线图的绘制相对直观但要做好细节。基本绘制步骤计算绘图区根据图表矩形、标题高度、图例区域等计算出真正的绘图区域plotRect。确定数据范围遍历所有CGraphSeries找出X和Y轴的最小最大值minX, maxX, minY, maxY。这里可以考虑是否自动扩展一点范围比如Y轴最大值增加5%让图表看起来不那么“顶天立地”。绘制坐标轴和网格用CDC::MoveTo和CDC::LineTo画出X轴和Y轴。网格线可以用浅灰色的虚线CPen::PS_DOT来画从坐标轴的刻度位置出发。绘制折线对每个数据系列将其所有数据点通过DataToPoint转换为设备坐标点CPoint数组。然后使用CDC::Polyline或CDC::PolylineTo一次性连接所有点。为了性能应避免在循环中多次调用LineTo。绘制数据点标记如果系列属性要求显示数据点如圆形、方形、三角形则在每个坐标点处根据形状调用CDC::Ellipse,CDC::Rectangle或自己用Polygon画三角形。让折线“平滑”起来 GDI本身不直接支持贝塞尔曲线。要实现平滑曲线一个经典方法是使用贝塞尔曲线拟合。我们可以将折线的顶点作为控制点生成一段段的贝塞尔曲线来连接。Windows GDI提供了CDC::PolyBezier函数。你需要将每四个点作为一组一个起点、两个控制点、一个终点传入。如何从原始数据点生成这些控制点有很多算法比如Catmull-Rom样条。一个简化实现是让控制点位于相邻数据点的连线上这样可以保证曲线穿过原始数据点并且过渡平滑。// 一个非常简化的示例假设 points 是 CPoint 数组count 是点数 if (count 2) { CPoint* bezierPoints new CPoint[3 * (count - 1) 1]; // 分配贝塞尔点数组 // ... 这里填充 bezierPoints根据 points 计算控制点 ... // 例如让控制点位于前后点的中点上 pDC-PolyBezier(bezierPoints, 3 * (count - 1) 1); delete[] bezierPoints; }注意事项平滑曲线计算量稍大如果数据点非常多比如实时采集的波形可能需要权衡效果和性能。另一种折中方案是使用CDC::SetPolyFillMode和CDC::Polyline配合宽线条和合适的画笔端点样式让折线看起来不那么生硬。3.2 柱状图并列、堆叠与视觉增强柱状图的核心是画矩形但排列方式有讲究。单系列柱状图最简单每个分类画一个矩形。矩形宽度 绘图区宽度 / 分类数 * 一个比例系数如0.8留出间隙。矩形高度由数据值映射到Y坐标决定。多系列柱状图并列柱状图在每个分类的位置上并排绘制多个窄矩形每个矩形代表一个系列。你需要计算每个系列矩形在这个分类下的起始X坐标和宽度。// 假设有 seriesCount 个系列分类索引为 categoryIndex float totalWidth categoryWidth; // 一个分类占据的总宽度 float barWidth totalWidth / seriesCount * 0.9; // 每个柱子的宽度留点间隙 for (int i 0; i seriesCount; i) { float barLeft categoryStartX i * (totalWidth / seriesCount); // 计算这个系列在当前分类下的值对应的矩形 top 和 bottom CRect rectBar(barLeft, bottom, barLeft barWidth, top); pDC-FillSolidRect(rectBar, seriesColor[i]); }堆叠柱状图在每个分类上绘制一个从下到上由不同系列数据累加而成的“叠罗汉”柱子。你需要从下往上累加每个系列的值并分段填充颜色。视觉增强技巧立体感不要只用FillSolidRect。可以用CDC::Draw3dRect画一个带立体边框的矩形或者更高级点自己用渐变填充。一个简单的立体效果是在矩形左侧和上方画一条亮色线右侧和下方画一条暗色线模拟光照。渐变填充GDI本身不支持直接渐变填充矩形。但可以通过绘制多个逐渐变色的细长矩形条来模拟。或者使用GdiplusWindows自带但需引入头文件和库的LinearGradientBrush这属于“轻度”依赖大部分Windows系统都支持。如果坚持纯GDI模拟渐变是个性能与效果的权衡点。圆角矩形用CDC::RoundRect可以画出圆角的柱子看起来更柔和。3.3 饼图扇形、分离与标签定位饼图的计算是几何问题。核心计算计算总和double sum 所有数据值之和。计算角度每个扇形的起始角度和扫过的角度。通常从12点钟方向-90度开始顺时针绘制。double sweepAngle 数据值 / sum * 360.0。绘制扇形使用CDC::Pie函数。这个函数需要指定一个外接矩形椭圆和扇形的起始点、终点。注意Pie函数使用的角度单位是1/10度所以360度要写成3600。并且它需要的是弧线的起点和终点坐标而不是角度。通常我们用CDC::Pie(int x1, int y1, int x2, int y2, int x3, int y3, int x4, int y4)其中(x1,y1)-(x2,y2)定义了椭圆的外接矩形(x3,y3)是弧线起点(x4,y4)是弧线终点。我们需要根据角度计算起点和终点的坐标。// 根据角度计算圆上一点的坐标 CPoint GetPointOnEllipse(const CRect rectEllipse, double angleDegree) { double angleRad angleDegree * 3.1415926535 / 180.0; double centerX (rectEllipse.left rectEllipse.right) / 2.0; double centerY (rectEllipse.top rectEllipse.bottom) / 2.0; double radiusX rectEllipse.Width() / 2.0; double radiusY rectEllipse.Height() / 2.0; int x centerX radiusX * cos(angleRad); int y centerY - radiusY * sin(angleRad); // 注意Y轴符号 return CPoint(x, y); }分离效果为了让某个扇形突出比如“鼠标悬停”或“最大项”可以将该扇形的绘制中心点沿其角度平分线方向偏移一段距离。这需要重新计算该扇形对应的椭圆外接矩形位置。标签绘制难点 饼图的标签百分比和类别名放在哪里是个学问。简单的做法是放在扇形外侧从扇形中心引一条线到标签。但如何避免标签重叠和跑到图表外面去计算标签理想位置通常放在扇形外缘的中点附近。CPoint labelPos 扇形外缘中点坐标 沿半径向外偏移一个固定距离。碰撞检测用一个CRect数组记录已放置标签的位置。在放置新标签前检查其理想位置是否与已有标签矩形重叠。如果重叠尝试向上、下、左、右微调位置。引线从扇形外缘中点到最终的标签位置画一条细线CDC::MoveTo和LineTo。绘制文本使用CDC::DrawText或CDC::TextOut在标签位置绘制文本。注意文本的对齐方式通常让文本的中心或边缘与标签点对齐。实操心得饼图标签的自动布局是个小型的优化问题非常复杂。对于项数不多的饼图比如少于8项一个简单有效的策略是将标签统一放在饼图的左右两侧左侧标签右对齐右侧标签左对齐按照扇形角度从上到下排序。这样可以很大程度上避免重叠实现起来也简单。4. 实现“炫酷”与可定制的关键技巧光画出图形还不够要“炫酷”和可定制得在细节上下功夫。4.1 颜色与样式系统不要硬编码颜色。我设计了一个CGraphTheme类或结构体来管理配色方案。struct GraphTheme { COLORREF backgroundColor; // 图表背景色 COLORREF plotAreaColor; // 绘图区背景色 COLORREF axisColor; // 坐标轴颜色 COLORREF gridColor; // 网格线颜色 COLORREF titleColor; // 标题颜色 COLORREF legendTextColor; // 图例文字颜色 std::vectorCOLORREF seriesPalette; // 数据系列默认颜色循环池 // ... 字体、线宽等 };然后在CGraph中持有一个CGraphTheme的指针或引用。绘制时所有颜色都从主题中获取。用户可以轻松切换预设主题如亮色、暗色、商务蓝或自定义每一个颜色。对于数据系列除了固定颜色还可以支持渐变颜色如前所述可通过GDI或模拟实现和纹理填充使用CDC::FillRect配合CBrush从位图创建。4.2 动画效果GDI实现动画本质上是快速连续地重绘。在MFC中可以在OnTimer消息处理函数中更新图表数据或状态然后调用InvalidateRect触发重绘。典型动画折线图绘制动画让折线像画笔画出来一样。可以维护一个“已绘制百分比”的变量在Draw函数中只绘制前percentage%的数据点。在定时器中逐步增加这个百分比并重绘。柱状图生长动画柱子从底部“长”到目标高度。绘制时柱子的高度用当前动画进度 * 目标高度来计算。饼图旋转或扇区分离动画通过连续改变扇形的起始角度或分离偏移量来实现。关键点动画计算要快重绘区域要精确只重绘图表变化的区域用InvalidateRect指定脏矩形避免整个窗口闪烁性重绘。可以使用双缓冲技术先在内存DCCDC中绘制完整图表再一次性BitBlt到屏幕DC上。4.3 交互功能交互的核心是命中测试。鼠标悬停提示在OnMouseMove中获取鼠标坐标。遍历图表元素折线图的点、柱状图的柱子、饼图的扇形判断鼠标是否落在其“热区”内。折线点/柱状图柱子计算鼠标点到元素几何中心的距离或是否在矩形内。饼图扇形判断鼠标点相对于椭圆中心的极角是否在扇形的角度范围内。这是一个几何计算问题。 如果命中则设置一个状态变量如m_nHighlightedSeriesIndex,m_nHighlightedDataIndex并触发重绘。在Draw函数中根据高亮状态绘制不同的颜色或效果如加粗、发光效果模拟。同时可以显示一个Tooltip用CToolTipCtrl来展示详细数据。点击事件与悬停类似在OnLButtonDown中进行命中测试并触发相应的业务逻辑回调比如弹出一个详细数据对话框。拖拽缩放/平移记录鼠标按下时的位置和当前的数据范围视图。在鼠标移动时根据位移计算出新的数据范围并更新坐标映射关系然后重绘。这相当于动态调整minX, maxX, minY, maxY。4.4 图例与坐标轴标签图例图例本质上是一个小型的颜色和文本的对照表。在CGraph::Draw中预留出图例区域通常在图表右侧或下方。遍历所有CGraphSeries在每个图例项的位置画一个小色块矩形或线段旁边用DrawText写上系列名称。坐标轴标签刻度计算根据数据范围[min, max]智能生成一组美观的刻度值。例如希望刻度值大概是10、20、30这样的整数而不是13.7、27.4。有一个简单的算法计算一个“漂亮”的步长tickStep然后从min向上取整到第一个步长的倍数开始生成刻度直到max。标签绘制在每个刻度对应的设备坐标位置画一个短的刻度线并在其旁边对于X轴在下方对于Y轴在左侧绘制格式化后的文本如CDC::TextOut。数值格式化可以使用CString::Format如_T(%.1f)。5. 性能优化与常见问题排查当数据量变大时GDI绘图的性能瓶颈就会显现。以下是一些优化和调试经验。5.1 性能优化要点减少不必要的绘制这是最重要的原则。在OnDraw或OnPaint中只绘制需要更新的区域。通过GetClipBox获取脏矩形只重绘与该矩形相交的图表部分。对于复杂的背景网格如果不变可以考虑缓存到位图中。使用双缓冲这是消除闪烁的标配。创建一个兼容的内存DC和位图大小等于客户区。所有绘图操作先在内存DC上完成最后用pDC-BitBlt一次性拷贝到屏幕。代码框架如下void CMyChartView::OnDraw(CDC* pDC) { CRect rect; GetClientRect(rect); CDC memDC; CBitmap memBitmap; memDC.CreateCompatibleDC(pDC); memBitmap.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); CBitmap* pOldBitmap memDC.SelectObject(memBitmap); // 1. 先用背景色填充内存DC memDC.FillSolidRect(rect, m_theme.backgroundColor); // 2. 在内存DC上绘制图表 m_graph.Draw(memDC); // 3. 一次性拷贝到屏幕DC pDC-BitBlt(0, 0, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBitmap); }对象复用不要频繁创建和销毁GDI对象CPen,CBrush,CFont。在图表类初始化时创建好常用的对象如坐标轴画笔、网格画笔、各种颜色的画刷并缓存起来。只在需要改变属性如颜色、线宽时才创建新对象。简化复杂计算例如饼图的标签碰撞检测如果非常耗时可以考虑只在数据改变时计算一次并将结果缓存起来直到数据再次变化。限制数据点数量对于实时流数据折线图如果数据点过多比如每秒上百个全部绘制会导致折线过于密集且性能下降。可以采用数据降采样如保留最大值、最小值、首尾点或滑动窗口只显示最近N个点。5.2 常见问题与调试技巧图表不显示或显示不全检查矩形坐标首先确认传递给图表Draw函数的CRect是否正确。在OnDraw中GetClientRect获取的是视图的整个客户区你的图表矩形可能只是其中一部分。用pDC-DrawRectangle把你的图表矩形框画出来看看它在哪。检查坐标映射添加调试输出打印出minX, maxX, minY, maxY以及转换后的关键点的screenX, screenY。看看这些屏幕坐标是否在你的绘图区域plotRect内。检查GDI对象状态确保在绘制前正确的画笔、画刷被选入DC。一个常见错误是创建了一个新画笔CPen pen(PS_SOLID, 1, RGB(255,0,0))但没有用pDC-SelectObject(pen)将其选入设备上下文结果还是用的默认画笔。内存泄漏GDI对象泄漏比内存泄漏更隐蔽最终会导致GDI资源耗尽程序界面异常。确保每一个CreatePen,CreateSolidBrush,CreateFont等都有对应的DeleteObject。MFC的CPen,CBrush等类在析构时会自动调用DeleteObject但如果你用new创建了它们就必须delete。更安全的方法是使用局部变量让MFC类在栈上自动管理生命周期。闪烁问题即使使用了双缓冲如果背景窗口比如父对话框也在频繁重绘可能仍会闪烁。尝试在视图或对话框类中重写OnEraseBkgnd函数并直接返回TRUE禁止Windows擦除背景因为我们在双缓冲中已经填充了背景色。BOOL CMyChartView::OnEraseBkgnd(CDC* pDC) { return TRUE; // 我们已经自己处理背景 }在高DPI显示器上模糊老式的GDI绘图默认不是DPI感知的。在高DPI屏幕上你的图表可能会被拉伸导致线条模糊。你需要让你的应用程序支持DPI感知。在清单文件或运行时调用SetProcessDpiAwareness。然后在绘图时将逻辑坐标转换为物理像素时需要考虑DPI缩放因子。使用GetDeviceCaps(pDC-GetSafeHdc(), LOGPIXELSX)获取DPI然后进行比例缩放。多线程绘图冲突如果你在后台线程中更新图表数据然后通知UI线程重绘需要确保对图表数据结构的访问是线程安全的例如使用临界区CCriticalSection。更安全的做法是后台线程将新数据准备好后通过PostMessage发送一个自定义消息到UI线程由UI线程来更新数据并重绘。切记GDI调用必须在创建窗口的线程通常是主UI线程中进行。6. 扩展与进阶思考实现基础的三件套之后这个图表库还有很多可以扩展的方向让它更加强大和实用。1. 复合图表比如在一个绘图区内同时显示柱状图和折线图双Y轴。这需要更灵活的坐标轴系统。可以设计一个CAxis类来管理每个坐标轴左Y轴、右Y轴、底部X轴、顶部X轴。每个CGraphSeries可以指定它绑定到哪个坐标轴。在绘制时根据系列绑定的轴进行不同的坐标映射。2. 数据绑定与动态更新设计一个数据源接口IDataSource图表类定期从数据源拉取或接收数据更新通知。这对于监控仪表盘类的应用非常有用。结合定时器和双缓冲可以实现流畅的实时数据曲线刷新。3. 导出功能将绘制好的图表导出为图片BMP, PNG, JPEG。思路是创建一个兼容的CDC和一个足够大的CBitmap将图表绘制到这个内存DC中然后使用CImage类MFC或ATL提供来保存位图。对于PNG等格式可能需要借助GDI。4. 打印支持重写OnPrint函数在打印时重新计算图表尺寸以适应纸张并调用图表的Draw函数。需要注意打印机的分辨率DPI与屏幕不同所有尺寸和字体大小都需要进行缩放。5. 与“热词”场景结合“鼠标放上去显示的数据加上单位”这属于Tooltip内容格式化。在生成Tooltip文本时不仅显示原始数据还根据系列或轴的单位属性如“℃”、“万元”进行拼接。“复合饼图第二个饼图如何选取数据”这通常指“子饼图”或称为旭日图的一部分。主饼图的某个扇区通常是“其他”或一个需要细分的类别可以展开为第二个饼图。实现时需要维护一个父子关系的数据结构。当用户点击或悬停主饼图的特定扇区时用该扇区对应的子数据集合来绘制第二个饼图。布局上第二个饼图可以画在旁边或用连线与主扇区关联。“VC 崩溃生成调试文件”虽然与图表绘制不直接相关但这是VC开发者必备技能。确保你的项目在“项目属性 - 配置属性 - 链接器 - 调试”中启用了“生成调试信息”为“是”并选择合适的“生成程序数据库文件”选项。这样在崩溃时配合符号文件.pdb才能进行有效的事后调试。回过头看基于GDI从头实现一套图表控件确实比调用现成库要费时费力。但这个过程让你对计算机图形学的基础、Windows消息机制、面向对象设计有了更深刻的理解。更重要的是你获得了一个完全受控、没有黑盒、可以随意“魔改”的工具。当产品经理再提出“这个柱子的颜色要能根据值动态渐变”、“点击这里要有一个爆炸式的动画效果”这种定制需求时你可以从容地打开代码而不是对着第三方库的文档发愁。这种掌控感可能就是老派程序员的一点浪漫吧。最后一个小建议把核心的绘图代码好好封装成DLL或静态库并在不同DPI、不同颜色深度的显示器上多做测试你的这个“轮子”会越来越结实说不定哪天就成了团队里的核心资产。
返回列表