把 CSV 解析放进 Task.Run 后,窗口还是在导入十万条记录时失去响应。一个常见原因是后台线程每解析一条,就向 Dispatcher 投递一次 Rows.Add。计算离开了 UI 线程,十万次集合通知、绑定和布局却仍排在 UI 线程上等待执行。
UI 队列也需要吞吐预算
WPF 的 Dispatcher 按优先级选择任务,单次任务执行期间不会因为用户输入而自动中断。因此,把十万次 Add 包进一个 InvokeAsync 也不等于解决卡顿,只是把许多小卡顿变成一次大冻结。WPF 线程模型文档说明了这一调度模型。
假设一批 100 条数据的应用阶段耗时 4 毫秒,调度及其他间接成本再占 1 毫秒,理论上每秒可应用约两万条。但如果某个单元格模板包含昂贵转换器,批次可能突然需要 40 毫秒。固定“每批 100 条”只是起点,真正要限制的是一次占用 UI 线程的时间及等待队列长度。
可以把导入分成四段:后台读取、解析和业务校验;有界缓冲;UI 批量应用;绑定与布局。每段记录耗时,才知道该优化文件读取、对象分配、通知,还是模板。仅看到 CPU 不高,无法排除 UI 线程在等待锁或处理频繁的小任务。
一个可取消、不会无限堆积的应用循环
下面是 .NET 8 WPF 的核心示例,适用于只追加、最多保留 5,000 条预览记录的界面。它展示调度边界,不包含完整窗口、XAML 或文件解析器,未在配套 WPF 项目中编译。
1 | using System.Collections.ObjectModel; |
调用方创建容量有限、FullMode 为 Wait 的 Channel,再让单个生产者顺序写入。ApplyAsync 必须被保存并等待,不能从按钮事件里无观察地启动。若生产者失败,应完成写端并传递异常;若用户重新加载,则取消旧生产任务,同时递增 generation。两者分别约束旧任务的资源消耗和旧结果的可见性。
这个循环每次只保留一个等待完成的 Dispatcher 操作,因此不会在 UI 队列积压上万批。不过它不是通用高性能集合实现:ObservableCollection 逐条 Add 仍会逐条通知,RemoveAt(0) 也有搬移成本。预览上限很大或更新极频繁时,应改用分页、固定窗口数据结构,或支持批量差量通知且经实际控件验证的集合方案。
过期结果比取消更隐蔽
用户先选择文件 A,随后马上选择文件 B。A 的读取即使支持取消,也可能已经产生一个准备提交的批次。只在后台 await 后检查令牌不够,因为检查与 UI 执行之间还存在排队时间。
generation 在 UI 应用点再次检查,形成明确规则:只有当前加载代的数据可以进入界面。旧加载还应有自己的 CancellationTokenSource,并由拥有它的 ViewModel 结束和释放;代号不会自动停止后台任务。
如果是实体更新而非整体加载,代号应细化为每个实体或查询请求的版本。简单使用“最后完成的请求覆盖界面”会让慢请求覆盖新选择;“最后发起的有效请求获胜”才通常符合用户操作顺序。
同步集合、批量刷新和虚拟化分别解决什么
EnableCollectionSynchronization 让 WPF 参与同一套集合访问同步,但所有应用访问仍需遵守同步规则,而且要在合适的 UI 线程初始化。它不是“允许任意后台线程修改 UI”的开关。API 的责任边界对此有明确说明。
如果后台线程持有集合锁后同步 Invoke UI,而 UI 线程正等待这把锁,就可能死锁。对常见 MVVM 页面,后台生成不可变批次、UI 单线程持有可观察集合,往往更容易推理。
CollectionView 的 DeferRefresh 主要用于合并视图配置刷新,例如一起改变排序和过滤条件;不要假定套上它就能把 ObservableCollection 的一千次 Add 自动变成一次 CollectionChanged。自定义 AddRange 也要确认目标视图是否接受多项通知,不能仅因为自己定义了一个同名方法就认为整条绑定链获得批处理能力。
UI 虚拟化减少不可见行的容器与布局成本,数据虚拟化则减少实际加载的数据量,两者不同。ListView、ListBox 的数据绑定场景通常启用 UI 虚拟化,但错误的面板、关闭 CanContentScroll 等设置可能使它失效;容器回收还要求把行状态绑定到数据,而不是留在复用容器里。WPF 控件性能文档给出了相关条件。
不要让“优化”破坏产品语义
对实时曲线,每个传感器只显示最后一个值,可以先在后台按设备键合并更新;对审计日志,合并会丢失历史,应该写入存储后分页展示。对十万行表格,用户通常无法同时检查全部记录,服务端或数据库分页比维护一个超大的 ObservableCollection 更直接。
另外,后台返回的行对象应尽量是不可变快照。若界面读取 Value 时后台恰好修改同一个对象,即使集合本身安全,也不代表对象属性的并发访问具有一致性。编辑型行则需要单独定义变更通知、提交和冲突策略。
批次大小不是越大越好
可以先给应用回调设一个例如 4 毫秒的观测目标。若一批 100 条实际耗时 20 毫秒,下一批缩为 20 条是合理起点;若只耗时 0.2 毫秒,则可以适当放大,减少调度开销。但这些数字只用于说明控制方法,不是对任何机器的保证。样本需要覆盖开启排序、分组和复杂模板后的真实页面。
每批的计时应覆盖通知发出与业务修改,却还要通过 UI 时间线观察它在回调之后引发的布局。只测 foreach 的耗时会漏掉延后执行的渲染工作。某些数据量下回调很短,后续 CollectionView 排序却很昂贵,继续缩小批次可能反而让排序重复更多次。
导入进度也要节流。若每解析一条就更新一次百分比文本,刚刚省下的集合调度可能又被十万次 PropertyChanged 抵消。进度通常只需按固定时间间隔或百分比变化发布最终快照;完成、失败与取消则应立即转成明确状态。
最后区分加载完成与显示完成。生产者读完文件时,UI 队列可能还未排空;只有应用循环完成并确认 generation 仍有效,才应显示“导入完成”。否则用户看到成功提示,却仍无法检索最后几千条记录。若完整数据已落库、界面仅展示预览,则提示要明确说明这两个范围,而不是用同一个行数混淆它们。
可以实际执行的比较实验
生成固定种子的十万条数据,保持模板、排序、窗口尺寸和机器一致,比较逐条投递、单次全部投递、每批 100 条三个版本。分别记录总加载时间、最长 UI 回调耗时、输入响应延迟、分配量和工作集,而不是只比较 Stopwatch 的总秒数。
接着在第 1 秒切换到第二份数据,检查最终列表不存在旧 generation;在等待 UI 时取消,确认任务结束且未吞掉故障;关闭窗口后确认没有后台任务继续持有 ViewModel。再把复杂转换器移入后台预计算,观察 UI 时间线是否相应缩短。
本文没有提供基准测试结果。判断方案是否有效的证据,应是同一工作负载下更稳定的输入响应和受控内存,同时满足数据完整性与取消语义,而不只是“现在看起来快一点”。