简介这份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 控件十毫秒级随点数线性增长极少演示、报表、点数小于 500GDI 自绘毫秒级与点数近似线性但常数小中等上位机、工控监控、几千点 30fpsWPF 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 Actionlong, 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 曲线越跑越慢内存一路涨现象刚开始很流畅挂半小时后掉到个位数帧率任务管理器里的内存曲线一路上扬。原因多半是在用ListPointF或者 Chart 控件的Points.AddXY无脑追加历史点永不清理每帧遍历的点数随时间线性增长。另一种情况是每帧new Bitmap(Width, Height)做双缓冲位图没有DisposeGDI 对象句柄会越积越多。解决换成定长环形缓冲写入永不扩容每帧只导出「时间窗口内」的一段而不是全部历史。双缓冲位图要么在控件构造时建一次、尺寸变化时才重建要么直接用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 用 33ms60fps 用 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跳变或者缓冲容量不足的典型表现能说清原因和排查路径比说「我再看看」强得多。从那以后我每次做带实时刷新的界面都会强制走一遍这三步先定采样频率和刷新频率两个数字再把缓冲容量和单帧耗时预算写进注释最后按上面那张表测一遍留档。这三步花不了两小时但能省掉交付前通宵改代码的麻烦。希望帮到你。本文还有配套的精品资源点击获取