简介:这份C#绘制曲线示例源码面向具备一定C#基础、希望掌握数据可视化与图形编程的开发者,重点解决如何将SQL数据库中的数据读取后动态绘制成曲线图的问题。资源包共25个文件,约50KB,以cs源代码、csproj项目文件、sln解决方案、sql数据库脚本及resx资源文件为主,另含exe可执行程序与pdb调试文件,结构紧凑、便于直接运行调试。源码演示了通过ADO.NET连接数据库、执行查询获取数据点,再借助GDI+或WPF的Graphics类绘制曲线的完整流程,并涉及窗体设计、资源与配置管理等环节。目前已有748人学习下载,适合作为课程设计、数据监控界面或报表模块的参考范例,帮助读者理解数据驱动绘图的核心思路,并在此基础上扩展异步加载、动态刷新与鼠标交互等优化功能。
1. 从一张实时曲线图说起:C# 绘制曲线到底在解决什么问题
工业上位机、实验室数据采集、设备状态监控,这些场景里最刚需的一个界面元素就是实时曲线图。你手上有一台设备每隔几百毫秒吐一个扭矩值或者温度值,你需要把这些点连成一条线,让操作员一眼看出趋势是否异常。C# 绘制曲线图这件事,本质上就是解决「数据怎么进来、曲线怎么画、历史怎么存」这三个问题。而标题里提到的 SQL 数据库,说明这套方案不只是画个图看看,还要把数据落盘,支持回查和追溯。
我见过太多人一开始用 Chart 控件拖一个上去,跑起来发现数据一多就卡成幻灯片,或者曲线刷新时整个界面闪得没法看。这不是控件不行,是没搞清楚数据流和渲染节奏的关系。这套「C# 绘制曲线示例源码 + SQL 数据库」的组合,适合做设备监控上位机的开发者、需要快速搭出数据可视化原型的工程师,以及想把采集数据持久化后做趋势分析的人。接下来我会把选型、建库建表、数据写入、曲线渲染、性能调优这条链路拆开讲清楚,代码可以直接抄。
2. 曲线绘制方案选型:Chart 控件、GDI+ 手绘还是第三方库
2.1 三种主流方案的能力边界对比
在 C# 里画曲线,常见做法无非三条路:WinForms 自带的 Chart 控件、GDI+ 手动绘制、以及第三方图表库。很多人上来就问「哪个最好」,这个问题没有统一答案,得看你数据量、刷新频率和交互需求。
Chart 控件是微软在 .NET Framework 时代就内置的,拖到窗体上就能用,Series 类型选 Line 或 Spline 就是曲线。它的优点是上手快、属性面板可视化配置、支持缩放和 Tooltip。缺点是数据点超过几千个之后刷新明显变慢,因为每次更新都会触发完整的重绘流程,而且默认开启了抗锯齿和渐变填充,这些在实时场景下都是负担。
GDI+ 手绘就是自己在 Paint 事件里用 Graphics.DrawCurve 或 DrawLines 画。灵活度最高,你可以控制每一个像素,可以做双缓冲、局部刷新、只重绘脏区域。代价是什么都要自己写:坐标轴、刻度、图例、缩放、十字光标,全是体力活。
第三方库比如 OxyPlot、LiveCharts、ScottPlot,这些在 NuGet 上都能直接装。它们对大数据量和实时刷新做了专门优化,ScottPlot 官方说能流畅渲染百万级数据点。但引入第三方库意味着你要接受它的 API 风格、版本升级可能带来的破坏性变更,以及在某些受限环境下的部署问题。
| 方案 | 上手难度 | 万点级刷新 | 自定义能力 | 部署依赖 |
|---|---|---|---|---|
| Chart 控件 | 低 | 卡顿明显 | 中等 | 无 |
| GDI+ 手绘 | 高 | 取决于实现 | 最高 | 无 |
| ScottPlot | 低 | 流畅 | 较高 | NuGet 包 |
| LiveCharts | 中 | 较好 | 高 | NuGet 包 |
2.2 我一般怎么选:按数据量和刷新率分档
如果数据点少于 2000 个、刷新间隔大于 500ms,Chart 控件完全够用,别折腾。如果数据点在 2000 到 50000 之间、刷新间隔 100ms 左右,我建议用 ScottPlot,它的 Render 方法可以指定只更新数据不重建图表结构。如果超过 50000 个点或者刷新间隔低于 50ms,那就得上 GDI+ 手绘加双缓冲,或者用 Direct2D 做硬件加速。
这里有个容易被忽略的点:曲线绘制的瓶颈往往不在画线本身,而在数据结构的选取。用 List 存点,每次 Add 都可能触发扩容和内存拷贝。实时场景下应该用环形缓冲区,固定大小,覆盖写入,读取时按索引顺序取。下面这段代码是一个简单的环形缓冲区实现:
public class RingBuffer { private readonly double[] _buffer; private int _head; private int _count; public RingBuffer(int capacity) { _buffer = new double[capacity]; _head = 0; _count = 0; } public void Add(double value) { _buffer[_head] = value; _head = (_head + 1) % _buffer.Length; if (_count < _buffer.Length) _count++; } // 按时间顺序返回数据,最旧的在前 public double[] ToArray() { var result = new double[_count]; int start = (_head - _count + _buffer.Length) % _buffer.Length; for (int i = 0; i < _count; i++) result[i] = _buffer[(start + i) % _buffer.Length]; return result; } }这段代码的关键参数是 capacity,它决定了缓冲区能存多少个点。假设采样间隔 100ms,你想在屏幕上显示最近 60 秒的数据,那 capacity 设为 600 就够了。ToArray 方法每次调用会分配一个新数组,如果刷新频率很高,可以考虑复用一个预分配的数组,把数据拷进去而不是新建。
提示:环形缓冲区的大小一旦确定就不应该频繁变动,否则失去固定内存的优势。如果确实需要动态调整,建议重建缓冲区而不是扩容。
3. SQL 数据库落盘:建库建表与批量写入的工程细节
3.1 表结构设计:时间戳、设备号与数值字段
曲线数据存数据库,最常见的坑是表结构设计得太随意。我见过有人用一张表存所有设备的采集数据,字段就三个:时间、设备名、值。数据量一上来,查询慢得没法看。正确的做法是按设备或按时间段分表,至少也要在时间戳和设备 ID 上建联合索引。
下面是一个我常用的建表语句,适用于 SQL Server,其他数据库改一下类型就行:
CREATE TABLE DeviceCurveData ( Id BIGINT IDENTITY(1,1) PRIMARY KEY, DeviceId NVARCHAR(50) NOT NULL, SampleTime DATETIME2(3) NOT NULL, Value FLOAT NOT NULL, Quality TINYINT DEFAULT 0 ); CREATE NONCLUSTERED INDEX IX_DeviceCurve_Device_Time ON DeviceCurveData (DeviceId, SampleTime DESC) INCLUDE (Value);SampleTime 用 DATETIME2(3) 精确到毫秒,很多采集卡的时标就是毫秒级。Quality 字段用来标记数据质量,比如 0 表示正常、1 表示超量程、2 表示通讯中断,画曲线时可以根据这个字段把异常段标红。索引的 INCLUDE 把 Value 带进去,查询时不用回表,这个细节在数据量上千万后差别很大。
3.2 批量写入:别用逐条 INSERT
实时采集场景下,数据是连续不断产生的。如果每来一个点就执行一次 INSERT,数据库的压力会非常大,而且每次插入都是一次网络往返和事务开销。正确的做法是攒一批再写,比如每 500 个点或者每 2 秒写一次。
public async Task BulkInsertAsync(List<CurvePoint> points) { if (points == null || points.Count == 0) return; var dt = new DataTable(); dt.Columns.Add("DeviceId", typeof(string)); dt.Columns.Add("SampleTime", typeof(DateTime)); dt.Columns.Add("Value", typeof(double)); dt.Columns.Add("Quality", typeof(byte)); foreach (var p in points) dt.Rows.Add(p.DeviceId, p.SampleTime, p.Value, p.Quality); using var conn = new SqlConnection(_connectionString); await conn.OpenAsync(); using var bulk = new SqlBulkCopy(conn); bulk.DestinationTableName = "DeviceCurveData"; bulk.BatchSize = 1000; bulk.BulkCopyTimeout = 30; bulk.ColumnMappings.Add("DeviceId", "DeviceId"); bulk.ColumnMappings.Add("SampleTime", "SampleTime"); bulk.ColumnMappings.Add("Value", "Value"); bulk.ColumnMappings.Add("Quality", "Quality"); await bulk.WriteToServerAsync(dt); }SqlBulkCopy 是 SQL Server 里批量写入最快的方式之一,BatchSize 设为 1000 表示每 1000 行提交一次。BulkCopyTimeout 单位是秒,数据量特别大时要适当调大。ColumnMappings 必须显式指定,否则列顺序不一致会写错数据。如果用的是 MySQL,对应的是 MySqlBulkLoader;PostgreSQL 用 Npgsql 的 BeginBinaryImport。
注意:SqlBulkCopy 不触发触发器,也不做约束检查(除非指定 CheckConstraints),所以数据质量要在写入前自己保证。
3.3 查询回显:按时间范围取数并降采样
曲线回查时,如果时间范围很大,比如查一周的数据,原始点可能有几百万个,全取出来画图既慢又没必要。常见做法是在 SQL 里做降采样,比如按分钟取平均值:
SELECT DATEADD(MINUTE, DATEDIFF(MINUTE, 0, SampleTime), 0) AS MinuteSlot, AVG(Value) AS AvgValue, MIN(Value) AS MinValue, MAX(Value) AS MaxValue FROM DeviceCurveData WHERE DeviceId = @DeviceId AND SampleTime >= @StartTime AND SampleTime < @EndTime GROUP BY DATEADD(MINUTE, DATEDIFF(MINUTE, 0, SampleTime), 0) ORDER BY MinuteSlot;这样返回的点数就控制在合理范围内,画出来的曲线趋势不变,但渲染压力小很多。如果用户放大到某个时间段,再按秒级精度去查原始数据。
4. 把数据画到界面上:Chart 控件与 GDI+ 的实操代码
4.1 用 Chart 控件快速出图:Series 配置与实时追加
Chart 控件虽然在大数据量下表现一般,但做原型和中小规模监控完全够用。关键配置有几个:ChartArea 的 AxisX 和 AxisY 范围、Series 的 ChartType、以及是否开启双缓冲。
private void InitChart() { chart1.Series.Clear(); var series = new Series("Torque"); series.ChartType = SeriesChartType.FastLine; // 比 Line 快很多 series.BorderWidth = 2; series.Color = Color.DodgerBlue; chart1.Series.Add(series); var area = chart1.ChartAreas[0]; area.AxisX.LabelStyle.Format = "HH:mm:ss"; area.AxisX.MajorGrid.LineColor = Color.FromArgb(40, 40, 40); area.AxisY.MajorGrid.LineColor = Color.FromArgb(40, 40, 40); area.AxisX.ScrollBar.Enabled = false; area.CursorX.IsUserSelectionEnabled = true; // 开启双缓冲,减少闪烁 typeof(Chart).GetProperty("DoubleBuffered", BindingFlags.Instance | BindingFlags.NonPublic) ?.SetValue(chart1, true, null); } private void AppendPoint(double value) { var series = chart1.Series[0]; series.Points.AddY(value); // 只保留最近 600 个点 while (series.Points.Count > 600) series.Points.RemoveAt(0); // 让 X 轴跟随滚动 chart1.ChartAreas[0].AxisX.Minimum = double.NaN; chart1.ChartAreas[0].AxisX.Maximum = double.NaN; chart1.Invalidate(); }SeriesChartType.FastLine 比普通 Line 快,因为它跳过了很多装饰性绘制。Points.RemoveAt(0) 是 O(n) 操作,点数多的时候会卡,所以更推荐用环形缓冲区存数据,然后整体替换 Series 的数据源。DoubleBuffered 是通过反射设置的,Chart 控件没有公开这个属性,但底层确实支持。
4.2 GDI+ 手绘曲线:双缓冲与局部刷新
当 Chart 控件扛不住的时候,就得自己上手画。核心思路是:在内存里建一个和画布同样大小的 Bitmap,所有绘制操作先画到这个 Bitmap 上,最后一次性 DrawImage 到屏幕。这样不会闪烁。
private Bitmap _backBuffer; private Graphics _bufferGraphics; private void OnPaint(object sender, PaintEventArgs e) { if (_backBuffer == null || _backBuffer.Width != Width || _backBuffer.Height != Height) { _backBuffer?.Dispose(); _backBuffer = new Bitmap(Width, Height); _bufferGraphics = Graphics.FromImage(_backBuffer); _bufferGraphics.SmoothingMode = SmoothingMode.AntiAlias; } _bufferGraphics.Clear(Color.Black); DrawGrid(_bufferGraphics); DrawCurve(_bufferGraphics); DrawAxisLabels(_bufferGraphics); e.Graphics.DrawImageUnscaled(_backBuffer, 0, 0); } private void DrawCurve(Graphics g) { var data = _ringBuffer.ToArray(); if (data.Length < 2) return; float xStep = (float)Width / (data.Length - 1); var points = new PointF[data.Length]; for (int i = 0; i < data.Length; i++) { float y = (float)(Height - (data[i] - _minValue) / (_maxValue - _minValue) * Height); points[i] = new PointF(i * xStep, y); } using var pen = new Pen(Color.Lime, 1.5f); g.DrawLines(pen, points); }DrawLines 比 DrawCurve 快,因为 DrawCurve 会做样条插值计算。如果数据点足够密,DrawLines 画出来已经很平滑了。_minValue 和 _maxValue 是 Y 轴范围,可以固定也可以动态计算。动态计算时要注意加一点余量,否则曲线会贴边。
提示:GDI+ 的 Graphics 对象用完要 Dispose,否则内存泄漏很快。双缓冲的 Bitmap 在窗体 Resize 时要重建。
5. 避坑与排查:曲线绘制和数据库写入的 5 个血泪教训
5.1 曲线刷新时界面闪烁严重
现象:每次追加新数据点,整个图表区域闪一下,操作员看久了眼睛疼。
原因:Chart 控件默认没有开启双缓冲,每次 Invalidate 都会先擦背景再画前景,中间有个短暂的白屏。
解决:用反射设置 DoubleBuffered 为 true,或者把绘制逻辑放到自定义控件里用 SetStyle 开启双缓冲。如果用的是 GDI+ 手绘,确保所有绘制都在内存 Bitmap 上完成,最后只做一次 DrawImage。
5.2 数据写入越来越慢,最后卡死
现象:程序跑几个小时后,写入数据库的耗时从几毫秒涨到几百毫秒,最后界面完全卡住。
原因:逐条 INSERT 导致数据库日志膨胀,或者事务没有及时提交,锁等待越来越长。还有一种可能是 DataTable 无限增长,内存被吃满。
解决:改成批量写入,每 500 到 1000 条提交一次。DataTable 每次写完就清空或重建。数据库那边定期做索引重建和日志备份,别让日志文件无限涨。
5.3 查询历史曲线时返回几十万条数据
现象:用户选了一周的时间范围,程序卡住不动,内存飙升。
原因:SQL 没有做降采样,直接把所有原始点查出来,DataReader 逐条读,UI 线程被阻塞。
解决:在 SQL 里按时间粒度聚合,或者用分页查询。查询放在后台线程,用 async/await 避免阻塞 UI。返回的数据量控制在几千条以内,不够再让用户缩小时间范围。
5.4 曲线 Y 轴范围固定导致异常值看不见
现象:设备偶尔出一个超量程的尖峰,但曲线上完全看不出来,因为 Y 轴范围是固定的。
原因:Y 轴 Min/Max 写死了,超出范围的点被裁剪掉了。
解决:Y 轴范围动态计算,取当前数据的最小值和最大值,上下各留 10% 余量。如果数据中有明显的异常值,可以考虑用对数坐标或者单独标记。
5.5 多设备曲线颜色重复,分不清哪条是哪条
现象:同时显示 4 台设备的扭矩曲线,结果有两条颜色一样,操作员分不清。
原因:颜色是随机分配的,或者用了固定的调色板但设备数量超过了颜色数。
解决:预定义一组高对比度的颜色,按设备 ID 哈希取模分配。同时在图例里显示设备名称和对应颜色。如果设备数量太多,考虑用不同线型(实线、虚线、点线)辅助区分。
6. 进阶技巧:让曲线图从「能看」变成「好用」
前面把基本链路跑通了,但实际项目里还有一些细节能让曲线图好用很多。第一个技巧是十字光标和数值提示。用户把鼠标移到曲线上,应该能显示当前点的精确数值和时间。Chart 控件可以用 CursorX 和 ToolTip 实现,GDI+ 手绘就要自己算鼠标位置对应的数据索引。
private void OnMouseMove(object sender, MouseEventArgs e) { if (_ringBuffer == null) return; var data = _ringBuffer.ToArray(); if (data.Length < 2) return; int index = (int)(e.X / ((float)Width / (data.Length - 1))); index = Math.Max(0, Math.Min(data.Length - 1, index)); _cursorX = e.X; _cursorValue = data[index]; Invalidate(); // 触发重绘,在 OnPaint 里画十字线 }这段代码的关键是索引计算,用鼠标 X 坐标除以每个点占的像素宽度。注意边界处理,别让索引越界。重绘时在 OnPaint 里画一条竖线和数值标签就行。
第二个技巧是数据导出。操作员经常需要把某段曲线导出成 CSV 或图片,发给主管或者存档。CSV 导出很简单,把环形缓冲区里的数据按时间顺序写文件就行。图片导出用 Bitmap.Save 方法,把当前画布存成 PNG。
第三个技巧是报警阈值线。在曲线上画一条水平虚线表示上限或下限,超过阈值的曲线段用红色高亮。这个在设备监控里非常实用,操作员一眼就能看出什么时候超限了。实现方式是在 DrawCurve 里判断每个点的值,超过阈值的用不同颜色的 Pen 分段绘制。
最后一个建议:曲线控件的刷新频率不要和采集频率绑死。采集可能是 10ms 一次,但界面刷新 100ms 一次就够了。用一个定时器或者单独的渲染线程,每 100ms 从缓冲区取最新数据重绘一次。这样既保证了数据的完整性,又不会让 UI 线程被高频刷新拖垮。
我自己在这个方向上踩过最大的坑,就是一开始把所有逻辑都塞在 UI 线程里,采集、写库、画图全在一起,结果数据量一上来整个界面就假死。后来把采集和写库放到后台线程,UI 只负责渲染,问题才解决。希望帮到你。
本文还有配套的精品资源,点击获取