简介:针对.NET平台的开源绘图库ScottPlot,提供了一份完整的WinForms图形展示演示包。该库以极为简洁的API实现折线图、柱状图、饼图、散点图以及贝塞尔曲线等大数据集交互式可视化,适合C#桌面应用开发者快速集成图表功能,也适用于数据分析工具、工业监控面板、科研实验数据展示等场景。资源共34个文件,压缩包约1MB,包含ScottPlot及其WinForms扩展的DLL、XML接口文档、PDB调试符号、示例输出的PNG图片,以及可直接运行的exe演示程序,下载解压后即可启动查看多种图表效果。附带的recipes.json、Cookbook等文件能帮助理解典型绘图模式,图片素材直观呈现不同图表样式与配色方案,便于对照代码二次开发。目前已有1554人学习下载,适合希望快速掌握ScottPlot基础用法、评估其图表展示能力或为WinForms项目选型的开发者。
1. ScottPlot 不是另一个 Chart 控件:WinForm 画图前先把账算清
做 .NET C# WinForm 的人,迟早要面对同一个问题:界面上的曲线、散点、柱状图怎么画。自带的 Chart 控件能跑通 demo,但真到上万点、要缩放要拖拽的场景,它就变成拖后腿的那个。ScottPlot 是专门干这个的轻量绘图库,几行代码就能出一张可缩放、可平移、可拾取坐标的图。
这里把「贝塞尔曲线展示」当主线,从控件接入、曲线采样、坐标换算,聊到实时刷新和拖拽编辑,把 ScottPlot 在 WinForm 里最高频的能力完整过一遍。适合两类人:被自带 Chart 折磨过的老项目维护者,以及第一次在 WinForm 里集成绘图、想少走弯路的入门者。
先给个结论:ScottPlot 上手比想象中平,坑基本集中在版本 API 差异和坐标换算上。后面每一章都有能直接抄的代码和参数。
2. 从 NuGet 到第一张图:控件接入、工具箱注册与四种高频图形的取舍
2.1 为什么选 ScottPlot:和自带 Chart 的真实差距
自带的 System.Windows.Forms.DataVisualization.Charting 在存量项目里很常见,但它有几个硬伤。第一是程序集引用绕,老项目里动不动就版本冲突;第二是默认样式过于「工业风」,标题、图例、网格都要写一大段配置才像样;第三是性能,超过几千个点就开始掉帧,做实时数据展示基本不可用。ScottPlot 的核心思路是把绘图做成纯渲染,而不是维护一个控件树,所以缩放、平移、坐标拾取都是内置的,十万个点用 Signal 类型也能保持流畅。
| 对比维度 | 自带 Chart 控件 | ScottPlot |
|---|---|---|
| 渲染方式 | 控件树 + GDI+ 重绘 | 直接绘制到画布 |
| 万点以上性能 | 明显掉帧 | Signal 类型流畅 |
| 缩放 / 平移 | 需要自己写事件 | 鼠标拖拽内置 |
| 样式定制 | 大量属性嵌套 | 链式 API 一行一个 |
| 维护状态 | 长期未更新 | 活跃迭代,4.x / 5.x 并存 |
选型时还有一点要提前定:5.x 用 SkiaSharp 渲染,高 DPI 和字体清晰度明显更好,API 也换了风格;如果项目被卡在 .NET Framework 4.7.2,4.x 还能用,功能差不多但写法不同。我一般以 5.x 为主,老项目才退回去。
2.2 最小 Demo:从 NuGet 安装到三行代码出图
接入步骤就三步:新建 WinForms 项目(.NET 6 或 8)→ 包管理控制台执行安装 → 工具箱拖入 FormsPlot 控件。
Install-Package ScottPlot.WinFormspublic partial class Form1 : Form { private ScottPlot.WinForms.FormsPlot formsPlot1; public Form1() { InitializeComponent(); DrawFirstCurve(); } private void DrawFirstCurve() { double[] xs = { 0, 1, 2, 3, 4, 5 }; double[] ys = { 1.2, 3.4, 2.1, 5.6, 4.3, 7.8 }; formsPlot1.Plot.Add.Scatter(xs, ys); // 5.x 写法 formsPlot1.Plot.Title("WinForm + ScottPlot"); formsPlot1.Plot.Axes.SetLimits(-0.5, 5.5, 0, 9); formsPlot1.Refresh(); } }逻辑说明:Add.Scatter 接收两组等长 double 数组,分别代表 X、Y 数据坐标,坐标系是数据坐标而不是像素。Title 设置标题,Axes.SetLimits 固定坐标范围,避免自动缩放后曲线贴边。最后一行 Refresh() 触发重绘,这行忘了图就不更新,是最常见的「黑匣子」时刻。
参数说明:如果只想画折线不画点,把 MarkerSize 设为 0;xs、ys 可以直接从 List 转数组,ScottPlot 不要求传入数组的底层类型必须是什么,只要是 double[] 就行。4.x 的对应 API 是 SetAxisLimits,别混着写。
2.3 四种高频图形:散点、折线、柱状、信号图的写法
// 1) 散点图:强调每个数据点本身 var scatter = formsPlot1.Plot.Add.Scatter(xs, ys); scatter.MarkerSize = 6; scatter.MarkerShape = MarkerShape.Circle; scatter.LineWidth = 0; // 只要点不要线 scatter.Color = Colors.RoyalBlue; // 2) 折线图:散点 + 连线,默认就是实线 var line = formsPlot1.Plot.Add.Scatter(xs, ys); line.LineWidth = 2; line.LineStyle = LineStyle.Dash; // 3) 柱状图:分类数据用 Bar var bar = formsPlot1.Plot.Add.Bar(new[] { 3.0, 5.0, 2.0 }, new[] { 1, 2, 3 }); bar.Color = Colors.OrangeRed; // 4) 信号图:等距采样数据的性能首选 var signal = formsPlot1.Plot.Add.Signal(yData, sampleRate: 20); signal.LineWidth = 1.5f;逻辑说明:Scatter 在 ScottPlot 里既能当散点图也能当折线图,区别只在 MarkerSize 和 LineWidth 的组合上。Bar 的第一组参数是柱子的值,第二组是位置,也就是第几根柱子。Signal 比较特殊:数据本身不带 X 坐标,库按 sampleRate(每个单位多少个样本)推断 X 轴跨度。
参数说明:Signal 的性能优势来自内部降采样,等距数据一定要用它,千万别用 Scatter 全量存点。sampleRate 直接影响 X 轴范围,1000 个点、sampleRate = 20,X 轴就是 50 个单位。这是个一改就影响整张图展示范围的关键参数。
3. 把贝塞尔曲线画进 ScottPlot:40 行 C# 采样实现与控制点分层绘制
3.1 贝塞尔曲线的数学基础:为什么不能直接画
贝塞尔曲线在 WinForm 里其实有两个画法:一是直接掉 Graphics.DrawBezier,但它只负责把像素路径画出来,拿不到曲线上的数据点,用户想拾取坐标、拖拽控制点就无从下手;二是把曲线转成点集再交给绘图库。ScottPlot 要的就是第二种,因为它的数据模型本质上是 X 数组 + Y 数组,任何曲线最终都要落到点集上。
核心公式就两个。二阶贝塞尔 B(t) = (1-t)²P0 + 2(1-t)tP1 + t²P2;三阶贝塞尔 B(t) = (1-t)³P0 + 3(1-t)²tP1 + 3(1-t)t²P2 + t³P3。P0、P3 是曲线端点,P1、P2 是控制点,控制点不在曲线上,但决定曲线往哪个方向弯。t 从 0 走到 1,曲线上每个点都能用这一组公式算出来。
采样密度怎么定?我的习惯是 t 均匀分成 n 段,n 取 100 对大多数屏幕足够;线段长或者控制点间距大导致曲率变化剧烈的组合,提到 200。不建议盲目设 1000,视觉上没有差别,刷新反而慢几倍。等距采样在曲率突变的地方会有轻微折痕感,但配合 LineWidth = 2 基本看不出来,没必要上自适应采样。
3.2 二阶与三阶采样:40 行 C# 代码
// 二阶贝塞尔曲线采样 public static List<PointF> SampleQuadBezier( PointF p0, PointF p1, PointF p2, int n = 100) { var pts = new List<PointF>(n + 1); for (int i = 0; i <= n; i++) { float t = i / (float)n; float mt = 1 - t; pts.Add(new PointF( mt * mt * p0.X + 2 * mt * t * p1.X + t * t * p2.X, mt * mt * p0.Y + 2 * mt * t * p1.Y + t * t * p2.Y)); } return pts; }// 三阶贝塞尔曲线采样:控制点 p1、p2 不经过曲线 public static PointF[] SampleCubicBezier( PointF p0, PointF p1, PointF p2, PointF p3, int n = 100) { var pts = new PointF[n + 1]; for (int i = 0; i <= n; i++) { float t = i / (float)n; float mt = 1 - t; pts[i] = new PointF( mt * mt * mt * p0.X + 3 * mt * mt * t * p1.X + 3 * mt * t * t * p2.X + t * t * t * p3.X, mt * mt * mt * p0.Y + 3 * mt * mt * t * p1.Y + 3 * mt * t * t * p2.Y + t * t * t * p3.Y); } return pts; }逻辑说明:mt = 1 - t 提前算出来是性能习惯,避免每个坐标分量重复计算。循环用闭区间 [0, n],保证 t 能取到 1,曲线首尾正好落在 P0 和 P3 上,不留缝。二阶版返回 List,三阶版返回数组,实际用的时候三阶更常见。
参数说明:n 控制在 50 到 200 之间。n 太小曲线会呈明显折线状;n 太大只对放大到极限时有细微差别,刷新成本却成倍上涨。返回值是 PointF,后面要转成两个 double[] 才能交给 ScottPlot。
3.3 在 ScottPlot 里呈现:曲线和控制点分两层画
PointF p0 = new(0, 0), p1 = new(1, 4), p2 = new(3, -1), p3 = new(5, 3); var pts = SampleCubicBezier(p0, p1, p2, p3, 120); double[] xs = pts.Select(p => (double)p.X).ToArray(); double[] ys = pts.Select(p => (double)p.Y).ToArray(); double[] cx = { p0.X, p1.X, p2.X, p3.X }; double[] cy = { p0.Y, p1.Y, p2.Y, p3.Y }; var curve = formsPlot1.Plot.Add.Scatter(xs, ys); curve.LineWidth = 2; curve.MarkerSize = 0; // 曲线不留点标记 curve.Color = Colors.Crimson; var ctrl = formsPlot1.Plot.Add.Scatter(cx, cy); ctrl.MarkerSize = 8; ctrl.LineWidth = 0; // 控制点只画点不连线 ctrl.Color = Colors.DarkGray; formsPlot1.Refresh();逻辑说明:先采样成折线再画,本质是用密集折线逼近曲线。曲线和控制点拆成两个 Scatter,后面做拖拽编辑时,只需要更新控制点数组并重新采样,一次 Refresh() 就能完成重绘。控制点用灰色和曲线区分,调试时一眼能看到曲线走向是哪几个点牵出来的。
参数说明:控制点散图的 MarkerSize 设 8 是为了后续鼠标拾取有足够命中面积。4.x 里同样的效果要把 Add.Scatter 换成 AddScatter,MarkerSize 属性名不变。代码需要 using System.Linq 和 using System.Drawing,PointF 在 System.Drawing 命名空间下。
4. 避坑手册:坐标偏移、DPI 模糊、掉帧与多线程的五条现场记录
4.1 鼠标点击位置和图上坐标对不上:坐标转换不是像素值
现象:鼠标点在曲线上,业务逻辑拿到的 X 值却差零点几甚至几个单位,越靠近绘图区边缘偏差越大。
原因:直接用 e.X、e.Y 控件像素坐标当数据坐标用了。ScottPlot 的绘图区有边距,轴标签、边框都占据像素空间,像素坐标必须经过 Plot 内部换算才能变成数据坐标。
解决:用库的坐标换算 API,4.x 用 formsPlot1.Plot.GetMouseX() 和 GetMouseY(),5.x 用 formsPlot1.Plot.GetCoordinates(e.X, e.Y) 拿到 Coordinates 结构。我踩过一次:在 MouseMove 里把 e.X 直接传给业务函数,调了半天才发现少了这一步换算。
4.2 高 DPI 屏幕下图形模糊:像被放大过的截图
现象:笔记本 150% 缩放下图表发虚,文字边缘有毛刺,和窗体里其他 UI 明显不在一个清晰度。
原因:WinForm 默认不感知 DPI,4.x 走 GDI+ 渲染,5.x 走 SkiaSharp,后者清晰度明显更好;程序入口如果没做 DPI 感知声明,整个窗口都会被系统位图拉伸,图表也跟着变糊。
解决:Program.cs 入口调用 SetProcessDPIAware(),或者在 app.manifest 里声明 PerMonitorV2;项目允许的话直接升 5.x 是最省事的。我一般两条同时做:manifest 声明加上 5.x,发布后不会再有「别的电脑看着糊」这类反馈。
4.3 数据点一多就掉帧:刷新时机比数据量更关键
现象:一万个点,拖动缩放时肉眼可见卡顿。
原因:用 Add.Scatter 全量存点,每次交互都触发全量重绘;或者后台每条数据到达都调 Refresh(),在 UI 线程堆了无谓绘制。
解决:等距数据改用 Add.Signal 或 Add.SignalXY,SignalXY 是带 X 数组的大数据版本,内部有降采样,几百万点也能撑住。刷新频率单独控制在 20 到 30 fps,不要每条数据都刷一次,积一批再画反而更顺。SignalXY 的参数是 xs、ys 两个 double[],X 数组也必须等距递增,这一点文档里写得不明显,容易踩。
4.4 后台线程更新图表:崩溃、花屏、数据错乱轮流来
现象:定时器或后台任务里直接改数据数组并调用 Refresh(),程序时不时崩,偶尔界面花屏。
原因:跨线程访问 UI 控件,控件被两个线程同时绘制,内部状态冲突。WinForm 的 UI 控件不是线程安全的,这个问题在数据量小的时候不发作,量一大就翻车。
解决:数据写入放后台线程,碰控件一律通过 SynchronizationContext 或 Control.BeginInvoke 回到 UI 线程执行。常见做法是后台只往队列写数据,UI 线程的 timer 定时取走再刷新,下一章会写这个模式的完整代码。
4.5 发布后别的机器打开图表空白:依赖没带全
现象:开发机一切正常,拷 exe 到别的机器,窗体能开,绘图区一片空白。
原因:5.x 依赖 SkiaSharp 的 native 资源,发布时没包含对应平台的原生库,渲染在目标机器上直接失败。
解决:用自包含发布,dotnet publish -r win-x64 --self-contained true,或者至少把 SkiaSharp 的 runtimes 文件夹一起拷贝。我一般还会在 csproj 里把内容拷贝策略设为 PreserveNewest,避免这种发布后空白的问题反复出现。
5. 进阶玩法:鼠标拾取、20ms 实时刷新与可拖拽的贝塞尔控制点
5.1 鼠标拾取坐标:跨版本写法与最近点计算
private void FormsPlot1_MouseMove(object sender, MouseEventArgs e) { // 5.x 写法:像素坐标 -> 数据坐标 Coordinates coord = formsPlot1.Plot.GetCoordinates(e.X, e.Y); labelStatus.Text = $"X = {coord.X:F2} Y = {coord.Y:F2}"; // 找曲线上离鼠标最近的数据点索引 int idx = FindNearestIndex(coord.X); labelIndex.Text = $"最近点索引 = {idx}"; }private int FindNearestIndex(double targetX) { double[] xs = _curveXs; int best = 0; double bestDist = double.MaxValue; for (int i = 0; i < xs.Length; i++) { double d = Math.Abs(xs[i] - targetX); if (d < bestDist) { bestDist = d; best = i; } } return best; }逻辑说明:GetCoordinates 是 5.x 的坐标换算入口,返回的 Coordinates 里有 X、Y 两个数据坐标。最近点查找是线性扫描,图上点多的时候够用;几十万点以上可以改二分查找,因为采样点的 X 是严格递增的。4.x 对应写法是 GetMouseX()、GetMouseY(),返回值是 double,用法一致。
5.2 实时刷新:20ms 一帧的队列消费模式
private readonly List<double> _samples = new(); private readonly object _sync = new(); private System.Windows.Forms.Timer _timer; // 后台采集线程只做这一件事:写数据 private void OnDataArrived(double v) { lock (_sync) { _samples.Add(v); if (_samples.Count > 6000) _samples.RemoveRange(0, 2000); // 丢头部,保留滑动窗口 } } // UI 线程定时器,间隔 20ms 取一次快照 private void OnTimerTick(object? sender, EventArgs e) { double[] snapshot; lock (_sync) snapshot = _samples.ToArray(); if (snapshot.Length < 2) return; formsPlot1.Plot.Clear(); formsPlot1.Plot.Add.Signal(snapshot, sampleRate: 20); formsPlot1.Refresh(); }逻辑说明:生产者只往队列写,UI 线程定时消费,两组线程在锁里交换数据,控件永远只在 UI 线程被触碰。RemoveRange 是防止队列无限增长的后悔药,丢头部不丢尾部,实时曲线窗口自然往前滚动。Clear 会清掉图例和所有图层,如果你叠画了多条曲线,5.x 的 Plot.Clear() 支持传类型参数按类型清除,只清 Signal 不清散点。
参数说明:sampleRate = 20 表示一个采样点占 1/20 个单位宽,窗口大小由数据量的上下限控制。定时器间隔 20ms 对应 50fps,和显示器刷新率匹配即可,不用更小。
5.3 把贝塞尔控制点做成可拖拽手柄
private PointF[] _ctrlPoints = Array.Empty<PointF>(); private int _dragIndex = -1; private void FormsPlot1_MouseDown(object sender, MouseEventArgs e) { var coord = formsPlot1.Plot.GetCoordinates(e.X, e.Y); for (int i = 0; i < _ctrlPoints.Length; i++) { float dx = _ctrlPoints[i].X - (float)coord.X; float dy = _ctrlPoints[i].Y - (float)coord.Y; if (dx * dx + dy * dy < 0.05f) // 阈值按坐标尺度调 { _dragIndex = i; break; } } } private void FormsPlot1_MouseMove(object sender, MouseEventArgs e) { if (_dragIndex < 0) return; var coord = formsPlot1.Plot.GetCoordinates(e.X, e.Y); _ctrlPoints[_dragIndex] = new PointF((float)coord.X, (float)coord.Y); RedrawBezier(); } private void FormsPlot1_MouseUp(object sender, MouseEventArgs e) { _dragIndex = -1; } private void RedrawBezier() { formsPlot1.Plot.Clear(); var pts = SampleCubicBezier( _ctrlPoints[0], _ctrlPoints[1], _ctrlPoints[2], _ctrlPoints[3], 120); double[] xs = pts.Select(p => (double)p.X).ToArray(); double[] ys = pts.Select(p => (double)p.Y).ToArray(); formsPlot1.Plot.Add.Scatter(xs, ys); formsPlot1.Refresh(); }逻辑说明:拖拽识别用平方距离判断,省掉开方。阈值 0.05 是数据坐标下的容差,我按 X 轴跨度 5 调的,坐标跨度变大就要等比放大这个值。MouseDown 命中控制点时记录索引,MouseMove 里实时更新控制点坐标并重新采样重绘,MouseUp 释放拖拽状态。这套交互把曲线从「展示」变成了「编辑器」。从那以后,我每次接 ScottPlot 图表需求,第一件事就是把坐标换算、刷新频率、拖拽命中这三个地基写进基类里,后面再叠曲线都不会乱。希望帮到你。
本文还有配套的精品资源,点击获取