简介:面向C# WinForm初学者的曲线编辑器开发演示工程,以自绘曲线面板为核心,展示在PictureBox控件中实现数据曲线显示、绘制与交互修改的完整思路。工程覆盖两种曲线绘制方法、曲线识别检测、外部TXT数据加载、关键帧数据点绘制及拖动改值等常用功能,适合软件/插件开发者在学习曲线控件、图表交互或编辑器框架时直接参考。压缩包共151个文件,60个cs源码文件为主体,配合csproj/sln工程文件、exe程序、pdb调试文件及少量txt配置说明等,整体约442KB,结构清晰,便于按需查阅。已有570人浏览学习,尤其适合需要快速上手WinForm自定义绘图与关键帧编辑逻辑的C#开发人员。
1. 从采集UI卡顿开始:用PictureBox搭建曲线编辑器面板
接触过几个C#上位机项目后,我发现曲线面板是最容易被低估的模块。刚开始做采集软件时,我直接用Chart控件,点数一多就卡顿,拖动缩放更是像幻灯片。后来我换成Winform里最常见的PictureBox做自绘,用GDI+在两个晚上实现了曲线显示、TXT数据导入、关键帧识别和拖动修改数值,整个工程就是对应的“DrawSpline”演示代码。它解决的不是“画一条线”的问题,而是让曲线面板在数据量变化时依然响应流畅、还能让用户直接编辑数据点。适合正在做C#入门项目、上位机采集界面或者编辑工具链的开发者参考。接下来按我自己拆这个Demo时的顺序,把绘制、命中和性能三块展开。
2. 两种曲线绘制方法与坐标换算:GDI+ DrawLines与DrawCurve
2.1 Paint事件与自绘基础
自绘曲线的第一件事是搞清楚PictureBox的绘制机制。很多初学者会直接在按钮点击里写pictureBox.CreateGraphics(),然后画完就没了。只要窗口被遮挡再恢复,图形就会消失。正确做法是重写OnPaint或者在Paint事件里绘制,每次Invalidate触发重绘时由系统传入PaintEventArgs,里面已经绑好了Graphics对象。这个Graphics对象由系统管理,不能在绘制方法里调用Dispose,否则后续绘制会异常。同时要在构造函数里开启DoubleBuffered双缓冲,否则刷新频率一高,窗口会像隔着水帘看屏幕一样闪烁。
public partial class CurvePanel : PictureBox { public CurvePanel() { DoubleBuffered = true; // 开启双缓冲,避免高频刷新闪烁 ResizeRedraw = true; // 控件尺寸变化时自动重绘 } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; // 打开抗锯齿 g.Clear(Color.White); } }这段代码里,DoubleBuffered在PictureBox默认是false,必须显式打开;ResizeRedraw让宽度/高度变化时自动调用Invalidate,否则缩放控件后曲线残影还在。SmoothingMode.AntiAlias会让斜线和曲线边缘平滑,缺点是有轻微性能损耗,数据点很多时可以去掉。
2.2 方法一:逐点连线 DrawLines
DrawLines方法适合等间隔采样的数据。它的做法是把所有数据点装进PointF[]数组,交给GDI+一次绘制成折线。这是最容易理解的方式,也是后面识别检测的基础,因为每两个相邻点之间就是一条直线段。
PointF[] points = new PointF[data.Count]; for (int i = 0; i < data.Count; i++) { points[i] = DataToScreen(data[i].X, data[i].Y, _plotRect, _range); } using (Pen pen = new Pen(Color.SteelBlue, 1.8f)) { g.DrawLines(pen, points); }注意:DrawLines要求points.Length至少为2,否则会抛ArgumentOutOfRangeException。Pen是IDisposable,用using包住。多个点一次性传入比循环DrawLine快得多,原因是GDI+减少了内部状态机的切换。如果数据本身就是等间隔时间序列,可以不传x,只传y值,然后用索引乘采样周期换算,这一步在坐标换算里处理。
2.3 方法二:平滑曲线 DrawCurve
想让曲线看起来像动画软件里的关键帧曲线,就不能用折线。Graphics.DrawCurve基于基数样条,会生成一条通过每个控制点的平滑曲线。它有一个tension参数,控制曲线在控制点处的张力:0是直线连接,0.5是默认值,大于1会明显过冲。
PointF[] controlPoints = keyFrames .Select(kf => DataToScreen(kf.Time, kf.Value, _plotRect, _range)) .ToArray(); using (Pen smoothPen = new Pen(Color.OrangeRed, 2f)) { g.DrawCurve(smoothPen, controlPoints, 0.5f); }这里第三个参数就是tension。对于关键帧数据,通常点数很少(几个到几十个),DrawCurve的计算量可以忽略。但要注意,DrawCurve绘制出来的曲线仍然经过所有控制点,只是端部方向由前两个点和后两个点决定,视觉上不会特别突兀。绘制逻辑比DrawLines复杂,识别检测时不能直接用直线段距离公式,需要把曲线转换成密集折线再检测,或者在每个控制点附近放宽阈值。
2.4 数据坐标到屏幕坐标的换算
绘制和数据编辑不能绕开坐标换算。数据坐标系里x可能是时间,y可能是温度/电压;屏幕坐标系里x从左往右,y从上往下。换算不对,画出来的图形就是上下颠倒或者偏移。我习惯先定义一块绘图区域_plotRect,把网格、曲线、关键帧都限制在这个矩形里。
private RectangleF _plotRect = new RectangleF(40, 20, 520, 260); private PointF DataToScreen(float x, float y, DataRange range) { float sx = _plotRect.Left + (x - range.MinX) / (range.MaxX - range.MinX) * _plotRect.Width; float sy = _plotRect.Bottom - (y - range.MinY) / (range.MaxY - range.MinY) * _plotRect.Height; return new PointF(sx, sy); } private PointF ScreenToData(PointF p, DataRange range) { float dx = (p.X - _plotRect.Left) / _plotRect.Width * (range.MaxX - range.MinX) + range.MinX; float dy = range.MinY + (_plotRect.Bottom - p.Y) / _plotRect.Height * (range.MaxY - range.MinY); return new PointF(dx, dy); }DataToScreen里,y方向用_plotRect.Bottom减去插值结果,是因为屏幕y轴向下;ScreenToData是它的逆过程,拖拽关键帧时需要把鼠标坐标还原成数据坐标。实际项目里range.MinX和range.MaxX要防止相等,否则会出现除零,最好在外部做一次判断。
| 绘制方式 | 是否经过原始数据点 | 视觉特点 | 性能表现 | 适合场景 |
|---|---|---|---|---|
| DrawLines | 是 | 折线、保留跳变 | 快,适合10万点以内 | 实时波形、等间隔采集 |
| DrawCurve | 是(控制点) | 平滑、有张力 | 慢一些,适合上百点 | 关键帧曲线、动画编辑 |
3. 曲线识别检测与关键帧拖动:像素级命中判定
3.1 曲线识别检测的本质
曲线识别检测不是做图像识别,而是在鼠标点击位置判断当前是否落在某条曲线附近,以及落在曲线的哪一个位置。这个功能在做曲线编辑器时很重要:用户想找到曲线上某个点,或者要点击选中曲线,都需要一个稳定的命中判定。
常见做法是把这个过程拆成两步。第一步,把鼠标坐标和曲线数据点都转换到屏幕坐标;第二步,遍历相邻两个数据点之间的线段,计算鼠标到线段的距离。如果最小距离小于设定的阈值,通常3到5个像素,就认为命中。阈值太小点击难度大,太大会误触,实际开发里我一般设4像素。
private static float DistancePointToSegment(PointF p, PointF a, PointF b) { float dx = b.X - a.X; float dy = b.Y - a.Y; if (dx == 0 && dy == 0) return Distance(p, a); float lenSq = dx * dx + dy * dy; float t = ((p.X - a.X) * dx + (p.Y - a.Y) * dy) / lenSq; t = Math.Clamp(t, 0f, 1f); float projX = a.X + t * dx; float projY = a.Y + t * dy; return (float)Math.Sqrt((p.X - projX) * (p.X - projX) + (p.Y - projY) * (p.Y - projY)); } private int HitTestLineSegments(PointF mouse, PointF[] pts, float threshold) { if (pts.Length < 2) return -1; for (int i = 0; i < pts.Length - 1; i++) { if (DistancePointToSegment(mouse, pts[i], pts[i + 1]) <= threshold) return i; } return -1; }投影参数t表示鼠标在线段上的投影点占整段的比例,Math.Clamp把它限制在0到1之间,这样即使鼠标在线段延长线上,也不会误判。Math.Clamp在.NET Core及以上的项目可以直接用,如果是.NET Framework需要手写if (t < 0f) t = 0f; if (t > 1f) t = 1f;。如果曲线是用DrawCurve画出来的,可以在绘制前把曲线按0.01步长采样成密集折线,再用同一套函数检测,代价是采样点数变多,但几十个控制点的曲线采样后也只有几千个点,性能没问题。
3.2 关键帧数据点的绘制与选中状态
关键帧是独立的控制数据,它们和渲染用的密集点不是同一个集合。关键帧通常只有几十个点,需要单独绘制成小方块或圆形,方便鼠标抓取。绘制时要做两件事:一是根据数值范围换算坐标,二是用填充色区分选中状态。
private void DrawKeyFrames(Graphics g, List<KeyFrame> frames, int selectedIndex) { foreach (var kf in frames) { PointF pt = DataToScreen(kf.Time, kf.Value, _range); RectangleF rect = new RectangleF(pt.X - 4, pt.Y - 4, 8, 8); Brush fill = selectedIndex == frames.IndexOf(kf) ? Brushes.Orange : Brushes.White; g.FillRectangle(fill, rect); g.DrawRectangle(Pens.DimGray, rect.X, rect.Y, rect.Width, rect.Height); } }8像素半宽是关键帧编辑的常用尺寸,太小不容易点中,太大遮住曲线。绘制顺序上,关键帧应该画在曲线之后,否则会被曲线盖住。frames.IndexOf(kf)在列表较大时会遍历查找,关键帧一般不超过几十个,没必要专门维护索引。
3.3 鼠标拖动修改关键帧数值的完整流程
拖动的核心是三个事件配对使用。MouseDown负责命中检测,MouseMove负责更新数据,MouseUp负责结束拖动。这里需要注意的是,拖动过程中如果鼠标移出控件,默认不会继续触发MouseMove,所以要设置Capture = true。
private int _dragIndex = -1; protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); if (!EnableKeyFrameEdit) return; _dragIndex = HitTestKeyFrames(e.Location, 5f); if (_dragIndex >= 0) { Capture = true; // 捕获鼠标,移出控件也能继续跟踪 Invalidate(); } } protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (_dragIndex >= 0) { PointF dataPos = ScreenToData(new PointF(e.X, e.Y), _range); dataPos.Y = Clamp(dataPos.Y, _range.MinY, _range.MaxY); keyFrames[_dragIndex].Value = dataPos.Y; KeyFrameModified?.Invoke(this, new KeyFrameModifiedEventArgs(_dragIndex, dataPos.Y)); Invalidate(); } else { Cursor = HitTestKeyFrames(e.Location, 6f) >= 0 ? Cursors.SizeNS : Cursors.Default; } } protected override void OnMouseUp(MouseEventArgs e) { base.OnMouseUp(e); _dragIndex = -1; Capture = false; }这里的KeyFrameModified是一个C#事件,外部可以通过它把修改后的值写回数据源。在MouseMove里只改Value不动Time,这样时间轴顺序不会被破坏。如果想让用户自由拖动时间轴,需要额外做排序和撤销逻辑,演示工程一般不处理。命中关键帧时把光标切成SizeNS,提示用户当前可以上下拖动。
| 鼠标状态 | 命中关键帧 | 动作 | 数据变化 |
|---|---|---|---|
| MouseDown | 是 | 记录_dragIndex | 无 |
| MouseMove | 拖动中 | 修改关键帧Value | 值改变并触发事件 |
| MouseMove | 未拖动 | 切换Cursor | 无 |
| MouseUp | 拖动结束 | 清空_dragIndex | 无 |
3.4 边界与撤回
实际使用中,关键帧拖动会遇到三个边界问题。第一个是数值越界,需要在MouseMove里把Y值限制在范围区间;第二个是多个关键帧重叠,命中后应该优先选中数组中靠后的那个,因为后绘制的覆盖先绘制的;第三个是撤销,简单方案是把拖动开始前的值保存到一个栈里,MouseUp时压栈,Ctrl+Z时弹出。演示工程里通常会预留这个接口,但不会做完整的撤销系统。
另外,命中检测和后续的数值修改都要用同一组坐标换算函数。如果绘制用一套换算,点击检测又写一套,用户会很快发现“点到旁边了但曲线没选中”。这一点在自绘控件里是排查交互问题的最快路径。
4. 读取外部TXT数据并按曲线显示:刷新性能与降采样处理
4.1 TXT数据格式约定
演示工程支持外部TXT数据导入。数据文件的最常见格式是每行两个数值,用逗号、空格或Tab分隔。第一列是时间,第二列是对应的数值。为了让解析函数健壮,我会忽略空行和以#开头的注释行。
private List<PointF> LoadDataFromFile(string path) { var points = new List<PointF>(4096); foreach (var line in File.ReadLines(path)) { if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#")) continue; string[] parts = line.Split( new[] { ' ', '\t', ',' }, StringSplitOptions.RemoveEmptyEntries); if (parts.Length < 2) continue; float x = float.Parse(parts[0], CultureInfo.InvariantCulture); float y = float.Parse(parts[1], CultureInfo.InvariantCulture); points.Add(new PointF(x, y)); } return points; }File.ReadLines是流式读取,不需要像ReadAllText那样把整个文件加载进内存。用CultureInfo.InvariantCulture是因为有些系统区域设置里小数点分隔符是逗号,会导致float.Parse崩溃。如果文件行数特别多,先给List一个容量能减少扩容次数。
4.2 大数据量下的抽稀与降采样
数据文件可能包含几十万行。GDI+画10万个点虽然也能跑,但全屏重绘时每一帧都要遍历所有点,CPU占用很高,拖动关键帧时会感觉不跟手。这个时候必须先降采样。最简单的方式是每隔N个点取一个,保留整体趋势。
private List<PointF> SimplifyByStep(List<PointF> source, int step) { var result = new List<PointF>((source.Count + step - 1) / step); for (int i = 0; i < source.Count; i += step) result.Add(source[i]); return result; }如果数据波动幅度大,等间隔抽稀容易丢掉尖峰,我一般会把step抽稀改成按屏幕像素列聚合:把屏幕宽度分成N列,每一列只保留该列数据里的最小值和最大值,然后用两次DrawLines画上下包络。这样视觉上峰值不会被抹掉。演示工程里可以先跑通step方式,再看数据形态决定要不要换成min-max聚合。
注意:TXT导入之后,还需要重新算一次数据范围用于坐标换算。不要直接用文件的原始范围,因为用户可能只想看其中一段,范围要在DataRange结构里单独维护。
4.3 数据采集与UI刷新卡顿的常见解法
曲线编辑器经常要接采集线程。很多C#上位机新手会在后台循环里直接操作PictureBox,结果界面卡成黑屏。根本原因是UI控件只能在UI线程访问,而采集线程在疯狂请求重绘,又和消息循环抢时间。
我采用的模式是:采集线程只负责把数据丢进ConcurrentQueue,UI线程用WinForms的Timer定时从队列里取数据并刷新。这样采集端和渲染端完全解耦。
private ConcurrentQueue<float> _sampleQueue = new ConcurrentQueue<float>(); private List<PointF> _displayData = new List<PointF>(); private void StartCollecting() { Task.Run(() => { while (_isCollecting) { float sample = ReadDevice(); _sampleQueue.Enqueue(sample); Thread.Sleep(2); } }); } private void refreshTimer_Tick(object sender, EventArgs e) { while (_sampleQueue.TryDequeue(out float v)) { _displayData.Add(new PointF(_timeCursor, v)); _timeCursor += 0.01f; } if (_displayData.Count > 20000) _displayData.RemoveRange(0, _displayData.Count - 20000); Invalidate(); }Timer间隔通常设20ms,也就是50帧每秒。如果取数太快,队列里还会积压,所以RemoveRange限制了显示点数的上界。这里没有用Invoke直接跨线程访问控件,因为队列消费已经发生在UI线程的Timer事件里,避免了Invoke的繁琐和死锁风险。
| 刷新方案 | 线程模型 | 优点 | 需要注意 |
|---|---|---|---|
| Timer + ConcurrentQueue | 采集线程入队,UI线程出队 | 线程安全、代码清晰 | 队列积压要处理 |
| BackgroundWorker + ReportProgress | 采集线程报告进度 | 写法简单 | 大数据量时ProgressChanged过于频繁 |
| Task.Run + Control.Invoke | 直接跨线程更新 | 实时性高 | 高频Invoke会拖慢UI |
| 全量Invalidate | 只在UI线程自绘 | 最简单 | 点数多时CPU占用高 |
4.4 让界面“看起来不卡”的小细节
除了降采样和队列,还有两个容易被忽略的点。一是在OnPaint里尽量少创建Pen和Brush,可以预先创建好,按需换颜色;二是把背景网格和曲线分成两层,网格在初始化时画成Bitmap缓存,曲线每次只用清空曲线区域再重画。这个缓存技巧在点数量极大的时候特别明显,DrawSpline项目里也保留了这两个优化接口。
5. 从演示工程到上位机复用:封装UserControl与ScottPlot对比
5.1 把PictureBox封装成UserControl
演示工程能跑之后,下一步是把它变成上位机可以复用的组件。直接继承PictureBox会有两个问题:一是使用方很难把它和另一个PictureBox区分,二是外部无法控制内部的关键帧编辑状态。常见做法是改成继承UserControl,内部把绘制逻辑提到OnPaint里。
public class CurveEditor : UserControl { public bool EnableKeyFrameEdit { get; set; } = true; public Color CurveColor { get; set; } = Color.SteelBlue; public event EventHandler<KeyFrameModifiedEventArgs> KeyFrameModified; protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); // 绘制曲线、关键帧、网格 } }这样外部引用时和普通控件一样拖到窗体上,属性窗口也能直接改。UserControl本身就带设计器支持,比直接绘制PictureBox更规范。
5.2 事件驱动回传修改结果
C#事件在这个场景里就是数据出口。拖动关键帧修改的是控件内部持有的数据副本,外部数据源需要知道某个值被改了,才能做保存或联动。我会在MouseUp时触发一次KeyFrameModified事件,并携带索引和新值,而不是在MouseMove里连续触发,避免外部做重复计算。
5.3 与ScottPlot对比后的选型边界
有同行会用ScottPlot来替代自绘。ScottPlot交互丰富,缩放平移开箱即用,普通显示场景足够了。但如果要做的不是单纯显示,而是让用户拖动关键帧、自定义命中规则、对曲线局部做编辑,自绘UserControl反而更好控。这个演示工程的意义在于,C#上位机项目里“显示数据”和“编辑数据”常常是一体的,现成图表库在自定义交互上反而难扩展。
5.4 验证渲染耗时的一个技巧
最后推荐一个调试手段:在OnPaint里用Stopwatch统计绘制耗时,显示在窗体的Text上。这样能直观看到每次Invalidate花了多少毫秒,定位是降采样前慢还是绘制样条慢。
protected override void OnPaint(PaintEventArgs e) { var sw = System.Diagnostics.Stopwatch.StartNew(); base.OnPaint(e); DrawGrid(e.Graphics); DrawCurve(e.Graphics); DrawKeyFrames(e.Graphics); sw.Stop(); ParentForm?.Text = $"Render {sw.Elapsed.TotalMilliseconds:F2} ms"; }如果Render时间超过20ms,就需要降采样或者减少抗锯齿。这是最直接验证性能的方式。另外,命中检测和绘制一定都走同一组坐标换算函数,这样屏幕上的点和鼠标点击的位置才会严格对齐。
本文还有配套的精品资源,点击获取