简介:这份基于C# Chart控件的实时曲线绘制Demo,面向刚接触Windows Forms与数据可视化的初学者,也适合需要在监控界面或数据采集软件中嵌入动态折线图的开发者参考。示例完整展示了从工具箱拖入Chart控件、配置Series与ChartAreas、用AddXY追加数据点、移除最旧数据点实现平滑滚动、调用Invalidate刷新,到借助Timer定时模拟数据并驱动更新这一整条流程,形成一条可直接跑通的入门链路。压缩包共31个文件、约55KB,以9个cs源文件为核心,配合sln与csproj工程文件、resx资源与config配置、exe与pdb运行调试产物,解压后用Visual Studio打开即可编译运行。目前已有1597人学习下载,读者可从中获取可运行的骨架代码与属性配置思路,便于替换成串口、网络或传感器数据,快速改造为实用的实时监控图表。
1. 从一次界面卡死说起:C# 实时曲线绘制慢在哪
做过上位机的多半碰过这个场面:模拟量一秒来一百个点,界面上那条用 C# 实时曲线绘制出来的波形先是越跑越慢,接着整块窗口变成灰白,鼠标点哪都没反应。新手第一反应是加个Timer,间隔调到 20ms,在Tick里chart1.Series[0].Points.AddXY(x, y),结果卡得更厉害——控件要重排、要重绘、还要算坐标轴标签。这份 Demo 把这些零散经验收拢成一条能跑通的最小链路:模拟数据源按固定节拍产生浮点数,采样线程写进环形缓冲,渲染线程按 30 帧的节奏取窗口数据,Chart 控件和 GDI+ 自绘两条路线各给一份可切换实现。适合刚接手上位机改造、或者要给设备做实时监视面板的开发者,直接拿它当骨架改,比从零搭省事得多。
2. 拆骨架:Core / Render / UI 三层与两条渲染路线
拿到一个曲线 Demo,最怕的是所有逻辑全塞进Form1.cs,改一处牵全身。这份工程的目录划分得比较克制,只有三层,但边界清楚:数据从哪来、数据怎么存、数据怎么画,各自独立,换其中一层不动另外两层。
2.1 目录结构:Core / Render / UI 三层怎么分
工程结构大致是这样的:
RealtimeChartDemo/ ├── src/ │ ├── Core/ │ │ ├── SamplePoint.cs # 采样点:时间戳 + 数值 │ │ ├── ISampleSource.cs # 数据源抽象 │ │ ├── SimulatedSource.cs # 模拟正弦+噪声,用来跑 Demo │ │ └── RingBuffer.cs # 定长环形缓冲 │ ├── Render/ │ │ ├── IChartRenderer.cs # 渲染器接口 │ │ ├── ChartControlRenderer.cs # 路线 A:WinForms Chart 控件 │ │ └── GdiRenderer.cs # 路线 B:GDI+ 双缓冲自绘 │ └── UI/ │ ├── MainForm.cs # 只负责拼装和按钮事件 │ └── MainForm.Designer.cs └── RealtimeChartDemo.slnCore层不引用任何 UI 命名空间,这是硬规矩——否则你想把这套逻辑搬到控制台或者服务端采集程序里就得大改。Render层只负责把SamplePoint序列变成像素,它不认识串口、不认识 Modbus,也不认识按钮。UI层最薄,MainForm里基本只有初始化、启动/停止、切换渲染器三类操作。这种分法在 Demo 阶段看不出多大好处,但等你真要把模拟源换成串口源,就知道省下的是整晚的返工。
SamplePoint建议用readonly struct而不是class,采样点一秒几百个,堆上分配太浪费:
public readonly struct SamplePoint { public readonly double Timestamp; // 秒,相对启动时刻的单调时间 public readonly double Value; public SamplePoint(double timestamp, double value) { Timestamp = timestamp; Value = value; } }时间戳用double存相对秒数,而不是直接存DateTime。原因后面避坑那章会细说——DateTime.Now会跳,用它做 X 轴,波形会出现回折。这里用Stopwatch.GetTimestamp()换算出来的单调时间,才是画曲线该用的东西。
2.2 渲染选型:Chart 控件、GDI+ 自绘、WPF 各自的成本
两条路线不是炫技,是应对不同点数规模的。下面这张表是我自己压测后的经验值,机器配置不同会有出入,但量级关系基本稳定:
| 方案 | 上手成本 | 500 点/30Hz 表现 | 5000 点/60Hz 表现 | 适用场景 |
|---|---|---|---|---|
| WinForms Chart 控件 | 低 | 流畅,CPU 5% 左右 | 明显掉帧 | 内部工具、点数少的监控面板 |
| GDI+ 双缓冲自绘 | 中 | 非常流畅 | 基本能扛住 | 上位机主界面、需要定制样式 |
| WPF + Polyline | 中 | 一般 | 差,绑定开销大 | 已在使用 WPF 的项目 |
| SkiaSharp 自绘 | 高 | 非常流畅 | 非常流畅 | 高频波形、多曲线叠加 |
Demo 默认走 Chart 控件,因为大部分人第一次写就是想快速看到一条线。等点数上去卡了,再切到GdiRenderer。两个渲染器实现同一个接口,切换只是改一行:
public interface IChartRenderer { // 把当前窗口内的采样点渲染出来;view 已经是按时间排好序的快照 void Render(IReadOnlyList<SamplePoint> view, double windowSeconds); }接口里只传快照和窗口长度,不传缓冲对象本身。这样渲染层没法回头去动数据,责任划分干净。windowSeconds是横轴显示的时间跨度,比如 10 秒就是“始终显示最近 10 秒”。
2.3 线程模型:采样线程与 UI 线程之间的两条边界
这是整套东西里最容易翻车的地方。我一般会固定成两条边界:
第一条,采样在独立线程(或System.Threading.Timer回调)里跑,只做一件事——把新点写进RingBuffer,绝不碰任何控件。第二条,渲染由 UI 线程上的一个Timer按固定频率触发,只做一件事——从缓冲取快照,喂给渲染器。
// 采样侧:后台线程,固定节拍,只写缓冲 private void SampleLoop(CancellationToken token) { var sw = Stopwatch.StartNew(); double next = 0; while (!token.IsCancellationRequested) { // 用绝对时间对齐节拍,避免 Sleep 累积误差 if (sw.Elapsed.TotalMilliseconds >= next) { _buffer.Add(new SamplePoint(sw.Elapsed.TotalSeconds, _source.NextValue())); next += _intervalMs; } else { Thread.Sleep(1); } } }这里next是累加的绝对目标时刻,不是“睡 10ms 再采一次”。后者每轮都会有调度误差,跑十分钟下来采样率能偏出好几个百分点。_intervalMs就是采样周期,100Hz 对应 10。CancellationToken用来在窗口关闭时干净地停掉线程,这个别省,否则进程退出时后台线程还在写缓冲,偶尔会抛ObjectDisposedException。
3. 数据链路:从模拟源到环形缓冲的落地写法
曲线画得顺不顺,一半取决于渲染,另一半取决于缓冲怎么设计。很多人卡顿的根因不在画,而在“每来一个点就往集合里 Add,集合无限长”。
3.1 把“设备”抽象成 ISampleSource
模拟源存在的意义不是造假数据,是让你在没有硬件的时候也能验证整条链路。接口只留一个方法:
public interface ISampleSource { // 返回下一个采样值;真实设备这里可能是读串口帧或 Modbus 寄存器 double NextValue(); } public sealed class SimulatedSource : ISampleSource { private readonly Random _rand = new Random(12345); // 固定种子,方便复现 private double _t; public double NextValue() { _t += 0.01; // 主信号是 0.5Hz 正弦,叠加 ±0.05 的噪声,模拟真实传感器的抖动 return 50 + 20 * Math.Sin(2 * Math.PI * 0.5 * _t) + (_rand.NextDouble() - 0.5) * 0.1; } }固定随机种子12345是有意为之。调试渲染问题时,如果每次跑出来的波形都不一样,你很难判断某个毛刺是算法问题还是数据本身。换成真实设备时,SimulatedSource整个文件删掉,写一个SerialPortSource实现同一个接口就行,上层一行不用改。
3.2 环形缓冲:固定容量的覆盖写与读写分离
缓冲的核心诉求只有两个:容量固定(内存不涨)、读写不互相阻塞太久。实现上用数组加头指针,写满就覆盖最老的:
public sealed class RingBuffer<T> { private readonly T[] _items; private readonly object _gate = new object(); private int _head; // 下一个写入位置 private int _count; // 当前有效元素数量 public RingBuffer(int capacity) { if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity)); _items = new T[capacity]; } public void Add(T item) { lock (_gate) { _items[_head] = item; _head = (_head + 1) % _items.Length; if (_count < _items.Length) _count++; // 满了就不再增长,只覆盖 } } // 复制一份给渲染线程,避免渲染过程中写入线程改动读写位置 public T[] Snapshot() { lock (_gate) { var result = new T[_count]; int start = (_head - _count + _items.Length) % _items.Length; for (int i = 0; i < _count; i++) result[i] = _items[(start + i) % _items.Length]; return result; } } }几个参数要拎清楚。capacity决定缓冲能存多少个点,按“采样率 × 想保留的秒数 × 2”来估,100Hz 想留 10 秒,给 2000 就够,多出来的余量是防止渲染偶尔卡一帧时数据被覆盖得太快。_head指向下一个空位,_count封顶在容量上不再增加,这是“环形”的关键——一旦让它无限增长,就退化成List了。
Snapshot()里的那次lock拷贝看起来有点重,但相比让渲染线程直接遍历共享数组、然后在写入时读到半个元素,这个代价是值得的。真要抠性能,可以换成双缓冲交换指针的写法,但 Demo 阶段没必要,先把正确性立住。
3.3 时间戳对齐与数据抽稀
如果采样率是 100Hz,而屏幕只显示 10 秒、宽度 800 像素,那一个像素要装 1.25 个点——画出来必然是锯齿。两个处理方向:
一是抽稀。窗口内点数超过像素宽度两倍时,按像素列取最小值和最大值各画一个点,视觉上保住包络,点数直接砍到 1/50:
// 按像素列做 min/max 抽稀,保留波形的峰谷,不丢毛刺 private static List<SamplePoint> Decimate(IReadOnlyList<SamplePoint> src, double tMin, double tSpan, int pixelWidth) { var result = new List<SamplePoint>(pixelWidth * 2); double perPixel = tSpan / pixelWidth; int i = 0; for (int px = 0; px < pixelWidth && i < src.Count; px++) { double upper = tMin + (px + 1) * perPixel; double min = double.MaxValue, max = double.MinValue; double minT = 0, maxT = 0; while (i < src.Count && src[i].Timestamp < upper) { var p = src[i]; if (p.Value < min) { min = p.Value; minT = p.Timestamp; } if (p.Value > max) { max = p.Value; maxT = p.Timestamp; } i++; } if (min <= max) { result.Add(new SamplePoint(minT, min)); result.Add(new SamplePoint(maxT, max)); } } return result; }二是直接换渲染路线。Chart 控件对点数敏感,超过两千点就开始明显拖,这时候切GdiRenderer,它天然适合处理这种量级。我一般会在 UI 上留个下拉框让用户自己选,比藏在配置文件里方便排查。
4. 两条渲染路线的核心实现
渲染这块,Demo 给了两套。写的时候刻意让两边输出看起来一致,方便对比性能差异。
4.1 Chart 控件路线:Series 绑定与 60 帧滚动窗口
Chart 最容易犯的错是“只增不减”。正确姿势是每一帧重画整个窗口:
private void RenderWithChart(IReadOnlyList<SamplePoint> view, double windowSeconds) { if (view.Count == 0) return; var series = chart1.Series["realtime"]; series.Points.SuspendUpdates(); // 批量写入前挂起刷新,减少重绘次数 try { series.Points.Clear(); // 只保留"这一帧"的数据,历史交给缓冲 foreach (var p in view) series.Points.AddXY(p.Timestamp, p.Value); } finally { series.Points.ResumeUpdates(); } // 横轴贴着最新时间滚动 var area = chart1.ChartAreas[0]; double tMax = view[view.Count - 1].Timestamp; area.AxisX.Minimum = tMax - windowSeconds; area.AxisX.Maximum = tMax; area.AxisX.LabelStyle.Format = "0.0"; // 只显示一位小数,减少标签计算 }SuspendUpdates / ResumeUpdates这一对是必须的,不加的话每次AddXY都会触发一次重绘,几百个点就是几百次重排。Clear()每帧都调看着激进,但正因为缓冲层已经保存了历史,Chart 只需要负责“当前这一屏”,职责反而更清晰。AxisX.Minimum/Maximum手动设定比让它自动缩放稳定得多——自动缩放会随着数据范围变化不断调整刻度,视觉上就是波形在抖。
4.2 GDI+ 路线:BufferedGraphics 与坐标映射
自绘的关键是双缓冲。不开双缓冲,每帧Clear之后到画完之前,屏幕上是空白,肉眼看到的就是闪。
protected override void OnPaint(PaintEventArgs e) { // 整帧画在内存位图上,最后一次性贴到屏幕,杜绝闪烁 using (var bg = BufferedGraphicsManager.Current.Allocate(e.Graphics, ClientRectangle)) { var g = bg.Graphics; g.Clear(Color.FromArgb(24, 24, 28)); g.SmoothingMode = SmoothingMode.AntiAlias; var view = _buffer.Snapshot(); if (view.Length >= 2) { // 绘图区留出四周的边距给坐标轴 float left = 48f, right = ClientSize.Width - 12f; float top = 12f, bottom = ClientSize.Height - 32f; double tMin = view[0].Timestamp; double tSpan = Math.Max(view[view.Length - 1].Timestamp - tMin, 1e-6); double vMin = view.Min(p => p.Value), vMax = view.Max(p => p.Value); double vSpan = Math.Max(vMax - vMin, 1e-6); var pts = new PointF[view.Length]; for (int i = 0; i < view.Length; i++) { float x = left + (float)((view[i].Timestamp - tMin) / tSpan) * (right - left); // 注意 Y 轴要翻转:屏幕坐标向下为正,数值向上为正 float y = bottom - (float)((view[i].Value - vMin) / vSpan) * (bottom - top); pts[i] = new PointF(x, y); } using (var pen = new Pen(Color.FromArgb(0, 200, 255), 1.5f)) g.DrawLines(pen, pts); } bg.Render(e.Graphics); // 一次性提交 } }坐标映射那两行是自绘的核心。X 方向是(时间 - 窗口起点) / 窗口跨度,值域 0 到 1,再乘上像素宽度。Y 方向必须先做减法再翻转,因为屏幕左上角是 (0,0),而波形图的纵轴是向上增长的,忘了这一步画出来就是倒的,我第一次写的时候盯着倒挂的正弦看了半天。
DrawLines一次画完整条折线,比循环DrawLine快一个数量级,这是 GDI+ 里少有的能明显感知到的优化点。vMin/vMax每帧重算会让纵轴跟着数据浮动,如果不想让波形上下跳,可以给纵轴加个平滑:vMin = vMin * 0.9 + newMin * 0.1,过渡会自然很多。
4.3 把绘制和刷新解耦:Invalidate 的正确用法
渲染线程(其实就是 UI 线程上的Timer)不要直接调OnPaint,正确做法是改完数据后调一次Invalidate(),让系统在合适的时机合并重绘:
private void OnRenderTick(object sender, EventArgs e) { var view = _buffer.Snapshot(); if (_useChart) { RenderWithChart(view, _windowSeconds); // Chart 内部控制重绘 } else { _latestView = view; // 只更新数据 gdiPanel.Invalidate(); // 请求重绘,系统会合并同一帧内的多次请求 } }Invalidate()是“请求”,不是“立即执行”。同一帧里连调十次,系统也只重绘一次,这正是我们想要的。反过来,如果每来一个数据点就Invalidate一次,100Hz 就是每秒请求 100 次重绘,UI 线程直接被拖死。Demo 里Timer的间隔设成 33ms,也就是 30 帧,肉眼已经很流畅了,没必要跟采样率对齐。
5. 避坑与排查:五个真实踩过的坑
5.1 曲线越跑越卡:Points 集合无限增长
现象是刚启动那几秒很顺,跑了五分钟之后每帧都要卡一下,任务管理器里内存缓慢上涨。
原因基本只有一个:series.Points.AddXY只加不清,五分钟 100Hz 就是三万个点,Chart 每次重绘都要遍历全部。解决办法就是 4.1 里那段——每帧Clear()后只加窗口内的点,历史数据交给环形缓冲。如果业务上确实需要看历史,另开一个“历史回放”模式,用抽稀后的数据,不要和实时刷新混在一条 Series 里。
5.2 界面偶发卡顿或闪屏:跨线程操作控件
现象是运行几分钟后随机弹一次InvalidOperationException: 线程间操作无效,或者窗口边缘一直闪。
前者是采样线程里直接改了控件的属性,比如在SampleLoop里调label1.Text = ...。控件的所有访问都应该在 UI 线程上,跨线程要么用Invoke/BeginInvoke封送,要么就按 2.3 的写法,采样线程只碰缓冲。后者是没开双缓冲,SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true)这两句加在自定义控件的构造函数里,或者干脆用BufferedGraphics自己管,效果更可控。
5.3 波形锯齿严重或断成几截:采样节拍和绘制节拍打架
现象是曲线看着不连续,或者局部有很密的毛刺,放大又看不出问题。
一类原因是Thread.Sleep(interval)累积误差,跑了十分钟实际采样率已经偏了。改成 2.3 里那种绝对时间对齐的写法就能压住。另一类是采样率高于绘制频率时,快照里的点比像素还多,DrawLines直接连会导致视觉混叠。这时候上 3.3 的 min/max 抽稀,先把点降到像素级别的两倍以内再画。
5.4 时间轴会往回跳:用 DateTime.Now 做 X 轴
现象是偶尔看到波形突然回折一下,或者横轴标签出现重复值。
DateTime.Now受系统对时影响,可能往前也可能往后跳,用它做 X 轴必然出问题。统一换成Stopwatch的单调时间,或者Environment.TickCount64,只关心“过了多少秒”,不关心“现在是几点”。如果界面上要显示真实时间,用启动时刻的DateTime加上单调偏移换算,别直接读Now。
5.5 内存缓慢上涨:定时器和事件没注销
现象是反复点“开始/停止”几十次之后,内存比刚开始高出一截,关窗口时进程还残留。
常见原因是每次点开始都new Timer(...)却没有Dispose旧的,或者给串口DataReceived挂了事件但停止时没-=。我一般的做法是把Timer和CancellationTokenSource都做成字段,启动前先Stop + Dispose一遍,窗口FormClosing里统一收尾。别嫌麻烦,这类问题在 Demo 里不显眼,搬到现场跑一周就会变成“软件越用越卡”的投诉。
6. 二次开发:换数据源、加多曲线、导出数据的改法
这份 Demo 真正的价值在于那三层划分——你想加什么,基本都能找到唯一该改的地方。
换数据源最省事。写一个类实现ISampleSource,把NextValue()里的模拟逻辑换成读串口帧、读 Modbus 保持寄存器、或者调一个 HTTP 接口拿最新值。注意两点:读操作本身可能阻塞,别放在渲染的Timer里;解析失败要返回上一帧的值或者double.NaN,渲染端遇到NaN时跳过该点并断开线段,比画一条直线到 0 更接近真实情况。
加多曲线要动的是缓冲和渲染两处。缓冲改成Dictionary<string, RingBuffer<SamplePoint>>,键是通道名;IChartRenderer.Render的参数从单条序列改成IReadOnlyDictionary<string, IReadOnlyList<SamplePoint>>。Chart 路线对应加多个Series,GDI+ 路线就是循环里换支笔。配色我一般固定成一组高对比度色板按通道顺序取,不要随机生成,否则每次启动颜色都变,排查问题时对不上号。
导出 CSV 是个很实用的附加功能,但别在 UI 线程里同步写盘。做法是把缓冲快照丢给Task.Run,用StreamWriter逐行写时间戳,通道名,值,写完再回 UI 线程更新状态栏。数据量大时记得加个进度提示,不然用户以为卡死了。
验证改动有没有引入退化,我习惯做两件事。一是把SimulatedSource的种子固定住,改完前后各跑一次,截图对比波形形状,形状变了就说明渲染路径被动过。二是看任务管理器里的 CPU 和内存曲线,运行十分钟,CPU 应该平稳、内存应该是一条水平线;内存往上走就回头查 5.1 和 5.5。这两条比写单元测试快得多,也更贴近实际使用。
我刚接手这类项目时吃过一次亏:为了赶进度把采样、缓冲、绘制全写在一个Tick里,本地测着挺顺,到了现场点数一多就卡成幻灯片,回头重构花的时间比一开始就分层多两倍。从那以后我每次开工前都强制走一遍“数据源能不能单独替换、缓冲能不能单独压测”这两个问题,答不上来就先不动手写界面。希望这份拆解能帮你少绕这一段。
本文还有配套的精品资源,点击获取