简介:这份C#绘制曲线示例源码面向具备一定C#基础、希望掌握数据可视化与图形编程的开发者,重点解决如何将SQL数据库中的数据读取后动态绘制成曲线的问题。资源包共25个文件,约50KB,以cs源代码、csproj项目文件、sln解决方案文件、sql数据库脚本及resx资源文件为主,另含exe可执行程序、dll类库与pdb调试文件,结构紧凑,便于在Visual Studio中直接打开运行。已有748人学习下载,适合作为课程设计或自学练手参考。源码完整演示了从建立数据库连接、执行SQL查询、解析结果集到使用GDI+绘制曲线的全过程,并附带建库脚本,读者可据此理解数据驱动绘图的基本思路,并在此基础上扩展异步加载、动态刷新与鼠标交互等优化功能,提升C#图形编程与数据可视化能力。
1. 从一条实时曲线说起:C# 绘制曲线图为什么总绕不开 SQL 数据库
很多做 C# 上位机的朋友都有过这样的场景:设备每秒往数据库里写一批温度、压力、流量数据,界面上要实时画出一条平滑曲线,历史数据还要能回放、能缩放、能导出。真动手时才发现,画曲线本身不难,难的是数据从 SQL 数据库里取出来之后怎么和绘图控件对上、几万条点怎么不卡、时间轴怎么对齐。这套「C# 绘制曲线示例源码(含 SQL 数据库)」要解决的正是这条链路:用 C# 把数据库里的时序数据读出来,在 WinForms 或 WPF 上画成可交互的曲线图。它适合做工业上位机、设备监控、实验数据采集的开发者,尤其是刚接触 C# 绘图、又必须和 SQL Server 打交道的人。下面我按自己实际做过的顺序,把选型、建库、取数、绘图、避坑一条条讲清楚,源码结构也会给到能直接抄的程度。
2. 绘图方案怎么选:GDI+、Chart 控件还是第三方库
2.1 三种主流画法的边界与适用场景
在 C# 里画曲线,绕不开三条路。第一条是System.Drawing的 GDI+,直接拿Graphics对象在Panel或PictureBox上DrawLine、DrawCurve。它的好处是零依赖、可控性最强,坐标轴、网格、游标全部自己算,坏处是什么都要自己写,缩放和命中测试得手动实现。第二条是 WinForms 自带的System.Windows.Forms.DataVisualization.Charting.Chart控件,拖一个到窗体上,绑定Series就能出图,支持缩放、滚动、图例,开发速度最快。第三条是第三方库,比如 OxyPlot、LiveCharts、ScottPlot,跨平台、性能好、API 现代,但引入外部依赖,团队要接受。
我一般的判断标准是:如果只是内部工具、点数在几千以内、要快速交付,直接用 Chart 控件;如果要做实时刷新、每秒上千点、还要自定义交互,用 GDI+ 自己画或者上 ScottPlot;如果是 WPF 项目,优先 OxyPlot 或 LiveCharts,别硬套 WinForms 控件。标题里说的是「示例源码」,通常这类源码为了让人看懂原理,多半用 GDI+ 或 Chart 控件,因为不依赖 NuGet 包,拷过去就能编译。
选型还要考虑一个现实问题:数据库取数速度和绘图刷新速度要匹配。SQL Server 一次SELECT几万行大概几十到几百毫秒,如果你用 Chart 控件每来一条数据就Points.Add再Invalidate,界面会闪。所以选型时就要想好是「批量取数 + 一次性重绘」还是「增量取数 + 局部刷新」,这决定了后面代码怎么写。
2.2 用 Chart 控件画第一条曲线的最小可运行代码
先给一段能直接跑的最小示例,窗体上放一个Chart控件,命名chart1,加一个按钮btnDraw。这段代码不连数据库,先把绘图这条链路跑通,确认环境没问题。
using System; using System.Drawing; using System.Windows.Forms; using System.Windows.Forms.DataVisualization.Charting; namespace CurveDemo { public partial class Form1 : Form { public Form1() { InitializeComponent(); InitChart(); } // 初始化图表区域、坐标轴样式 private void InitChart() { chart1.Series.Clear(); chart1.ChartAreas.Clear(); ChartArea area = new ChartArea("main"); area.AxisX.Title = "时间(s)"; area.AxisY.Title = "温度(℃)"; area.AxisX.MajorGrid.LineColor = Color.LightGray; area.AxisY.MajorGrid.LineColor = Color.LightGray; // 关闭自动滚动,避免数据多时坐标轴乱跳 area.AxisX.ScrollBar.Enabled = false; chart1.ChartAreas.Add(area); Series series = new Series("temp"); series.ChartType = SeriesChartType.Spline; // 平滑曲线 series.BorderWidth = 2; series.Color = Color.Red; series.XValueType = ChartValueType.Double; chart1.Series.Add(series); } private void btnDraw_Click(object sender, EventArgs e) { chart1.Series["temp"].Points.Clear(); // 模拟 200 个点,实际项目里换成数据库查询结果 for (int i = 0; i < 200; i++) { double x = i * 0.5; double y = 25 + 5 * Math.Sin(i * 0.1); chart1.Series["temp"].Points.AddXY(x, y); } chart1.Invalidate(); } } }逻辑说明:InitChart里先清空默认的 Series 和 ChartArea,避免设计器自动生成的样式干扰;ChartType选Spline是平滑曲线,如果数据本身抖动大、不想被平滑掩盖真实波动,改成Line。AddXY一次加一个点,200 个点没问题,但上万点就要用Points.DataBindXY批量绑定,性能差一个数量级。
参数说明:BorderWidth控制线宽,单位像素;XValueType设成Double才能让 X 轴按数值比例排布,默认是Auto,遇到时间字符串会退化成等距排列,曲线就失真了。AxisX.ScrollBar.Enabled关掉是因为示例数据少,数据多了再打开配合IsZoomed用。
提示:
System.Windows.Forms.DataVisualization在 .NET Framework 项目里默认可用,但在 .NET 6/8 的 WinForms 项目里需要额外引用System.Windows.Forms.DataVisualizationNuGet 包,否则编译不过。
3. SQL 数据库这一层:建表、取数和参数化查询
3.1 时序数据表怎么设计才不拖累绘图
曲线数据本质是「时间 + 数值」的序列,建表时最容易犯的错是把时间存成字符串。字符串比较慢、无法做范围索引、排序还依赖格式。正确做法是用datetime2或bigint存毫秒时间戳。下面这张表是我在多个上位机项目里用过的结构,字段不多但够用。
CREATE TABLE CurveData ( Id BIGINT IDENTITY(1,1) PRIMARY KEY, DeviceId INT NOT NULL, -- 设备编号,多设备共表 SampleTime DATETIME2(3) NOT NULL, -- 毫秒精度采样时间 Value FLOAT NOT NULL, -- 采样值 Tag VARCHAR(32) NOT NULL -- 测点名称,如 Temp1 ); -- 覆盖查询条件,避免全表扫描 CREATE NONCLUSTERED INDEX IX_CurveData_Query ON CurveData (DeviceId, Tag, SampleTime) INCLUDE (Value);逻辑说明:DeviceId + Tag + SampleTime建联合索引,是因为绘图查询几乎永远是「某设备某测点某时间段」,索引顺序要和 WHERE 条件顺序一致。INCLUDE (Value)把数值列带进索引,查询时不用回表,几百万行也能在百毫秒级返回。
参数说明:DATETIME2(3)的 3 表示毫秒精度,够用且比DATETIME2(7)省空间。如果采样频率高于 1kHz,考虑用BIGINT存 Unix 毫秒时间戳,比较和索引更快。FLOAT对应 C# 的double,别用REAL,精度不够画曲线会出现台阶。
3.2 用参数化查询把数据读进 DataTable
取数这一步,新手最容易写出字符串拼接的 SQL,既慢又有注入风险。下面这段是标准写法,用SqlDataAdapter一次填满DataTable,再交给绘图层。
using System.Data; using System.Data.SqlClient; public DataTable QueryCurve(int deviceId, string tag, DateTime from, DateTime to) { string sql = @"SELECT SampleTime, Value FROM CurveData WHERE DeviceId = @DeviceId AND Tag = @Tag AND SampleTime >= @From AND SampleTime <= @To ORDER BY SampleTime"; DataTable dt = new DataTable(); using (SqlConnection conn = new SqlConnection("Server=.;Database=Demo;Integrated Security=true;")) using (SqlCommand cmd = new SqlCommand(sql, conn)) using (SqlDataAdapter adapter = new SqlDataAdapter(cmd)) { cmd.Parameters.Add("@DeviceId", SqlDbType.Int).Value = deviceId; cmd.Parameters.Add("@Tag", SqlDbType.VarChar, 32).Value = tag; cmd.Parameters.Add("@From", SqlDbType.DateTime2).Value = from; cmd.Parameters.Add("@To", SqlDbType.DateTime2).Value = to; conn.Open(); adapter.Fill(dt); } return dt; }逻辑说明:using保证连接、命令、适配器都被释放,上位机长时间运行最怕连接泄漏。SqlDataAdapter.Fill内部用 DataReader 逐行读,比DataSet轻。参数用Add显式指定类型和长度,VarChar的长度 32 要和表定义一致,否则 SQL Server 会做隐式转换,索引可能失效。
参数说明:连接字符串里Integrated Security=true是 Windows 身份验证,部署到工控机时如果 SQL Server 和程序不在同一账户下,改成User Id=xx;Password=xx;。时间段查询一定要带上下界,别只写>=,否则数据量随时间无限增长,某天查询会突然变慢。
3.3 大数据量下的分页与降采样
当一条曲线要显示几十万个点时,全查出来再画,内存和渲染都扛不住。常见做法是降采样:在 SQL 里按时间分桶取平均,或者用ROW_NUMBER()分页。下面这个查询按每 10 秒一个桶取平均值,把点数压到可控范围。
SELECT DATEADD(SECOND, DATEDIFF(SECOND, 0, SampleTime) / 10 * 10, 0) AS BucketTime, AVG(Value) AS AvgValue FROM CurveData WHERE DeviceId = @DeviceId AND Tag = @Tag AND SampleTime BETWEEN @From AND @To GROUP BY DATEDIFF(SECOND, 0, SampleTime) / 10 ORDER BY BucketTime;逻辑说明:DATEDIFF(SECOND, 0, SampleTime)把时间转成秒数,除以 10 取整再乘 10,就得到 10 秒对齐的桶。AVG做降采样,曲线趋势保留,毛刺被抹平。如果要做峰值保持,把AVG换成MAX或MIN。
参数说明:桶大小 10 秒不是固定的,按「总点数 / 屏幕像素宽度」估算,一般让最终点数在 1000 到 2000 之间,肉眼看起来足够平滑。桶太小降采样没意义,桶太大曲线会失真,尤其是尖峰信号。
4. 把数据库数据画成曲线:绑定、刷新与交互
4.1 DataTable 到 Chart 的批量绑定
上一章取到的DataTable直接喂给 Chart 控件,用DataBindXY比循环AddXY快得多。下面这段把查询结果画到图上,同时处理时间轴。
private void DrawFromDatabase(int deviceId, string tag, DateTime from, DateTime to) { DataTable dt = QueryCurve(deviceId, tag, from, to); if (dt.Rows.Count == 0) return; // 时间转 OADate,Chart 内部按 double 处理 double[] xs = new double[dt.Rows.Count]; double[] ys = new double[dt.Rows.Count]; for (int i = 0; i < dt.Rows.Count; i++) { xs[i] = ((DateTime)dt.Rows[i]["SampleTime"]).ToOADate(); ys[i] = Convert.ToDouble(dt.Rows[i]["Value"]); } Series s = chart1.Series["temp"]; s.Points.Clear(); s.Points.DataBindXY(xs, ys); // X 轴按时间格式化显示 chart1.ChartAreas["main"].AxisX.LabelStyle.Format = "HH:mm:ss"; chart1.ChartAreas["main"].AxisX.IntervalType = DateTimeIntervalType.Auto; chart1.Invalidate(); }逻辑说明:ToOADate()把DateTime转成 OLE 自动化日期,本质是 double,Chart 的 X 轴能正确按时间比例排布。DataBindXY一次性绑定两个数组,内部批量处理,比逐点AddXY快 5 到 10 倍。绑定前先Points.Clear(),否则新旧数据会叠在一起。
参数说明:LabelStyle.Format设成HH:mm:ss是显示格式,如果跨度是几天,改成MM-dd HH:mm。IntervalType设Auto让控件自己算刻度间隔,数据密集时手动设Interval更可控,比如AxisX.Interval = 60表示每 60 秒一个刻度。
4.2 实时刷新:定时器 + 增量追加
实时曲线不能每次重查全表,正确做法是记住上次查询的最大时间,只取增量。下面用System.Windows.Forms.Timer每 500ms 拉一次新数据追加到曲线尾部。
private DateTime _lastTime = DateTime.Now; private void timer1_Tick(object sender, EventArgs e) { string sql = @"SELECT SampleTime, Value FROM CurveData WHERE DeviceId = @DeviceId AND Tag = @Tag AND SampleTime > @LastTime ORDER BY SampleTime"; // ... 执行查询,得到 dt ... Series s = chart1.Series["temp"]; foreach (DataRow row in dt.Rows) { DateTime t = (DateTime)row["SampleTime"]; double v = Convert.ToDouble(row["Value"]); s.Points.AddXY(t.ToOADate(), v); _lastTime = t; } // 只保留最近 2000 个点,防止内存无限增长 while (s.Points.Count > 2000) s.Points.RemoveAt(0); chart1.ChartAreas["main"].AxisX.Minimum = s.Points[0].XValue; chart1.ChartAreas["main"].AxisX.Maximum = s.Points[s.Points.Count - 1].XValue; chart1.Invalidate(); }逻辑说明:_lastTime记录已取到的最大时间,下次只查比它新的数据,避免重复。追加后裁剪掉最老的点,保持窗口滑动。手动设置AxisX.Minimum和Maximum让视图跟着数据滚动,否则坐标轴会自动缩放导致曲线抖动。
参数说明:定时器间隔 500ms 是刷新频率和数据库压力的折中,太快数据库扛不住,太慢曲线不连续。保留点数 2000 对应屏幕宽度约 1000 像素,每个像素两个点,视觉上够用。如果采样频率高,可以改成保留最近 60 秒的数据,按时间裁剪而不是按点数。
4.3 鼠标游标与数据点命中测试
光有曲线不够,用户要能看某个点的具体数值。Chart 控件自带HitTest,可以拿到鼠标位置最近的数据点。
private void chart1_MouseMove(object sender, MouseEventArgs e) { HitTestResult result = chart1.HitTest(e.X, e.Y); if (result.ChartElementType == ChartElementType.DataPoint) { int idx = result.PointIndex; DataPoint p = chart1.Series["temp"].Points[idx]; double time = DateTime.FromOADate(p.XValue).TimeOfDay.TotalHours; toolTip1.SetToolTip(chart1, $"时间 {time:F2}h 值 {p.YValues[0]:F2}"); } }逻辑说明:HitTest返回命中的图表元素类型和点索引,只有命中DataPoint才显示提示。FromOADate把 X 值还原成时间,YValues[0]取第一个 Y 值(多 Y 轴时会有多个)。
参数说明:HitTest的命中范围默认是点周围几个像素,数据密集时容易命中相邻点,可以调HitTestResult的容差,或者自己算最近点。ToolTip的显示延迟用toolTip1.InitialDelay控制,默认 500ms,实时监控场景可以调到 200ms。
5. 避坑与排查:曲线画不出来、卡顿、时间错乱的常见原因
5.1 曲线一片空白,数据明明查到了
现象:DataTable里Rows.Count大于 0,但图上什么都没有。原因通常是 X 值类型不对。如果SampleTime直接以字符串形式AddXY,Chart 会把它当分类轴,点与点等距排列,数值全挤在一起或者超出可视范围。解决:统一转成ToOADate()的 double,并确认Series.XValueType = ChartValueType.Double。另一个原因是AxisX.Minimum和Maximum被手动设成了固定值,而数据不在这个区间内,检查一下有没有残留的轴范围设置。
5.2 数据量一大就卡死,界面无响应
现象:查询几万行后界面冻结几秒,鼠标转圈。原因有两个:一是在 UI 线程里执行 SQL 查询和绘图,二是逐点AddXY。解决:查询放到Task.Run或BackgroundWorker里,查完再Invoke回 UI 线程绑定;绘图用DataBindXY批量绑定,或者先降采样再画。如果还卡,检查是不是每次刷新都Points.Clear()后重新绑定全量数据,实时场景应该只追加增量。
5.3 时间轴显示成 1900 年或者乱码
现象:X 轴标签显示1900-01-01或者一串数字。原因是ToOADate转换后没有设置LabelStyle.Format,Chart 默认按数值显示。解决:设置AxisX.LabelStyle.Format = "HH:mm:ss",并且确认AxisX.IsMarginVisible = false,否则首尾会留白导致时间看起来偏移。如果用的是DateTime直接绑定,注意 Chart 内部还是转 double,格式化必须显式设。
5.4 SQL 查询越来越慢,索引没生效
现象:刚上线时查询很快,数据积累到几百万行后查询要好几秒。原因是查询条件顺序和索引列顺序不匹配,或者参数类型和列类型不一致导致隐式转换。解决:用 SQL Server 的「显示实际执行计划」看是不是走了索引扫描而不是查找;确认WHERE里DeviceId、Tag、SampleTime的顺序和索引一致;参数用SqlDbType.DateTime2而不是SqlDbType.DateTime,避免精度转换。另外定期做索引重建,时序表写入频繁,碎片增长快。
5.5 多设备数据串线,曲线混在一起
现象:查询某个设备的数据,图上却混进了别的设备的值。原因是WHERE条件漏了DeviceId或者Tag,或者参数赋值时搞错了变量。解决:查询方法里把DeviceId和Tag作为必填参数,SQL 里两个条件都要有;绑定前检查DataTable的列值范围,必要时在代码里加断言。多测点场景建议每个测点一个Series,用不同颜色区分,别全画在一条线上。
6. 让曲线源码真正可复用的几个进阶习惯
把上面这套跑通之后,我一般会做三件事让它从「示例」变成「能进项目」。第一件是把绘图逻辑抽成一个CurveChartHelper类,输入DataTable和Series名称,内部处理时间转换、降采样、轴范围,窗体代码只负责调方法。第二件是给查询加缓存,同一时间段重复查询直接返回内存里的DataTable,用MemoryCache设 30 秒过期,实时刷新时命中率很高。第三件是导出功能,把当前Series.Points写回 CSV 或者另存到数据库的归档表,方便事后复盘。
验证方法上,我习惯用「已知函数」测绘图正确性:往数据库插一段y = sin(x)的数据,画出来和数学软件对比,看相位和幅值对不对。再插一段带尖峰的方波,检查降采样有没有把尖峰抹掉。性能上用Stopwatch卡住查询和绑定两段耗时,查询超过 200ms 就去看执行计划,绑定超过 100ms 就考虑降采样。
// 简单的耗时统计,定位瓶颈在查询还是绘图 var sw = System.Diagnostics.Stopwatch.StartNew(); DataTable dt = QueryCurve(1, "Temp1", from, to); long queryMs = sw.ElapsedMilliseconds; sw.Restart(); BindToChart(dt); long drawMs = sw.ElapsedMilliseconds; System.Diagnostics.Debug.WriteLine($"查询 {queryMs}ms, 绘图 {drawMs}ms");参数说明:查询耗时目标控制在 200ms 以内,绘图控制在 100ms 以内,超过就优化对应环节。这个阈值不是绝对的,取决于刷新频率,500ms 刷新一次的话,两段加起来别超过 400ms,否则定时器会堆积。
最后说个我踩过的坑:早期做实时曲线时,我把Timer间隔设成 100ms,数据库每秒被查 10 次,跑了半天 SQL Server 连接池就满了。后来改成 500ms 加增量查询,稳定跑几个月没问题。曲线这东西,画出来容易,画得稳、画得久才是真功夫。希望帮到你。
本文还有配套的精品资源,点击获取