☰
C# GDI+自绘曲线图:从坐标映射到性能优化
2026/10/14 1:05:12 网站建设 项目流程

简介:面向需要在.NET平台Windows Forms或WPF环境中实现数据可视化、特别是曲线图绘制的C#开发者,这份资源提供了一套可直接运行的曲线图制作工程。代码以System.Windows.Forms.DataVisualization.Charting为主要切入点,依次演示Chart对象的创建、Titles标题设置、AxisX/AxisY坐标轴配置、Line类型Series的添加,以及通过AddXY逐点绑定时间与数值数据。整个过程清晰展示了从原始数据列表到屏幕图表的转化路径,适合股票涨跌、性能监控、实验数据趋势等频繁需要动态曲线展示的场景。压缩包共53个文件,其中15个.cs源文件构成主逻辑,配合11个.dll依赖库、3个.exe可执行文件、4个.resx/.resources界面资源、4个.pdb调试符号,以及.sln/.csproj等工程文件,目录结构完整,可直接在Visual Studio中打开编译并调试运行;整个包仅约681KB,轻量易用。目前已有265人学习下载。除曲线图绘制外,资源还涉及数据获取与处理、图表刷新显示及输出打印等常见环节,适合希望快速掌握图表控件用法或想在此基础上扩展自定义趋势图功能的开发者参考。

1. C#曲线图:别急着找库,先把绘制坐标这件事定下来

做C#曲线图的人往往第一反应是去下载第三方图表库,但真正需要长期维护的工业数据看板,用原生GDI+自绘反而更踏实。不是所有场景都值得引入额外依赖:当你只需要一条实时滚动曲线、几个可调参数,并且要能导出高清图片时,自绘控件的可控性比黑匣子图表库高得多。这份资源要解决的就是一件事——用C#和.NET自绘曲线图,从坐标映射到交互缩放,再到避开那些让人头皮发麻的绘制坑。适合做设备监控、传感器数据回放、或任何需要把数据点连成线的桌面程序开发者。

2. 画布与坐标系:先把GDI+的“第二坐标系”捋直

2.1 世界坐标与像素坐标:映射公式是不变的骨架

曲线图本质上干的事只有一件:把数据的“世界坐标”(比如温度20°C、电压3.3V、时间12:30:00)换算成控件上的“像素坐标”(比如x=123px, y=45px)。很多新手一上来就DrawLine,结果数据一变曲线就跑飞,根本原因是没把坐标系搞清楚。

GDI+的坐标原点在窗体左上角,x轴向右增长,y轴向下增长;而曲线图的数学坐标通常是x向右、y向上。所以做映射时y方向必须翻转一次,不然曲线是倒的。我一般会先定义一个视口结构,把“当前显示的数据范围”和“控件上用于绘图的矩形区域”分开维护:

// 视口:当前查看的世界坐标范围 public struct PlotView { public double XMin; public double XMax; public double YMin; public double YMax; } // 像素画布区域:控件中真正用来画曲线的矩形(扣掉边距之后) private Rectangle _plotRect; // 世界坐标 -> 像素坐标 private PointF WorldToPixel(double x, double y) { float px = _plotRect.Left + (float)((x - _view.XMin) / (_view.XMax - _view.XMin) * _plotRect.Width); float py = _plotRect.Top + _plotRect.Height - (float)((y - _view.YMin) / (_view.YMax - _view.YMin) * _plotRect.Height); return new PointF(px, py); }

这里的关键是:(x - XMin) / (XMax - XMin)把世界坐标归一化到0~1区间,再乘以绘图区宽度得到像素偏移。y方向用“绘图区高度减去计算值”来实现上下翻转。这样做的好处是,缩放和平移只需要改变_view这个视口结构,绘制代码完全不用动。

参数上要注意_plotRect是固定的绘图区域,通常由ClientRectangle减去一个Padding得出。如果控件尺寸变化,需要重新计算_plotRect并触发重绘。这个映射公式是整个资源包的地基,后面所有功能——鼠标缩放、十字光标、数据导出——都依赖它。

2.2 初始化画布:从一条正弦曲线开始

拿到源码包的第一步,我建议先跑通一个最简单的场景:画一条正弦曲线。这样能最快验证坐标映射是否正确,也能让你在改别人的代码之前先建立“绘制流程”的整体印象。

在WinForms里自绘曲线图,核心是重写OnPaint方法。不要在Paint事件里挂一堆临时创建的逻辑,直接重写控件方法更干净:

protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; // 扣掉边距,得到真正画图的矩形区域 _plotRect = new Rectangle( ClientRectangle.Left + _padding.Left, ClientRectangle.Top + _padding.Top, ClientRectangle.Width - _padding.Horizontal, ClientRectangle.Height - _padding.Vertical); // 画浅灰色网格 using (Pen gridPen = new Pen(Color.FromArgb(230, 230, 230), 1f)) { for (int i = 1; i < 5; i++) { float x = _plotRect.Left + _plotRect.Width * i / 5f; g.DrawLine(gridPen, x, _plotRect.Top, x, _plotRect.Bottom); } } // 画正弦曲线 using (Pen linePen = new Pen(Color.DodgerBlue, 1.5f)) { PointF? prev = null; for (int i = 0; i < _points.Count; i++) { PointF p = WorldToPixel(_points[i].X, _points[i].Y); if (prev.HasValue) g.DrawLine(linePen, prev.Value, p); prev = p; } } }

注意两点:第一,SmoothingMode.AntiAlias能让曲线边缘平滑,但会牺牲少量性能,数据量大时可以改成HighSpeed;第二,用using包裹Pen对象是个好习惯,GDI+的Pen和Brush是稀缺资源,不释放会慢慢把GDI句柄耗光,表现就是“画着画着整块界面变白”。

2.3 重绘时机:Invalidate与Refresh的差别

曲线图控件最容易让人犯迷糊的是“什么时候重新画”。很多同学发现数据变了但界面不更新,就猛点Refresh(),结果CPU飙高界面还是卡。这得从WinForms的WM_PAINT机制说起。

Invalidate()只是通知系统“这片区域已失效,请尽快发送WM_PAINT消息”,它不会立即执行绘制;Refresh()则是先让控件失效,然后强制同步去重绘。两者用错位置,效果天差地别。

我一般遵循这个原则:数据更新频率低于30Hz时,在数据变化后调用Invalidate()即可;如果因为外部原因界面立刻要响应(比如鼠标拖拽缩放),才用Refresh()。连续高频数据(比如每秒1000个点)就不适合每次点都重绘,应该先积累一批数据,再触发一次Invalidate()。源码包里默认用了一个简单计时器驱动重绘,间隔设为50ms,也就是20帧左右,肉眼已经足够流畅。

3. 实时曲线与性能:别让UI线程成为数据流的瓶颈

3.1 数据源更新:Timer还是BackgroundWorker

实时曲线最容易翻车的地方是数据采集和界面绘制挤在同一个线程里。很多人的第一版代码长这样:设备回调里直接list.Add(data),再chart.Invalidate()。如果设备的回调频率高,或者数据量突然暴涨,界面立即卡成幻灯片。

更稳的做法是把“数据采集”和“界面绘制”解耦。我一般会用一个后台线程专门收数据,入队到ConcurrentQueue;UI线程按固定间隔把队列里的数据批量取出来追加到曲线列表,再触发一次重绘。下面这段是源码包里的核心结构:

private ConcurrentQueue<PointF> _queue = new ConcurrentQueue<PointF>(); private List<PointF> _points = new List<PointF>(); private System.Windows.Forms.Timer _refreshTimer; private void InitTimer() { _refreshTimer = new System.Windows.Forms.Timer(); _refreshTimer.Interval = 50; // 20帧刷新率 _refreshTimer.Tick += (s, e) => DrainQueue(); _refreshTimer.Start(); } private void DrainQueue() { bool changed = false; while (_queue.TryDequeue(out PointF data)) { _points.Add(data); changed = true; if (_points.Count > _maxPoints) // 控制最大点数,防止内存无上限增长 { _points.RemoveAt(0); } } if (changed) Invalidate(); } // 后台采集线程 private void DataCollectWorker() { Task.Run(() => { Random rnd = new Random(); while (true) { float t = DateTime.Now.Second + DateTime.Now.Millisecond / 1000f; float v = (float)(Math.Sin(t * 2 * Math.PI) + rnd.NextDouble() * 0.1); _queue.Enqueue(new PointF(t, v)); Thread.Sleep(10); // 模拟采集间隔 } }); }

这里用System.Windows.Forms.Timer而不是System.Threading.Timer,关键原因是前者回调在UI线程上执行,可以直接碰控件;后者回调在工作线程,一碰控件就抛InvalidOperationException。如果必须用后台线程更新数据,也得通过Invoke或BeginInvoke封送回去。

参数上,_maxPoints决定内存占用和绘制速度,我通常根据屏幕像素宽度来定,比如控件宽1000px就保留2000个点,超出部分从头部踢掉。这样曲线始终填满画布,不至于越攒越多。

3.2 双缓冲与局部重绘:先解决闪烁再谈效果

曲线图闪烁是个老生常谈的问题。闪烁的根因是:每次重绘前系统先擦掉旧画面,再画新画面,中间存在一个“空白瞬间”,视觉上就表现为闪烁。WinForms虽然提供了DoubleBuffered属性,但很多人设了之后就万事大吉,其实并不够。

我建议同时开启控件级双缓冲和样式优化:

public CurveChart() { DoubleBuffered = true; SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); UpdateStyles(); }

AllPaintingInWmPaint让系统不要先擦除背景,OptimizedDoubleBuffer把每帧绘制都缓冲到内存位图再一次性贴到屏幕。组合起来之后,闪烁基本消失。

不过还有一个隐藏性能问题:即使双缓冲开了,如果整个控件很复杂,每次Invalidate()都全量重绘,CPU开销也很可观。更好的做法是只重绘“数据发生变化区域”。如果曲线是从右往左滚动,脏区通常是右侧新数据的那一小条矩形。可以用Invalidate(Rectangle)指定脏区,让GDI+只画这块。诚实说,这个优化在点少时感觉不明显,但数据量上到几万点后差别非常直观。

3.3 大数据量降采样:均匀抽样是个陷阱

当一屏要显示几万个点时,很多人的第一反应是“每隔N个点取一个”。均匀抽样在数据平稳时没问题,但遇到尖峰脉冲就可能直接漏掉,画出来的曲线和真实数据差着十万八千里。做设备监控的人最忌讳这个,因为峰值往往就是报警点。

常见的替代方案是“最大最小抽稀”,也叫桶状降采样:把数据点分成若干个桶,每个桶里保留最大值和最小值两个点,这样波峰波谷都能保住。我用了一段很精简的实现:

public static List<PointF> MinMaxDecimate(List<PointF> data, int targetCount) { if (data.Count <= targetCount) return data; int bucketSize = data.Count / targetCount; if (bucketSize < 1) bucketSize = 1; List<PointF> result = new List<PointF>(targetCount * 2); for (int i = 0; i < data.Count; i += bucketSize) { int end = Math.Min(i + bucketSize, data.Count); float minY = float.MaxValue, maxY = float.MinValue; PointF minPt = data[i], maxPt = data[i]; for (int j = i; j < end; j++) { if (data[j].Y < minY) { minY = data[j].Y; minPt = data[j]; } if (data[j].Y > maxY) { maxY = data[j].Y; maxPt = data[j]; } } result.Add(minPt); result.Add(maxPt); } return result; }

这个函数应在绘制前调用,不要在OnPaint里对每个点逐帧循环。我一般把降采样结果缓存起来,只有“数据版本号”变化时才重新算。源码包里把这一步封装成了GetRenderPoints(),画图只拿缓存结果。如果你是做高频采样类应用,这几乎是最重要的性能优化点。

4. 交互与导出:做成能用的工具而不是Demo

4.1 鼠标框选缩放与平移

曲线图光能看不行,做数据回放的人一定需要局部放大、平移回看。实现思路其实很简单:鼠标按下记录起点,鼠标抬起计算选框,把选框的屏幕坐标反算成世界坐标,然后直接更新_view视口并重绘。

反算公式就是正向映射的逆运算:

private void Chart_MouseDown(object sender, MouseEventArgs e) { if (e.Button == MouseButtons.Left) { _dragStart = e.Location; _isDragging = true; } } private void Chart_MouseUp(object sender, MouseEventArgs e) { if (e.Button == MouseButtons.Left && _isDragging) { RectangleF sel = GetSelectionRect(e.Location); if (sel.Width > 5 && sel.Height > 5) // 过滤误点击 { // 屏幕坐标 -> 世界坐标,填入新视口 _view = new PlotView { XMin = PixelToWorld(sel.Left, false), XMax = PixelToWorld(sel.Right, false), YMin = PixelToWorld(sel.Bottom, true), YMax = PixelToWorld(sel.Top, true) }; Invalidate(); } _isDragging = false; } } private float PixelToWorld(float px, bool isY) { if (!isY) return (float)(_view.XMin + (px - _plotRect.Left) / _plotRect.Width * (_view.XMax - _view.XMin)); else return (float)(_view.YMax - (px - _plotRect.Top) / _plotRect.Height * (_view.YMax - _view.YMin)); }

这段里容易被忽略的是y方向的反算:像素坐标向下增加,世界坐标向上增加,所以必须用_view.YMax减去映射值。很多人的缩放“方向反了”或者“选中的范围和预期不一样”,十有八九是这里忘了翻转。框选缩放加一个“右键双击恢复全图”逻辑会更顺手,就是在双击时把_view重置为全局数据范围。

4.2 十字光标与数值提示

做曲线图配套的十字光标是设备调试时的刚需。鼠标移动时,根据当前世界坐标找到最近的数据点,画一条十字线并在角落显示坐标值。十字线绘制要放在数据曲线之上,所以我一般会在OnPaint最后绘制:

private void Chart_MouseMove(object sender, MouseEventArgs e) { if (!_showCrosshair) return; _mousePixel = e.Location; Invalidate(); } // 在OnPaint末尾追加 if (_showCrosshair && _plotRect.Contains(_mousePixel)) { // 找到离鼠标最近的数据点 float worldX = PixelToWorld(_mousePixel.X, false); PointF nearest = FindNearestPoint(worldX); using (Pen crossPen = new Pen(Color.FromArgb(120, 128, 128, 128), 1f)) { g.DrawLine(crossPen, _mousePixel.X, _plotRect.Top, _mousePixel.X, _plotRect.Bottom); g.DrawLine(crossPen, _plotRect.Left, _mousePixel.Y, _plotRect.Right, _mousePixel.Y); } string text = $"t={nearest.X:F2}, v={nearest.Y:F2}"; g.DrawString(text, Font, Brushes.Black, _plotRect.Right - 130, _plotRect.Top + 5); }

这里FindNearestPoint在数据量大时需要做优化。我一般用“二分查找最近点”:因为点集按x有序,先二分找到插入位置,再比较前后两个点哪个更近。如果是降采样后的渲染点,索引和原始点的对应关系需要在降采样时一并记录,否则查到的坐标是抽稀后的近似值,这一步很容易被遗漏。

4.3 图片导出:DrawToBitmap未必可靠

导出曲线图给报告用,很多人用Control.DrawToBitmap,但在高DPI屏幕或控件有较多视觉细节时,导出的图片经常发虚甚至变形。更可控的做法是新建一个Bitmap,用Graphics.FromImage按固定分辨率重新绘制一遍。绘制逻辑复用OnPaint里的核心方法,只是把e.Graphics换成新的Graphics对象。

public void ExportToPng(string filePath, int width = 1920, int height = 1080) { using (Bitmap bmp = new Bitmap(width, height)) using (Graphics g = Graphics.FromImage(bmp)) { g.SmoothingMode = SmoothingMode.AntiAlias; g.Clear(Color.White); DrawChart(g); // 把OnPaint里的绘制逻辑抽到这里来 bmp.Save(filePath, ImageFormat.Png); } }

注意DrawChart里不能再用e.Graphics和ClientRectangle,应该统一换成传入的Graphics和Rectangle。这也是为什么源码包里把绘制逻辑单独拆了一个方法,而不是全部堆在OnPaint里。导出图片和屏幕显示的最大区别是分辨率不同,屏幕有100%或125%缩放,导出图则按实际像素数计算。所以DrawChart里所有参数都从传入的作图区域获取,不依赖控件尺寸。

5. 避坑:画曲线图最常踩的五个坑

5.1 曲线拖动时疯狂闪烁

现象:每次数据更新,曲线像灯管一样闪个不停,甚至出现整块空白。

原因:控件没有开启双缓冲,系统在重绘前先把背景擦成白色,再逐条绘制网格和曲线,这个擦除再绘制的过程暴露了空白间隙。

解决:在控件构造函数里设置DoubleBuffered = true,并补充SetStyle三件套。如果还闪,检查是否每次Invalidate()都伴随了Refresh()——Refresh()是同步重绘,高频调用会把双缓冲的作用抵消掉。我后来统一只调用Invalidate(),闪烁问题再没出现过。

5.2 最大值很大的数据曲线被压成一条直线

现象:数据范围是0到10000,曲线看起来像一条贴着底部或顶部的直线,但数据明显有波动。

原因:直接拿原始数值当像素坐标用,没有做坐标映射。数值1到10000在1000px的控件上,1的变动只占0.1px,肉眼当然看不出变化。

解决:所有绘制统一走WorldToPixel和PixelToWorld双向映射。不要因为某个中途版本画出来“看起来还行”就跳过映射,那只是数据恰好碰上了合适的范围。正确的做法是_view随着数据范围自动调整,或者让用户手动缩放。

5.3 Timer事件里直接改List抛“线程间操作无效”

现象:运行几秒后程序突然抛出InvalidOperationException,提示“变量正在其他线程上使用”。

原因:后台采集线程修改了List<PointF>,而UI线程同时读取同一份列表,两个线程没有同步。

解决:要么用ConcurrentQueue做数据中转,要么在后台线程里把数据改成副本再赋值给UI线程的列表。我在源码包里选择第一种方案,因为队列天然支持生产者消费者模型,采集线程只管入队,UI线程按自己的节奏消费。

5.4 125%缩放的屏幕上鼠标点击位置和曲线错位

现象:在笔记本上把显示缩放调成125%后,鼠标框选的位置和实际曲线明显错位,点不准。

原因:WinForms默认不知道系统DPI缩放,鼠标坐标用的是物理像素,而OnPaint里的ClientRectangle被系统缩放后变成了逻辑像素。两边基准不一致,坐标映射自然全错。

解决:在程序入口加Application.SetHighDpiMode(HighDpiMode.PerMonitorV2),或者在app.manifest里声明DPI感知。如果控件运行在动态缩放的场景,还要监听DpiChanged事件,重新计算_plotRect并重绘。

5.5 放大后曲线“白屏”只剩网格

现象:框选放大后,数据曲线消失,只剩下网格和坐标轴。

原因:放大后_view的世界坐标范围极小,而数据点中可能有“NaN”或“Infinity”值参与映射,或者某个坐标计算出现除以零。

解决:绘制前过滤掉非有限值,并在WorldToPixel里加一个保护判断。另外框选时加上最小宽高条件(比如宽度小于5像素就忽略),防止用户单击产生一个宽度为零的视口,让除法直接崩坏。这个坑看似简单,但排查起来会浪费不少时间。

6. 进阶:把曲线图封装成控件并做坐标往返验证

6.1 封装成UserControl:对外只留必要接口

源码包最后把曲线图做成了一个自绘UserControl,外部使用者不需要关心坐标映射和绘制细节。核心是继承UserControl并重写OnPaint,对外暴露AddDataPoint、ClearData、ZoomToFit几个方法,以及MaxPoints、ShowGrid、LineColor等属性。这样不管是WinForms主界面还是内嵌到某个面板,都能直接拖进去用。

封装时的要点是所有绘制细节都放进自定义控件内部,外部只能通过属性设置样式,不能直接拿到Graphics对象乱画。画布初始化、双缓冲、DPI适配都在控件内部完成,外部代码量能压到最小。

6.2 用坐标往返验证映射:比肉眼靠谱的验收方法

写完坐标映射后,我最怕检查时漏掉翻转或边界问题。后来养成了一个习惯:每次改动坐标相关代码后,跑一遍“世界坐标转像素再转回世界坐标”的往返验证。这是肉眼对比曲线之外更可靠的手段。

// 验证:已知一个世界坐标,经过两次转换后应能还原 float originalX = 12.5f; float originalY = 3.7f; PointF pixel = WorldToPixel(originalX, originalY); float restoredX = PixelToWorld(pixel.X, false); float restoredY = PixelToWorld(pixel.Y, true); Debug.Assert(Math.Abs(originalX - restoredX) < 0.001f, $"X还原失败 {originalX} -> {restoredX}"); Debug.Assert(Math.Abs(originalY - restoredY) < 0.001f, $"Y还原失败 {originalY} -> {restoredY}");

参数上,验证逻辑要覆盖四个角点和画布中心,而不仅是数据点本身。尤其是y轴翻转,如果正反公式不一致,往返误差会立刻暴露。从那以后我每次动坐标映射代码都强制跑一遍这组断言,哪怕只是改了一个缩放逻辑,也要先过验证再上真实数据。这条习惯帮我省下过不少肉眼排查的时间,希望帮到你。

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

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

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

立即咨询