简介:这份C#实时曲线图Demo面向Windows Forms入门开发者,以及希望在数据分析、监控界面中快速实现动态折线图的初学者。项目以Chart控件为核心,演示Series与ChartAreas配置、AddXY动态追加数据点、RemoveAt移除旧点以保持点数恒定、Invalidate刷新图表,以及用Timer定时生成模拟数据等关键环节,可帮助读者理解实时曲线从数据产生到界面刷新的完整链路。压缩包共31个文件,约55KB,包含9个cs源码、csproj与sln项目文件、resx/resources界面资源,以及config、exe、pdb等运行与调试文件,结构紧凑,解压后可直接打开解决方案运行对照。已有1597人学习,适合作为课程实验、监控面板原型或Chart控件练手项目,在现有Demo上替换数据源即可继续扩展。
1. 一条卡成幻灯片的曲线:C# 实时曲线绘制到底难在哪
C# 实时曲线绘制看着只是把一串点连成线,真正做进上位机里,问题全在时序上:采样线程每毫秒塞一个点,UI 线程还在慢慢重排控件,两边速度不一致时,画面就会抖动、卡顿。这套 Demo 不追求酷炫的图表效果,它解决的是「数据到来时,界面仍然流畅」这件事:低依赖、可暂停、可缩放,采样频率和刷新频率各管各的。适合正在做上位机课设、工控方向毕设,或者要给设备补一块实时监控面板的人。
2. 技术选型:Chart 控件、GDI+ 自绘与 WPF 渲染的三条路
动手前先想清楚一件事:这条曲线一秒钟要重画多少次,每次要画多少个点。这两个数字决定了后面所有代码的写法。常见的错误是先拖一个控件上去,跑通了再优化,结果优化阶段发现整条路都走不通,只能推倒重来。
2.1 三条路线的能力边界
第一条路是System.Windows.Forms.DataVisualization.Charting里的 Chart 控件。它的优点是快:拖上去、绑个 Series、Points.AddXY一调就有图,图例、导出图片、多 Y 轴这些功能都现成。缺点是内部维护了一个完整的点集合,每次追加点都会触发重绘,而且点集合本身只增不减。几百个点、刷新 10 次每秒的时候它很舒服;一旦到几千点、30 次每秒,就会明显感觉鼠标拖动窗口都变粘。用FastLine类型能缓解一部分,但缓解不了本质。
第二条路是 GDI+ 自绘:继承Control,重写OnPaint,自己算坐标、自己DrawLines。这条路代码量大概三五百行,但重绘成本只跟「这一次要画多少个像素」有关,跟历史数据总量无关,解耦得干净。实时曲线这种「只要最新窗口」的场景,自绘是性价比最高的选择。
第三条路是 WPF。用Polyline绑PointCollection在小数据量下很优雅,但点一多,布局和渲染管线的开销就上来了,而且每帧新建集合会产生大量垃圾。真要在 WPF 里做,稳的做法是DrawingVisual或者往WriteableBitmap里直接写像素,跳过元素树。再往上是 SkiaSharp 之类的硬件加速方案,属于「点数上万还要 60 帧」时才需要考虑的事。
| 路线 | 千点级单帧耗时量级 | 代码量 | 适用场景 |
|---|---|---|---|
| Chart 控件 | 十毫秒级,随点数线性增长 | 极少 | 演示、报表、点数小于 500 |
| GDI+ 自绘 | 毫秒级,与点数近似线性但常数小 | 中等 | 上位机、工控监控、几千点 @30fps |
| WPF DrawingVisual | 毫秒级,需自己管理帧 | 中等偏多 | 界面本身就是 WPF 的项目 |
| SkiaSharp / GPU | 亚毫秒级 | 多 | 上万点、多曲线、高刷新 |
提示:表里的耗时量级只用于横向比较,实际数字跟机器、抗锯齿开关、曲线条数都有关系。选型时自己写一个 20 行的压测循环,比看任何对比表都靠谱。
2.2 采样频率和刷新频率必须分开
这是整套方案里最关键的一句话。采样可能是一秒一千次,但人眼能分辨的刷新也就 30 到 60 次每秒,多画的部分全是浪费。所以数据到达时不该直接触发重绘,而应该先落到缓冲区里,再由一个独立的刷新定时器按固定节奏取一次快照去画。
// 采样侧:只管往缓冲里塞,不碰任何控件 private void OnSampleArrived(long timeMs, double value) { _buffer.Add(timeMs, value); // 环形缓冲,写入是 O(1) } // 刷新侧:UI 定时器,固定 33ms 一帧,约 30fps private void RenderTimer_Tick(object sender, EventArgs e) { int n = _buffer.Snapshot(_snapValue, _snapTime); // 拷一份快照 if (n == 0) return; _plot.UpdateData(_snapValue, _snapTime, n); // 只更新数据,内部 Invalidate }Snapshot这一步拷贝看着多余,其实是把「写」和「读」彻底分开:采样线程不需要锁住绘制,绘制也不需要关心采样线程什么时候来。快照数组按最大容量预先分配好,每帧复用,不会产生新的堆分配,也就不会给 GC 添麻烦。
2.3 选型怎么落地
我把判断压成三句话:点数常年在 500 以内、只求快速出图,用 Chart 控件;点数千级、要长期挂着跑、要自己控制缩放和游标,用 GDI+ 自绘;项目本身是 WPF 且点数上万,才值得上 DrawingVisual 或 GPU。
这套 Demo 走的是第二条路,WinForms + GDI+ 自绘。理由很实在:WinForms 部署简单,工控现场的老机器跑得动,GDI+ 的绘制 API 稳定,出了问题好排查。如果你用的是 WPF,第 4 章的坐标变换和像素聚合逻辑可以原样搬过去,只需要把OnPaint换成DrawingContext.DrawLine。
3. 总体架构设计:采集、缓冲、绘制、交互四层怎么切
自绘曲线的代码很容易写成一坨:定时器里读数据、算坐标、画线、处理鼠标,全在一个方法里。一开始能跑,加两个功能就改不动了。所以第二部分先做减法,把职责切干净。
3.1 四层结构与数据流向
采集层负责产出(时间戳, 数值)二元组,来源可以是串口、TCP、PLC 点位,也可以是一个模拟信号发生器,对外只暴露一个事件或者一个IDataSource接口。缓冲层是一个定长环形缓冲,只做写入和快照导出两件事。绘制层是一个自定义控件,持有视图状态,负责把数值映射成像素并画出来。交互层改动视图状态——时间窗口多宽、Y 轴量程多少、是否暂停——然后通知绘制层重绘。
数据是单向流动的:采集 → 缓冲 → 快照 → 绘制。唯一反向的路径是交互层修改视图状态,绘制层读取。这样一来,任何一层出问题都能单独定位:曲线不动,先看采集有没有在写;曲线画歪了,看坐标变换;曲线卡,看绘制和刷新节奏。
| 层 | 主要类型 | 输入 | 输出 |
|---|---|---|---|
| 采集层 | IDataSource/SimulatedSource | 设备或定时器 | 时间戳 + 数值 |
| 缓冲层 | RingBuffer | 单点写入 | 定长快照数组 |
| 绘制层 | PlotControl | 快照 + 视图状态 | 控件画面 |
| 交互层 | Viewport | 鼠标、按钮 | 时间窗、量程、暂停标志 |
3.2 环形缓冲的数据结构
定长数组加一个写指针,写满就从头覆盖。这样内存占用在启动那一刻就固定了,跑一小时和跑一天占的一样多。读取时按时间升序导出,最老的点在前。
public sealed class RingBuffer { private readonly double[] _value; private readonly long[] _timeMs; private int _next; // 下一个写入位置 private int _count; // 当前有效点数,上限为 Capacity public RingBuffer(int capacity) { if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity)); _value = new double[capacity]; _timeMs = new long[capacity]; } public int Capacity => _value.Length; public int Count => _count; // 单生产者写入是安全的;多生产者时在外层加锁 public void Add(long timeMs, double value) { _value[_next] = value; _timeMs[_next] = timeMs; _next = (_next + 1) % Capacity; if (_count < Capacity) _count++; } // 按时间升序导出,返回值是实际导出点数 public int Snapshot(double[] dstValue, long[] dstTime) { int n = Math.Min(_count, dstValue.Length); int start = (_next - n + Capacity) % Capacity; for (int i = 0; i < n; i++) { int idx = (start + i) % Capacity; dstValue[i] = _value[idx]; dstTime[i] = _timeMs[idx]; } return n; } }容量怎么定?用「采样频率 × 最长观察窗口 × 安全系数」来估。比如 100Hz 采样、最想看 60 秒,就是 6000 点,取 8192 留余量。容量定小了,缩放回看会缺数据;定大了也没意义,因为绘制时只会取窗口内的部分。
时间戳建议用Stopwatch.GetTimestamp()换算成毫秒,而不是DateTime.Now。后者在系统对时或者夏令时切换时会跳变,曲线会出现一段莫名其妙的横线或者断层,这类问题排查起来很费时间。
3.3 视图状态与重绘触发
视图状态集中放在一个小类里,绘制层只读它:
public sealed class Viewport { public double TimeMin; // 窗口左边界(相对起始毫秒) public double TimeMax; // 窗口右边界 public double ValueMin; // Y 轴下限 public double ValueMax; // Y 轴上限 public bool AutoScale = true; public bool Paused; public int CursorIndex = -1; // 游标命中的采样点下标,-1 表示无 }重绘不是什么时候都触发。数据快照变了要重绘,视图状态变了要重绘,鼠标移动改了游标要重绘,其他时候一律不画。暂停状态下,刷新定时器照跑但跳过取数,这样暂停时缩放、拖动游标依然流畅,不会因为停了定时器导致整个界面「冻住」。
注意:自动量程要加迟滞,否则数据在阈值附近晃动时,Y 轴会每一帧都在跳,看起来比曲线本身还晕。我的做法是超出当前量程 5% 以上才重新计算,且只在整数刻度上取整。
4. 关键模块实现:从环形缓冲到像素映射的完整代码
架构定完,第三部分进入代码。这里的每一段都可以直接摘到自己项目里用,参数按实际场景改。
4.1 采样源与模拟数据
调试阶段最需要的是一个能造数据的源,否则每次都要连设备,效率太低。模拟源用两个正弦叠加一点噪声,这样曲线的疏密、抖动、幅度变化都能覆盖到。
public sealed class SimulatedSource { private readonly System.Windows.Forms.Timer _timer = new(); private readonly Random _rnd = new(7); private readonly Stopwatch _sw = Stopwatch.StartNew(); private double _phase; public event Action<long, double>? SampleReady; public SimulatedSource(int sampleHz) { _timer.Interval = Math.Max(1, 1000 / sampleHz); _timer.Tick += (_, _) => { _phase += 0.05; // 主信号 + 高频分量 + 噪声,覆盖平滑段和跳变段 double v = 50 + 20 * Math.Sin(_phase) + 3 * Math.Sin(_phase * 7) + (_rnd.NextDouble() - 0.5) * 0.6; SampleReady?.Invoke(_sw.ElapsedMilliseconds, v); }; } public void Start() => _timer.Start(); public void Stop() => _timer.Stop(); }sampleHz决定定时器间隔,1000 除以它就得到毫秒数。真实设备接入时,把SampleReady的触发点换成串口DataReceived或者网络回调就行,后面的代码一行不用改。这里的Stopwatch是整条链路的时间基准,采样、缓冲、绘制全都用它,别混用DateTime。
4.2 坐标变换与刻度算法
数值到像素的映射本质是两次线性插值。X 轴按时间,Y 轴按数值,注意 Y 轴方向要和屏幕相反,所以用矩形的底边减。
private RectangleF _plot; // 去掉坐标轴标签后的绘图区 private float X(double t) => _plot.Left + (float)((t - _view.TimeMin) / (_view.TimeMax - _view.TimeMin) * _plot.Width); private float Y(double v) => _plot.Bottom - (float)((v - _view.ValueMin) / (_view.ValueMax - _view.ValueMin) * _plot.Height);刻度用「1、2、5 乘 10 的整数次幂」这套老办法,算出来的刻度值读起来顺眼,不会冒出 3.7、7.4 这种数字。
private static double NiceStep(double range, int targetTicks) { if (range <= 0) return 1; double raw = range / Math.Max(1, targetTicks); double mag = Math.Pow(10, Math.Floor(Math.Log10(raw))); // 数量级 double norm = raw / mag; // 落在 [1,10) double nice = norm <= 1.5 ? 1 : norm <= 3 ? 2 : norm <= 7 ? 5 : 10; return nice * mag; }targetTicks一般给 5 到 8,取决于绘图区高度。太高标签会叠在一起,太低又看不出量程。算完步长后,把下限向下取整到步长的整数倍、上限向上取整,这样网格线永远落在「整」数上。
4.3 曲线主体绘制与像素聚合
先解决双缓冲。控件构造时打开三个样式位,闪烁问题基本就没了:
public PlotControl() { SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.UserPaint, true); SetStyle(ControlStyles.ResizeRedraw, true); }AllPaintingInWmPaint把擦除背景和绘制合并到一次,OptimizedDoubleBuffer让 GDI+ 先画到内存位图再一次性贴出来。三个位缺一个,都可能在高频刷新下看到闪。
真正的绘制逻辑:
protected override void OnPaint(PaintEventArgs e) { if (_snapCount == 0) { e.Graphics.Clear(BackColor); return; } using var g = e.Graphics; g.SmoothingMode = SmoothingMode.None; // 千点以上关抗锯齿,代价是线条略毛,速度翻倍 DrawGrid(g); DrawSeries(g); DrawCursor(g); } private void DrawSeries(Graphics g) { int n = _snapCount; float w = Math.Max(1f, _plot.Width); if (n > w) { // 点数超过像素列:每列取 min/max 画一条竖线,绘制量恒定为列数 int cols = (int)w; var pts = new PointF[cols * 2]; int k = 0; for (int c = 0; c < cols; c++) { int from = (int)((long)n * c / cols); int to = (int)((long)n * (c + 1) / cols); if (to <= from) to = from + 1; double lo = double.MaxValue, hi = double.MinValue; for (int i = from; i < to && i < n; i++) { if (_snapValue[i] < lo) lo = _snapValue[i]; if (_snapValue[i] > hi) hi = _snapValue[i]; } float x = _plot.Left + c; pts[k++] = new PointF(x, Y(hi)); pts[k++] = new PointF(x, Y(lo)); } g.DrawLines(_pen, pts); } else { // 常规情况:逐点连线 var pts = new PointF[n]; for (int i = 0; i < n; i++) pts[i] = new PointF(X(_snapTime[i]), Y(_snapValue[i])); g.DrawLines(_pen, pts); } }聚合那一段是整个绘制层的性能核心。假设一屏 800 像素宽、缓冲里有 6000 个点,直接画 6000 段线,有一半以上的线段会落在同一个像素列上,最后被后画的覆盖掉——做了无用功还掉帧。按列取最大最小值画竖线,视觉上几乎看不出差别,但每帧的绘制调用量从 6000 降到 1600,稳定在毫秒级。
4.4 暂停、缩放与游标取值
三个交互都只改Viewport,不碰数据。
缩放:鼠标滚轮时以光标所在时间为锚点,把时间窗口按 0.8 或 1.25 的比例缩放,锚点前后各乘一次,保证光标下的那条数据在缩放前后位置不动。
暂停:Paused = true时刷新定时器跳过Snapshot,但依然在Tick里检查视图状态是否变化,变了就Invalidate。记得把暂停按钮和「停止采集」分开——暂停只是不看,采集中断会导致缓冲区出现空洞,恢复后曲线会断一截。
游标:MouseMove时把鼠标 X 坐标反算成时间,在快照里二分查找最接近的下标,存进CursorIndex,然后画一条竖线和一个数值标签。
private int FindNearest(double t) { int lo = 0, hi = _snapCount - 1; while (lo < hi) { int mid = (lo + hi) / 2; if (_snapTime[mid] < t) lo = mid + 1; else hi = mid; } // 取左邻和命中点里更近的那个 if (lo > 0 && Math.Abs(_snapTime[lo - 1] - t) < Math.Abs(_snapTime[lo] - t)) return lo - 1; return lo; }二分查找要求快照按时间有序,环形缓冲导出时天然满足。用线性扫描也能跑,但鼠标一动就是几千次比较,在低配机器上能感觉到拖影。
5. 避坑与排查:曲线抖动、闪烁、越画越慢的五个常见原因
代码跑起来只是开始,真正的成本在调。下面这五个是最常遇到的,每一个都带过现场。
5.1 曲线越跑越慢,内存一路涨
现象:刚开始很流畅,挂半小时后掉到个位数帧率,任务管理器里的内存曲线一路上扬。
原因:多半是在用List<PointF>或者 Chart 控件的Points.AddXY无脑追加,历史点永不清理,每帧遍历的点数随时间线性增长。另一种情况是每帧new Bitmap(Width, Height)做双缓冲,位图没有Dispose,GDI 对象句柄会越积越多。
解决:换成定长环形缓冲,写入永不扩容;每帧只导出「时间窗口内」的一段,而不是全部历史。双缓冲位图要么在控件构造时建一次、尺寸变化时才重建,要么直接用OptimizedDoubleBuffer样式让框架托管。用任务管理器看 GDI 对象数是个好习惯,数字只涨不落就是没释放。
5.2 画面闪、拖动窗口时线条撕裂
现象:曲线刷新时整块区域忽明忽暗,或者快速拖动窗口时出现半截线。
原因:OnPaintBackground先用背景色刷一遍,OnPaint再画一遍,两次之间被显示出来,就是闪。撕裂则是绘制过程中窗口被重绘打断。
解决:检查SetStyle的三个位是不是都开了,AllPaintingInWmPaint会抑制背景擦除消息,OptimizedDoubleBuffer保证整幅画完再一次性输出。如果还闪,看是不是在OnPaint之外(比如定时器里)调了CreateGraphics()直接画——这种画法不受双缓冲保护,是闪烁的高发区,一律改成Invalidate()触发重绘。
5.3 定时器越调越快,CPU 反而更高
现象:为了「更实时」,把刷新定时器的间隔从 33ms 改到 5ms,结果曲线没变流畅,CPU 占用反而翻了几倍。
原因:System.Windows.Forms.Timer的最小分辨率受系统时钟限制,间隔设得比实际能调度的还小,只会让消息队列里堆积 WM_TIMER 消息,实际帧率还是那个数,但每次都要走一遍完整的绘制流程。同时绘制频率超过显示器刷新率,画了也看不见。
解决:刷新间隔按目标帧率定,30fps 用 33ms,60fps 用 16ms,再往下没意义。真正影响「实时感」的不是刷新率,而是端到端延迟——采到一个点,到它出现在屏幕上,这段时间控制在 100ms 以内,人眼就觉得是实时的。降间隔不如减单帧耗时。
5.4 缩小时间窗后曲线糊成一片
现象:把窗口从 60 秒缩到 2 秒,曲线变成一团毛刺或者一条粗带。
原因:窗口变窄后,落在同一个像素列上的点数暴涨,逐点连线时后面的点不断覆盖前面的,画的其实是「最后到达的那个点」,峰谷信息全丢了。
解决:启用 4.3 节的像素聚合,点数超过列数时按列取 min/max。另外抗锯齿在高密度下要关掉——SmoothingMode.None会让线条略毛,但避免了半透明像素反复叠加造成的「发虚」。如果业务上关心峰值,还要在聚合时单独标记全局最大值的位置,用一个小三角标出来。
5.5 关窗时报跨线程操作,或者对象已释放
现象:点关闭按钮时弹一个InvalidOperationException,或者偶发ObjectDisposedException。
原因:采样线程还在往缓冲里写、还在往控件上Invoke,而窗体已经开始销毁。采集定时器和刷新定时器没有在FormClosing里停掉。
解决:窗体关闭事件里先停采集源、再停刷新定时器、最后Dispose缓冲和画笔,顺序不能反。跨线程更新控件统一走BeginInvoke而不是Invoke,前者不阻塞采集线程;同时在回调里判断IsHandleCreated && !IsDisposed,句柄没了就直接返回。这类偶发问题最难复现,与其事后追,不如在关闭流程里一次写对。
6. 验收与答辩:用可复现的指标证明这条曲线是实时的
做完不等于能交。验收的关键是把「实时」这个模糊的说法拆成几个能测出来的数字,至少在答辩现场被追问时,你能给出方法和结果,而不是一句「感觉挺流畅的」。
我一般按这张表过一遍,每项都写清怎么测:
| 验收项 | 怎么测 | 合格线 |
|---|---|---|
| 单帧绘制耗时 | 在OnPaint首尾打Stopwatch,连续 300 帧取均值 | 800 像素宽、5000 点,均值小于 8ms |
| 端到端延迟 | 采样写入时记时间戳,绘制完成后减去 | 小于 100ms |
| 长时间稳定性 | 连续跑 2 小时,每 10 分钟记录内存与帧率 | 内存波动小于 5MB,帧率不掉 |
| 数据完整性 | 已知输入的模拟信号,对比屏幕取值与理论值 | 误差在半像素以内 |
| 交互响应 | 滚轮缩放、拖动游标时主观观察 | 无卡顿、无跳变 |
测的时候有个细节要注意:Stopwatch的计时点要放在OnPaint内部,别放在定时器里——定时器只是「请求重绘」,真正的耗时在OnPaint里,测错位置得到的数字会好看得离谱。
答辩时被问得最多的三个问题,答案都藏在前面几章里。第一个是「为什么不用现成的图表控件」,答性能边界:点数和刷新率一上去,控件内部的点集合就成了瓶颈,自绘能把重绘成本压到与历史数据无关。第二个是「采样频率再翻十倍怎么办」,答解耦:缓冲区容量按新频率重算,绘制侧的聚合逻辑不变,单帧耗时不会跟着涨。第三个是「曲线突然断了一截是怎么回事」,这就是DateTime.Now跳变或者缓冲容量不足的典型表现,能说清原因和排查路径,比说「我再看看」强得多。
从那以后,我每次做带实时刷新的界面,都会强制走一遍这三步:先定采样频率和刷新频率两个数字,再把缓冲容量和单帧耗时预算写进注释,最后按上面那张表测一遍留档。这三步花不了两小时,但能省掉交付前通宵改代码的麻烦。希望帮到你。
本文还有配套的精品资源,点击获取