当时那个项目是一个用 WPF 写的设备数据采集客户端,走 TCP 同时接十台左右的工业设备,正常态每台每秒上报 2~5 条数据,界面上要实时刷新温度、压力、转速这些曲线和表格。这套系统联调时特别顺,一到现场就跑出问题,隔三差五界面卡死,CPU 直接冲到 100%。后来我把日志拉出来看,问题几乎都集中在两个时刻:设备掉线重连后的补发数据,以及工控机网络抖动恢复后积压的一大堆报文。这些消息一口气涌进来,UI 线程根本招架不住。
这种情况在工控上位机、物联网网关、行情客户端这类场景里非常典型。你不能要求上游设备"温柔一点",只能让自己程序具备抗压能力。围绕消息洪峰与数据抖动这两类问题,我整理了自己在几个项目里反复验证过的 8 种策略,从消息接收、UI 渲染、数据预处理、列表更新、兜底保护这几个层面逐个拆解。这篇内容适合正在做 WPF 上位机、实时监控客户端,或者任何 C# 桌面程序却经常被高频数据折腾到界面卡顿的朋友参考。
1. 知己知彼:洪峰和抖动为什么能把 WPF"锤死"
1.1 我刚接手的一个典型崩溃现场
先描述一下那个现场,因为这比任何理论都直观。某厂区的数据采集客户端,主界面是一个 DataGrid 显示所有设备的实时数据,下面有几个 LiveCharts 折线图。平时数据量不大,CPU 占用只有 5% 左右。但一旦设备批量重连,软件就会在几秒钟内收到上千条数据,界面开始白屏、拖不动,甚至直接被系统标记为"未响应"。
更隐蔽的是数据抖动问题。有一台计量设备,温度值偶尔会从 42.5 度瞬间跳到 87.3 度,一两个采样点后又跳回来。图表上就是一个尖刺,报警逻辑还会误触发。这类问题不像卡死那么致命,但用户天天盯着看,体验极差。
我把现象分成两类,消息洪峰是"量"的问题,数据抖动是"质"的问题。两者经常同时出现,但处理思路完全不同,需要分开治理。
1.2 洪峰和抖动的本质区别
消息洪峰:短时间内消息数量远超系统处理能力。典型来源是设备重连补传、网络恢复后积压数据、批量指令回执。特征是瞬时吞吐量大,队列积压,UI 线程被海量小操作淹没。
数据抖动:单个或少量数据点的值异常跳变。典型来源是传感器干扰、AD 转换毛刺、通信错帧。特征是数值突变、持续时间短、视觉上表现为曲线上的尖刺或表格数值乱跳。
处理洪峰的核心是"削峰填谷",思路包括队列缓冲、批量消费、渲染节流、背压。处理抖动的核心是"去伪存真",思路包括限幅滤波、滑动平均、中值滤波。两种问题叠加时,通常先做滤波,再做流量控制,因为垃圾数据即使不卡死,也会污染视图和报警。
1.3 洪峰打崩 UI 线程的三条路径
根据我排查的经验,洪峰导致卡死通常不是单一原因,而是三条路径叠加。
第一条是直接路径:接收线程里调 Dispatcher.Invoke 更新控件,每条消息都在等 UI 线程响应,接收线程被卡住,内核缓冲区迅速填满,丢包和重传又产生更多消息,形成恶性循环。
第二条是队列路径:虽然很多人知道要切线程操作,但用的是 BeginInvoke 无脑派发,每条消息一个委托。假设 3000 条消息 = 3000 个待执行委托,即便每个委托只做一次赋值,UI 线程也要逐个处理,期间布局、渲染、输入事件全都排不上队。
第三条是绑定路径:即使 UI 只在后台线程更新了 ObservableCollection,集合的每次增删都会触发 UI 线程的 CollectionChanged 事件。批量加入 5000 条数据,就会触发 5000 次更改通知,每次都要走一遍 NotifyCollectionChangedAction.Add 的绑定管道,开销非常可观。
这三条路径互相叠加,就是"卡死"的真相。下面 8 种策略,就是针对这三条路径逐层堵漏。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 泄洪第一层:消息缓冲队列与批量消费
2.1 策略一:别在事件回调里直接操作界面
这是最基本的门槛,但我真见过不少项目连这关都没过。接收事件触发后,正确的做法不是去改界面,而是把原始数据扔进一个线程安全的缓冲结构,然后继续读下一条。
csharp复制// 接收线程(生产者)
private void OnDataReceived(SensorData data)
{
// 只做入队,不碰任何 UI 控件
if (!_queue.TryAdd(data, 50))
{
Interlocked.Increment(ref _dropCount);
}
}
TryAdd(data, 50) 表示最多等 50 毫秒,队列满就放弃,并记录丢弃数。这里要说明一下,为什么不用 Add 直接阻塞等队列腾位置?因为接收线程往往就是网络线程,一旦阻塞,TCP 接收窗口不再滑动,内核缓冲区满后底层可能丢包。对于实时监控类系统,丢了旧数据可以接受,不能接受的是把接收链路卡死。
2.2 策略二:用 BlockingCollection 或 Channel 做缓冲池
缓冲结构我推荐 BlockingCollection<T> 或者 .NET Core/.NET 5+ 的 System.Threading.Channels。前者是老牌方案,简单直观;后者性能更好,API 也更现代。
csharp复制// 老方案:有界阻塞集合
BlockingCollection<SensorData> _queue =
new BlockingCollection<SensorData>(boundedCapacity: 10000);
// 新方案:有界通道
Channel<SensorData> _channel = Channel.CreateBounded<SensorData>(
new BoundedChannelOptions(10000)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false
});
有界容量很关键。不加界,队列会无限膨胀,洪峰过去后内存已经爆了。设置为 10000 的意思是,最多允许积压 10000 条未处理消息,超过部分按策略丢弃。DropOldest 适合"最新值最重要"的监控场景;Wait 则适合"一条都不能丢"的记录场景,但需要接受接收线程阻塞的代价。
消费端用独立的后台任务持续读取:
csharp复制// 消费线程
Task.Run(async () =>
{
await foreach (var item in _channel.Reader.ReadAllAsync())
{
_pendingBatch.Add(item);
if (_pendingBatch.Count >= 50)
{
var batch = _pendingBatch.ToArray();
_pendingBatch.Clear();
await _dispatcher.InvokeAsync(() => ProcessBatch(batch),
DispatcherPriority.Background);
}
}
});
这里注意两点:一是消费线程常驻,不要每来一条数据就新建一个 Task,那本身也是开销;二是积攒到一定数量再成批派发给 UI 线程,这就是策略三。
2.3 策略三:批量消费,把"逐条通知"变成"批次通知"
如果每条数据都触发一次 UI 派发,即使有队列缓冲,UI 线程的待处理任务还是会堆到几千。批量消费的核心是攒一批再送,比如攒够 50 条或攒够 30 毫秒,一次性交给 UI 线程。
csharp复制private void ProcessBatch(SensorData[] batch)
{
foreach (var data in batch)
{
// 更新 ViewModel 集合
_latestValues[data.DeviceId] = data.Value;
}
// 只触发一次 UI 刷新
RefreshSummaryPanel();
RefreshChart();
}
实测下来,同样的 3000 条消息,逐条派发需要 UI 线程处理 3000 个委托,批量派发只需要 60 个委托,前者耗时是后者的 20 倍以上。批量大小需要根据消息结构和刷新频率调整,我一般是 50~100 条一批,或者按 30~50 毫秒聚合一次,两种触发条件配合使用。
3. 防抖第二层:渲染节流与 Dispatcher 优先级调度
3.1 策略四:Throttle 和 Debounce,用时间窗合并刷新
有了队列和批量消费,UI 线程的压力已经小了很多,但还有一类问题需要处理:短时间内多个业务模块各自请求刷新,比如表格要刷新、图表要追加、状态栏要更新。如果每个请求都立即执行,同一帧内可能重复渲染多次。
这里我用一个基于 CancellationTokenSource 的节流工具:
csharp复制public class UiThrottle
{
private readonly Dispatcher _dispatcher;
private readonly int _intervalMs;
private CancellationTokenSource _cts;
private readonly object _lock = new object();
public UiThrottle(Dispatcher dispatcher, int intervalMs)
{
_dispatcher = dispatcher;
_intervalMs = intervalMs;
}
public void Request(Action action)
{
lock (_lock)
{
_cts?.Cancel();
_cts = new CancellationTokenSource();
var token = _cts.Token;
_ = DelayAndRunAsync(action, token);
}
}
private async Task DelayAndRunAsync(Action action, CancellationToken token)
{
try
{
await Task.Delay(_intervalMs, token);
if (token.IsCancellationRequested) return;
await _dispatcher.InvokeAsync(action, DispatcherPriority.Background, token);
}
catch (TaskCanceledException) { }
catch (OperationCanceledException) { }
}
}
这段代码实现的是 debounce 语义:100 毫秒内反复调用 Request,只有最后一次会真正执行刷新。对图表这类高频追加场景,我更推荐 throttle 语义,也就是"无论来多少消息,最多每 100 毫秒刷一次",实现方式是把上次执行时间记录下来,未到时间就置一个 pending 标记。
两种语义的区别在于:throttle 保证周期性的更新,适合连续数据流;debounce 保证"停下来才更新",适合搜索、筛选这类操作。监控类 UI 用 throttle 更合适,避免在持续洪峰下永远不刷新。
3.2 Dispatcher 优先级的坑:Normal 会饿死渲染
Dispatcher.Invoke 和 BeginInvoke 默认优先级是 Normal,这个默认值在高频场景里是个大坑。Dispatcher 的优先级排序大致是:
| 优先级 | 说明 | 场景 |
|---|---|---|
| Send | 同步,最高 | 极少使用 |
| Normal | 默认优先级 | 普通更新 |
| DataBind | 数据绑定 | Binding 内部 |
| Render | 渲染(此前) | 布局、绘制 |
| Background | 后台任务 | 非交互更新 |
当消息洪峰到来时,大量 Normal 级别的委托被压入队列,而 UI 的布局和绘制是 Render 级别,比 Normal 低。也就是说,只要 Normal 队列里还有活,界面就没机会重绘,表现出来就是窗口"白屏未响应"。这不是 CPU 不够,而是该干的正经事排在后面。
解决思路:数据更新使用 DispatcherPriority.Background,把优先级放低,让渲染有机会插队。同时配合策略四,减少派发次数,双管齐下。
csharp复制await _dispatcher.InvokeAsync(() =>
{
// 更新数据
}, DispatcherPriority.Background);
还有一个相关技巧:如果一个窗口已经关闭或尚未加载,就没必要继续更新它的控件了。更新前用 PresentationSource.FromVisual(window) == null 做一次判断,能省掉大量无效工作。这个判断在窗口最小化时尤其有用,实测能减少 30% 左右的 UI 开销。
3.3 async/await 的误用:异步不等于不卡 UI
很多人以为把更新代码改成 async 就万事大吉,但 async void 事件处理器里如果有同步阻塞操作,照样卡住 UI。await 只解决线程切换,不解决工作量。真正该做的是把耗时的数据整理、滤波、快照计算放到后台线程做,UI 线程只做轻量赋值。
csharp复制private async void OnTimerTick(object sender, EventArgs e)
{
var raw = await Task.Run(() => _dataAggregator.BuildSnapshot());
// UI 线程只做赋值
TextBlock.Text = raw.PrettyPrint();
}
我个人经验是:UI 线程每帧的活越轻越好,一切超过 1 毫秒的逻辑都值得怀疑,尤其是循环里做字符串拼接、频繁创建对象这类。把这些挪到后台,UI 自然就流畅了。
4. 策略五:数据抖动的前置处理,滤波算法怎么选
4.1 限幅滤波:几行代码挡掉大部分毛刺
处理数据抖动,性价比最高的就是限幅滤波,也叫限幅消抖滤波。核心思想很简单:如果新值相对上一个有效值的差值超过物理上合理的最大变化量,就认为这是干扰,丢弃或保留旧值。
csharp复制public class LimitFilter
{
private double _lastValid;
private bool _hasValue;
private readonly double _maxDelta;
public LimitFilter(double maxDelta)
{
_maxDelta = maxDelta;
}
public double Next(double rawValue)
{
if (!_hasValue)
{
_lastValid = rawValue;
_hasValue = true;
return rawValue;
}
if (Math.Abs(rawValue - _lastValid) <= _maxDelta)
{
_lastValid = rawValue;
}
// 超出限幅范围,判定为干扰,保持旧值
return _lastValid;
}
}
_maxDelta 怎么定?要结合具体物理量的变化速率。比如温度传感器每秒最多变化 0.5 度,但采样周期是 200 毫秒,那 _maxDelta 设成 0.2 度左右就合理。定得太大滤波器不起作用,定得太小会把真实快速变化也滤掉。这个参数需要看实际数据标定,没有万能值。
4.2 滑动平均和中值滤波的场景取舍
限幅滤波只挡大跳变,解决不了小幅随机噪声。这时候滑动平均是常规选择:
csharp复制public class MovingAverage
{
private readonly Queue<double> _queue;
private readonly int _windowSize;
public MovingAverage(int windowSize)
{
_windowSize = windowSize;
_queue = new Queue<double>(windowSize);
}
public double Next(double value)
{
if (_queue.Count >= _windowSize)
_queue.Dequeue();
_queue.Enqueue(value);
return _queue.Average();
}
}
滑动平均的优点是平滑,缺点也有:对真实突变反应迟钝,窗口越大越迟钝。如果数据里除了随机噪声还有偶发尖峰,中值滤波更合适——取窗口内排序后的中间值,能干净地剔除尖峰,同时保留边缘陡变。
csharp复制public class MedianFilter
{
private readonly int _windowSize;
private readonly Queue<double> _window = new Queue<double>();
public MedianFilter(int windowSize)
{
_windowSize = windowSize;
}
public double Next(double value)
{
_window.Enqueue(value);
if (_window.Count > _windowSize)
_window.Dequeue();
var sorted = _window.OrderBy(x => x).ToArray();
return sorted[sorted.Length / 2];
}
}
实际项目里我常组合使用:先限幅滤波挡毛刺,再中值滤波去尖峰,最后按需要叠加滑动平均做平滑。三层下来,曲线基本稳定。
4.3 滤波会引入延迟,必须分级处理
滤波不是免费的。任何滤波都会引入延迟,尤其滑动平均,窗口越大相位滞后越明显。如果数据要用于报警、闭环控制,过度滤波可能让系统反应迟钝,甚至导致误判。
我的做法是分级:原始数据完整落库,用于事后追溯;报警逻辑用限幅后的数据;界面展示用平滑后的数据。这样既保证报警及时性,又保证视觉稳定。分级处理要在架构上就支持,不要等到 UI 层再去补。
5. 列表刷新的终极手段:虚拟化与增量 Diff
5.1 策略六:DataGrid 的虚拟化,你以为开了其实没开
很多 WPF 开发者知道有虚拟化这回事,但不知道它默认状态下经常没生效。最常见的原因有几个:
第一,DataGrid 被包进了 ScrollViewer。一旦外层有 ScrollViewer,内部列表的虚拟化就被禁用,因为 ScrollViewer 希望一次性布局所有内容来测量尺寸。这是最常见的"开了等于没开"。
第二,CanContentScroll 被显式设为 False,导致容器按像素滚动而不是按项滚动。
第三,ItemsSource 不是 IList 接口的实现。如果绑定了 LINQ 的 Select() 结果,集合类型是 Iterator,虚拟化无法正确获取 Count,性能会劣化。
正确的配置是:
xml复制<DataGrid EnableRowVirtualization="True"
EnableColumnVirtualization="True"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
ScrollViewer.CanContentScroll="True">
VirtualizationMode="Recycling" 表示容器复用,比默认的 Standard 模式少创建大量 DataGridRow。实测在 1 万行数据下,Recycling 模式比 Standard 模式滚动流畅度提升非常大,内存占用也低很多。
5.2 策略七:增量 Diff 更新,别动不动清空重来
虚拟化解决的是"显示 1 万行"的问题,但解决不了"每秒更新 500 行"的问题。很多项目里,收到新快照后直接把 ObservableCollection Clear() 再挨个 Add,这会触发 N 次集合变更通知,UI 线程疯狂重建行容器,卡死是必然的。
正确做法是增量更新,只看新增、删除、修改三类变化:
csharp复制public void ApplySnapshot(IEnumerable<DeviceSnapshot> newItems)
{
var newList = newItems.ToList();
var oldMap = _items.ToDictionary(x => x.DeviceId);
var newIds = new HashSet<string>(newList.Select(x => x.DeviceId));
// 1. 更新已有项
foreach (var item in newList)
{
if (oldMap.TryGetValue(item.DeviceId, out var vm))
vm.UpdateFrom(item); // 属性级更新,触发轻量 PropertyChanged
else
_items.Add(new DeviceViewModel(item));
}
// 2. 删除已消失的项,倒序删除避免索引问题
for (int i = _items.Count - 1; i >= 0; i--)
{
if (!newIds.Contains(_items[i].DeviceId))
_items.RemoveAt(i);
}
}
关键点是 UpdateFrom 只更新变化了的属性,而不是重建整个 ViewModel。如果每次都是新建 ViewModel 再 Add,前面的 Diff 就白做了。另外,ObservableCollection 在批量添加时仍然会逐条通知,如果一次新增上百条,可以在批量操作期间挂起通知,或者换用支持批量通知的自定义集合。
对于图表这类高频追加的控件,增量更新同样适用:维护一个环形缓冲,新数据来了只追加到最后,超出窗口长度就移除头部,不要每次都重新绑定整个数据集。
5.3 大列表的实测对比
我在一个模拟 500 台设备的界面上做过对比,数据刷新频率 2Hz,每次全量快照包含 500 行。全量重建方式在 Clear + Add 时界面掉帧明显,滚动时卡顿;改成增量 Diff 后,500 行里只有 20 行有数据变化时,几乎无感。如果 500 行全变,增量 Diff 也快不了多少,那就需要配合前面的节流策略降低刷新频率。
这里补充一个重要认知:增量更新不是万能药,它解决的是"部分变化"场景。如果数据本身就是整表刷新,增量 Diff 的对比开销反而成了负担。设计前先确认数据特征,别为炫技引入无谓复杂度。
6. 兜底设计:背压、熔断与降级
6.1 策略八的背压部分:让上游适当地等一等
背压是我最后加进去的策略,但生产环境里它极其重要。前面用的是 DropOldest 丢弃策略,但有些场景不能丢数据,比如审计记录、订单数据。这时候必须让生产者等待。
Channel.CreateBounded 的 FullMode.Wait 就是这么用的:
csharp复制var channel = Channel.CreateBounded<AuditRecord>(
new BoundedChannelOptions(5000)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true
});
// 消费者慢的时候,WriteAsync 会等待
await channel.Writer.WriteAsync(record);
但要注意:如果生产者是网络接收线程,长时间 WriteAsync 等待会导致接收缓冲区积压,最终内核丢包。所以背压不能无限等待,要配合超时和策略选择。我通常的做法是:重要数据用 Wait 带有超时,超时后走磁盘落盘暂存;实时数据用 DropOldest。两种通道分开,互不干扰。
6.2 熔断:当系统扛不住时主动降低负载
熔断是借鉴微服务中的概念,思路是:当队列积压超过阈值,持续一段时间,就进入"熔断状态",主动丢弃或降频非关键数据的处理。等积压降下来,再恢复正常。
csharp复制if (_channel.Reader.Count > 8000)
{
_circuitState = CircuitState.Open;
}
if (_circuitState == CircuitState.Open && _channel.Reader.Count < 3000)
{
_circuitState = CircuitState.Closed;
}
熔断状态下的行为包括:丢弃非关键设备的数据、降低图表采样率、暂停日志记录、在状态栏显示"数据积压中"提示。这比单纯让程序硬扛要好得多——用户能明确看到系统状态,而不是看到一个无响应的窗口。
6.3 降级:宁可显示旧数据,也不要显示空白
数据链路断了或者大面积丢数据时,UI 不能一片空白。降级策略是保住"最后已知正常"的数据,并在视觉上明确标记数据状态。
我的做法是在 ViewModel 里记录 LastUpdateTime,超过设定阈值就在界面显示灰色数值并标注"过期"。曲线图保留最后一段正常数据,不因为新的异常数据进来而乱跳。
csharp复制public string StatusText =>
IsDataStale ? $"数据过期({DateTime.Now - LastUpdateTime:d\.hh\:mm\:ss})" : "数据正常";
降级不是逃避,而是让系统在极端情况下仍然可用、可诊断。这一点在工业场景尤为重要,一次界面假死可能意味着操作员错过一次关键报警。
7. 一场实战:8 种策略组合改造一个采集客户端
7.1 改造前的性能基线
我在自己的测试环境里搭了一个模拟器,模拟 10 台设备 + 突发洪峰。故障注入方式:每分钟触发一次 2000 条消息的突发补发,持续 10 秒。同时叠加 1% 概率的随机数据抖动。
改造前的指标:
| 场景 | CPU | UI 状态 |
|---|---|---|
| 常规 100 msg/s | 15~20% | 正常 |
| 突发 2000 msg | 80~100% | 白屏、未响应,恢复耗时长 |
| 加抖动数据 | 不变 | 曲线尖刺明显 |
7.2 逐步改造过程
第一步,加缓冲队列。接收事件改为只入队,消费线程常驻读取。这一步完成后,突发场景从"完全卡死"变成"有延迟但能恢复"。
第二步,批量消费。消费线程攒 50 条一批派发,UI 线程待处理任务骤减。
第三步,节流刷新。图表和表格共用节流器,100 毫秒最多刷一次。
第四步,滤波。对温度类数据用限幅 + 滑动平均,曲线尖刺消失。
第五步,虚拟化和 Diff 更新。DataGrid 配置修正,快照改成增量应用。
第六步,熔断与降级。队列积压超过 8000 条进入熔断,状态栏显示积压数量和丢弃数量。
改造后的指标:
| 场景 | CPU | UI 状态 |
|---|---|---|
| 常规 100 msg/s | 8~12% | 正常 |
| 突发 2000 msg | 20~30% | 有轻微掉帧,不白屏 |
| 突发 5000 msg | 35~45% | 降级模式,状态正常不崩溃 |
7.3 改造后的反思
这个结果不是靠某个单一策略拿到的,而是组合拳的效果。队列解决了量的问题,节流解决了渲染频率问题,滤波解决了质量问题,熔断兜住了极端场景。每减少一层压力,后面一层就能更从容。
我特别想强调一点:改造前先埋点测量。没有队列积压数和 UI 线程任务数的量化数据,你很难知道瓶颈到底在哪。我在消费端加了 Interlocked.Increment 统计丢弃数,在节流器里记录了积压峰值,这些数据对定位问题帮助极大。
8. 踩坑记录:这些细节不处理,前面的功夫全白费
8.1 事件未注销导致的内存泄漏和重复更新
为了做增量 Diff,我给每个设备 ViewModel 挂了属性变化订阅。一开始忘了在设备移除时退订,结果设备反复上下线后,旧 ViewModel 还留在内存里被通知,界面越跑越慢。排查手段是用内存分析工具看存活实例数量。规范做法是:设备移除时显式退订事件,或者用弱事件。
8.2 Freezable 冻结:被忽略的 GPU/内存优化
WPF 里很多资源类型继承自 Freezable,比如 Brush、Pen、Transform。创建后调用 Freeze(),可以把它变为只读并跨线程安全共享。在高频更新图表时,如果每条曲线都新建画刷,内存和渲染开销都很大。把笔刷定义成静态资源并冻结,实测渲染性能提升明显。
csharp复制var brush = new SolidColorBrush(Colors.DodgerBlue);
brush.Freeze(); // 只读共享
8.3 这 8 种策略的优先级排序
如果项目时间紧,不可能全做,我的个人排序是:
- 缓冲队列 + 批量消费,收益最大,优先做。
- 渲染节流,只要 UI 还在频繁刷新,一定要做。
- 增量 Diff 更新,列表数据多时必做。
- 限幅滤波,数据抖动明显时做,成本极低。
- 虚拟化,列表超过 1000 行时做。
- Dispatcher 优先级调整,成本低但单独用效果有限。
- 熔断降级,生产环境长期运行前做。
- 背压,有"不能丢数据"的强需求时做。
每一个策略单独拎出来都不算难,难的是组合时的取舍。比如批量大小、节流间隔、队列容量,这些参数在不同设备上报频率下差异很大,需要实际压测调整。我的习惯是先把队列和批量做起来,再根据线上指标逐步加节流和滤波,每加一层都重新压测对比,而不是一次性全部上。
最后再分享一个排查小技巧:如果 UI 还卡,先把数据更新相关的绑定逐个禁用排查。很多卡顿不是来自你的业务代码,而是某个 DataTemplate 里绑定了高开销的转换器或 StringFormat。用 Performance Profiler 抓一下 WPF 的 UI 线程活动,比靠猜高效得多。
