C#曲线绘制完整方案:SQL数据库查询到GDI+自绘控件

发布时间:2026/9/14 14:29:19
C#曲线绘制完整方案:SQL数据库查询到GDI+自绘控件 简介这是一份面向C#开发者的综合示例源码聚焦于曲线绘制与SQL数据库的结合应用适合想掌握GDI/WPF绘图及ADO.NET数据操作的初中级读者。压缩包共25个文件包括6个C#源文件、项目解决方案/工程文件、SQL脚本以及编译生成的exe/dll等资源总大小仅50KB结构紧凑便于对照学习。目前已有253人学习下载。示例项目Case06_11演示了使用Pen、Graphics绘制曲线或借助Path/PathGeometry构建复杂矢量图形的方法同时通过scriptdb.sql建表利用SqlConnection、SqlCommand等完成曲线数据点的插入与查询最终在界面上动态呈现。读者可从中获得一套“绘图数据库存取”的完整实现思路既适合课堂实验也适用于需要可视化数据曲线的中小型项目开发。1. C#曲线绘制这套方案解决什么问题把 SQL Server 里存的时间序列数据画成曲线是上位机、工业监控和数据分析工具里最常见的需求之一。C# 做这件事的难点不在画图本身而在于数据怎么取、坐标怎么算、交互怎么做、量大了怎么不卡。很多人一上手就用 WinForms 自带的 Chart 控件数据一多、需要自定义交互时又发现处处受限回头再用 GDI 重新写一遍。本文围绕“C# 绘制曲线示例源码含 SQL 数据库”这个题目讲一套从 SQL 查询到界面绘制的完整落地路径先看数据怎么查、怎么缓存再写核心绘制代码最后把缩放、十字光标、导出图片这些高频功能补上。适合用 C# 做上位机、数据采集系统的开发者阅读即使你最终不采用这套代码里面的选型思路和参数取舍也能直接用在现有项目里。2. 从SQL数据库读取曲线数据的查询与缓存设计2.1 曲线数据在 SQL 里的两种典型存储形态做曲线绘制前先要搞清楚数据在数据库里是什么样子。最常见的两种形态一种是流水表每一行是一条带时间戳的测量记录另一种是宽表一行包含多个测点的值。前者适合存储长期历史数据后者适合展示某一时间断面的多路数据。-- 流水表每行一个时间点 CREATE TABLE TrendData ( Id INT IDENTITY PRIMARY KEY, TagName NVARCHAR(50) NOT NULL, SampleTime DATETIME2 NOT NULL, Value FLOAT NOT NULL ); -- 宽表每行一个时间点多列对应多路信号 CREATE TABLE MultiChannel ( SampleTime DATETIME2 NOT NULL, Channel1 FLOAT NULL, Channel2 FLOAT NULL, Channel3 FLOAT NULL );流水表按 TagName 过滤后取出的记录天然是按时间排序的宽表则直接按时间过滤即可。实际项目中我见到的曲线查询90% 以上是查流水表因为列数不固定、后续加测点方便。如果你要画的曲线来自视图或存储过程推荐在查询里就完成排序和过滤不要在 C# 端对 DataTable 做二次排序。2.1.1 索引对查询的影响曲线查询基本是范围查询给一个开始时间、一个结束时间取中间所有点。这种情况在 SampleTime 上建索引是基本要求如果按 TagName 过滤还需要建立 (TagName, SampleTime) 的复合索引。CREATE INDEX IX_TrendData_TagTime ON TrendData(TagName, SampleTime);TagName放前面、SampleTime放后面能同时满足“按测点查时间范围”的查询路径。如果不加这个索引大表上每次画曲线都要全表扫描SQL Server 可以跑但查询时间会随数据量线性恶化。2.2 SQL 查询语句的写法与参数化画曲线时最忌讳把时间范围拼进 SQL 字符串正确做法是使用参数化查询。这样既能防止注入也能让 SQL Server 复用执行计划。private DataTable QueryTrend(string tagName, DateTime start, DateTime end) { string sql SELECT SampleTime, Value FROM TrendData WHERE TagName tag AND SampleTime BETWEEN start AND end ORDER BY SampleTime; using (var conn new SqlConnection(_connectionString)) using (var cmd new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(tag, tagName); cmd.Parameters.AddWithValue(start, start); cmd.Parameters.AddWithValue(end, end); var dt new DataTable(); conn.Open(); using (var adapter new SqlDataAdapter(cmd)) { adapter.Fill(dt); } return dt; } }AddWithValue在 SQL Server 的字符串参数上可能会引发隐式类型转换问题tag对应的是NVARCHAR列就传SqlDbType.NVarChar更稳妥。时间参数用SqlDbType.DateTime2避免精度被截断。2.2.1 采样间隔怎么定曲线显示不需要把所有原始点都拉出来。假设窗口宽度是 1000 像素一个测点一秒钟存一条查 8 小时就是 28800 条画在 1000 像素宽的区域里必然有大量点在同一个像素列上重叠。常见做法是先按显示分辨率降采样再做聚合或抽稀。-- 按分钟聚合后再返回减少行数 SELECT DATEADD(MINUTE, DATEDIFF(MINUTE, 0, SampleTime), 0) AS TimeBucket, AVG(Value) AS AvgValue, MAX(Value) AS MaxValue, MIN(Value) AS MinValue FROM TrendData WHERE TagName tag AND SampleTime BETWEEN start AND end GROUP BY DATEADD(MINUTE, DATEDIFF(MINUTE, 0, SampleTime), 0) ORDER BY TimeBucket;聚合的粒度根据缩放级别动态决定显示 5 分钟用原始点显示 24 小时用 5 分钟聚合。判断方法很直接先算时间跨度除以像素宽度的值如果每个像素对应的原始点超过 3 个就升一级聚合。2.3 取数用 DataTable 还是 SqlDataReaderDataTable 用起来方便绑定数据源很顺手但它的内存开销偏高。画曲线最终要把数据转成ListPointF或Listdouble经过 DataTable 中转其实是绕路。数据量小几千行以内感觉不明显几十万行时 DataTable 需要额外的内存复制。我一般这么处理预估数据量超过 5 万行时用SqlDataReader直接流式读取把值塞进ListDateTime和Listdouble两个并行数组数据量小的时候用 DataTable 也无妨代码可读性优先。public (ListDateTime times, Listdouble values) QueryTrendFast( string tagName, DateTime start, DateTime end) { var times new ListDateTime(); var values new Listdouble(); string sql SELECT SampleTime, Value FROM TrendData WHERE TagName tag AND SampleTime BETWEEN start AND end ORDER BY SampleTime; using (var conn new SqlConnection(_connectionString)) using (var cmd new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(tag, tagName); cmd.Parameters.AddWithValue(start, start); cmd.Parameters.AddWithValue(end, end); conn.Open(); using (var reader cmd.ExecuteReader()) { while (reader.Read()) { times.Add(reader.GetDateTime(0)); values.Add(reader.GetDouble(1)); } } } return (times, values); }两个List的索引一一对应后面画图时直接用索引取值。比 DataTable 省掉了一层 DataRow 到对象的转换也不影响代码清晰度。2.4 查询量大时的缓存与抽样策略历史曲线有个特点用户可能在同一个时间范围内来回切换测点反复触发查询。把最近一次查询的时间范围和测点名记住再次查询相同范围时直接从内存缓存返回是一个立竿见影的优化。缓存实现不复杂一个键值对就行private readonly Dictionarystring, TrendCacheItem _cache new(); private TrendCacheItem GetCachedData(string tag, DateTime start, DateTime end) { string key ${tag}|{start:O}|{end:O}; if (_cache.TryGetValue(key, out var item) item.ExpireTime DateTime.Now) { return item; } var (times, values) QueryTrendFast(tag, start, end); item new TrendCacheItem { Times times, Values values, ExpireTime DateTime.Now.AddSeconds(30) }; _cache[key] item; return item; }查询量极大时在 SQL 层面做抽样更划算比如每第 N 条取一条。SQL Server 里可以用ROW_NUMBER()实现WITH Ordered AS ( SELECT SampleTime, Value, ROW_NUMBER() OVER (ORDER BY SampleTime) AS RowNum FROM TrendData WHERE TagName tag AND SampleTime BETWEEN start AND end ) SELECT SampleTime, Value FROM Ordered WHERE RowNum % step 0抽样步长的选择逻辑和聚合粒度一样核心是“最终绘制点数不要超过像素宽度太多”。实际测试下来2000 像素宽的画布超过 10000 个点再用 DrawLines 绘制就会出现明显的闪烁抽样是性价比最高的手段。3. C#控件内曲线绘制的核心实现与交互3.1 选择控件自绘 UserControl 还是用现成 Chart关于控件选型有一个真实结论WinForms 自带的Chart控件在快速缩放和平移时明显拖后腿DataPoint对象开销大、刷新慢。GDI 直接用DrawLines画曲线快一个数量级。Chart控件适合报表展示不适合需要实时交互的监控画面。做曲线自绘至少要有三层结构底层是数据源中间是坐标转换数据坐标到屏幕像素上层是绘制逻辑。坐标转换是整个自绘方案的地基它做不好缩放和平移全乱套。3.2 GDI 绘制曲线的核心代码把曲线画到画布上最关键的是坐标映射数据范围值域和时间范围到控件像素区域的线性映射。public class TrendChart : UserControl { private ListDateTime _times new(); private Listdouble _values new(); private DateTime _viewStart; private DateTime _viewEnd; private double _minValue; private double _maxValue; protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); if (_times.Count 0) return; Graphics g e.Graphics; g.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; // 计算 X 轴和 Y 轴的缩放比例 double totalSeconds (_viewEnd - _viewStart).TotalSeconds; if (totalSeconds 0) totalSeconds 1; double yRange _maxValue - _minValue; if (yRange 0) yRange 1; // 绘制背景网格 DrawGrid(g); var points new PointF[_times.Count]; for (int i 0; i _times.Count; i) { double xRatio (_times[i] - _viewStart).TotalSeconds / totalSeconds; float x (float)(Padding.Left xRatio * (Width - Padding.Left - Padding.Right)); float y (float)(Height - Padding.Bottom - ((_values[i] - _minValue) / yRange) * (Height - Padding.Top - Padding.Bottom)); points[i] new PointF(x, y); } using (var pen new Pen(Color.SteelBlue, 1.5f)) { g.DrawLines(pen, points); } } }这段代码把时间范围线性映射到控件的宽度区间Padding给坐标轴留出空间。注意DrawLines不像DrawLine画的是单段线它接收一个PointF[]数组在内部把所有点连起来效率远高于循环调用DrawLine。3.2.1 Y 轴自适应范围的细节_minValue和_maxValue不能直接用_values.Min()和_values.Max()因为可见区域只是全部数据的一个子集。应该按当前视图范围去取可见点的最小值和最大值并且留 5% 的余量避免曲线紧贴画布边缘private void UpdateViewRange() { if (_times.Count 0) return; int startIndex FindIndex(_viewStart); int endIndex FindIndex(_viewEnd); double min double.MaxValue, max double.MinValue; for (int i startIndex; i endIndex i _times.Count; i) { if (_values[i] min) min _values[i]; if (_values[i] max) max _values[i]; } double margin (max - min) * 0.05; _minValue min - margin; _maxValue max margin; }FindIndex可以用二分查找实现因为_times是按时间递增的。顺序遍历在数据量上来了之后会拖慢刷新速度尤其在拖动视图时每一帧都要调用一次。3.3 坐标轴自适应与缩放平移缩放和平移的本质是改变_viewStart和_viewEnd两个时刻然后触发Invalidate()让控件重绘。鼠标滚轮缩放的比例取 1.2 比较合适每滚动一格视图范围变化 20%。protected override void OnMouseWheel(MouseEventArgs e) { base.OnMouseWheel(e); double totalSeconds (_viewEnd - _viewStart).TotalSeconds; double centerRatio (double)e.X / Width; DateTime cursorTime _viewStart.AddSeconds(totalSeconds * centerRatio); if (e.Delta 0) { // 放大以鼠标为中心缩小时间范围 double newSeconds totalSeconds / 1.2; _viewStart cursorTime.AddSeconds(-newSeconds * centerRatio); _viewEnd _viewStart.AddSeconds(newSeconds); } else { double newSeconds totalSeconds * 1.2; _viewStart cursorTime.AddSeconds(-newSeconds * centerRatio); _viewEnd _viewStart.AddSeconds(newSeconds); } UpdateViewRange(); Invalidate(); }缩放的中心是鼠标位置而非画布中心这个体验差异很大。如果缩放中心固定在画布中心用户想看哪一段就要先把鼠标移过去再滚动操作效率低很多。平移一般用鼠标按住拖拽的方式实现。记录MouseDown时的起始点和对应的_viewStart在MouseMove时计算位移对应的秒数偏移private Point _dragStartPoint; private DateTime _dragStartViewTime; protected override void OnMouseDown(MouseEventArgs e) { if (e.Button MouseButtons.Left) { _dragStartPoint e.Location; _dragStartViewTime _viewStart; } base.OnMouseDown(e); } protected override void OnMouseMove(MouseEventArgs e) { if (e.Button MouseButtons.Left) { double totalSeconds (_viewEnd - _viewStart).TotalSeconds; double secondsPerPixel totalSeconds / (Width - Padding.Left - Padding.Right); double deltaSeconds (e.X - _dragStartPoint.X) * secondsPerPixel; _viewStart _dragStartViewTime.AddSeconds(-deltaSeconds); _viewEnd _viewStart.AddSeconds(totalSeconds); UpdateViewRange(); Invalidate(); } base.OnMouseMove(e); }一个容易忽略的细节拖拽时鼠标移动对应的方向是反向的。鼠标向右拖视图应该往左移动也就是时间起点提前所以代码里用负号。这个方向反了用户会觉得整个控件在跟手反着走。3.4 十字光标读取曲线值十字光标是曲线图鉴的重要工具。实现方法不是真的去画一个跨全窗的十字而是在MouseMove里记录鼠标位置在OnPaint里画一条竖线并实时找到该 X 坐标对应的值。protected override void OnMouseMove(MouseEventArgs e) { _mousePosition e.Location; Invalidate(); base.OnMouseMove(e); } // 在 OnPaint 末尾追加 if (_times.Count 0 _mousePosition.X Padding.Left) { using (var pen new Pen(Color.Gray, 1f) { DashStyle DashStyle.Dash }) { g.DrawLine(pen, _mousePosition.X, Padding.Top, _mousePosition.X, Height - Padding.Bottom); g.DrawLine(pen, Padding.Left, _mousePosition.Y, Width - Padding.Right, _mousePosition.Y); } // 根据 X 坐标反算时间找到最近的曲线点 double totalSeconds (_viewEnd - _viewStart).TotalSeconds; DateTime cursorTime _viewStart.AddSeconds(totalSeconds * (_mousePosition.X - Padding.Left) / (Width - Padding.Left - Padding.Right)); int nearestIndex FindNearestIndex(cursorTime); if (nearestIndex 0) { double value _values[nearestIndex]; g.DrawString(${value:F2}, Font, Brushes.Black, _mousePosition.X 10, _mousePosition.Y - 20); } }光标移动时整张控件都在重绘高频Invalidate会带来闪烁。处理方案有两个一是把十字光标改为全屏半透明的层叠窗口让鼠标移动只更新覆盖层二是用DoubleBuffered true开启双缓冲。前者效果好但实现成本高后者一行代码就能解决大部分闪烁问题项目里通常先用后者。3.5 多曲线绘制时的颜色管理画多条曲线时颜色选择要用预设的调色板而不是随机生成颜色。随机生成的 RGB 容易和背景色或相邻曲线撞车且深浅不一影响阅读。private static readonly Color[] Palette new Color[] { Color.SteelBlue, Color.OrangeRed, Color.ForestGreen, Color.DarkViolet, Color.SaddleBrown, Color.DeepSkyBlue, Color.HotPink, Color.Olive };超过 8 条曲线建议改用虚线、点划线等线型区分而不是继续加颜色。人对颜色的分辨能力有限在 1080P 屏幕上并排显示超过 8 种颜色的效果很差。线型加上色相的组合能扩展到 1624 路信号而不混淆。4. 曲线图进阶导出图片、双Y轴与性能检查4.1 导出高清图片的几个参数导出图片不能直接截屏因为控件的当前显示尺寸有限截图放大后就模糊了。应该用Bitmap按目标分辨率绘制绕开控件现有的OnPaint单独调用一个带尺寸参数的渲染方法。public void ExportToPng(string filePath, int width, int height) { using (var bitmap new Bitmap(width, height)) using (var g Graphics.FromImage(bitmap)) { g.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; g.TextRenderingHint System.Drawing.Text.TextRenderingHint.AntiAlias; g.Clear(Color.White); // 以目标尺寸重新绘制所有元素网格、曲线、坐标轴文字 RenderToGraphics(g, width, height); bitmap.Save(filePath, ImageFormat.Png); } }RenderToGraphics就是把OnPaint里的绘制逻辑抽出来接收一个Graphics对象和尺寸参数。导出 PNG 时注意两点SmoothingMode必须设置为 AntiAlias否则导出图的曲线边缘有明显锯齿导出分辨率按 2 倍屏计算即实际显示尺寸乘以 2保证在 Retina 屏上不模糊。PNG 是无损压缩适合曲线这种包含细线的图像JPG 压缩会产生振铃噪声。4.2 双Y轴画法双 Y 轴的场景很典型温度曲线和压力曲线单位不同量级差异大共用同一个 Y 轴时小量程的信号会被压成一条平线。正确做法是左右各建一个 Y 轴各配一套值的映射范围。private double MapValueToY(double value, double min, double max, int height) { double ratio (value - min) / (max - min); return height - Padding.Bottom - ratio * (height - Padding.Top - Padding.Bottom); }左 Y 轴对应主数据右 Y 轴对应辅助数据。绘制时给辅助曲线单独指定它所属的映射范围绘制坐标轴文字时也分别输出到左右两侧。需要注意图例里标明每条曲线对应的 Y 轴是左还是右否则用户会对曲线读数的正确性产生怀疑。4.3 性能检查清单最后列一份自查表用来判断你画出来的曲线控件是否达到交付标准检查项方法达标标准首次加载 10 万点耗时Stopwatch 计时低于 800ms缩放/平移后的刷新耗时在 OnPaint 里计时低于 30ms内存占用任务管理器或 dotMemory不随缩放次数持续增长CPU 占用拖拽平移时观察任务管理器不超过单核 50%文字锯齿放大 200% 查看坐标轴文字无锯齿且清晰OnPaint里耗时超过 30ms 用户就能感觉到顿挫超过 100ms 能明确感知到卡顿。如果刷新耗时超标优先检查有没有在OnPaint里做了数据库查询、文件 IO 或 LINQ 的OrderBy这些操作都不允许出现在绘制路径里数据查询必须提前完成并缓存结果。把图例和坐标轴文字绘制到一个离屏Bitmap上作为静态层只让曲线区域参与重绘是这个场景下性价比最高的优化手法改造后刷新性能通常能提升一倍以上。本文还有配套的精品资源点击获取