WPF 大列表卡顿优化:10 万行从 3 秒到 0.2 秒,我只改了 4 处

3 阅读2分钟

长列表卡顿大概是 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 开发的实战记录。