☰
C#实时曲线绘制:GDI+高性能数据可视化Demo
2026/10/17 14:00:52 网站建设 项目流程

简介:这份C#实时曲线图Demo面向数据分析、监控系统及WinForms界面开发的初学者,帮助解决动态数据可视化中曲线实时刷新与平滑滚动的入门难题。资源包共31个文件,约55KB,以9个cs源码文件为核心,配合csproj、sln工程文件、config配置、resx与resources资源文件及exe可执行程序,完整呈现一个可直接运行的Chart折线图示例工程。教程围绕Chart控件的Series与ChartAreas配置、Points.AddXY动态加点、RemoveAt移除旧点实现平滑动画、Invalidate刷新图表以及定时器驱动数据更新等关键环节展开,并附带模拟数据生成思路,便于无真实数据源时快速验证效果。目前已有1597人学习下载,适合希望快速掌握C#实时曲线绘制基础、对照源码理解控件配置与刷新机制的开发者参考。

1. 从一块“心电图”说起:这个 C# 实时曲线 Demo 到底能干什么

如果你做过工业上位机、医疗监护界面或者传感器数据采集,大概率绕不开一个需求:把每秒几十上百个数据点,实时画成一条不断向右滚动的曲线。我见过不少项目,一开始用 Chart 控件硬怼,数据量一上来界面直接卡成 PPT,鼠标拖动窗口都要等两秒才响应。这个 C# 绘制实时曲线图 Demo,解决的就是这类场景——它不追求花哨的 3D 效果,而是把“高频数据进来、曲线平滑滚动、界面不卡”这条链路跑通。适合谁?正在做数据采集上位机、实验数据监控、设备状态面板的 C# 开发者,尤其是刚接触 GDI+ 或 WPF 绘图、想找一个能直接跑起来改的起点的人。它把实时刷新、坐标轴映射、缓冲区管理这些容易翻车的点都摆出来了,你拿到手就能看到一条真正在动的曲线,而不是一张静态截图。

2. 技术选型与整体架构:为什么不用现成 Chart 控件

2.1 绘图方案对比:Chart 控件、GDI+ 与 WriteableBitmap

很多新手第一反应是拖一个System.Windows.Forms.DataVisualization.Charting.Chart控件,设置Series类型为Line,然后定时Refresh。这个做法在数据量小于 500 点、刷新率低于 10Hz 时勉强能用,但一旦每秒要更新上千个点,Chart 控件内部会反复重建绘图资源,CPU 占用飙升,界面开始“呼吸式”卡顿。这个 Demo 没有走这条路,而是直接基于 GDI+ 的Graphics对象在PictureBox或自定义Control上绘制。核心思路是:把“数据更新”和“界面重绘”解耦,用双缓冲避免闪烁,用固定长度的环形缓冲区控制内存增长。

常见做法是:在 WinForms 里继承Control或Panel,重写OnPaint,在Timer或独立线程里更新数据数组,然后调用Invalidate触发重绘。WPF 的话会用WriteableBitmap直接操作像素,或者用DrawingVisual配合CompositionTarget.Rendering。这个 Demo 选择 WinForms + GDI+,因为它的依赖最少,新建一个 .NET Framework 或 .NET 6+ 的 WinForms 项目就能跑,不需要额外 NuGet 包,对新手最友好。

2.2 数据流与刷新机制:生产者-消费者模型

实时曲线的本质是一个生产者-消费者问题。数据采集线程(或模拟数据定时器)是生产者,不断往缓冲区写新点;UI 绘制线程是消费者,按固定帧率读取缓冲区并画线。如果直接在数据到达时调用Invalidate,数据一密集就会触发大量重绘消息,界面反而更卡。Demo 里通常用一个ConcurrentQueue<double>或者加锁的List<double>做缓冲,UI 侧用System.Windows.Forms.Timer以 30~60ms 的间隔取数据。这样即使数据源每秒来 1000 个点,界面也只按自己节奏刷新,不会因为重绘排队而雪崩。

// 数据缓冲区:固定容量,新点覆盖旧点,避免内存无限增长 private readonly double[] _dataBuffer; private int _writeIndex = 0; private readonly object _bufferLock = new object(); public void PushData(double value) { lock (_bufferLock) { _dataBuffer[_writeIndex] = value; _writeIndex = (_writeIndex + 1) % _dataBuffer.Length; } } // UI 定时器回调:按固定间隔取快照并重绘 private void UiTimer_Tick(object sender, EventArgs e) { double[] snapshot; lock (_bufferLock) { snapshot = (double[])_dataBuffer.Clone(); } _renderer.UpdateData(snapshot); _canvas.Invalidate(); // 触发 OnPaint }

这里_dataBuffer的长度决定了屏幕上能看到多少个历史点,常见取值是 500 到 2000。PushData用取模运算实现环形覆盖,UiTimer_Tick里克隆一份快照再交给渲染器,避免绘制过程中数据被后台线程修改。Invalidate只是标记区域无效,真正的绘制发生在OnPaint里,WinForms 会合并多次无效请求,不会每个点都重画一次。

2.3 坐标映射与双缓冲:把数据值变成屏幕像素

数据值到屏幕坐标的映射是绘图的核心。假设画布高度 300 像素,上下各留 20 像素边距,数据范围是 0 到 100,那么一个值v对应的 Y 坐标就是height - margin - (v - min) / (max - min) * (height - 2 * margin)。X 轴通常按索引等距排列,最新点在最右侧。双缓冲的做法是在OnPaint里先画到内存Bitmap,再一次性DrawImage到屏幕,或者直接设置控件的DoubleBuffered = true。

protected override void OnPaint(PaintEventArgs e) { // 双缓冲:先画到内存位图,再整体贴到屏幕 using (var bmp = new Bitmap(Width, Height)) using (var g = Graphics.FromImage(bmp)) { g.SmoothingMode = SmoothingMode.AntiAlias; g.Clear(Color.Black); // 画坐标轴和网格 using (var pen = new Pen(Color.FromArgb(60, 60, 60))) { for (int y = 0; y < Height; y += 30) g.DrawLine(pen, 0, y, Width, y); } // 画曲线 if (_data != null && _data.Length > 1) { using (var curvePen = new Pen(Color.Lime, 1.5f)) { PointF[] points = new PointF[_data.Length]; for (int i = 0; i < _data.Length; i++) { float x = (float)i / (_data.Length - 1) * Width; float y = Height - 20 - (float)((_data[i] - _min) / (_max - _min)) * (Height - 40); points[i] = new PointF(x, y); } g.DrawLines(curvePen, points); } } e.Graphics.DrawImageUnscaled(bmp, 0, 0); } }

SmoothingMode.AntiAlias让曲线边缘不那么锯齿,但会稍微增加 CPU 开销,数据点特别密时可以关掉。_min和_max是动态计算的,每次更新数据后重新扫描一遍找极值,这样曲线不会跑出画布。DrawLines比逐段DrawLine快很多,因为它一次性提交所有线段。注意Bitmap和Graphics都要用using包住,否则频繁重绘会快速耗尽 GDI 句柄,这是 WinForms 绘图最常见的翻车点之一。

3. 从零跑起来:项目结构、依赖与启动步骤

3.1 目录结构与关键文件说明

这个 Demo 的目录结构很扁平,没有复杂的分层,适合快速理解。典型布局如下:

RealtimeCurveDemo/ ├── RealtimeCurveDemo.csproj # 项目文件,目标框架 net6.0-windows 或 net48 ├── Program.cs # 入口,启动主窗体 ├── MainForm.cs # 主窗体,包含画布控件和定时器 ├── MainForm.Designer.cs # 设计器生成的控件布局 ├── CurveRenderer.cs # 曲线渲染逻辑,坐标映射和绘制 ├── DataBuffer.cs # 环形缓冲区,线程安全读写 └── SimulatedDataSource.cs # 模拟数据源,产生正弦波+噪声

CurveRenderer.cs是核心,它不依赖任何 UI 控件,只接收数据数组和画布尺寸,输出绘制指令。DataBuffer.cs封装了锁和取模逻辑,SimulatedDataSource.cs用System.Timers.Timer每 10ms 产生一个点,模拟传感器的高频输出。把数据源独立出来,是为了让你替换成真实的串口、TCP 或采集卡读取时,只需要改这一个文件。

3.2 环境准备与编译命令

你需要安装 .NET SDK。如果目标是 .NET Framework 4.8,用 Visual Studio 2019+ 打开即可;如果目标是 .NET 6/7/8,命令行就能跑。检查环境:

dotnet --version # 输出类似 8.0.100 即可

进入项目目录后,还原并运行:

cd RealtimeCurveDemo dotnet restore dotnet run

如果用的是 .NET Framework 项目,dotnet run可能不适用,需要用msbuild编译后直接运行 exe,或者在 Visual Studio 里按 F5。常见报错是NETSDK1045: 当前 .NET SDK 不支持目标框架,这时候要么装对应版本的 SDK,要么把.csproj里的<TargetFramework>改成你已安装的版本,比如从net8.0-windows改成net6.0-windows。

3.3 启动后的界面与交互

程序启动后会出现一个黑色背景的窗口,中间有一条绿色曲线从左向右滚动。窗口顶部或底部通常有几个按钮:Start开始模拟数据,Stop暂停,Clear清空缓冲区。曲线滚动的速度取决于SimulatedDataSource的定时器间隔和 UI 刷新定时器的间隔。如果你看到曲线一顿一顿的,先检查 UI 定时器的Interval是不是设成了 100ms 以上,改成 33ms(约 30 帧)会顺滑很多。如果曲线完全不动,看Start按钮的事件里有没有真正启动数据源定时器,以及UiTimer有没有被Start()。

提示:第一次运行时,如果窗口闪烁严重,确认画布控件的DoubleBuffered属性是否设为true,或者在构造函数里调用SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true)。

4. 核心模块拆解:数据缓冲、坐标计算与绘制循环

4.1 环形缓冲区:为什么不用 List 一直 Add

用List<double>不断Add再RemoveAt(0)是最直观的做法,但RemoveAt(0)会触发整个数组的元素搬移,数据量一大就是 O(n) 的代价,每秒执行几十次会让 GC 压力陡增。环形缓冲区用一个固定数组和写指针,新数据覆盖最旧数据,写入是 O(1),读取快照是 O(n) 但只在 UI 刷新时发生。下面是一个简化实现:

public class RingBuffer { private readonly double[] _buffer; private int _head = 0; // 下一个写入位置 private int _count = 0; // 当前有效数据量 private readonly object _lock = new object(); public RingBuffer(int capacity) { _buffer = new double[capacity]; } public void Write(double value) { lock (_lock) { _buffer[_head] = value; _head = (_head + 1) % _buffer.Length; if (_count < _buffer.Length) _count++; } } public double[] GetSnapshot() { lock (_lock) { var result = new double[_count]; int start = (_head - _count + _buffer.Length) % _buffer.Length; for (int i = 0; i < _count; i++) result[i] = _buffer[(start + i) % _buffer.Length]; return result; } } }

_head始终指向下一个要写入的位置,_count记录当前有多少个有效点。GetSnapshot从最旧的点开始按顺序拷贝,保证曲线从左到右的时间顺序正确。锁的粒度很小,只包住数组操作,不会阻塞数据采集线程太久。容量一般设为屏幕能显示的最大点数,比如 1000,这样即使数据源跑飞了,内存也不会涨。

4.2 动态极值与坐标映射:让曲线始终在画布内

如果 Y 轴范围固定为 0 到 100,而实际数据在 0 到 5 之间波动,曲线就会挤在底部一条线。Demo 里通常每次取快照后重新计算min和max,并留 10% 的余量,让曲线上下都有呼吸空间。计算时要注意排除NaN和无穷大,否则坐标映射会得到非法值,DrawLines直接抛异常。

private void UpdateMinMax(double[] data) { double min = double.MaxValue, max = double.MinValue; foreach (var v in data) { if (double.IsNaN(v) || double.IsInfinity(v)) continue; if (v < min) min = v; if (v > max) max = v; } if (min == double.MaxValue) { min = 0; max = 1; } // 全是非法值时的兜底 double margin = (max - min) * 0.1; if (margin < 1e-6) margin = 1; // 数据完全不变时给个最小范围 _min = min - margin; _max = max + margin; }

margin取范围的 10%,如果数据几乎不变(max - min接近 0),强制给一个最小范围,避免除以零。这个逻辑放在每次UpdateData里执行,计算量是 O(n),n 是快照长度,通常几百到一千,对现代 CPU 来说微不足道。

4.3 绘制循环与帧率控制:Timer 还是独立线程

WinForms 里用System.Windows.Forms.Timer最简单,它的Tick在 UI 线程执行,可以直接调Invalidate,不用考虑跨线程访问控件。但它的精度不高,Interval设 33ms 实际可能在 30~50ms 之间波动。如果对帧率稳定性要求高,可以用System.Threading.Timer配合Control.BeginInvoke,但要注意BeginInvoke在窗口关闭后可能抛异常,需要在FormClosing里停掉定时器。

// 在构造函数里初始化 _uiTimer = new System.Windows.Forms.Timer(); _uiTimer.Interval = 33; // 约 30 FPS _uiTimer.Tick += (s, e) => { var snapshot = _ringBuffer.GetSnapshot(); _renderer.UpdateData(snapshot); _canvas.Invalidate(); }; // 启动和停止 private void StartButton_Click(object sender, EventArgs e) { _dataSource.Start(); _uiTimer.Start(); } private void StopButton_Click(object sender, EventArgs e) { _dataSource.Stop(); _uiTimer.Stop(); }

Interval = 33是 30 帧每秒,人眼看起来已经足够流畅。如果 CPU 占用还是高,可以降到 50ms(20 帧),曲线滚动会稍微顿一点但资源消耗明显下降。注意Invalidate不要设成Invalidate(true),那会强制重绘子控件,这里画布没有子控件,用默认的Invalidate()就行。

5. 避坑与常见问题:那些让我熬夜的绘图翻车现场

5.1 曲线闪烁严重,像老式日光灯

现象:窗口一出现,曲线还没画出来,背景就疯狂闪白,鼠标移动时更明显。
原因:没有启用双缓冲,OnPaint里先Clear再画线,屏幕在清空和绘制之间被刷新,人眼就看到了闪烁。
解决:在画布控件的构造函数里设置DoubleBuffered = true,或者调用SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true)。如果还是闪,检查是不是在OnPaintBackground里也做了绘制,把它重写为空方法。

5.2 跑几分钟后程序崩溃,报 GDI+ 一般性错误

现象:运行一段时间后抛出System.Runtime.InteropServices.ExternalException: GDI+ 中发生一般性错误。
原因:Pen、Brush、Bitmap这些 GDI+ 对象没有释放,句柄数达到系统上限(默认每个进程 10000 个)。每次OnPaint都new Pen而不Dispose,几分钟就能耗尽。
解决:所有 GDI+ 对象都用using包住,或者把常用的Pen和Brush提成字段,在窗体关闭时统一释放。OnPaint里尽量复用对象,不要每次重绘都新建。

5.3 数据更新了但曲线不动

现象:后台日志显示数据在源源不断写入,但界面上的曲线就是一条直线或者完全静止。
原因:最常见的是Invalidate没有调用,或者 UI 定时器没启动。另一个隐蔽原因是数据快照取出来后,_min和_max没有更新,导致所有点映射到同一个 Y 坐标。
解决:在UpdateData里加一行Console.WriteLine($"min={_min}, max={_max}, count={data.Length}"),确认极值在变化。如果极值正常但曲线不动,检查OnPaint里用的_data是不是同一个引用,有没有被意外置空。

5.4 跨线程访问控件抛 InvalidOperationException

现象:用System.Threading.Timer或后台线程直接调Invalidate,报“线程间操作无效”。
原因:WinForms 控件只能在创建它的线程(UI 线程)上访问,后台线程直接调用会触发异常。
解决:用Control.BeginInvoke把Invalidate封送到 UI 线程,或者干脆用System.Windows.Forms.Timer,它的Tick本来就在 UI 线程。如果数据量特别大,BeginInvoke排队太多也会卡,这时候要考虑降低刷新频率或者用WriteableBitmap在后台线程直接写像素。

5.5 曲线滚动方向反了,最新点在左边

现象:数据明明是最新写入的,但曲线从右向左跑,或者最新点出现在最左侧。
原因:GetSnapshot返回的数组顺序是从最旧到最新,但绘制时 X 坐标映射用了i / (length - 1) * Width,如果数组顺序反了,最新点就跑到左边。
解决:确认GetSnapshot里start的计算逻辑,start = (_head - _count + _buffer.Length) % _buffer.Length得到的是最旧点的索引,按这个顺序拷贝出来就是时间递增的。如果还是反的,检查_head的更新是不是在写入之后自增,顺序错了会导致快照整体偏移。

6. 二次开发改造:换成真实数据源与性能调优

拿到这个 Demo 之后,最直接的改造就是把SimulatedDataSource替换成真实的数据读取。如果你用的是串口,把System.Timers.Timer换成SerialPort.DataReceived事件,在事件里解析帧并调用_ringBuffer.Write(value)。注意DataReceived在后台线程触发,写环形缓冲区是线程安全的,但不要在里面碰任何控件。如果是 TCP 客户端,用NetworkStream.ReadAsync循环读取,解析出数值后同样写入缓冲区。改造完数据源后,先别急着调 UI,用Debug.WriteLine把原始值打出来,确认解析正确再开画布。

性能调优方面,如果数据点特别密(比如 5000 点以上),DrawLines本身会成为瓶颈。这时候有两个方向:一是抽稀,每两个点取一个,或者用 Douglas-Peucker 算法压缩,视觉上几乎看不出差别但绘制量减半;二是换渲染后端,WinForms 下可以用SharpDX或SkiaSharp直接操作 GPU,WPF 下用WriteableBitmap配合Parallel.For写像素。我一般会先测OnPaint的耗时,用Stopwatch包住绘制代码,如果超过 10ms 就考虑抽稀,超过 30ms 就换方案。

还有一个容易被忽略的点是窗体的Resize事件。窗口大小改变时,画布的Width和Height变了,但缓冲区里的数据点数没变,坐标映射会拉伸或压缩。常见做法是在Resize里重新计算 X 轴步长,或者干脆保持数据点数不变,让曲线自适应宽度。如果窗口最小化再恢复,OnPaint可能收到异常的ClipRectangle,导致只画了一部分,这时候在OnPaint开头强制e.Graphics.Clear整个画布再画。

// 在 Resize 里触发重绘,避免拉伸后曲线错位 protected override void OnResize(EventArgs e) { base.OnResize(e); Invalidate(); // 重新计算坐标映射 } // 性能计时:定位绘制瓶颈 protected override void OnPaint(PaintEventArgs e) { var sw = Stopwatch.StartNew(); // ... 绘制逻辑 ... sw.Stop(); if (sw.ElapsedMilliseconds > 10) Debug.WriteLine($"绘制耗时 {sw.ElapsedMilliseconds}ms,考虑抽稀"); }

从那以后我每次接实时曲线需求,都强制先跑一遍这个 Demo 的骨架,确认数据源、缓冲、绘制三段都通了,再往里填业务逻辑。希望帮到你。

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

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

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

立即咨询