简介:这份C#绘制曲线示例源码面向具备一定C#基础、希望掌握数据可视化与图形编程的开发者,重点解决如何将SQL数据库中的数据读取后动态绘制成曲线图的问题。压缩包共25个文件,约50KB,以cs源代码、csproj项目文件、sln解决方案、sql数据库脚本及resx资源文件为主,另含exe可执行程序、dll类库与pdb调试文件,结构完整,可直接在Visual Studio中打开运行。源码演示了通过ADO.NET连接数据库、执行查询、解析数据点,再借助GDI+的Graphics类绘制曲线的完整流程,并涉及窗体控件与资源文件的组织方式。目前已有748人学习下载,适合作为课程设计、数据监控界面或报表模块的参考范例,帮助读者理解数据库与图形编程的结合思路,并在此基础上扩展异步加载、动态刷新与交互提示等功能。
1. 从一份 WinForm 曲线源码说起:C# 绘图 + SQL 数据落地的真实场景
很多做 C# 上位机或者工控数据采集的朋友,都会遇到一个绕不开的需求:把传感器或者业务系统里的数据,用曲线的方式实时画出来。我最近拆了一份Case06_11的源码包,它做的事情很朴素——用 C# 的 GDI+ 在 WinForm 窗体上画曲线,数据来源是 SQL Server 数据库,配套一个scriptdb.sql建库脚本。这不是什么高大上的图表库,没有 WPF 的 MVVM 绑定,也没有第三方控件的花哨动画,但它把「数据从数据库出来、经过 ADO.NET 拿到内存、再用Graphics.DrawCurve画到窗体上」这条链路完整跑通了。对于刚接触 C# 图形编程、或者需要快速给现有 WinForm 项目加一个数据可视化模块的人来说,这份源码的价值在于它足够小、足够透明,你能看清每一行代码在干什么,而不是被封装好的控件黑匣子挡住视线。它适合谁?适合那些手头有 SQL Server 数据、想在 WinForm 里快速看到曲线效果、并且愿意自己动手改坐标轴和刷新逻辑的开发者。
2. 拆解 Case06_11 工程结构:从 .sln 到 Form1.cs 的代码地图
拿到一个源码包,我习惯先看目录结构,因为文件布局往往暴露了作者的开发习惯和项目的真实复杂度。Case06_11这个包解压后,根目录下是Case06_11.sln解决方案文件,旁边跟着Case06_11.csproj项目文件,再往下是Form1.cs、Form1.Designer.cs、Program.cs这三个核心代码文件,以及Properties文件夹里的AssemblyInfo.cs、Settings.settings、Resources.resx等标准配置。最值得注意的是根目录下的scriptdb.sql,这说明数据库不是靠代码里硬编码建表,而是单独给了脚本,部署的时候先跑脚本再跑程序,思路是对的。
2.1 解决方案与项目文件的组织逻辑
.sln文件本身不包含代码,它记录的是项目之间的引用关系和全局配置。用 Visual Studio 打开Case06_11.sln之后,你会看到解决方案资源管理器里只有一个项目Case06_11。.csproj文件里定义了目标框架,从这类教学案例的常见做法来看,大概率是 .NET Framework 4.x,因为 GDI+ 的System.Drawing在 .NET Core 之后的跨平台版本里虽然还能用,但 WinForm 设计器的支持在旧版框架下更稳定。项目文件里还会列出所有Compile项,也就是参与编译的.cs文件,以及None或Content项,比如scriptdb.sql通常会被标记为不参与编译但随项目保留。
打开.csproj你可以关注两个地方:一是<TargetFrameworkVersion>,它决定了你能用哪些 API;二是<Reference>节点里有没有System.Data.SqlClient或者System.Data,这直接关系到数据库连接能不能编译通过。如果引用缺失,哪怕代码写得再对,using System.Data.SqlClient;那一行也会报红。
2.2 Form1.cs 与 Designer.cs 的分工
Form1.cs是业务逻辑的主战场,Form1.Designer.cs是窗体设计器自动生成的布局代码。很多新手会把绘图代码写到Designer.cs里,这是大忌,因为下次你在设计器里拖一个按钮,自动生成的代码就会把你的手写逻辑冲掉。正确的做法是:所有控件声明和初始化留在Designer.cs,所有绘制逻辑、数据库查询、按钮点击事件处理都放在Form1.cs。
在Form1.cs里,你通常会看到构造函数public Form1()调用InitializeComponent(),然后可能绑定一个Load事件或者按钮的Click事件。绘图的核心入口一般是窗体的Paint事件,或者你手动获取Graphics对象。如果作者用的是Paint事件,那么每次窗体重绘(比如被其他窗口遮挡后恢复)曲线都会自动重画,这是比较稳妥的做法。如果是在按钮点击里直接CreateGraphics(),那窗口一最小化再恢复,曲线就没了,这是常见的翻车点。
2.3 scriptdb.sql 里的数据库初始化线索
scriptdb.sql这个文件名很直白,就是建库建表插数据的脚本。用文本编辑器打开它,你大概率会看到CREATE DATABASE、USE、CREATE TABLE以及若干条INSERT INTO语句。表结构通常很简单,比如一张CurveData表,字段可能是Id、XValue、YValue,或者RecordTime、SensorValue这种更贴近工控场景的命名。插入的数据就是用来画曲线的原始点集。
这里有个细节值得留意:脚本里有没有GO批处理分隔符。如果有,说明它是在 SQL Server Management Studio 里直接执行的;如果没有,用 ADO.NET 的SqlCommand一次性执行整个脚本可能会因为多语句批处理而出错。我一般会建议先在 SSMS 里把脚本跑一遍,确认数据库和表都建好了,再回到 Visual Studio 里跑程序。这样能把数据库问题和代码问题分开排查,省得两头抓瞎。
3. 用 ADO.NET 把 SQL 数据读进内存:连接、查询与 DataTable 填充
曲线要画得出来,前提是数据得先到手。这份源码用的是 ADO.NET 那套经典组合:SqlConnection、SqlCommand、SqlDataAdapter、DataTable。虽然现在 Entity Framework 和 Dapper 很流行,但在这种小体量教学项目里,原生 ADO.NET 反而更清晰,没有隐藏的映射逻辑,每一步都看得见。
3.1 连接字符串的构造与安全边界
连接字符串是数据库访问的第一道门槛。在这类示例里,常见写法是直接拼一个string connStr = "Data Source=.;Initial Catalog=CurveDB;Integrated Security=True";。Data Source=.表示本机默认实例,Initial Catalog指定数据库名,Integrated Security=True表示用 Windows 身份验证,不需要在代码里写用户名密码。如果你的 SQL Server 是命名实例,比如SQLEXPRESS,那就要写成Data Source=.\SQLEXPRESS。
提示:把连接字符串硬编码在
Form1.cs里虽然跑得快,但换一台机器就可能连不上。更稳妥的做法是放到App.config的<connectionStrings>节点里,用ConfigurationManager.ConnectionStrings["MyConn"].ConnectionString读取。这样部署的时候只改配置文件,不用重新编译。
如果你用的是 SQL 身份验证,连接字符串里就要加User ID=sa;Password=你的密码;。但我不建议在示例代码里用 sa,权限太大,一旦泄露风险很高。本地开发用 Windows 验证就够了。
3.2 查询语句与 DataTable 的填充过程
数据读取的核心代码通常长这样:
using System.Data; using System.Data.SqlClient; // 假设连接字符串已经定义好 string connStr = "Data Source=.;Initial Catalog=CurveDB;Integrated Security=True"; string sql = "SELECT XValue, YValue FROM CurveData ORDER BY XValue"; DataTable dt = new DataTable(); using (SqlConnection conn = new SqlConnection(connStr)) { using (SqlCommand cmd = new SqlCommand(sql, conn)) { SqlDataAdapter adapter = new SqlDataAdapter(cmd); adapter.Fill(dt); // 把查询结果填充到 DataTable } } // 到这里 dt 里就是按 XValue 排序好的数据点这段代码的逻辑很直白:SqlConnection负责建立物理连接,SqlCommand承载 SQL 语句,SqlDataAdapter是连接DataSet/DataTable和数据库之间的桥梁。adapter.Fill(dt)内部会自己打开连接、执行查询、把结果集映射成行和列、然后关闭连接。你不需要手动conn.Open(),Fill方法会帮你管理连接状态。
参数说明方面,ORDER BY XValue很重要。曲线绘制要求数据点按 X 轴顺序排列,如果数据库里插入顺序是乱的,画出来的线就会来回折返,看起来像一团乱麻。我一般会在 SQL 里就排好序,而不是拿到内存再用 LINQ 排,因为数据库的排序效率通常更高,而且减少 C# 端的代码量。
3.3 把 DataTable 转成绘图用的点数组
DataTable虽然方便,但绘图的时候用DataRow逐行访问效率不高,而且Graphics.DrawCurve需要的是PointF[]或者Point[]数组。所以中间要做一次转换:
PointF[] points = new PointF[dt.Rows.Count]; for (int i = 0; i < dt.Rows.Count; i++) { float x = Convert.ToSingle(dt.Rows[i]["XValue"]); float y = Convert.ToSingle(dt.Rows[i]["YValue"]); points[i] = new PointF(x, y); }这里用Convert.ToSingle而不是直接强制转换,是因为数据库里的数值类型可能是decimal或者double,直接(float)在某些情况下会抛异常。Convert.ToSingle会做安全的类型转换,失败时抛FormatException或InvalidCastException,方便你定位是哪一行的数据有问题。
如果数据量很大,比如几万个点,一次性全画出来会卡顿。常见做法是抽稀或者只画可见区域内的点。但在教学示例里,数据量通常只有几十到几百个,直接全画没问题。
4. GDI+ 绘制曲线的核心:Graphics、Pen 与 DrawCurve 参数调优
数据准备好了,接下来就是把它画到窗体上。GDI+ 的绘图 API 不算复杂,但有几个参数如果没设对,曲线要么看不见,要么锯齿严重,要么位置偏到窗体外面。
4.1 获取 Graphics 对象的正确姿势
在 WinForm 里绘图,最稳妥的方式是重写OnPaint方法或者订阅Paint事件:
protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g = e.Graphics; // 用 Paint 事件给的 Graphics,不要自己 CreateGraphics g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; // 抗锯齿 // 后续绘图代码写在这里 }e.Graphics是系统在重绘时传进来的,它已经处理好了裁剪区域和背景擦除。如果你在按钮点击里用this.CreateGraphics(),那画出来的东西是临时的,窗口一刷新就消失。这是新手最容易踩的坑之一。
SmoothingMode.AntiAlias开启抗锯齿,曲线边缘会平滑很多。代价是绘制速度稍微慢一点,但对于几百个点的曲线来说,这点开销可以忽略。
4.2 Pen 的构造与坐标变换
Pen决定了线条的颜色、粗细和样式:
using (Pen pen = new Pen(Color.Blue, 2f)) { pen.StartCap = System.Drawing.Drawing2D.LineCap.Round; pen.EndCap = System.Drawing.Drawing2D.LineCap.Round; // 绘图 }Color.Blue是线条颜色,2f是线宽(单位是像素)。StartCap和EndCap设成Round可以让线条两端圆润,不会出现方头。Pen实现了IDisposable,所以用using包起来,避免资源泄漏。
坐标变换是另一个关键点。数据库里的 XValue 和 YValue 可能是实际物理量,比如温度 20~80 摄氏度,压力 0~1.5 MPa。而窗体的绘图区域只有几百像素宽高。你需要把数据坐标映射到屏幕坐标:
// 假设数据范围 xMin~xMax, yMin~yMax,绘图区域 rect float scaleX = rect.Width / (xMax - xMin); float scaleY = rect.Height / (yMax - yMin); PointF[] screenPoints = points.Select(p => new PointF( rect.Left + (p.X - xMin) * scaleX, rect.Bottom - (p.Y - yMin) * scaleY // Y 轴翻转,因为屏幕坐标向下增长 )).ToArray();注意 Y 轴要翻转,因为数学坐标系原点在左下,屏幕坐标系原点在左上。如果不翻转,曲线上下就是反的。
4.3 DrawCurve 与 DrawLines 的选择差异
GDI+ 提供了两个画线方法:DrawCurve和DrawLines。DrawLines是折线,点与点之间用直线连接,转折处有棱角。DrawCurve是平滑曲线,它用 Cardinal 样条插值,让曲线穿过所有数据点,转折处圆滑。
g.DrawCurve(pen, screenPoints); // 平滑曲线 // 或者 g.DrawLines(pen, screenPoints); // 折线DrawCurve有一个可选参数tension,默认值是 0.5f。tension越大,曲线越弯曲;tension为 0 时,DrawCurve退化成DrawLines。如果你发现曲线在数据点之间摆动太厉害,可以试着把tension调小到 0.3f 左右。但要注意,DrawCurve的平滑是视觉上的,它并不改变数据本身,所以不要用它来做数据拟合。
注意:
DrawCurve要求至少 3 个点才能画出曲线效果,如果只有 2 个点,它和DrawLines没区别。如果数据点少于 2 个,直接跳过绘制,否则会抛ArgumentException。
4.4 坐标轴与网格线的补充绘制
光有曲线还不够,没有坐标轴和刻度,读者不知道曲线代表什么数值。常见的做法是在OnPaint里先画坐标轴:
using (Pen axisPen = new Pen(Color.Black, 1f)) { g.DrawLine(axisPen, rect.Left, rect.Top, rect.Left, rect.Bottom); // Y 轴 g.DrawLine(axisPen, rect.Left, rect.Bottom, rect.Right, rect.Bottom); // X 轴 }然后用g.DrawString在轴旁边标注刻度值。DrawString需要Font和Brush,记得也用using包起来。网格线可以用浅灰色虚线,pen.DashStyle = DashStyle.Dot,这样不会喧宾夺主。
如果窗体大小会变化,记得在Resize事件里调用Invalidate()触发重绘,否则曲线还是按旧尺寸画的,窗口拉大后右边会留白。
5. 避坑与排查:数据库连不上、曲线不显示、刷新闪烁的常见问题
这份源码跑起来不难,但环境差异和细节疏忽会让它在你机器上翻车。下面这几条是我实际调试时遇到过的典型问题,按「现象 → 原因 → 解决」整理。
5.1 现象:程序启动报「无法打开登录所请求的数据库」
原因:scriptdb.sql没有执行,或者执行了但数据库名和连接字符串里的Initial Catalog不一致。也有可能是 SQL Server 服务没启动,或者当前 Windows 账户没有访问该数据库的权限。
解决:先在 SSMS 里连上本机 SQL Server,打开scriptdb.sql执行一遍,确认CurveDB数据库和CurveData表都存在。然后检查连接字符串里的Data Source是否和 SSMS 里连接的实例名一致。如果是命名实例,Data Source要写成.\SQLEXPRESS这种形式。最后确认 SQL Server 的 Windows 身份验证模式已启用。
5.2 现象:窗体上什么都没有,曲线不显示
原因:绘图代码写在了CreateGraphics()里,窗口被遮挡后重绘时曲线丢失;或者Paint事件没有正确绑定;或者数据点数组为空,DrawCurve直接跳过。
解决:把绘图逻辑移到OnPaint重写方法或Paint事件处理器里。在OnPaint开头加一句Console.WriteLine(points.Length);确认数据点数量大于 2。如果数据点来自数据库,检查adapter.Fill(dt)之后dt.Rows.Count是不是 0,如果是 0,说明 SQL 查询没返回任何行,去数据库里SELECT COUNT(*) FROM CurveData看看有没有数据。
5.3 现象:曲线画出来了,但位置偏了或者上下颠倒
原因:坐标变换时没有做 Y 轴翻转,或者rect的Bottom和Top用反了。屏幕坐标的 Y 轴向下增长,而数据坐标的 Y 轴通常向上增长。
解决:在计算屏幕 Y 坐标时用rect.Bottom - (p.Y - yMin) * scaleY,而不是rect.Top + (p.Y - yMin) * scaleY。同时确认rect的Height是正数,如果rect.Bottom < rect.Top,Height会是负数,导致所有点跑到窗体外面。
5.4 现象:窗口拖动或调整大小时曲线闪烁严重
原因:WinForm 默认在重绘时会先用背景色擦除整个客户区,然后再画曲线,这个「擦除-绘制」的过程被人眼捕捉到就是闪烁。
解决:在窗体构造函数里开启双缓冲:
this.SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); this.UpdateStyles();AllPaintingInWmPaint禁止系统擦除背景,UserPaint让控件自己负责绘制,OptimizedDoubleBuffer启用双缓冲。这三者配合,闪烁基本就消失了。
5.5 现象:数据量大了之后界面卡死
原因:在 UI 线程里同步执行数据库查询和绘图,数据量大时Fill和DrawCurve都会阻塞消息循环。
解决:把数据库查询放到Task.Run里异步执行,查询完成后用Invoke回到 UI 线程更新数据并调用Invalidate()。绘图本身如果点太多,可以考虑只画可见区域或者做抽稀。但在这份教学源码的体量下,通常不至于卡死,除非你往CurveData表里插了几十万行。
6. 进阶技巧:让曲线动起来、可交互、能导出
把静态曲线画出来只是第一步。实际项目里,你往往需要曲线能实时刷新、鼠标悬停能看到具体数值、甚至导出成图片存档。下面这几个技巧是我在这个源码基础上经常加的,代码量不大,但实用性提升明显。
6.1 用 Timer 实现数据定时刷新
如果数据库里的数据是持续写入的,你可以用System.Windows.Forms.Timer每隔几秒重新查询一次并重绘:
private Timer refreshTimer; private void InitTimer() { refreshTimer = new Timer(); refreshTimer.Interval = 3000; // 3 秒 refreshTimer.Tick += (s, e) => { LoadDataAndRefresh(); // 重新查询数据库并更新 points 数组 this.Invalidate(); // 触发重绘 }; refreshTimer.Start(); }Interval单位是毫秒,3000 就是 3 秒。Tick事件里不要做太耗时的操作,否则 UI 还是会卡。如果查询慢,就改成异步查询,查完再Invalidate。
6.2 鼠标悬停显示数据点数值
在OnPaint里画完曲线后,可以记录每个数据点的屏幕坐标。然后在MouseMove事件里判断鼠标位置是否靠近某个点:
private PointF[] screenPoints; // 在 OnPaint 里赋值 private void Form1_MouseMove(object sender, MouseEventArgs e) { for (int i = 0; i < screenPoints.Length; i++) { float dx = e.X - screenPoints[i].X; float dy = e.Y - screenPoints[i].Y; if (dx * dx + dy * dy < 25) // 距离小于 5 像素 { this.Text = $"X={points[i].X:F2}, Y={points[i].Y:F2}"; return; } } this.Text = "C# 曲线绘制示例"; }这里用平方距离比较,避免开方运算。25是 5 像素的平方。找到最近的点后,把数值显示在窗体标题栏上,简单直接。如果要更美观,可以用ToolTip控件。
6.3 把曲线导出为 PNG 图片
有时候需要把当前曲线保存下来发给别人,或者插入报告。Graphics对象可以从Bitmap创建,画完之后保存:
private void ExportToPng(string filePath) { using (Bitmap bmp = new Bitmap(this.ClientSize.Width, this.ClientSize.Height)) using (Graphics g = Graphics.FromImage(bmp)) { g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; // 这里调用和 OnPaint 里一样的绘图方法,传入 g DrawChart(g); bmp.Save(filePath, System.Drawing.Imaging.ImageFormat.Png); } }关键是把绘图逻辑抽成一个DrawChart(Graphics g)方法,OnPaint里调用它,导出时也调用它。这样保证屏幕显示和导出图片完全一致。bmp.Save的第二个参数指定格式,PNG 无损压缩,适合曲线图;如果要更小的文件,可以用 JPEG,但曲线边缘会有压缩伪影。
6.4 参数配置表:常用调优项速查
| 参数 | 位置 | 推荐值 | 说明 |
|---|---|---|---|
| SmoothingMode | Graphics | AntiAlias | 抗锯齿,曲线更平滑 |
| Pen.Width | Pen 构造 | 1.5f ~ 2f | 太细看不清,太粗遮挡数据 |
| DrawCurve tension | DrawCurve | 0.3f ~ 0.5f | 越小越接近折线,越大越弯曲 |
| Timer.Interval | Timer | 1000 ~ 5000 | 根据数据更新频率调整 |
| 双缓冲 | 窗体构造 | 全部开启 | 消除刷新闪烁 |
这份源码的骨架很清晰,数据库、查询、绘图三层分得开,你可以在任何一层做扩展。我自己的习惯是,每次拿到这类示例,先跑通默认流程,确认数据库和绘图都正常,然后再动代码加功能。如果一上来就改连接字符串和绘图参数,出了问题很难判断是环境问题还是代码问题。从那以后我每次调试 WinForm 绘图项目,都强制先跑一遍原始版本,再逐步替换成自己的逻辑。希望这份拆解帮到你。
本文还有配套的精品资源,点击获取