长列表卡顿大概是 WPF 出场率最高的性能问题。最近交付的一个改造案例:10 万行数据用 ItemsControl 展示,首屏 3 秒多、滚动像 PPT。最后没换任何第三方控件库,改了 4 处,0.2 秒打开、滚动满帧。按收益从大到小讲。
1. 先确认虚拟化真的开着
最大的误区在这:UI 虚拟化只由 VirtualizingStackPanel 提供,ItemsControl 默认的 ItemsPanel 是普通 StackPanel,不虚拟化——10 万行全量生成可视元素,内存直接爆炸。
翻车点清单:
ItemsControl做长列表 → 换ListBox/ListView(默认模板自带VirtualizingStackPanel);- 为「平滑滚动」设了
ScrollViewer.CanContentScroll="False"→ 虚拟化整体被关闭,改回True,用VirtualizingPanel.ScrollUnit="Pixel"保住像素级滚动; - 分组
GroupStyle破坏虚拟化 → .NET 4.5+ 加IsVirtualizingWhenGrouping="True"。
推荐配置:
<ListBox ItemsSource="{Binding Rows}"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
VirtualizingPanel.ScrollUnit="Pixel"
ScrollViewer.CanContentScroll="True">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="160"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="120"/>
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding Code}"/>
<TextBlock Grid.Column="1" Text="{Binding Name}"/>
<TextBlock Grid.Column="2" Text="{Binding Amount, StringFormat=N2}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
Recycling 模式复用容器而不是销毁重建,10 万行内存从 ~1.8GB 降到 ~260MB。
2. 数据端:逐条 Add 改整批赋值
// 反例:每条 Add 触发一次 CollectionChanged + 布局失效
foreach (var row in rows) Rows.Add(row);
// 正解:后台构建完整列表,整批替换(只触发一次 Reset)
var rows = await Task.Run(() => _repo.QueryPage(0, 500));
Rows = new ObservableCollection<RowVm>(rows);
RaisePropertyChanged(nameof(Rows));
配套细节:ViewModel 实现 INotifyPropertyChanged;高频字段精确通知,别用 RaisePropertyChanged(string.Empty) 全量刷新。
3. 模板瘦身
虚拟化决定「生成几个容器」,模板决定「每个多贵」:
- 模板层级压到一层
Grid; TextBlock代替Label(少一层 ContentPresenter);- 图片:
DecodePixelWidth=48+BitmapCacheOption.OnLoad+Freeze(),异步小图 + 免变更通知:
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.DecodePixelWidth = 48;
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.UriSource = new Uri(path);
bmp.EndInit();
bmp.Freeze();
4. 数据层 keyset 分页
能滚 ≠ 一次捞 10 万行。深分页 LIMIT OFFSET 越翻越慢(要跳过前 N 行),改 keyset:
SELECT Id, Code, Name, Amount
FROM Positions
WHERE Id > @lastId
ORDER BY Id
LIMIT @n;
滚动到底 Task.Run 拉下一页追加,SQLite 连接串加 Pooling=true。
效果
首屏 3.1s → 0.2s;内存 1.8GB → 260MB;滚动满帧。排查顺序 = 收益顺序:虚拟化 → 批量赋值 → 模板 → 分页,多数项目前两步就达标。
这套 keyset 分页协议独立成服务层后可以直接挂 WebAPI 给小程序 / APP 复用,桌面端的优化经验一点不浪费。觉得有用的话点个关注,后续会持续更新 .NET 桌面端、小程序和 APP 开发的实战记录。