趋势曲线卡顿,90% 的情况不是控件不行,是你把屏幕上根本显示不下的点,全塞给它画了。一个 1200 像素宽的图表,画 100 万个点和画 2000 个点,肉眼看不出区别,但前者每次缩放重绘都在做 500 倍无用功。这个化工园区能源上位机项目,历史表攒了 1800 万条温度压力数据,查一天曲线要 12 秒、拖动缩放像放幻灯片。四刀优化下来,加载 300 毫秒、缩放跟手。全过程记录如下。
先量化,别凭感觉调:卡顿时的三个基线数据
调性能最忌讳"我觉得是数据库慢"。我们先在现场工控机(i5-7代、8G 内存、机械硬盘)上量了三个数:
用 Stopwatch 分段一卡,时间分布很清楚:SQL 查询 3.1 秒、DataTable 填充 4.8 秒、Chart 控件加点和重绘 4.5 秒。数据库和绘图各打五十大板,内存则是 17 万个 DataPoint 对象连同一个大 DataTable 全堆在内存里。方向明确了:少查、少传、少画。
第一刀:视口查询,只查屏幕要显示的时间段
原来的写法是打开窗体就把整月数据查进 DataTable,再由控件"自己显示"。改成视口驱动:当前 X 轴可视范围是什么时间段,就只查这个时间段;历史表按月分表 + SampleTime 聚簇索引,时间段查询直接走索引 Seek。更进一步,在 SQL 里按像素列做分段取最值,17 万行进库、2000 多列出库:
-- 把可视时间段按像素列数(约2000列)分桶,每桶取min/max,轮廓一点不丢 SELECT Bucket, MIN(SampleTime) AS T, MIN(RawValue) AS VMin, MAX(RawValue) AS VMax FROM ( SELECT RawValue, SampleTime, ABS(DATEDIFF(SECOND, @t0, SampleTime)) / @bucketSec AS Bucket FROM dbo.TagHistory_202609 WHERE TagKey = @tag AND SampleTime BETWEEN @t0 AND @t1 ) x GROUP BY Bucket ORDER BY Bucket;
- 每桶输出 min 和 max 两个值(包络线画法),尖峰毛刺不会被平均掉——这对温度报警追溯特别重要,平均值抽稀会把超限尖峰抹掉
- 缩放/平移时拿新的可视时间段重新查,查的永远是"屏幕那一屏"的数据量
第二刀:应用层降采样,LTTB 保趋势形状
有些场景数据不走 SQL(比如断网缓存的本地文件、或实时内存序列),降采样就得在 C# 侧做。别用"每隔 N 个取一个"的均匀抽样——那会把曲线的峰值和拐点抽没。用 LTTB(Largest-Triangle-Three-Buckets)算法:把点分成若干桶,每桶选一个和前后桶构成三角形面积最大的点,视觉上曲线形状几乎不变,点数压缩几十倍。我们封了一个通用方法,目标点数直接按图表像素宽度 ×2 给:
// LTTB 降采样:保留趋势轮廓,threshold = 目标点数 public static List<PointF> Lttb(List<PointF> data, int threshold) { if (data.Count <= threshold) return data; var result = new List<PointF> { data[0] }; float bucketSize = (float)(data.Count - 2) / (threshold - 2); int a = 0; for (int i = 0; i < threshold - 2; i++) { int rangeStart = (int)((i + 1) * bucketSize) + 1; var avgX = 0f; var avgY = 0f; int cnt = 0; for (int j = rangeStart; j < rangeStart + bucketSize && j < data.Count; j++) { avgX += data[j].X; avgY += data[j].Y; cnt++; } avgX /= cnt; avgY /= cnt; // 下一桶的平均点 int bucketStart = (int)(i * bucketSize) + 1; float maxArea = -1; int maxIdx = bucketStart; for (int j = bucketStart; j < rangeStart && j < data.Count; j++) { // 三角形面积:前一选中点 a、候选点 j、下一桶均值点 float area = Math.Abs((data[a].X - avgX) * (data[j].Y - data[a].Y) - (data[a].X - data[j].X) * (avgY - data[a].Y)) / 2f; if (area > maxArea) { maxArea = area; maxIdx = j; } } result.Add(data[maxIdx]); a = maxIdx; } result.Add(data[data.Count - 1]); return result; }
▲ 报警追溯类曲线用 min/max 包络(第一刀的 SQL),工艺趋势类用 LTTB,按场景选。
第三刀:别再一个点一个点 Add,自定义控件一笔画线
Chart 控件(System.Windows.Forms.DataVisualization)功能全但偏重,上万点后每点都是一个 DataPoint 对象,内部布局和命中检测全在跑。我们对只需要看趋势、不需要 tooltip 逐点命中的场景换了自绘控件:开双缓冲消闪烁,OnPaint 里用 Graphics.DrawLines 一支笔把整条线画完——GDI+ 内部走多边形连线,比循环调 DrawLine 快一个数量级。坐标换算自己做,x 按时间线性映射,y 按当前量程映射:
public class TrendPanel : Control { private PointF[] _pts; // 已经降采样到像素量级的点 public TrendPanel() { DoubleBuffered = true; // 等价 SetStyle 双缓冲三件套 SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); } protected override void OnPaint(PaintEventArgs e) { var g = e.Graphics; g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; using (var pen = new Pen(Color.FromArgb(0,90,170), 1.2f)) { if (_pts != null && _pts.Length > 1) g.DrawLines(pen, _pts); // 一笔画完,不要 foreach DrawLine } } }
- 缩放平移时只重算可见点的像素坐标数组,然后 Invalidate,不要重建数据对象
- 必须保留 Chart 控件做细节分析页也可以——总览页用自绘轻控件,分析页用 Chart,各取所长
第四刀:实时曲线用环形缓冲,别让点数无限涨
实时监控曲线还有个慢性病:每来一个点就 Add 一次、Invalidate 一次,跑一晚上点数涨到几十万,内存和重绘量一起爆。做法是定一个显示窗口容量(比如屏幕 2000 像素 ×2 = 4000 点),用环形数组存最新 4000 个点,新点覆盖最老点,对象数量恒定不增长;刷新用 500ms~1s 的定时器统一重绘,数据采集线程绝不碰 UI,采集只管往环形数组写。这样内存不随时间涨,重绘量永远是 4000 点封顶。
优化前后实测对比
| 指标(工控机实测) | 优化前 | 优化后 |
|---|---|---|
| 单日曲线加载(17万点) | 12.4 秒 | 0.3 秒 |
| 缩放/平移帧率 | 0.5 FPS 拖影 | 30+ FPS 跟手 |
| 窗体内存峰值 | 680 MB | 90 MB |
| 整月曲线(180万点) | 直接超时无响应 | 1.1 秒(分桶包络) |
| 超限尖峰保真 | — | min/max 包络无丢失 |
你的曲线卡在哪一刀?
回去做个一分钟的排查:在曲线加载代码前后加 Stopwatch,把"查询、数据填充、加点重绘"三段时间分别打出来。哪段长先砍哪段——但记住这个项目的共性结论:屏幕只有 1000 多列像素,任何让参与绘制的点数远超像素数的设计都是浪费。先把"取数按视口、点数按像素"这两条做到,八成卡顿当场消失。剩下的尖峰保真、实时内存问题,再按第二、第四刀补。
