温度、压力、转速曲线是上位机的标配界面,也是最容易做"糊"的地方:用 DataTable 绑个第三方图表,数据点攒到几万,拖一下滚动条卡三秒;缩放一次全界面假死;想回看半小时前的曲线,发现内存里早没了。这篇用定长环形缓冲 + 采集/绘制解耦 + 自绘管线 + 降采样四件套,做一个工控机上能 7×24 小时跑、万级点缩放平移不卡的实时趋势控件,全部附 C# 代码。
一、卡顿先定位:不是画得慢,是"喂得太多 + 每次喂都全画"
| 常见写法 | 问题 | 本方案对策 |
|---|---|---|
| List<double> 无限 Add | 内存只涨不降,跑三天几十万点,GC 压力和 LOH 碎片全来 | 定长环形缓冲,容量按窗口算好,覆盖最旧 |
| 采集线程里直接 AddPoint + Refresh | 200ms 来一批就全量重绘,Invalidate 排队导致消息泵堵车 | 采集只写缓冲;绘制按固定节拍(如 250ms)按需失效 |
| 把全部点逐一连线交给图表控件 | 10 万个点画 10 万条线段,99% 的点落在同一个像素列 | 按像素列降采样,每列只保留 min/max |
| 历史靠内存 | 程序一关、窗口一滚,旧数据没了 | 内存只放热窗口,历史落 SQLite,按需分页加载 |
| 每帧 new Bitmap/Pen/Font | GDI 对象分配和回收吃掉大量 CPU,还可能句柄泄漏 | 画刷画笔缓存为字段,双缓冲一次性输出 |
▲ 先建立一个量级概念:屏幕上绘图区常见 1200 像素宽,哪怕缓冲里有 10 万个点,最终需要表达的有效信息也只有约 1200 列。把 10 万压到 1200,是整个性能优化的核心动作。
二、环形缓冲:定长、无锁读、覆盖最旧
实时窗口显示最近 N 个点(例如 200ms 一个点、显示最近 1 小时 = 18000 点),天然适合环形缓冲:数组一次性分配,写指针 head 前进,绕回覆盖:
▲ 单写者(采集线程)+ 单读者(UI 线程)场景下不必上锁:写者只动 head 和自己的槽位,读者按当前 Count 快照遍历,最坏情况读到一个正在更新的点(数值陈旧一帧),对趋势显示完全可接受。
public readonly struct Sample { public readonly long Ticks; public readonly double Value; public Sample(long t, double v) { Ticks = t; Value = v; } } public sealed class RingBuffer { private readonly Sample[] _a; private int _head; // 下一个写入位置 private int _count; public int Count => Volatile.Read(ref _count); public RingBuffer(int capacity) { _a = new Sample[capacity]; } public void Push(long ticks, double value) { _a[_head] = new Sample(ticks, value); _head = (_head + 1) % _a.Length; if (_count < _a.Length) Volatile.Write(ref _count, _count + 1); } // 按时间顺序(旧→新)把点拷贝到调用方数组;count取读瞬间快照 public int Snapshot(Sample[] dst) { int n = Math.Min(Count, dst.Length), c = Count, h = _head; int start = (h - c + _a.Length) % _a.Length; // 最旧有效点 for (int i = 0; i < n; i++) dst[i] = _a[(start + i) % _a.Length]; return n; } }
三、采集与绘制解耦:生产者只管写,绘制按节拍走
采集线程(或 OPC UA 订阅回调)只做一件事:Push 进缓冲,绝不触碰任何 UI。控件内部用一个 250ms 的定时器驱动重绘,人眼对 200ms 以上的刷新间隔已经感觉流畅,而 CPU 占用比"来数据就刷"低一个数量级:
public class TrendControl : Control { private readonly Dictionary<string, RingBuffer> _series = new(); private readonly Sample[] _snap; private readonly Timer _tick; public TrendControl() { DoubleBuffered = true; // 控件级双缓冲,灭闪烁根本 SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); _snap = new Sample[18000]; _tick = new Timer { Interval = 250 }; _tick.Tick += (_, _) => { if (!_paused) Invalidate(); }; _tick.Start(); } // 采集线程调用:线程安全(单写者),不经过UI线程 public void AddSample(string series, double v, DateTime t) => _series[series].Push(t.Ticks, v); }
四、自绘管线:五步,所有画笔缓存复用
网格、边框、坐标轴文字这些静态内容在 Resize 时画到一张离屏 Bitmap 缓存,每帧只贴一次;每帧变化的只有折线和游标。画笔、字体全部做成静态只读字段,杜绝帧内分配:
private static readonly Pen GridPen = new(Color.FromArgb(230, 236, 244)); private static readonly Font AxisFont = new("Consolas", 8.5f); private Bitmap _bg; // 静态背景缓存,Resize时重建 protected override void OnPaint(PaintEventArgs e) { var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit; g.DrawImageUnscaled(_bg, 0, 0); // ④ 背景贴图 foreach (var (name, buf) in _series) { int n = buf.Snapshot(_snap); var win = CurrentWindow(); // 暂停时是固定窗口,实时时末端跟随now Span<PointF> poly = stackalloc PointF[PlotW * 2]; int m = DownSampleMinMax(_snap, n, win, poly, axisOf(name)); // ② 10万点→约2×像素列数 using var pen = SeriesPen(name); // 颜色有限,实际用缓存字典 g.DrawLines(pen, poly.Slice(0, m).ToArray()); // ⑤ 一次提交 } DrawCrosshair(g); // 暂停拖动时的游标与数值标签 } // ② MinMax降采样:每个像素列对应的时间段内取最小、最大两点。 // 关键点:不能只取平均/抽稀——尖峰会被抽没,min/max保证毛刺和越限可见。 private int DownSampleMinMax(Sample[] src, int n, TimeWindow win, Span<PointF> dst, YAxis axis) { if (n == 0) return 0; double colMs = win.SpanMs / (double)PlotW; int k = 0, col = -1; double min = double.MaxValue, max = double.MinValue; for (int i = 0; i < n; i++) { var s = src[i]; if (s.Ticks < win.StartTicks) continue; int c = (int)((s.Ticks - win.StartTicks) / TimeSpan.TicksPerMillisecond / colMs); if (c >= PlotW) break; if (c != col) { if (col >= 0 && k + 1 < dst.Length) { dst[k++] = Map(col, min, axis); dst[k++] = Map(col, max, axis); } col = c; min = max = s.Value; } else { if (s.Value < min) min = s.Value; if (s.Value > max) max = s.Value; } } if (col >= 0 && k + 1 < dst.Length) { dst[k++] = Map(col, min, axis); dst[k++] = Map(col, max, axis); } return k; }
五、坐标映射:时间轴不等距数据、多 Y 轴各走各的
private PointF Map(int col, double value, YAxis axis) { float x = PlotX + col; float y = PlotBottom - (float)((value - axis.Min) / (axis.Max - axis.Min)) * PlotH; return new PointF(x, y); }
- X 轴按时间戳映射,不按数组下标。现场采集中断 5 分钟,下标等距画法会把两段数据硬连在一起;按时间映射,缺口就是一段真实空白,一眼能看出"这段没数据";
- 左轴/右轴双 Y 轴:温度 0~300℃ 走左轴(红),转速 0~3000rpm 走右轴(蓝),每轴独立 min/max 和刻度,图例颜色与轴标签颜色一致,避免读图张冠李戴;
- 纵轴量程三模式:固定量程(工艺标准,超界即出绘图区表示异常,推荐);自动量程(跟随数据,适合未知信号,但会掩盖量级变化,不做默认);手动设定(工程师分析用)。
六、暂停、游标、缩放平移:实时追绘和回看是同一套模型
关键设计:把"当前显示窗口"抽象成一个 TimeWindow(start, span)。实时态时窗口末端每帧跟随 now;用户一旦拖动/缩放,置 _paused=true,窗口变为用户指定区间,新数据照常进缓冲但不影响视图;点"回到实时"再恢复跟随。这样实时与历史回看共用同一条绘制管线:
- 滚轮缩放:以鼠标所在时刻为锚点缩放 span(×0.5 / ×2 分档),保证缩放时鼠标下的数据点不跑;
- 拖动平移:MouseMove 里换算时间偏移改 start,拖动期间 Invalidate 重绘——因为有降采样,画的永远只有像素列级的点,再拖也不卡;
- 十字游标:暂停态鼠标移动画十字线,标签显示该像素列对应的时刻和各曲线在该列的 min/max 值,支持键盘左右逐点移动;
- 超出内存窗口自动查历史:start 早于环形缓冲最旧点时,触发异步查询从 SQLite 按时间段取数(见下节),加载期间显示"加载中"而不是假死。
七、历史回放:热数据在内存,冷数据在 SQLite
环形缓冲解决"最近 1 小时",更长的历史不能放内存。采集服务把点批量写入 SQLite(按天分表或加时间索引),回看时按窗口分段懒加载,查出来的数据同样走"降采样→映射→绘制"管线,控件根本不关心数据来自内存还是数据库:
// 存储:攒批事务写入(别一个点一条insert),时间+测点联合索引 // CREATE TABLE trend(ts INTEGER, series TEXT, v REAL, PRIMARY KEY(series, ts)) WITHOUT ROWID; public async Task<List<Sample>> LoadAsync(string series, DateTime from, DateTime to) { // 窗口点远超像素列时,SQL层先按时间桶聚合,取回的就是降采样结果(每桶min/max两行) var bucketTicks = (long)(to - from).TotalMilliseconds / PlotW * TimeSpan.TicksPerMillisecond; var sql = @"SELECT (ts/?) * ? AS b, MIN(v), MAX(v) FROM trend WHERE series=? AND ts BETWEEN ? AND ? GROUP BY b ORDER BY b"; return await _db.QueryAsync<Sample>(sql, bucketTicks, bucketTicks, series, from.Ticks, to.Ticks); }
八、性能实测与验收要点
| 指标(工控机 i5-8500,1080p,4 条曲线) | 朴素绑定图表 | 本方案 |
|---|---|---|
| 1.8 万点实时刷新 CPU(单核) | 18%~30%,偶发尖峰 | 2%~4% |
| 拖动/缩放 1.8 万点 | 1.5~3 秒卡顿 | 流畅,<16ms/帧 |
| 连续运行 72 小时内存 | 持续上涨 300MB+ | 稳定,缓冲定长无增长 |
| 20ms 窄尖峰可见性 | 抽稀后丢失 | MinMax 完整保留 |
- 拔通讯线验证曲线出现真实缺口而不是拉平或连线;恢复后缺口边界正确;
- 注入已知幅度的 40ms 尖峰,反复缩放各档时间轴,尖峰在任何尺度都可见;
- 拖动跨越"内存/历史"边界,确认懒加载不卡 UI、时间轴无缝衔接;
- 最小化窗口、远程桌面(禁用部分 GDI 加速)、DPI 150% 缩放下各看一次,网格和文字不糊不歪;
- 多实例压测:同一界面放 8 个控件共 32 条曲线,CPU 仍可接受再交付。
趋势曲线控件的护城河不在漂亮外观,而在四条工程底线:内存有界、刷新有节、降采样不失真、历史不丢。把 TimeWindow + 环形缓冲 + MinMax + 分层存储这套骨架搭好,实时追绘、暂停回看、历史回放、游标读数全部自然成立,而且工控机上连跑一周也不会再有"内存涨、界面卡"的售后电话。
