
简介这是一份面向C#开发者的曲线绘制与SQL数据库联动示例源码适合需要在WinForms/WPF中实现数据可视化并配合数据库完成曲线数据持久化存取的中级学习者也可作为课程设计或日常练习的参考。压缩包共25个文件以C#窗体与程序入口源码、SQL数据库脚本、解决方案文件为主同时包含exe可执行程序、DLL与PDB调试信息整体仅50KB结构紧凑便于完整查看和二次编译。项目通过GDI的Pen、Graphics及DrawCurve/DrawBezier方法演示曲线绘制并利用ADO.NETSqlConnection、SqlCommand等将坐标点数据写入数据库或读取回显覆盖表结构设计、INSERT写入与SELECT查询等关键环节能帮助理解图形界面与数据层之间的协作方式。已有253人学习下载适合希望同时掌握图形绘制、数据库交互并获取可直接运行或改造模板的开发者。1. 从“C#绘制曲线示例源码(含SQL数据库)”说起一条曲线背后是三条链路“C#绘制曲线示例源码(含SQL数据库)”这串名字放到一起说的是C#上位机里一类相当常见的需求采集值先落进SQL数据库界面上再把这些值按时间画成曲线。数据看板、设备趋势图、监控大屏全是这个套路。这类示例的价值是帮刚入门C#的开发者把整条链路跑通——从建表、取数到Chart控件上出线。但实际动手会发现卡人的往往不是绘图控件本身而是三件事SQL查出来的数据对不对、时间字段有没有被Chart识别成时间、刷新的数据能不能高效追加。任何一环断了曲线就是白的或乱的。这篇文章按这条链路拆开讲最后列常见坑。2. 选型与数据链路为什么 WinForms Chart 控件是这套源码的主流底座2.1 四类方案横评官方Chart、ZedGraph、ScottPlot、自绘GDI先给结论只要项目跑在Windows上位机上、用的是WinForms官方自带的Chart控件System.Windows.Forms.DataVisualization.Charting就是性价比最高的选择。它随VS直接可用在.NET Framework 4.x里不需要额外安装第三方库支持折线、面积、柱状图自带缩放、游标和鼠标悬停提示这几点对监控界面来说是刚需。方案维护状态上手难度大数据量表现适合场景WinForms自带Chart随.NET版本维护低中可用FastLine上位机、数据看板ZedGraph基本停更低中老项目维护ScottPlot活跃更新中高新项目、科学绘图自绘GDI无第三方依赖高取决于实现特殊定制界面老项目里见得比较多的是ZedGraph这个库当年很流行画科学曲线确实顺手但维护基本停滞新项目不建议再引入。如果你在评估新方案还有一类是ScottPlot它专门做数据可视化画几万个点很流畅缺点是样式体系和WinForms原生控件差异较大需要一点适应成本。至于完全自己用GDI绘制适合特殊定制但坐标变换、缩放、防闪烁都要自己实现工程量远大于画线本身。所以一套“C#绘制曲线示例源码(含SQL数据库)”的合理底座通常是WinForms窗体 官方Chart控件 SQL Server 数据源。开发环境用VS 2019/2022创建WinForms项目目标框架选.NET Framework 4.x或者.NET 6都可以.NET 6下Chart控件没有内置需要从NuGet引用winforms-datavisualization包用法与Framework版一致。如果只是把曲线画出来方案差异不大难点在数据链路怎么设计下面拆开看。2.2 四层数据链路SQL、取数层、缓存与绘制的职责很多示例代码把取数和绘制写在一个方法里能用但数据量一旦上来或者要实时刷新就会四处漏风。我一般会把链路拆成四段第一段是SQL负责持久化和按条件查询“最近一小时”“指定设备”这类过滤条件在这里完成第二段是取数层负责把查询结果转成DataTable或List这一层返回值是纯数据不碰控件第三段是缓存实时场景下在内存里保留最近N条数据避免每次刷新都全表重查第四段是绘制只负责把缓存或查询结果里的时间、数值绑定到Series上。这样拆有一个很现实的好处后续把数据源从SQL Server换成SQLite或者从轮询改成设备主动上报只需要改取数层绘制和缓存不动。对于上位机这类需求频繁变更的项目这个边界能省不少返工。对应的纪律是取数层不写任何和Chart有关的代码绘制层不去连数据库。坚持下来曲线不对时定位会快很多——先查取数层返回的数据正不正常再查绑定逻辑。2.3 最小工程骨架一个窗体里需要哪些东西一个能跑通的曲线示例窗体上的控件不需要多一个Chart画曲线一个DataGridView显示原始数据两个Button分别用于加载历史和启动实时刷新一个StatusStrip显示当前查询状态。数据连接串放在App.config里不要在代码里硬编码。这是最小骨架先把链路跑通后续再根据业务加设备选择下拉框、测点复选、时间区间控件。提示骨架阶段先别急着调曲线样式。X轴标签格式、线宽、颜色这类显示问题等数据链路通了再处理优先保证“查得到、绑得上、画得出”。connectionStrings add nameSqlServer connectionStringServerlocalhost;DatabaseDeviceDB;User Idsa;Passwordyour_password; providerNameSystem.Data.SqlClient / /connectionStrings连接串里Server、Database、User Id、Password四个参数是必填项开发机如果用Windows身份验证把User Id和Password换成Integrated SecuritySSPI即可。连接串集中存放后面换测试库、生产库不用改业务代码。这里有个容易忽略的点Password里的特殊字符如果包含分号或引号需要用转义或改用其他认证方式否则配置解析会直接失败。3. 用 C# 从 SQL 读出数据画出第一条曲线建库、取数与 Chart 绑定3.1 先建库建表一张能存曲线数据的表长什么样“含SQL数据库”说明这套示例一定带着数据落库的部分。跨版本还原数据库备份容易踩版本不兼容的坑低版本SQL Server还原不了高版本备份对话框里甚至看不到备份文件所以正规做法是把建库脚本和建表脚本直接放进源码包手动执行一遍比带.bak备份文件通用得多。动手前把库和表建出来CREATE DATABASE DeviceDB; GO USE DeviceDB; GO CREATE TABLE dbo.CurveData ( Id INT IDENTITY(1,1) PRIMARY KEY, DeviceId NVARCHAR(32) NOT NULL, PointName NVARCHAR(50) NOT NULL, Value FLOAT NOT NULL, CollectTime DATETIME NOT NULL ); GO CREATE INDEX IX_CurveData_DeviceTime ON dbo.CurveData(DeviceId, CollectTime); GO建表有几个点要注意。Value用FLOAT而不是DECIMAL温度这类模拟量用FLOAT精度足够Chart绑定数值类型时也更省事。CollectTime用DATETIME如果准备做毫秒级分析可以改成DATETIME2(3)但SQL和C#两侧类型必须保持一致。DeviceId和PointName分开存是为了支持同一个界面切换设备、切换测点。复合索引(DeviceId, CollectTime)针对“某设备某时段”这种最常见的查询模式没有这个索引表大了以后查询走全表扫描曲线刷新的延迟会从毫秒级变成秒级。3.2 取数方法参数化查询和连接释放建完表写C#取数。这一步返回一个DataTable不涉及任何界面逻辑using System.Configuration; using System.Data; using System.Data.SqlClient; public DataTable GetCurveData(string deviceId, DateTime start, DateTime end) { string connStr ConfigurationManager.ConnectionStrings[SqlServer].ConnectionString; string sql SELECT CollectTime, Value FROM dbo.CurveData WHERE DeviceId DeviceId AND CollectTime BETWEEN Start AND End ORDER BY CollectTime;; using (SqlConnection conn new SqlConnection(connStr)) using (SqlCommand cmd new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(DeviceId, deviceId); cmd.Parameters.AddWithValue(Start, start); cmd.Parameters.AddWithValue(End, end); SqlDataAdapter adapter new SqlDataAdapter(cmd); DataTable dt new DataTable(); adapter.Fill(dt); return dt; } }这段代码有四个点说明一下。第一用了usingSqlConnection和SqlCommand在方法结束时自动释放这是防连接泄漏的底线后面避坑章节里“数据库文件被占用”的问题多半是没遵守这条。第二查询条件全部用参数化写法DeviceId这种占位符既防SQL注入也避免时间字符串在不同系统语言环境下解析出歧义。第三SqlDataAdapter.Fill内部会自动打开和关闭连接适合一次性取数要做耗时很长的流式读取再考虑SqlDataReader手动控制。第四返回值是DataTable而不是直接绑定Chart保持取数层纯数据理由在第2章说过。3.3 初始化Chart并绑定数据Series配置决定曲线成败取数做好后写绘制。Chart控件的使用分两步初始化ChartArea坐标系和Series数据系列然后绑定数据using System.Windows.Forms.DataVisualization.Charting; private void InitChart() { chart1.ChartAreas.Clear(); ChartArea area new ChartArea(Main); area.AxisX.LabelStyle.Format HH:mm:ss; area.AxisX.IntervalType DateTimeIntervalType.Minutes; area.AxisX.Interval 5; area.AxisY.LabelStyle.Format 0.0; chart1.ChartAreas.Add(area); chart1.Series.Clear(); Series series new Series(Temp); series.ChartType SeriesChartType.Line; series.XValueType ChartValueType.DateTime; series.ChartArea Main; series.BorderWidth 2; series.Color Color.OrangeRed; chart1.Series.Add(series); } private void DrawCurve(DataTable data) { Series series chart1.Series[Temp]; series.Points.Clear(); foreach (DataRow row in data.Rows) { DateTime t Convert.ToDateTime(row[CollectTime]); double v Convert.ToDouble(row[Value]); series.Points.AddXY(t, v); } chart1.ChartAreas[Main].RecalculateAxesScale(); }初始化里最关键的一行是series.XValueType ChartValueType.DateTime。很多人第一次画曲线数据里明明有时间列画出来却是一根斜线或X轴变成0、1、2这样的序号原因就是Chart不知道X轴是时间把时间当成普通数值或者按索引排列。设置了这一行Chart才会按时间做刻度规划。DrawCurve里逐行AddXY数据量不大的时候完全够用几千点以上时逐行Add有性能损耗避坑章节给优化方案。RecalculateAxesScale是强制Chart根据新数据重新计算坐标轴范围切换时间段后调用避免旧范围残留。提示如果某个时间点SQL里恰好没有数据曲线会直接连过去。要严格体现数据缺失需要把缺的点设成double.NaN再AddXY这个按业务需求决定监控类界面通常不需要。3.4 把取数和绘制串起来加载按钮的完整流程最后在加载按钮里把两步串起来private void btnLoad_Click(object sender, EventArgs e) { DateTime end DateTime.Now; DateTime start end.AddHours(-1); DataTable dt GetCurveData(DEV-001, start, end); dataGridView1.DataSource dt; DrawCurve(dt); statusLabel.Text $共加载 {dt.Rows.Count} 条记录; }到这一步从SQL建库到C#取数再到Chart绑定已经完整跑通。第一次跑通常见的情况是曲线是空白原因多半是数据库里没有这个时间段的数据。所以在骨架里特意放了DataGridView先看一眼返回的原始记录确认有值再画曲线。这条“先看数据、再看曲线”的习惯能帮你省掉很多无效排查。4. 让曲线动起来从 Timer 轮询到 Task 异步刷新的实时曲线方案4.1 用Timer轮询实现最小实时刷新上位机的监控界面里曲线模块几乎是标配而且十有八九要面对实时刷新。最常见的数据流是设备或采集程序每秒往SQL插一条数据界面上的曲线跟着往后滚动。最小实现是用System.Windows.Forms.Timer每秒查一次最近一小时数据并重绘private System.Windows.Forms.Timer refreshTimer; private void StartRefresh() { refreshTimer new System.Windows.Forms.Timer(); refreshTimer.Interval 1000; refreshTimer.Tick OnTick; refreshTimer.Start(); } private void OnTick(object sender, EventArgs e) { DateTime end DateTime.Now; DateTime start end.AddHours(-1); DataTable dt GetCurveData(DEV-001, start, end); DrawCurve(dt); }这个方案能用但有一件事要提前讲清楚Timer的Tick事件跑在UI线程上GetCurveData里的数据库查询如果是慢查询界面会直接卡住。Interval最小能设到1毫秒实际使用时不要低于1000毫秒做整表重查否则既有查询压力又有界面卡顿风险。设备数据量小、演示性质的场景用这个方案完全没问题至少能先看到曲线动起来。4.2 把取数丢到后台Task.Run 加防重入要解决卡顿把取数从UI线程挪走。WinForms里比较顺手的做法是Task.Run配合async事件处理器private bool _isRefreshing; private async void OnTick(object sender, EventArgs e) { if (_isRefreshing) return; _isRefreshing true; refreshTimer.Stop(); try { DataTable dt await Task.Run(() GetCurveData(DEV-001, DateTime.Now.AddHours(-1), DateTime.Now)); DrawCurve(dt); } catch (Exception ex) { statusLabel.Text 刷新失败 ex.Message; } finally { _isRefreshing false; refreshTimer.Start(); } }关键改动是await Task.Run数据库查询在线程池上执行UI线程不会因为慢查询被拖死查询结束后await会回到UI线程上下文这时才能安全地往Chart控件里塞数据。_isRefreshing标志位防止重入上一轮查询没回来下一轮Tick不启动新查询。refreshTimer.Stop和Start放在try/finally里避免异常把定时器停死这个细节在长期运行的界面上特别重要不然一次查询异常就会让曲线从此不再刷新。有两件事要提醒。第一async void只能在事件处理器里用普通方法要返回Task而不是void否则异常无法被正常捕获。第二DrawCurve仍然在UI线程上执行如果一次要往图表里塞几万个点卡顿来源就从“查库”变成“重绘”这个问题在避坑章节展开。4.3 增量追加与滚动缓存不要每次都全量重绘实时场景下最近一小时甚至一天的数据量在涨每次Tick都全量查、全量画数据库和Chart的压力都会线性增长。我在类似方案里习惯的做法是查询范围保持最近一小时不变绘制方式改成增量追加。先准备一个内存缓存维护最近N条数据class CurvePoint { public DateTime Time { get; set; } public double Value { get; set; } } private QueueCurvePoint _cache new QueueCurvePoint(); private void AppendPoint(CurvePoint point, int maxPoints 500) { _cache.Enqueue(point); while (_cache.Count maxPoints) { _cache.Dequeue(); } }这里选Queue而不是数组或List原因很直接实时曲线缓存需要频繁“从尾部追加、从头部移除”Queue的Enqueue/Dequeue都是O(1)数组定义时需要固定长度头部移除会整体搬移List的RemoveAt(0)同样有搬移开销。集合语义在这里更契合。绘制时不再清空重绑而是只追加新点、移除最旧的点private void DrawIncremental() { Series series chart1.Series[Temp]; while (series.Points.Count _cache.Count) { CurvePoint p _cache.ElementAt(series.Points.Count); series.Points.AddXY(p.Time, p.Value); } while (series.Points.Count 500) { series.Points.RemoveAt(0); } }这段逻辑成立的基础是数据本身按时间递增Series里的点也按时间递增刷新时只需要补齐比现有点数多的那部分再把超出500的点从头移除。点数被压制在500以内后Chart重绘的压力可控实时刷新自然流畅。注意查询出的新数据如果出现时间倒序设备时钟回拨或数据补录增量追加逻辑会乱。排查曲线乱序时先看SQL里ORDER BY CollectTime有没有生效再看设备时钟不要在绘制逻辑上找半天。5. 曲线绘制的常见问题排查五个必看现象与对应解法以下五条按“现象、原因、解决”的顺序写都是照着这套示例跑下来常见的翻车现场每条都值得先在本地复现一遍再记结论。5.1 曲线是一条斜线X轴从0开始编号而不是时间现象数据库里CollectTime列有值但画出来X轴是0、1、2这种序号曲线变成一条奇怪的斜线。原因Series.XValueType没有设置成ChartValueType.DateTime。Chart不知道X值含义把AddXY的第一个参数当成普通数值时间被转成长整型或其他数字刻度和格式全部错乱。这是画时间曲线的最高频翻车点基本十次里有五次是这个问题。解决在InitChart里设置series.XValueType ChartValueType.DateTime并把Area.AxisX.LabelStyle.Format设为HH:mm:ss或MM-dd HH:mm。改完这两处还不对再检查AddXY传进去的t确实是DateTime对象而不是Convert之后变成了字符串。Chart控件有一个特性一旦Series里已有数据点XValueType再修改可能不会重新解释旧点所以要么在绑定前设置要么先Clear再设置再重新AddXY。5.2 查询慢或者刷新时整个窗口无响应现象点击加载按钮窗口标题栏显示“未响应”几秒后恢复刷新过程中拖动窗口明显卡顿。原因取数方法GetCurveData在UI线程里同步执行。SqlDataAdapter.Fill是阻塞调用查询期间UI消息循环被堵住窗口自然假死。数据量上来以后问题越来越明显这不是Chart的问题是线程用错了。解决把取数调用挪到Task.Run里await回到UI线程再DrawCurve。如果项目还没用async/await这一步正好把4.2节的写法抄过去。改完后查询期间界面可以正常拖动这是最直观的验证方式。还有一种隐蔽情况数据库查询本身很慢慢的原因是索引缺失或者SQL写法问题这时异步只是让界面不卡但数据出来依然慢需要配合执行计划排查不能只靠异步解决一切。5.3 数据点一多Chart越画越慢CPU飙升现象一条线上几千个点以后刷新一次要几百毫秒界面整体变慢点越多越明显。原因默认的Line系列在添加大量数据点时每次都要做抗锯齿、阴影、样式计算加上逐点AddXY多次触发重绘绘图引擎反复重画整条曲线CPU开销自然大。解决数据量大时把系列类型切换成FastLineseries.ChartType SeriesChartType.FastLine。FastLine针对几千几万个点的折线做了专门优化少了很多样式计算曲线照样能看。但FastLine不支持阴影、透明度等高级样式视觉上朴素一点监控场景完全够用。另一个备用手段是降采样只保留最近N个点或按固定步长抽点输出损失一点细节换流畅度。上位机屏幕通常也就一两千像素宽画几万个点本来就看不全降采样是合理取舍。5.4 SQL数据库文件被占用备份、复制或替换时提示正在使用中现象想把数据库文件拷走备份到别的机器提示文件被占用开发阶段改了表结构想重建数据库一直失败。原因应用运行中打开的SqlConnection没有关闭或者关闭了但连接池里还缓存着物理连接。连接未释放时数据库文件处于锁定状态文件操作自然失败。这个问题的迷惑性在于窗口右上角关闭后进程似乎退出了但VS里调试进程没真正结束或者连接池还没清空文件锁就一直挂着。解决第一所有数据库操作保持using写法确保Dispose一定执行。第二开发调试遇到文件锁确认进程退出后在代码里主动调用SqlConnection.ClearAllPools()清空连接池再操作文件。这条对SQL Server的.mdf文件、SQLite的.db文件都适用锁定的根源都是连接没释放干净。顺手做的预防连接串不要开PoolingFalse除非确认要做短连接压测正常默认池化配合using就够了池化本身不是文件锁的原因未释放才是。5.5 时间轴的刻度标签叠成一团或者出现一堆凌晨零点现象横轴标签挤在一起完全看不清或者刻度全是00:00这种整点和实际数据时间对不上。原因AxisX.LabelStyle.Format设了但IntervalType和Interval没有跟着设。Chart默认按数据范围自动取刻度间隔数据跨多个小时时优先出整点刻度格式和间隔不匹配时标签就会糊成一团。解决初始化ChartArea时把间隔定死短时间段用秒长时间段用分钟或小时area.AxisX.IntervalType DateTimeIntervalType.Minutes; area.AxisX.Interval 5;时间范围超过一天把IntervalType改成Hours、Interval设成6LabelStyle.Format改成MM-dd HH:mm。间隔、格式、时间范围三者要配套属于调参的活没有绝对标准按实际数据量调试到标签可读为止。还有一个容易忽略的点LabelStyle.Format里用了HH24小时制如果界面设计想要上午下午的观感改成hh:mm tt但中文环境下一般不这么用。6. 进阶一小时跑通历史回放、曲线导出与万点压测6.1 历史回放游标查询加Timer驱动历史回放是这套曲线方案里比较讨喜的功能做法不复杂用TrackBar或DateTimePicker选时间游标每次查询“从起始时间到游标时间”的数据并重绘曲线就会像录像一样逐段长出来。最小实现可以这样private void btnReplay_Click(object sender, EventArgs e) { DateTime cursor startTime; while (cursor endTime) { DataTable dt GetCurveData(DEV-001, startTime, cursor); DrawCurve(dt); cursor cursor.AddMinutes(10); Application.DoEvents(); Thread.Sleep(50); } }这个写法窗口会有轻卡演示够用。认真做的话把游标前进放到Timer里每秒前进一个步长界面全程可交互代码结构就是从4.1节的StartRefresh改造出来的。6.2 曲线导出SaveImage一行搞定导出曲线图片是监控界面的刚需用于报表或记录归档就一行chart1.SaveImage(C:\\temp\\curve_export.png, ChartImageFormat.Png);SaveImage导出的是整个Chart控件当前画面包括ChartArea和坐标轴只想导曲线区域的话先裁剪控件区域再导出。导出前确认目标目录存在否则抛DirectoryNotFoundException。文件名里带上时间戳避免连续导出互相覆盖。6.3 万点压测批量造数与验证流程最后是压测。不要等真实设备数据直接往库里灌几万条模拟数据把刷新、回放、导出一并验证完。灌数据时批量INSERT比逐条执行快几十倍这是慢的根源不在数据库而在循环逐条提交的经典案例Random rnd new Random(); DateTime t startTime; StringBuilder sb new StringBuilder( INSERT INTO dbo.CurveData(DeviceId, PointName, Value, CollectTime) VALUES ); for (int i 0; i 5000; i) { double v 20 5 * Math.Sin(i / 50.0) (rnd.NextDouble() - 0.5) * 2; sb.Append($(DEV-001, Temp, {v.ToString(0.00, CultureInfo.InvariantCulture)}, {t:yyyy-MM-dd HH:mm:ss}),); t t.AddSeconds(5); } string sql sb.ToString().TrimEnd(,);注意数值格式化一定要带CultureInfo.InvariantCulture否则中文系统下小数点可能变成逗号SQL执行直接报错。灌完数据后用最近一小时查询验证取数速度再用回放逻辑跑一遍确认数据量大时的绘制表现。我现在接到画曲线的需求不管界面多简单都会先花十分钟把数据链路跑通、把时间字段确认清楚再去调颜色和线宽。这个顺序换来的是后面大量的省事这条经验也分享给你希望帮到你。本文还有配套的精品资源点击获取