温度、压力、转速曲线是上位机的标配界面,也是最容易做"糊"的地方:用 DataTable 绑个第三方图表,数据点攒到几万,拖一下滚动条卡三秒;缩放一次全界面假死;想回看半小时前的曲线,发现内存里早没了。这篇用定长环形缓冲 + 采集/绘制解耦 + 自绘管线 + 降采样四件套,做一个工控机上能 7×24 小时跑、万级点缩放平移不卡的实时趋势控件,全部附 C# 代码。

一、卡顿先定位:不是画得慢,是"喂得太多 + 每次喂都全画"

常见写法问题本方案对策
List<double> 无限 Add内存只涨不降,跑三天几十万点,GC 压力和 LOH 碎片全来定长环形缓冲,容量按窗口算好,覆盖最旧
采集线程里直接 AddPoint + Refresh200ms 来一批就全量重绘,Invalidate 排队导致消息泵堵车采集只写缓冲;绘制按固定节拍(如 250ms)按需失效
把全部点逐一连线交给图表控件10 万个点画 10 万条线段,99% 的点落在同一个像素列按像素列降采样,每列只保留 min/max
历史靠内存程序一关、窗口一滚,旧数据没了内存只放热窗口,历史落 SQLite,按需分页加载
每帧 new Bitmap/Pen/FontGDI 对象分配和回收吃掉大量 CPU,还可能句柄泄漏画刷画笔缓存为字段,双缓冲一次性输出

▲ 先建立一个量级概念:屏幕上绘图区常见 1200 像素宽,哪怕缓冲里有 10 万个点,最终需要表达的有效信息也只有约 1200 列。把 10 万压到 1200,是整个性能优化的核心动作。

二、环形缓冲:定长、无锁读、覆盖最旧

实时窗口显示最近 N 个点(例如 200ms 一个点、显示最近 1 小时 = 18000 点),天然适合环形缓冲:数组一次性分配,写指针 head 前进,绕回覆盖:

71.2将被覆盖
71.5tail 最旧
72.0
72.4
73.1
72.8
72.2
71.9
71.6head 最新
--空槽

▲ 单写者(采集线程)+ 单读者(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;
    }
}
容量怎么定:容量 = 显示窗口时长 ÷ 采样间隔。1 小时窗口、200ms 采样 = 18000 点,每条曲线一个 RingBuffer。想支持"暂停回看"和"多档时间轴"(1 分钟/10 分钟/1 小时),给每条曲线挂多个不同密度的缓冲(原始 + 1 秒/10 秒压缩层),切换档位换缓冲读,思路类似时序数据库的降采样层。

三、采集与绘制解耦:生产者只管写,绘制按节拍走

采集线程(或 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);
}

四、自绘管线:五步,所有画笔缓存复用

① 快照+定窗拷贝缓冲,按当前时间窗裁剪
② 降采样像素列分桶取min/max
③ 坐标映射时间→X,值→Y(多Y轴)
④ 画背景网格/刻度/图例,离屏缓存
⑤ 画折线DrawLines一次性提交

网格、边框、坐标轴文字这些静态内容在 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;
}
降采样只能用 MinMax(或保形的 LTTB),不能等间隔抽稀。把 10 万个点每隔 50 个取一个,一个持续 40ms 的越限尖峰恰好全被跳过——操作员看着正常的曲线,产品已经超差。MinMax 每列保留上下极值,尖峰再窄也会在图上撑出一个像素,这是工业趋势图和"好看图表"的本质区别。

五、坐标映射:时间轴不等距数据、多 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);
}
分层降采样,越界越少画线。1 天窗口 43 万个点,先在 SQL 层按像素桶 GROUP BY 取回 ~1200 行 min/max;内存层的 MinMax 负责热窗口;控件层 Map 只做线性换算。三级分工后,任何时间尺度的缩放都是一次有界查询 + 一次有界绘制,这正是时序数据库/组态软件历史曲线的通用套路。

八、性能实测与验收要点

指标(工控机 i5-8500,1080p,4 条曲线)朴素绑定图表本方案
1.8 万点实时刷新 CPU(单核)18%~30%,偶发尖峰2%~4%
拖动/缩放 1.8 万点1.5~3 秒卡顿流畅,<16ms/帧
连续运行 72 小时内存持续上涨 300MB+稳定,缓冲定长无增长
20ms 窄尖峰可见性抽稀后丢失MinMax 完整保留
  1. 拔通讯线验证曲线出现真实缺口而不是拉平或连线;恢复后缺口边界正确;
  2. 注入已知幅度的 40ms 尖峰,反复缩放各档时间轴,尖峰在任何尺度都可见;
  3. 拖动跨越"内存/历史"边界,确认懒加载不卡 UI、时间轴无缝衔接;
  4. 最小化窗口、远程桌面(禁用部分 GDI 加速)、DPI 150% 缩放下各看一次,网格和文字不糊不歪;
  5. 多实例压测:同一界面放 8 个控件共 32 条曲线,CPU 仍可接受再交付。

趋势曲线控件的护城河不在漂亮外观,而在四条工程底线:内存有界、刷新有节、降采样不失真、历史不丢。把 TimeWindow + 环形缓冲 + MinMax + 分层存储这套骨架搭好,实时追绘、暂停回看、历史回放、游标读数全部自然成立,而且工控机上连跑一周也不会再有"内存涨、界面卡"的售后电话。