C#曲线绘制完整方案:SQL数据库查询到GDI+自绘控件
2026/9/14 14:29:09 网站建设 项目流程

简介:这是一份面向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 还是 SqlDataReader

DataTable 用起来方便,绑定数据源很顺手,但它的内存开销偏高。画曲线最终要把数据转成List<PointF>List<double>,经过 DataTable 中转其实是绕路。数据量小(几千行以内)感觉不明显,几十万行时 DataTable 需要额外的内存复制。

我一般这么处理:预估数据量超过 5 万行时用SqlDataReader直接流式读取,把值塞进List<DateTime>List<double>两个并行数组;数据量小的时候用 DataTable 也无妨,代码可读性优先。

public (List<DateTime> times, List<double> values) QueryTrendFast( string tagName, DateTime start, DateTime end) { var times = new List<DateTime>(); var values = new List<double>(); 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 Dictionary<string, 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 List<DateTime> _times = new(); private List<double> _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 种颜色的效果很差。线型加上色相的组合,能扩展到 16~24 路信号而不混淆。

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上作为静态层,只让曲线区域参与重绘,是这个场景下性价比最高的优化手法,改造后刷新性能通常能提升一倍以上。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询