简介C#绘制实时曲线图Demo面向刚接触WinForms与Chart控件的初学者也适合需要在数据分析、监控界面或桌面GUI中实现动态折线展示的开发者通过一个可运行示例说明如何把随时间变化的数据持续绘制到图表上。压缩包共31个文件、约55KB以C#源码为主体配合项目配置、窗体设计器资源及编译产物便于在Visual Studio中直接打开、运行和对照修改。示例围绕Chart控件的添加与属性配置展开涉及Series与ChartAreas设置、使用AddXY追加数据点、移除最旧点以保持点数恒定并形成平滑移动效果、调用Invalidate及时刷新以及借助计时器定时获取数据同时给出模拟数据生成思路方便没有真实数据源时先验证绘制链路。已有1597人学习下载可作为课程设计、自学入门与二次开发的基础参照也可用于监控面板、实验数据展示等小型项目原型。1. 从一条越跑越卡的曲线说起这个 C# 实时曲线图 Demo 能解决什么前阵子帮某设备监控上位机做排查界面上一共四条曲线采样率标称 1 kHz刚启动时顺滑跑到十几分钟开始拖影拖一下窗口直接白屏。问题不在 Chart 会不会画线而在采样、缓冲、刷新、绘制全挤在一个循环里。C# 实时曲线绘制真正要解决的是稳定帧率下的可读性和可控内存而不是把点塞进控件。这个 Demo 就是围绕这件事拆出来的最小可运行例子固定容量环形缓冲、滚动时间窗、定时刷新、双缓冲自绘、按像素降采样数据源可替换。适合做上位机、测试测量、串口采集的 C# 开发者新手能照着跑熟手能直接看参数边界。2. 选型先定生死Chart 控件、自定义双缓冲和 WPF 的边界2.1 三条实时绘制路线怎么选实时曲线第一件事不是写代码是判断你手里的采样率和窗口长度到底该不该用控件自带的绘制能力。我一般先问三个数每秒多少个点、窗口保留多少秒、同时几条曲线。这三个数乘一下再对比控件每帧要遍历的点数选型基本就清楚了。下面这张表是常见路线的边界不是绝对标准但足够用来做第一轮排除。路线适合场景优点常见坑WinForms Chart 控件采样率低于 100 Hz、窗口点数几千以内、曲线数量少上手快、坐标轴和标签现成频繁AddXY会触发重算点集合只增不减会越跑越慢自定义双缓冲控件1 kHz 左右、窗口 520 秒、需要多曲线叠加刷新节奏可控、内存可控、降采样好插入坐标轴、网格、交互都要自己写DPI 要单独处理WPFPolyline/DrawingVisual已有 WPF 界面、需要矢量缩放和动画渲染管线现代、组合能力强每帧重建几何对象会吃 GC绑定大量点容易卡这个 Demo 的主线是 WinForms 自定义双缓冲控件Chart 控件只作为对照入口。原因很直接实时曲线最怕的是“每来一个点就通知界面”而 Chart 控件的默认用法很容易写成这样。自绘控件把“数据到达”和“界面刷新”拆成两个节奏采样线程只管往缓冲里写UI 定时器按固定帧率取快照绘制时只画窗口内可见的点。对新手来说自绘看起来代码多但排错路径清楚对熟手来说所有参数都在自己手里不用猜控件内部什么时候重算坐标轴。如果采样率只有几十赫兹曲线数量一两条Chart 控件完全够用没必要为了“实时”两个字上自绘。反过来说如果采样率上千、窗口十秒、还要保留尖峰Chart 控件每帧遍历上万个点卡顿只是时间问题。选型时不要看 Demo 跑起来顺不顺要看你自己的数据规模乘上去之后一帧要处理多少个点。2.2 为什么实时曲线不建议直接往 Chart 里塞点先看一段常见的反例。它的逻辑是每个采样点到达就更新控件看起来最直观实际把采样线程和 UI 线程绑死在了一起。采样率一高UI 消息队列会被刷新请求塞满鼠标拖动和窗口重绘都会排在后面。// 反例每个采样点都直接碰 UI 控件 private void OnSample(double value) { // 每来一个点就扩一次点集合 chart1.Series[0].Points.AddY(value); // 每来一个点就重算一次坐标轴范围 chart1.ChartAreas[0].AxisX.Minimum _firstTime; chart1.ChartAreas[0].AxisX.Maximum _lastTime; // 频繁请求重绘消息队列容易堆积 chart1.Invalidate(); }这段代码的问题不在AddY本身而在调用频率。1 kHz 采样就是每秒 1000 次AddY、1000 次坐标轴赋值、1000 次Invalidate。UI 线程正常刷新 3060 帧每秒多出来的请求不会让曲线更顺滑只会让消息队列更长。血泪经验是曲线卡顿的时候先数一数每秒调了多少次Invalidate很多问题当场就能定位。正确的做法是批量更新采样线程把点写进环形缓冲UI 定时器每 33 毫秒取一次最新窗口控件只在这时候重绘。坐标轴也不是每帧全量重算而是按窗口右边界滚动Y 轴用滞回或固定量程。这样每秒真正触发的重绘次数固定在 30 次左右采样率再高也只是缓冲写入变快不会把 UI 线程拖死。2.3 Demo 的分层结构采样层、缓冲层、渲染层这个 Demo 把链路拆成三层替换数据源时只动采样层缓冲和渲染不动。采样层用一个接口收口真实串口、采集卡、模拟源都实现同一个契约缓冲层负责固定容量和覆盖策略渲染层只认快照不认采样线程。// 采样层契约时间戳用单调时钟单位秒 public readonly struct CurveSample { public readonly double Timestamp; public readonly double Value; public CurveSample(double timestamp, double value) { Timestamp timestamp; Value value; } } public interface ICurveDataSource { event ActionCurveSample SampleArrived; void Start(); void Stop(); }这段接口代码的关键点是Timestamp的类型和含义。实时曲线的时间轴不能依赖DateTime.Now高频采样下它的精度和回拨都会让曲线出现台阶或倒刺。常见做法是用Stopwatch取单调时间再换算成秒。SampleArrived事件在采样线程触发订阅方只做入队或写缓冲不直接碰控件。参数上Timestamp用秒而不是毫秒是为了后面算窗口范围和降采样时少做一次除法如果采集卡给的是纳秒或 tick在实现类里统一换算不要让渲染层看到多种单位。分层之后排错也变成分段排查曲线不动先看采样层有没有事件有事件但曲线不更新看缓冲快照和 UI 定时器两者都正常但画面卡看绘制和降采样。比起在一个几千行的窗体里猜这种拆法更适合长期维护。3. 把 Demo 跑起来从数据源到曲线的完整链路3.1 环形缓冲区固定容量与覆盖策略实时曲线不需要保留全部历史只需要保留当前窗口内的点。环形缓冲的好处是容量固定写入不扩容旧数据被新数据覆盖内存不会随着运行时间上涨。下面这个实现保留时间和值两个数组写入时移动头指针读取时按时间顺序复制最新的一段。public sealed class RingBuffer { private readonly double[] _times; private readonly double[] _values; private readonly object _gate new object(); private int _head; // 下一个写入位置 private int _count; // 当前有效点数 public RingBuffer(int capacity) { if (capacity 0) throw new ArgumentOutOfRangeException(nameof(capacity)); _times new double[capacity]; _values new double[capacity]; } public int Capacity _times.Length; public void Add(double time, double value) { lock (_gate) { _times[_head] time; _values[_head] value; _head (_head 1) % _times.Length; if (_count _times.Length) _count; } } // 复制最新的一段返回实际复制数量 public int CopyLatest(Spandouble times, Spandouble values, int maxCount) { lock (_gate) { int n Math.Min(Math.Min(maxCount, _count), times.Length); int start (_head - n _times.Length) % _times.Length; for (int i 0; i n; i) { int idx (start i) % _times.Length; times[i] _times[idx]; values[i] _values[idx]; } return n; } } }这段代码的逻辑是固定数组加取模回绕Add覆盖最旧位置CopyLatest从当前头指针往前取 n 个点并按时间顺序展开。参数上capacity按“采样率 × 窗口秒数 × 1.2”估算比如 1 kHz、10 秒窗口容量取 12000 左右多出来的 20% 是给刷新抖动和降采样留余量。锁的粒度只包住数组复制绘制前拿一次快照就释放不要在锁里做绘制或格式化。注意CopyLatest的目标Span长度要足够否则会截断如果采样线程和 UI 线程并发很高单生产者单消费者也可以换成无锁队列但第一版用锁更不容易写错。3.2 定时刷新与滚动窗口UI 刷新用 WinForms 定时器就够关键是别把它当采样时钟。定时器只负责“取快照、更新控件、请求重绘”时间窗口的右边界由单调时钟决定不由 Tick 次数决定。下面这段是窗体里的刷新骨架33 毫秒对应大约 30 帧每秒窗口长度 10 秒。private readonly System.Windows.Forms.Timer _uiTimer new() { Interval 33 }; private readonly Stopwatch _clock Stopwatch.StartNew(); private readonly RingBuffer _buffer new(12000); private readonly double _windowSeconds 10.0; private void OnUiTick(object? sender, EventArgs e) { double now _clock.Elapsed.TotalSeconds; var times new double[4096]; var values new double[4096]; int n _buffer.CopyLatest(times, values, times.Length); if (n 0) return; // 只保留窗口内的点渲染层不再关心历史 int first 0; double left now - _windowSeconds; while (first n times[first] left) first; _curve.SetData(times.AsSpan(first, n - first), values.AsSpan(first, n - first), left, now); _curve.Invalidate(); }这段代码做的是“按时间窗口裁快照”不是“按固定点数裁”。参数上Interval设 33 毫秒是折中再低会增加绘制压力再高鼠标拖动会感觉曲线发涩_windowSeconds决定 X 轴可见范围窗口越长同样宽度下每个像素对应的点越多越需要降采样。CopyLatest的maxCount不要设成缓冲容量否则每次复制全量UI 线程会多一次大数组拷贝。注意now来自Stopwatch和采样时间戳同一个时钟源这样窗口右边界和点的时间戳才对得上。3.3 替换数据源从模拟正弦到串口/采集卡Demo 自带模拟源目的是让你先把 UI 和缓冲跑顺再接真实设备。模拟源按固定周期产生正弦加随机游走时间戳用Stopwatch不依赖系统时间。下面这段可以独立替换成串口事件或采集卡回调。public sealed class SimulatedSource : ICurveDataSource { private readonly Stopwatch _clock Stopwatch.StartNew(); private readonly Random _rng new(1234); private Timer? _timer; private double _phase; public event ActionCurveSample? SampleArrived; public void Start() { // 10 毫秒一个点约 100 Hz只用于验证链路 _timer new Timer(Generate, null, 0, 10); } private void Generate(object? state) { _phase 0.05; double value Math.Sin(_phase) * 10 (_rng.NextDouble() - 0.5) * 0.6; SampleArrived?.Invoke(new CurveSample(_clock.Elapsed.TotalSeconds, value)); } public void Stop() { _timer?.Dispose(); _timer null; } }这段模拟源的参数是 10 毫秒周期、正弦幅值 10、噪声幅值 0.6。它不保证硬实时线程池定时器会有几毫秒抖动正好用来验证曲线对时间戳抖动的容忍度。替换成真实采集时把Generate里的赋值换成串口DataReceived或采集卡回调时间戳用设备采样时钟或Stopwatch换算别在回调里直接Invoke到 UI 再写缓冲。常见做法是回调里只做TryAdd入队UI 定时器统一出队写环形缓冲这样采集线程不会被绘制拖慢。3.4 坐标轴自适应与固定量程Y 轴量程是最容易翻车的地方。全自动每帧重算会让曲线像心电图一样上下跳全固定又可能把信号压成一条直线。Demo 里给了一个带死区和指数平滑的自适应方案参数不多但每个都要按信号特性调。参数典型值作用调整建议deadBandRatio0.05触发量程调整的死区按当前量程比例算噪声大就调大信号变化慢就调小smoothFactor0.15新量程向目标量程靠近的速度越小越稳越大越跟手padRatio0.10上下留白比例别设 0否则峰值贴边fixedRange按传感器定固定量程优先于自动已知信号范围时直接用private double _yMin -12, _yMax 12; private const double DeadBandRatio 0.05; private const double SmoothFactor 0.15; private const double PadRatio 0.10; private void UpdateYAxis(double dataMin, double dataMax) { double range _yMax - _yMin; double deadBand range * DeadBandRatio; // 数据还在当前量程内不急着动 if (dataMin _yMin deadBand dataMax _yMax - deadBand) return; double pad Math.Max(dataMax - dataMin, 1e-6) * PadRatio; double targetMin dataMin - pad; double targetMax dataMax pad; _yMin (targetMin - _yMin) * SmoothFactor; _yMax (targetMax - _yMax) * SmoothFactor; }这段逻辑先判断数据是否越过死区越过才计算带留白的目标量程再用平滑系数靠近。参数上deadBandRatio太小会让量程频繁触发太大又会让超量程信号被裁掉smoothFactor取 0.10.2 之间比较稳太小响应慢太大又回到跳动。固定量程适合已知传感器输出范围的场景比如 420 mA 换算后的工程值直接把自适应关掉曲线读数更可信。4. 实时曲线不卡顿的关键双缓冲、降采样与重绘策略4.1 双缓冲自绘控件的 OnPaint 骨架自绘控件第一件事是打开双缓冲和用户绘制否则拖动窗口时背景和曲线会互相闪烁。下面这个骨架只保留关键样式和绘制入口网格、坐标轴、曲线都从OnPaint走重绘范围由控件自己管。public sealed class RealtimeCurveControl : Control { public RealtimeCurveControl() { SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); BackColor Color.Black; } protected override void OnPaint(PaintEventArgs e) { var g e.Graphics; g.SmoothingMode SmoothingMode.AntiAlias; // 先画背景和网格再画曲线顺序反了会被盖住 DrawGrid(g); DrawCurve(g); } private void DrawGrid(Graphics g) { /* 按边距和刻度画网格 */ } private void DrawCurve(Graphics g) { /* 从快照取点并降采样后画线 */ } }这段代码的逻辑是用ControlStyles.OptimizedDoubleBuffer把绘制放到后台缓冲AllPaintingInWmPaint减少背景擦除带来的闪烁。参数上抗锯齿SmoothingMode.AntiAlias会让线条更顺但点数和曲线数量多时开销明显如果帧率吃紧可以先关抗锯齿或者只对曲线开、网格不开。边距不要写死在绘制里左边距要留给 Y 轴标签右边距留一点给最后一个点否则峰值会贴边被裁掉。4.2 按像素降采样MinMax 抽稀窗口内点数远大于控件像素宽度时逐点画线没有意义多个点会落在同一列像素上。常见做法是按像素列聚合每列保留最小值和最大值这样尖峰不会被平均掉。下面这个函数按目标像素宽度输出抽稀后的点输出点数最多是像素宽度的两倍。public static int DownsampleMinMax( ReadOnlySpandouble xs, ReadOnlySpandouble ys, int count, int pixelWidth, Spandouble outX, Spandouble outY) { if (count 0 || pixelWidth 0) return 0; if (count pixelWidth * 2) { xs.Slice(0, count).CopyTo(outX); ys.Slice(0, count).CopyTo(outY); return count; } int write 0; double minX xs[0], maxX xs[count - 1]; double span Math.Max(maxX - minX, 1e-9); int bucket 0; while (bucket pixelWidth write 1 outX.Length) { double left minX span * bucket / pixelWidth; double right minX span * (bucket 1) / pixelWidth; double minY double.MaxValue, maxY double.MinValue; double minYx left, maxYx left; bool any false; for (int i 0; i count; i) { if (xs[i] left || xs[i] right) continue; any true; if (ys[i] minY) { minY ys[i]; minYx xs[i]; } if (ys[i] maxY) { maxY ys[i]; maxYx xs[i]; } } if (any) { // 同一列先写最小值再写最大值保留尖峰 outX[write] minYx; outY[write] minY; write; outX[write] maxYx; outY[write] maxY; write; } bucket; } return write; }这段代码的逻辑是把 X 轴范围切成pixelWidth个桶每个桶里找最小值和最大值按时间顺序输出。参数上pixelWidth取控件宽度减左右边距不要用整个控件宽度阈值count pixelWidth * 2才启用降采样点数不多时直接复制避免无谓开销。注意内层循环是每桶扫全量点数特别大时可以先按 X 排序或预分桶但实时窗口一般几千到几万点这个版本够用。MinMax 的好处是尖峰不丢坏处是线条会比原始数据“厚”一点读趋势够用做精确测量要看原始值。4.3 刷新与采样解耦生产者消费者队列采样线程和 UI 线程之间最好有一个有界队列采样线程只入队UI 定时器批量出队写环形缓冲。队列有界是为了防止 UI 卡顿时采样数据无限堆积满了要有丢弃策略不能默默吃内存。private readonly BlockingCollectionCurveSample _queue new(new ConcurrentQueueCurveSample(), 8192); // 采样线程调用非阻塞满了就丢避免拖慢采集 private void OnSampleArrived(CurveSample sample) { if (!_queue.TryAdd(sample)) { Interlocked.Increment(ref _droppedSamples); } } // UI 定时器调用一次最多取一批避免长时间占用 UI 线程 private void DrainQueue() { int drained 0; while (drained 4096 _queue.TryTake(out var sample)) { _buffer.Add(sample.Timestamp, sample.Value); drained; } }这段代码用BlockingCollection包一个有界并发队列TryAdd不阻塞采样线程满了就丢并计数DrainQueue在 UI 定时器里批量出队单次上限 4096防止一次 Tick 处理太久。参数上队列容量按“采样率 × 2 秒”估算比如 1 kHz 取 20488192drained上限按单帧预算调30 帧每秒下每帧留给数据搬运的时间不多。丢点计数要显示在调试栏里曲线看着正常但计数一直涨说明 UI 已经在拖后腿该降采样或降刷新率了。提示刷新帧率和采样率不是一回事别把 UI 定时器当成采样时钟也别用采样次数去推算时间轴。5. 避坑排查实时曲线最常见的五类翻车现场5.1 曲线越跑越慢点集合无限增长现象是刚启动 30 帧很顺跑十分钟后帧率掉到个位数任务管理器里内存曲线一路向上。原因通常是把所有历史点都存在List或 Chart 的点集合里绘制时又全量遍历窗口外的点早就不该参与绘制。解决方法是固定容量环形缓冲或者至少按窗口时间裁掉旧点绘制前只取可见范围快照。// 错误只加不删历史越长越慢 _points.Add(new PointF(x, y)); // 正确固定容量覆盖或按时间窗口裁剪 if (_points.Count _capacity) { _points.RemoveRange(0, _points.Count - _capacity); }这里的参数是_capacity按采样率乘窗口秒数再留 10%20% 余量。注意RemoveRange(0, n)在List上仍然是 O(n) 搬移点数大时不如环形缓冲如果一定要用List批量删比逐点删好别在每帧删一个。判断是否中招很简单运行十分钟看内存和帧耗时是不是单调上升是的话先查集合长度。5.2 坐标轴跳动像心电图自动量程每帧重算现象是曲线本身没怎么变Y 轴刻度却上下跳波形被压扁又拉长读数根本没法看。原因是每帧都按当前窗口的 min/max 重设量程噪声一抖量程就跟着抖。解决方法是加死区和指数平滑已知信号范围时优先固定量程。double range _yMax - _yMin; double deadBand range * 0.05; if (dataMin _yMin deadBand || dataMax _yMax - deadBand) { double targetMin dataMin - (dataMax - dataMin) * 0.1; double targetMax dataMax (dataMax - dataMin) * 0.1; _yMin (targetMin - _yMin) * 0.15; _yMax (targetMax - _yMax) * 0.15; }参数上死区取当前量程的 5% 左右平滑系数取 0.10.2。死区太小等于没加太大又会漏掉缓慢漂移平滑系数太大照样跳太小则响应迟钝。注意自动量程和固定量程不要同时开界面上给一个明确开关排错时先固定量程看波形是否稳定再切回自动。5.3 高 DPI 下线条发虚坐标未按 DPI 缩放现象是在 4K 屏或系统缩放 150% 时曲线线条变细、发虚网格和文字错位。原因是绘制代码里用了固定像素值控件本身又被系统缩放了一次线宽和边距对不上。解决方法是让窗体按 DPI 缩放绘制时用DeviceDpi / 96f换算线宽和关键边距。float scale DeviceDpi / 96f; using var curvePen new Pen(Color.Lime, 1.5f * scale); using var gridPen new Pen(Color.FromArgb(60, 60, 60), 1f * scale);这段代码的逻辑是把逻辑线宽按当前 DPI 换算成设备像素DeviceDpi在控件创建后可用。参数上曲线线宽 1.5 逻辑像素、网格 1 逻辑像素是常见起点边距也要乘scale否则高 DPI 下 Y 轴标签会挤在一起。注意不要在OnPaint里每次新建大量画刷和画笔能缓存的按 DPI 缓存缩放变化时再重建。5.4 定时器叠加刷新未完成又触发现象是偶尔一顿一顿CPU 出现尖峰鼠标拖动窗口时更明显。原因是 UI 定时器的 Tick 在消息队列里排队某次绘制耗时过长后面的 Tick 接着进来越积越多。解决方法是加一个重入保护上一帧没画完就跳过本次同时记录绘制耗时超过两个刷新周期就降采样。private int _rendering; private void OnUiTick(object? sender, EventArgs e) { if (Interlocked.Exchange(ref _rendering, 1) 1) return; try { var sw Stopwatch.StartNew(); RenderOnce(); sw.Stop(); if (sw.ElapsedMilliseconds 66) _slowFrames; } finally { Interlocked.Exchange(ref _rendering, 0); } }参数上66 毫秒对应 33 毫秒刷新周期的两倍超过就说明单帧已经跟不上_slowFrames可以显示在调试栏里。注意Interlocked.Exchange保护的是整个渲染入口不是只保护Invalidate如果你在渲染里又触发异步重绘要确保不会绕过这个标志。这个保护只能防止叠加不能提升单帧速度单帧还是慢就得回到降采样和减少曲线数量。5.5 关闭窗口后后台线程还在跑资源泄漏现象是窗口关了进程还在任务管理器里调试器里能看到定时器线程或采集线程还在跑。原因是数据源、UI 定时器、队列没有在关闭时停止和释放事件订阅也没解绑。解决方法是在窗体关闭事件里按“停采样、停刷新、取消等待、释放队列”的顺序收尾。protected override void OnFormClosing(FormClosingEventArgs e) { _cts.Cancel(); _source.Stop(); _uiTimer.Stop(); _uiTimer.Dispose(); _queue.CompleteAdding(); base.OnFormClosing(e); }这段代码的顺序很重要先取消后台等待再停数据源最后停 UI 定时器并释放队列。参数上CancellationTokenSource要传给采集线程的循环循环里检查IsCancellationRequestedCompleteAdding之后不要再往队列写否则会抛异常。注意事件订阅要在Stop里解绑尤其是静态事件或长生命周期对象否则窗口对象会被一直引用。注意队列满时一定要有丢弃策略否则内存会替你“记住”所有历史关窗口也救不回来。6. 进阶验证用固定帧率压测和采样回放确认曲线可信6.1 固定帧率压测看 P95 而不是平均值曲线顺不顺不能只看平均帧耗时。平均值会被大量快帧拉低真正让人感觉卡的是 P95 和最大帧耗时。压测时让模拟源按目标采样率灌数据UI 固定 30 帧每秒连续跑十分钟记录每帧绘制耗时和丢点计数。下面这段是记录骨架重点是每帧耗时进环形统计最后算 P95。private readonly double[] _frameMs new double[600]; private int _frameIndex; private void RecordFrame(double ms) { _frameMs[_frameIndex % _frameMs.Length] ms; } private double P95() { var copy _frameMs.Where(v v 0).OrderBy(v v).ToArray(); if (copy.Length 0) return 0; return copy[(int)(copy.Length * 0.95)]; }参数上统计窗口取 600 帧30 帧每秒就是最近 20 秒P95 超过 33 毫秒说明已经有明显掉帧超过 66 毫秒基本能肉眼看到顿挫。压测时把曲线数量、窗口点数、降采样开关分别改一遍记录对应 P95比单看任务管理器 CPU 靠谱。6.2 采样回放确认降采样没有吃掉尖峰降采样最怕把尖峰平均掉。验证方法是准备一段固定采样数据按时间戳回放分别用原始点和降采样点绘制比较同一像素列上的最大值和最小值。如果降采样后的极值和原始窗口极值差得太多说明桶划分或 MinMax 逻辑有问题。回放时不要用Thread.Sleep精确控时按时间戳一次性灌入缓冲再让 UI 按正常帧率取快照这样测的是绘制链路不是定时器精度。6.3 一个具体技巧时间戳统一用单调时钟实时曲线里最玄学的一类问题是曲线偶尔出现倒刺或台阶查采样值又没问题。后来发现是时间戳混用了DateTime.Now和Stopwatch系统时间校准或夏令时切换时就会抖一下。从那以后我每次接实时曲线都先把采样时钟、缓冲容量、刷新帧率三个数写在纸上采样时间戳统一用Stopwatch.GetTimestamp()换算UI 窗口右边界也用同一个时钟源压测十分钟不通过就不接真实设备。希望帮到你。本文还有配套的精品资源点击获取