文章目录
-
- 防止数据抖动
- Interlocked锁
- 节流(Throttling)控制执行速率
- 响应式编程 (Rx.NET)
- [界面冻结与 Loading 状态](#界面冻结与 Loading 状态)
- 数据批处理(攒够了再发)
- [UI虚拟化(UI Virtualization)](#UI虚拟化(UI Virtualization))
- [数据虚拟化(Data Virtualization)](#数据虚拟化(Data Virtualization))
-
- [1. 基于 IList 的简易数据虚拟化逻辑](#1. 基于 IList 的简易数据虚拟化逻辑)
- [2. 注意事项](#2. 注意事项)
-
- 2.1.设置容器(占位)
- 2.2.处理"白屏"与占位
- [2.3.内存管理(LRU 缓存)](#2.3.内存管理(LRU 缓存))
防止数据抖动
针对短时间内,消息大量冲击,防抖机制,在一段连续的触发中,只执行最后一次,或者说将执行推迟到之后
csharp
private CancellationTokenSource _cts;
private readonly TimeSpan _delayTime = TimeSpan.FromMilliseconds(500);
private readonly ConcurrentDictionary<string, string> Cache = new ConcurrentDictionary<string, string>();
private readonly object _lock = new object();
private void XXX_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
CancellationToken token;
lock (_lock)
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
token = _cts.Token;
}
if (e.PropertyName == nameof(XXXDevice.temperature))
{
var currentTemperature = XXXDevice.temperature;
var key = XXXDevice.GetHashCode().ToString();
// 死区过滤
if (!double.TryParse(XXXDevice.temperature, out var currentVal)) return;
int deviceId = XXXDevice.GetHashCode();
if (Cache.TryGetValue(deviceId, out var oldVal))
{
if (Math.Abs(currentVal - oldVal) < 1.0) return;
}
Cache[deviceId] = currentVal;
#region 法一
Task.Delay(_delayTime, token).ContinueWith(task =>
{
if (task.IsCanceled) return;
// TODO
}, token);
#endregion
#region 法二
try
{
await Task.Delay(_delayTime, token);
// TODO
}
catch (OperationCanceledException)
{
}
#endregion
}
}
- 防抖 (Debouncing):一种策略,规定在事件触发 n 秒后才执行,若在 n 秒内再次触发,则重新计时。
- 竞态条件 (Race Condition):多个线程同时读写共享数据,最终结果取决于线程执行的精确时序,通常会导致不可预知的错误。
- 闭包 (Closure):函数能够记住并访问其定义时所在的词法作用域,即使函数在作用域外执行。代码中 task => { ... } 捕获了外部变量。
- 原子性 (Atomicity):指一个操作要么全部执行成功,要么全部不执行,中间状态对外部不可见。
Interlocked锁
csharp
private int _isExecuting = 0;
private void HandleDoubleClick(object sender, MouseButtonEventArgs e)
{
if (Interlocked.Exchange(ref _isExecuting, 1) == 1) return;
try
{
if (sender != null && sender is ListViewItem)
{
if (XXX.XXXCommand.CanExecute(null))
{
XXX.XXXCommand.Execute(null);
}
}
}
finally
{
Interlocked.Exchange(ref _isExecuting, 0);
}
}
| 步骤 | 操作 | _isExecuting 值 | 返回值 | 结果 |
|---|---|---|---|---|
| 初始状态 | - | 0 | - | - |
| 第一次点击 | Exchange(ref v, 1) | 1 | 0 | 0 == 1 为假,继续执行 |
| 极速第二次点击 | Exchange(ref v, 1) | 1 | 1 | 1 == 1 为真,直接 return |
| 业务执行完毕 | Exchange(ref v, 0) | 0 | 1 | 门开了,下次点击可进入 |
节流(Throttling)控制执行速率
与防抖(只执行最后一次)不同,节流保证在一段连续的时间内,以固定的频率执行逻辑。
- 场景:滚动条监听、触摸屏上的滑动手势、实时温度波形刷新。
- 实现:记录上一次执行的时间戳,如果当前时间与上次执行时间差小于设定的阈值(如 100ms),则直接拦截。
csharp
private long _lastTicks = 0;
private readonly long _thresholdTicks = TimeSpan.FromMilliseconds(200).Ticks;
private void HandleHighFrequencyEvent()
{
long currentTicks = DateTime.Now.Ticks;
if (currentTicks - _lastTicks < _thresholdTicks) return;
_lastTicks = currentTicks;
// 执行逻辑...
}
响应式编程 (Rx.NET)
将事件流看作"河流",通过各种算子(Filter, Buffer, Sample)对河流进行治理。
- 场景:极其复杂的事件组合(例如:当 A 事件触发且 B 事件在 2 秒内没触发时,执行 C)。
csharp
// 将 PropertyChanged 转换为流,并在 500ms 内只取最后一次
Observable.FromEventPattern<PropertyChangedEventArgs>(this, "PropertyChanged")
.Where(x => x.EventArgs.PropertyName == "temperature")
.Throttle(TimeSpan.FromMilliseconds(500))
.ObserveOn(SynchronizationContext.Current) // 切回 UI 线程
.Subscribe(x => { /* 执行业务 */ });
// 假设有一个原始的高频数据流
IObservable<DataItem> ultraHighFrequencyDataStream = ...;
// 在数据订阅链中插入采样操作
ultraHighFrequencyDataStream
.Sample(TimeSpan.FromMilliseconds(100)) // 关键:每100毫秒最多发射一次最近的数据
.Buffer(TimeSpan.FromMilliseconds(500)) // 可选:将500ms内的数据打包成一个列表
.ObserveOn(RxApp.MainThreadScheduler)
.Subscribe(batchOfItems =>
{
_dataCache.AddOrUpdate(batchOfItems);
});
界面冻结与 Loading 状态
从心理学和交互确定性出发。在执行重型逻辑时,物理性地切断交互入口。
- 操作 :
- Disable UI :点击瞬间将
Button.IsEnabled = false。 - 遮罩层 :弹出透明或半透明的
Overlay,拦截所有触摸/鼠标事件。
- Disable UI :点击瞬间将
- 优点:不仅解决了重入问题,还给了用户明确的"正在处理"反馈,防止用户因为怀疑程序没反应而产生疯狂连击。
数据批处理(攒够了再发)
不丢失任何数据,但也不立即处理。将短时间内涌入的多个消息装进一个"篮子",达到一定数量或时间后,一次性批量处理。
- 场景:日志写入、数据库批量插入、多设备状态同步刷新。
- 实现 :使用
ConcurrentQueue暂存消息,启动一个后台定时器(Timer)每隔 1 秒取空队列进行统一处理。
csharp
public class DataBatchProcessor
{
// 1. 缓冲区:暂存高频流入的数据
private readonly ConcurrentQueue<string> _buffer = new ConcurrentQueue<string>();
// 2. 触发阈值
private readonly int _batchSize = 50;
private readonly TimeSpan _maxDelay = TimeSpan.FromSeconds(3);
private DateTime _lastFlushTime = DateTime.Now;
private readonly object _flushLock = new object();
// 外部调用的写入接口
public void EnqueueData(string message)
{
_buffer.Enqueue(message);
// 如果缓冲区数量达到阈值,立即触发处理
if (_buffer.Count >= _batchSize)
{
Flush();
}
}
// 核心处理逻辑:把篮子清空,统一发送
private void Flush()
{
// 简单互斥,防止高频冲击下多个 Flush 同时运行
if (!Monitor.TryEnter(_flushLock)) return;
try
{
var itemsToSend = new List<string>();
// 批量取出当前队列中的所有数据
while (itemsToSend.Count < _batchSize && _buffer.TryDequeue(out var item))
{
itemsToSend.Add(item);
}
if (itemsToSend.Any())
{
ExecuteBatchTask(itemsToSend);
_lastFlushTime = DateTime.Now;
}
}
finally
{
Monitor.Exit(_flushLock);
}
}
private void ExecuteBatchTask(List<string> data)
{
// TODO: 这里执行真正的重型操作,比如写入数据库或调用 Web API
Console.WriteLine($"[Batch] 成功处理 {data.Count} 条数据");
}
// 定时器补丁:防止数据因为凑不满数量而死在缓冲区里
public void StartTimer()
{
Task.Run(async () =>
{
while (true)
{
await Task.Delay(1000); // 每秒检查一次
if (DateTime.Now - _lastFlushTime >= _maxDelay)
{
Flush();
}
}
});
}
}
UI虚拟化(UI Virtualization)
UI虚拟化是WPF内置的最重要性能优化功能,它只创建可视区域内的UI元素。对于滚动条之外的项,不会创建相应的ListBoxItem或DataGridRow,从而节省了大量内存 和计算资源。
确保你的列表控件启用了虚拟化(默认通常是开启的,但需避免意外破坏):
csharp
<!-- ListBox 示例 -->
<ListBox VirtualizingStackPanel.IsVirtualizing="True"
VirtualizingStackPanel.VirtualizationMode="Recycling" <!-- 复用UI元素 -->
ScrollViewer.CanContentScroll="True"> <!-- 必需项,用于精确滚动 -->
<!-- ItemTemplate -->
</ListBox>
<!-- DataGrid 示例 -->
<DataGrid EnableRowVirtualization="True"
EnableColumnVirtualization="True"
VirtualizingPanel.ScrollUnit="Pixel" <!-- 更平滑的滚动 -->
RowHeight="25"> <!-- 固定行高有助于虚拟化计算 -->
</DataGrid>
提示:
避免在ItemsControl内部使用ScrollViewer包装其内容,这会破坏虚拟化。
尽可能为项容器(如DataGridRow)设置固定高度,或实现IValueConverter进行高度估算,以帮助虚拟化面板正确计算布局。
数据虚拟化(Data Virtualization)
你告诉控件总共有 100 万行(设置占位高度),但内存里只存当前屏幕显示的 20 条。当用户滚动时,你才去后台或数据库"瞬移"取数。
- UI 虚拟化:解决了"100 万个按钮怎么画"的问题(只画可见的)。
- 数据虚拟化:解决了"100 万个对象怎么存内存"的问题(只存可见的)。
- 控件需要知道总数(Count),以便渲染滚动条长度,但在访问索引
[999999]时,你必须能即时拉取数据。
1. 基于 IList 的简易数据虚拟化逻辑
WPF 的 ItemsControl(如 ListBox, DataGrid)如果发现 ItemsSource 实现了 IList 接口,它会通过索引器 this[int index] 来取值。我们可以利用这一点拦截取数逻辑。
csharp
public class VirtualizingCollection<T> : IList<T>, IList
{
private readonly int _totalCount;
private readonly Dictionary<int, T> _cache = new Dictionary<int, T>();
private readonly int _pageSize = 50; // 每一页加载的数量
public VirtualizingCollection(int count)
{
_totalCount = count;
}
// 关键点:WPF 访问索引器时触发按需加载
public T this[int index]
{
get
{
if (!_cache.ContainsKey(index))
{
// 异步或同步拉取数据(此处为演示使用同步模拟)
LoadPage(index);
return default; // 加载中先返回默认值,页面刷新后再显示
}
return _cache[index];
}
set => throw new NotSupportedException();
}
private void LoadPage(int index)
{
int pageIndex = index / _pageSize;
int start = pageIndex * _pageSize;
// TODO: 调用 Service 从数据库获取第 pageIndex 页的数据
// var data = _service.GetItems(start, _pageSize);
// 模拟加载数据存入缓存
for (int i = 0; i < _pageSize; i++)
{
_cache[start + i] = (T)Convert.ChangeType($"数据行 {start + i}", typeof(T));
}
// 触发 UI 通知,告诉 WPF 这一块数据变了,重新来取
// 注意:实际开发中需结合 INotifyCollectionChanged
}
public int Count => _totalCount;
// 其他 IList 接口成员(IsReadOnly, GetEnumerator 等)...
}
2. 注意事项
2.1.设置容器(占位)
必须开启 UI 虚拟化,否则即使数据是虚拟的,WPF 也会尝试一次性创建 100 万个 UI 容器。
csharp
<DataGrid ItemsSource="{Binding MyVirtualCollection}"
VirtualizingStackPanel.IsVirtualizing="True"
VirtualizingStackPanel.VirtualizationMode="Recycling">
</DataGrid>
2.2.处理"白屏"与占位
当用户快速滚动到第 50 万行时,数据还没从数据库回来。
- 方案 :索引器先返回一个
Loading...的对象或null。 - UI 反馈 :使用
DataTemplate的TargetNullValue来显示一个灰色色块。
2.3.内存管理(LRU 缓存)
如果用户反复滚动,_cache 字典会越来越大。
- 优化 :引入 最近最少使用(LRU) 算法。当缓存超过 500 条时,自动丢弃距离当前可视区域最远的旧数据,释放内存。
