简介这是一套面向C#初学者的实时曲线图绘制Demo演示如何基于Chart控件实现数据动态可视化适用于数据分析、监控显示或GUI开发等需要曲线展示的场景。压缩包共31个文件整体仅55KB包含9个C#源文件、解决方案、项目配置与窗体设计文件另附少量exe、pdb等编译产物结构清晰可在Visual Studio中直接打开运行。目前已有1597人学习或下载是入门实时绘图较为实用的小例子。资源内含完整的Windows Forms示例覆盖Chart控件基本配置、Series数据系列与坐标轴调整、通过AddXY动态追加数据点、利用定时器模拟实时数据、移除最旧数据点形成平滑滚动效果等关键写法并带有模拟数据生成逻辑同时提供编译好的可执行文件便于观察实际效果可帮助读者快速理解从界面搭建到曲线刷新的完整实现思路并进一步扩展出多曲线或更复杂的监控图表。1. 一个实时曲线图DemoC#里最容易被低估的活儿从设备监控界面到信号采集上位机几乎每个做 C# 桌面端的工程师都接到过同一个需求把实时数据画成动态曲线。有人觉得拖一个 Chart 控件进去就行可一旦数据频率上来画面开始闪烁、卡顿、时间轴漂移才意识到 C# 绘制实时曲线图 Demo 的难点根本不在画一条线而在画线之前的数据缓存、刷新调度和重绘成本。我自己做过几轮这类开发从 GDI 手绘到封装成自定义控件都试过今天把这些经验按一条可复现的路径讲清楚适合刚接手上位机监控、实验数据可视化或者任何需要高频刷新的桌面端开发者。2. 自绘还是现成控件实时曲线图的第一道选择题许多刚接触实时曲线的开发者第一反应是拉一个 Chart 控件绑一个数据源让它自己刷新。这确实能快速做出像样的静态图表但当你把刷新频率提到 10Hz 以上就发现图表控件像一个黑匣子缩放、轴重算、图例、默认动画全在替你干活每一项都叠加在重绘开销里。做某图像处理 Demo 时我需要把 50Hz 的图像灰度均值实时画出来用现成图表控件怎么调都有肉眼可见的掉帧换成 GDI 自绘之后第一版就稳了。所以这里先把选型边界讲清楚再给一组能跑起来的最小自绘代码。2.1 为什么我优先选 GDI 自绘而不是现成图表库选型标准不是功能多少而是你能否控制每次重绘的成本。实时曲线的核心动作只有三个取最新数据、把数值换算成像素、把折线画出来。用 GDI 自绘每一帧做了什么一眼能看穿用现成图表库你更新数据时它还会额外做图例、坐标轴刻度的自动排版、鼠标命中测试这些在纯展示场景下你并不需要却全部摊进了每次重绘。换句话说功能越全的控件实时性越打折。当然自绘的代价是要自己处理刻度、网格和边距这些静态图表库已经处理得很好。如果你的数据一秒只刷新几次或者用户要频繁缩放、查看提示框那就用现成控件更省如果你的场景是持续高速流转的数据监控先把自绘方案作为默认。我做判断时习惯用一个比较实用的拆分把「数据本身是否稳定易预测」和「刷新频率是否超过 10Hz」当作两个条件两个条件不满足任何一条才考虑现成控件。我习惯用一张表来判断边界判断维度现成图表库GDI 自绘静态报表展示好用省事可用但需要自己写坐标轴10Hz 以下实时刷新可以注意别开多余动画更可控50Hz 以上连续刷新容易掉帧推荐重绘路径自己控制需要缩放、取点提示优势明显需要额外实现数据格式自定义要先转换数据源任意结构都可以这张表不是否定现成图表库而是告诉你什么时候值得自己动手。实时曲线一旦超过某个频率最大的瓶颈不是画线本身而是框架层替你做的那些“增值功能”。自绘时我只需要一个 Graphics 对象没有中间层。2.2 最小自绘方案一个 Panel 画出第一条曲线在 WinForms 里自绘最直接的宿主是 Panel。给它挂上 Paint 事件在事件里用 Graphics 对象画线然后调用 Invalidate 让区域持续刷新。下面这段代码先画一条固定正弦曲线不接定时器先走通坐标换算private void PnlChart_Paint(object sender, PaintEventArgs e) { Graphics g e.Graphics; g.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; g.Clear(Color.White); int w pnlChart.Width; int h pnlChart.Height; int n 100; // 采样点数量先写死 float dx (float)(w - 20) / (n - 1); // 左右各留 10px 边距 double[] data new double[n]; for (int i 0; i n; i) { data[i] 5 4 * Math.Sin(i * 0.15); // 模拟数据范围约 1~9 } PointF[] points new PointF[n]; for (int i 0; i n; i) { float x 10 i * dx; // 数据范围 [1,9] 映射到像素 [10, h-10] float y 10 (float)((9 - data[i]) / (9 - 1) * (h - 20)); points[i] new PointF(x, y); } using (Pen pen new Pen(Color.SteelBlue, 2f)) { g.DrawLines(pen, points); // 一次调用画完整条折线 } }这里几个关键参数以后都会用到。dx 是把数据下标换算成屏幕坐标的步长如果你要做随时间横滚这里要换成按时间戳计算的横轴位置。y 的换算里(9 - data[i])是先归一化再乘可用高度这样数据变大曲线向上走符合仪表视觉习惯。最后 using 包裹 Pen 是为了及时释放 GDI 句柄WinForms 下 Pen 和 Brush 泄漏是重绘次数的线性放大后面避坑章节还会提。这段代码跑起来后你会发现它只画了一次。让界面连续重绘需要调用控件的 Invalidate 方法。这里提前建立一个思维习惯绘制事件描述“画什么”定时器或数据变更决定“什么时候画”。两者解耦是后面一切优化的大前提。2.3 双缓冲是底线不是优化上面代码不接刷新还能看一旦接上高频刷新画面就会闪。闪烁的根源是 Windows 先擦掉旧画面再调 Paint 画新画面擦和画的间隙里用户看到了背景色刷新越快越明显。解决手段叫双缓冲先把内容画到内存位图再一次性把位图贴到屏幕用户看不到中间状态。要开启它需要自定义一个 Panel 子类因为 DoubleBuffered 在设计器里默认不可见。public class ChartPanel : Panel { public ChartPanel() { DoubleBuffered true; // 重绘先画到后台缓冲区 SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer, true); ResizeRedraw true; // 窗体大小变化时自动重绘 } }AllPaintingInWmPaint把擦除和绘制合并成一个 Windows 消息不再先擦旧图OptimizedDoubleBuffer进一步减少颜色翻转。ResizeRedraw如果不设窗体拉伸时旧的边缘会残留。双缓冲技术不是为实时曲线发明的GDI 之前游戏开发就在用放在这里的作用就是给你一个不闪的底座。有些人以为双缓冲会消耗内存其实每帧只是多了一次位图拷贝对几百像素宽的曲线面板来说开销是几十微秒级完全可以接受。它是底线不是可选项。3. 让曲线按固定节奏动起来数据源、定时器与重绘调度有了能画线的 Panel下一步是解决“数据从哪来”和“什么时候重绘”。Demo 里最容易走的两条弯路要么把数据读取和重绘都放在 UI 线程里轮询界面被拖卡要么直接开一个后台线程循环改控件属性跨线程异常找上门。我会把系统拆成两层采集线程只负责把数据推进共享缓冲区UI 用 Timer 定期把缓冲区数据画到屏幕。数据到达频率和画面刷新频率可以不一致比如数据每秒来 100 条画面每秒只重绘 30 次。3.1 用 System.Windows.Forms.Timer 驱动刷新为什么不用线程System.Windows.Forms.Timer 并不是真正独立的定时线程它依赖 UI 消息循环触发 Tick。好处是 Tick 内部可以直接调用控件方法避免跨线程问题。常见的替代方案是后台线程里做 Thread.Sleep 循环但那会在需要刷新时面临控制权切换的复杂局面如果直接把循环写在前台 UI 线程里连续 Sleep 会阻塞消息队列窗体拖动都成问题。所以在 WinForms 的惯例里30~60 FPS 的界面刷新用窗体级 Timer 是成本最低的。System.Windows.Forms.Timer renderTimer new System.Windows.Forms.Timer(); renderTimer.Interval 33; // 约 30 FPS每帧间隔 33ms renderTimer.Tick RenderTimer_Tick; renderTimer.Start();Interval 默认单位是毫秒但你千万别把它当成精确节拍器。它受消息循环负载影响一次 Tick 到下一次可能间隔 50ms。因此凡是用时间决定横轴位置的场景都不能用 Tick 次数换算要使用采集端记录的真实时间戳。Tick 里执行的代码越少越好最好的情况只有一行private void RenderTimer_Tick(object sender, EventArgs e) { pnlChart.Invalidate(); // 只标记需要重绘不直接 Paint }Invalidate 和 Paint 的区别很重要。Invalidate 只是把控件标记为“脏”Windows 会在适当的时机集中处理绘制消息如果你在同一帧里调用两次 Invalidate它们会合并成一次重绘。如果不小心在循环里直接调用了 Refresh 或控件的 Update 方法就会强制同步刷新把定时器的节拍打乱曲线看起来一顿一顿。3.2 用环形缓冲存最新 N 个点波形才能横滚曲线要在屏幕上滚动不能用一个无限增长的 List。我一般用固定容量的 Queue 保存最近 N 个点新数据入队最老的数据出队。这样画面永远只显示最近一段时间窗口内的波形。这个结构的时间复杂度是 O(1)不随窗口长度增长。private readonly object dataLock new object(); private Queuedouble dataQueue new Queuedouble(); private int maxPoints 500; public void AppendData(double value) { lock (dataLock) { dataQueue.Enqueue(value); if (dataQueue.Count maxPoints) dataQueue.Dequeue(); // 弹出最老的点保持窗口长度 } }这里的锁是必须的因为数据采集线程在写UI 绘制线程在读不加锁会出现集合被同时修改的异常甚至让整个程序崩溃。锁的使用也要克制采集线程只在入队和出队时持有不参与任何绘制绘制线程只在拷贝快照时持有不做耗时操作。这样即使采集频率很高锁的竞争时间也极短。maxPoints 的值直接影响横轴显示多少波形它由采样率乘以显示窗口决定。比如采样率 50Hz、显示最近 10 秒就设 500显示窗口太长曲线会挤在一起太短则看不到趋势。3.3 数据范围自适应先扫一遍 buffer 再画数据上下限随时会变Y 轴不能写死。每次绘制前要扫一遍当前缓冲区找到最大值最小值再在上下各留一定边距。这样信号走向不明的采集量也能稳定地显示在画面中。扫描的成本是 O(N)N 等于曲线点数500 个点每帧扫一遍完全没问题如果上千点还可以进一步优化但从真实项目看500 到 2000 个点都不是瓶颈。Listdouble snapshot; lock (dataLock) { snapshot new Listdouble(dataQueue); } if (snapshot.Count 0) return; double min double.MaxValue; double max double.MinValue; for (int i 0; i snapshot.Count; i) { if (snapshot[i] min) min snapshot[i]; if (snapshot[i] max) max snapshot[i]; } if (max - min 1e-9) { min - 1; max 1; } else { double margin (max - min) * 0.1; min - margin; max margin; } PointF[] points new PointF[snapshot.Count]; int w pnlChart.Width; int h pnlChart.Height; float topMargin 10; float bottomMargin 10; float usableH h - topMargin - bottomMargin; for (int i 0; i snapshot.Count; i) { float x (float)i / (snapshot.Count - 1) * w; float y topMargin (float)((max - snapshot[i]) / (max - min)) * usableH; points[i] new PointF(x, y); }上面把最小值和最大值扫进同一个循环避免用 LINQ 的 Min 和 Max 多遍历一次。边距 margin 取 10% 是一个常用值太大曲线会缩在中间太小曲线顶到边缘。另一个细节是min 和 max 的统计必须在锁外进行这里先拷贝快照再释放锁再在快照上统计是更稳的写法。如果你后续要支持多条曲线只要对每个数据集各自做极值扫描或者共用同一组上下限看需求取舍。3.4 采集频率不固定时给数据打时间戳有些采集设备的频率并不可控比如网络包到达时间不固定、传感器偶尔丢点。这种情况下只按点序号画横轴时间轴就是假的。正确做法是把数据和到达时刻打包成一条记录绘制时用时间戳决定 X 坐标。常见做法是做一个小的值对象public readonly struct DataPoint { public readonly double Value; public readonly double Timestamp; // 单位秒由采集端写入 public DataPoint(double value, double timestamp) { Value value; Timestamp timestamp; } }AppendData 接这个结构队列类型换成 Queue 。绘制时横轴不是等分的下标而是 timestamp 减去起始时间再映射到控件宽度。这样做有两个好处数据延迟或丢点不会导致波形整体平移因为每个点有自己的真实位置即使定时器偶尔跳帧曲线时间轴依然稳定。代价是代码从“按点序画”变成“按时间画”复杂度增加不多但对实时监控类项目很值得。4. 实时曲线图的 4 个常见坑闪烁、漂移、DPI 与线程这一章聊的是我调实时曲线时反复遇到的四类问题每一条按“现象 → 原因 → 解决”写。这些坑单独来看都不大但组合起来足以让一个看似简单的 Demo 怎么都跑不顺。顺序是从界面到数据先解决画得稳再解决画得准最后解决换环境还能不能看。4.1 现象波形闪烁得像霓虹灯DoubleBuffered 设了也没用如果双缓冲已经打开闪烁还是存在大概率是背景擦除和绘制没有合并。WinForms 的默认背景擦除发生在 Paint 之前刷新频率一高背景反复被擦成白色看起来就是闪。解决方式是在自定义控件里增加 UserPaint 样式SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true);UserPaint 表示这个控件的绘制完全由自己接管不需要系统再画默认背景。同时检查 Paint 事件里是不是每次都创建了大型 Bitmap、读取字体或做文件 IO这些操作会拉长两帧之间的空白让闪烁更明显。GDI 对象的创建和销毁也值得警惕Pen、Brush 能缓存的缓存不能缓存的用 using 保证释放。除了绘制本身还要确认窗口内其他控件没有高频触发 Invalidate多个控件交错重绘时闪烁会互相传染。把曲线绘制收敛到一个自定义 UserControl 里能有效隔离这种问题。4.2 现象曲线越画越歪横轴时间对不上最隐蔽的一次翻车是采集 30 秒后波形整体向右或向左偏移了半个屏幕。原因是 Timer 的 Tick 间隔不稳定有人用“点的序号”当横轴有人每次刷新取 DateTime.Now 当横轴两种做法都会积累误差。前者把时间当匀速后者把每次 Tick 的抖动直接写进时间。解决思路是横轴时间应该由数据源决定而不是显示刷新决定。采集端拿到数据时记录时间戳绘制端用这个时间戳计算 X 坐标DateTime.Now 只在计算启动偏移量时用一次不参与逐点定位。如果采集器本身给定时钟就直接使用它如果数据是均匀采样可以在队列里记录起始时间和采样间隔推导出每个点的时间。我一般把这条规则写死在控件接口注释里防止后来者又拿帧序号当时间。4.3 现象高 DPI 下刻度文字变糊曲线边缘发虚在 100% 缩放的台式机上一切正常换到 150% 缩放的笔记本上曲线边缘和文字全糊了。原因是程序没有声明 DPI 感知Windows 用位图整体拉伸来放大界面GDI 画出来的细线被放大后自然虚。解决的第一步是在程序入口处开启 DPI 感知Application.SetHighDpiMode(HighDpiMode.PerMonitorV2);老版本 .NET Framework 项目不支持这个方法时可以在 app.config 或程序清单里声明 dpiAware。开启后WinForms 会按实际 DPI 重新布局和绘制文字不再被拉伸。第二步是线条宽度和字号不要写死比如 Pen 的宽度如果是 2f在 DPI 为 144 时按2 * dpiX / 96f换算。否则即使 DPI 感知开了曲线在 150% 屏幕上看起来也会比其他界面元素细。刻度文字同理用 Font 时按 DPI 比例缩放字号或者直接用 AutoScaleMode.Dpi 让容器自己处理。4.4 现象采集线程直接改控件UI 一会儿卡死一会儿闪后台采集线程拿到新数据后直接调用曲线控件的 AppendData甚至直接设置 Label 的 Text轻则偶发异常重则整个界面失去响应。WinForms 的控件必须在创建它的线程里操作跨线程访问在长时间运行的采集流程里尤其危险。这里用血泪经验总结出一套分工采集线程只写共享队列完全不碰控件UI 刷新线程通过 Timer 读取队列并 Invalidate。如果有些一次性提示必须在数据到达时立刻弹出用 SynchronizationContext.Post 把操作切回 UI 线程不要在循环里连续 Control.Invoke。后者的开销随调用频率线性上涨而且 Invoke 会阻塞采集线程等待 UI 执行完毕采集线程被拖垮之后数据丢失反而更严重。5. 从 Demo 到复用做一个能直接拖到项目的曲线控件跑到这一步绘制代码和数据缓冲已经分开一边是 Paint 事件里的绘图逻辑一边是 AppendData 入口。这个结构已经具备控件化雏形。我一直主张Demo 和正式项目之间最缺的一步是把散落在窗体里的代码收敛成一个有明确接口的 UserControl。接入新页面时拖控件、配参数、调方法三步完成。5.1 把绘制逻辑包进 UserControl公开数据入口我通常新建一个 UserControl把之前在 Panel Paint 事件里的逻辑整体搬进去。这样绘制时需要的尺寸、Pen、队列都成为一个控件的私有状态不会泄漏到窗体上。控件的公开接口越小越好对外只暴露数据入口和配置参数。下面是控件骨架和核心绘制逻辑public partial class RealTimeChartControl : UserControl { private readonly object dataLock new object(); private Queuedouble dataBuf new Queuedouble(); private int maxPoints 500; public Color LineColor { get; set; } Color.SteelBlue; public int MaxPoints { get { return maxPoints; } set { maxPoints Math.Max(20, value); } } public RealTimeChartControl() { InitializeComponent(); DoubleBuffered true; ResizeRedraw true; } public void AppendData(int value) { AppendData((double)value); } public void AppendData(double value) { lock (dataLock) { dataBuf.Enqueue(value); while (dataBuf.Count maxPoints) dataBuf.Dequeue(); } } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Listdouble snapshot; lock (dataLock) snapshot new Listdouble(dataBuf); if (snapshot.Count 0) return; double min double.MaxValue, max double.MinValue; for (int i 0; i snapshot.Count; i) { if (snapshot[i] min) min snapshot[i]; if (snapshot[i] max) max snapshot[i]; } if (max - min 1e-9) { min - 1; max 1; } else { double m (max - min) * 0.1; min - m; max m; } float topMargin 10, bottomMargin 10; float usableH Height - topMargin - bottomMargin; PointF[] pts new PointF[snapshot.Count]; for (int i 0; i snapshot.Count; i) { float x (float)i / (snapshot.Count - 1) * Width; float y topMargin (float)((max - snapshot[i]) / (max - min)) * usableH; pts[i] new PointF(x, y); } using (Pen pen new Pen(LineColor, 2f)) { e.Graphics.DrawLines(pen, pts); } } }这段代码把前面的各种片段合到了一起。注意控件的 Width 和 Height 在绘制时直接使用不再依赖外部 Panel。MaxPoints 属性带有一个下限保护防止误设成 0 或负数。AppendData 增加了一个 int 重载因为上位机里很多数据源是整数采集这样可以免去调用方反复写强转。使用 UserControl 的另一个好处是设计器能把它显示在工具箱里拖到窗体即可真正实现“页面只关心数据投递”。5.2 给控件加上暂停、清空和导出数据调试实时曲线时最需要的是让画面停下来看某个瞬间或者把这段波形的原始数据导出来复盘。我一般在控件里加三个方法SetPaused、Clear 和 ExportToCsv。暂停不是停止接收数据而是停止界面重绘让当前画面留存在屏幕上。private bool paused; public void SetPaused(bool pause) { paused pause; if (!pause) Invalidate(); // 恢复时立即刷新一帧 } public void RefreshFrame() // 由窗体的 Timer 每帧调用 { if (!paused) Invalidate(); } public void Clear() { lock (dataLock) dataBuf.Clear(); Invalidate(); } public void ExportToCsv(string path) { double[] snapshot; lock (dataLock) snapshot dataBuf.ToArray(); using (var sw new StreamWriter(path, false)) { for (int i 0; i snapshot.Length; i) sw.WriteLine({0},{1}, i, snapshot[i]); } }SetPaused 的恢复分支里多了一次 Invalidate因为暂停期间画面没有刷新恢复时如果不主动画一帧要等下一个周期才显示最新数据。Clear 的锁只覆盖数据清空Invalidate 放在锁外避免绘制线程等待锁变长。导出 CSV 时先把队列转成数组、释放锁再写文件。这里有一个关键取舍如果文件很大写盘时间会比较长放在锁里会让采集线程阻塞。CSV 格式我建议第一列是点序第二列是数据值字段用逗号分隔。文件路径由调用方传入不在控件内部弹 SaveFileDialog保持职责单一。5.3 在模拟项目X里接入这个控件的完整流程假设现在要在一个数据采集上位机里接入实时曲线我一般按四步走。第一步在解决方案里新建 WinForms 项目引用这个控件所在的 UserControl 项目编译后把控件从工具箱拖到窗体的内容面板设置 Dock 为 Fill。第二步在窗体 Load 事件里启动采集线程采集线程每个周期拿到新读数后只调用 chartControl.AppendData(value)绝不碰其他控件。第三步在构造函数里把 MaxPoints 设为采样率乘以需要的窗口秒数比如采样率 20Hz、想看 30 秒就设 600。第四步运行后先观察画面是否流畅再决定窗体 Timer 的 Interval 是否需要改到 30 或 40。这套流程我在某跨平台系统调试面板和某图像处理 Demo 里都用同一个模式界面部分基本不换每次调整的都是数据采集层。6. 进阶给实时曲线做一次性能验证和两个提效技巧实时效果不能只靠肉眼判断最终还要量化。很多开发者把曲线调到“看着不卡”就觉得完成了但实时的意思是数据到达多少界面就该刷新多少。最后分享我的验证方法和两个常用优化。6.1 用 Stopwatch 量化重绘开销在 Paint 事件开头记一个 Stopwatch画完立刻停表观察单帧耗时是否稳定小于刷新周期的 50%。比如 Interval 设 33ms单帧应在 15ms 以内否则曲线绘制本身就会拖垮 UI。private Stopwatch paintStopwatch new Stopwatch(); protected override void OnPaint(PaintEventArgs e) { paintStopwatch.Restart(); // ... 你的绘制逻辑 paintStopwatch.Stop(); System.Diagnostics.Debug.WriteLine(Paint cost: {0} ms, paintStopwatch.Elapsed.TotalMilliseconds); }如果耗时超标先看是不是在循环里反复创建 Pen 或 Bitmap再看是否调用了 DrawString 画刻度文字。最耗时的往往是字符串排版而不是画线本身。6.2 把文字绘制从高频重绘里拆出去坐标轴文本、标题、图例这些内容数据变化时并不需要每帧重画。我的做法是把曲线区和坐标轴文字拆到两个独立控件曲线区只管折线Y 轴标签单独放在左侧只有数据范围变化时才刷新。这样 30 FPS 的曲线重绘不会重复计算刻度字符串。当场刷新需求极低时甚至可以把文本预先画到一张静态 Bitmap只在缩放或范围变化时重新生成。高频曲线追求的是每一帧里只保留最低限度的操作不把设计模式搞复杂性能自然有底气。C# 的实时曲线调到这里我心里才算有底。希望帮到你。本文还有配套的精品资源点击获取