Skip to Content
Week 09Day 2 - 列表虚拟化

Day 2 - 大列表和虚拟化

建议用时:180-210 分钟

你将学会什么

  • 为什么列表数据一多,窗口就容易卡
  • 什么是“数据很多”和“界面控件很多”的区别
  • 为什么不能一次把几千条都显示出来
  • 怎么用“总数据”和“可见数据”拆开解决问题
  • 怎么用搜索和首屏数量减少界面压力
  • 怎么用 Stopwatch 记录加载耗时

本页不要先背概念。先看一个完整页面,知道“慢在哪里”和“怎么改”,再去下面拆原理,最后跟着敲完整代码。

本页固定顺序

  1. 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
  2. 再学第二部分:把刚学的知识组合成一个完整例子。
  3. 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
  4. 最后做第四部分:先独立完成作业,再用完整答案检查。

学习衔接

上一页学习的是“性能认知”,今天继续学习“大列表和虚拟化”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。

今天的最低通过线

第一次学习不要求背完整页。完成下面 3 项,就可以继续:

  • 能用自己的话说明“大列表和虚拟化”解决什么问题。
  • 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
  • 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。

第一部分:先学原理和最小知识

这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。这里要解决的是:为什么大列表会慢,为什么要拆成 allProductsVisibleProducts

先把“数据”和“控件”分开

程序里的 5000 条商品,只是 5000 个数据对象。

窗口上的一行商品,不只是一个字符串。它可能会变成 ListBoxItemGrid、多个 TextBlock,还要参与布局、测量、绘制、滚动。

所以这两个概念不同:

概念它是什么数量太多会怎样
数据对象内存里的商品记录占内存,查询会变慢
界面控件屏幕上显示出来的元素布局和绘制变慢,滚动卡

写列表页面时,真正容易拖慢窗口的是:一次让界面创建太多行、每一行又放太多控件。

什么是虚拟化

虚拟化的意思不是“数据变少了”。

虚拟化的意思是:数据可以很多,但界面尽量只创建当前需要显示的那一小部分控件。

例如列表里有 5000 条数据,屏幕一次只能看到 20 条。好的列表控件不会真的把 5000 行界面全部创建好,而是优先处理可见区域附近的内容。

但是要注意:虚拟化不是万能的。

如果你给每一行放很多复杂控件、每一行都加载图片、每次搜索都把 5000 条全部重新塞进界面,还是会慢。

为什么还要限制首屏数量

本页用了一个更容易控制的做法:把数据拆成两份。

变量含义谁使用
allProducts全部 5000 条商品ViewModel 内部保存完整数据
VisibleProducts当前要显示到界面的最多 100 条ListBox 绑定它

这样做的好处是:

  • 界面永远只关心 VisibleProducts
  • 搜索逻辑可以在 allProducts 里完成。
  • 列表不会因为全部数据太多而突然塞满界面。
  • 后面改成分页、加载更多、数据库查询时,结构也不乱。

ObservableCollection 为什么放在可见数据上

ObservableCollection<T> 的作用是:集合变化时通知界面。

例如:

VisibleProducts.Clear(); VisibleProducts.Add(product);

界面绑定的是 VisibleProducts,所以它能知道列表清空了、又新增了哪些行。

allProducts 在本页只是内部数据源,不直接绑定到界面,所以用普通 List<ProductItem> 就够了。

为什么用 Take(PageSize)

Take(PageSize) 的意思是:只取前 PageSize 条。

本页写了:

private const int PageSize = 100;

所以:

allProducts.Take(PageSize)

就是只取前 100 条。

这一步不是为了让数据消失,而是为了控制当前界面显示量。

为什么要记录耗时

性能问题不能只靠感觉。

Stopwatch 可以记录一段代码运行了多久:

var watch = Stopwatch.StartNew(); // 这里放要测量的代码 watch.Stop(); Message = $"耗时 {watch.ElapsedMilliseconds}ms";

以后遇到“感觉变慢了”,不要先乱改代码。先把耗时打出来,看慢的是加载、搜索、排序、刷新列表,还是图片和文件读取。

搜索为什么不直接改 ListBox

搜索的正确思路是:

  1. 用户输入关键字。
  2. ViewModel 从全部数据里筛选。
  3. 最多取 100 条。
  4. 刷新 VisibleProducts
  5. ListBox 自动显示新的 VisibleProducts

不要直接去操作 ListBox.Items

原因是:MVVM 里界面负责显示,ViewModel 负责数据和规则。规则写在 ViewModel 里,后面才容易测试、修改和复用。

大列表页面的判断顺序

遇到列表卡顿,按这个顺序排查:

  1. 数据是不是一次加载太多。
  2. 界面是不是一次显示太多。
  3. 单行模板是不是太复杂。
  4. 搜索和排序是不是每敲一个字都全量执行。
  5. 图片、文件、网络请求是不是放在界面线程里。

本页先解决前 3 个问题:控制显示数量、保持模板简单、记录耗时。

大列表常用操作速查

需求写法说明
保存全部数据allProducts不直接绑定全部
显示可见数据VisibleProducts绑定界面
截取前几条.Take(100)控制显示数量
搜索过滤.Where(...)从全部数据筛
重新加载RefreshVisibleProducts()条件变化后调用
计时Stopwatch判断刷新耗时
空状态IsEmpty搜索无结果时提示

大列表原则:

全部数据留在内存 界面只显示需要看的部分 搜索和筛选后再刷新 VisibleProducts

第二部分:把知识组合成完整例子

这一页要做的是一个“大列表页面”。

程序里面准备 5000 条商品数据,但是窗口上不要一次塞 5000 条,而是只显示前 100 条。搜索时也是先在全部数据里找,再最多显示 100 条结果。

最终效果

你会得到一个窗口:

  • 打开窗口后,列表显示 100 条商品。
  • 顶部可以输入关键字搜索。
  • 搜索后,下面列表只显示匹配结果的前 100 条。
  • 界面会显示“本次显示多少条,总数据多少条,耗时多少毫秒”。
  • 代码里能清楚区分“全部数据”和“界面正在显示的数据”。

完整 ViewModel

文件位置:

ViewModels/MainWindowViewModel.cs
using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.Linq; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ProductApp.ViewModels; public partial class MainWindowViewModel : ViewModelBase { private const int PageSize = 100; private readonly List<ProductItem> allProducts = Enumerable .Range(1, 5000) .Select(i => new ProductItem { Name = $"Product {i:0000}", Price = i * 1.5m }) .ToList(); public ObservableCollection<ProductItem> VisibleProducts { get; } = new(); [ObservableProperty] private string searchText = ""; [ObservableProperty] private string message = "点击“加载首屏”显示前 100 条。"; public MainWindowViewModel() { LoadFirstPage(); } [RelayCommand] private void LoadFirstPage() { var watch = Stopwatch.StartNew(); VisibleProducts.Clear(); foreach (var product in allProducts.Take(PageSize)) { VisibleProducts.Add(product); } watch.Stop(); Message = $"首屏显示 {VisibleProducts.Count} / {allProducts.Count} 条,耗时 {watch.ElapsedMilliseconds}ms"; } [RelayCommand] private void Search() { string keyword = SearchText.Trim(); var watch = Stopwatch.StartNew(); IEnumerable<ProductItem> query = allProducts; if (!string.IsNullOrWhiteSpace(keyword)) { query = allProducts.Where(product => product.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase)); } List<ProductItem> result = query .Take(PageSize) .ToList(); VisibleProducts.Clear(); foreach (var product in result) { VisibleProducts.Add(product); } watch.Stop(); Message = $"搜索后显示 {VisibleProducts.Count} / {allProducts.Count} 条,耗时 {watch.ElapsedMilliseconds}ms"; } } public sealed class ProductItem { public string Name { get; set; } = ""; public decimal Price { get; set; } }

完整窗口

文件位置:

Views/MainWindow.axaml
<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ProductApp.ViewModels" x:Class="ProductApp.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Width="760" Height="560" Title="Large List Demo"> <Grid RowDefinitions="Auto,Auto,Auto,*" Margin="20" RowSpacing="12"> <TextBlock Text="大列表页面" FontSize="24" FontWeight="Bold" /> <TextBlock Grid.Row="1" Text="程序里有 5000 条数据,但界面一次最多显示 100 条。" Foreground="#555555" /> <StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8"> <TextBox Width="240" PlaceholderText="输入 Product 01 试试" Text="{Binding SearchText, Mode=TwoWay}" /> <Button Content="搜索" Command="{Binding SearchCommand}" /> <Button Content="加载首屏" Command="{Binding LoadFirstPageCommand}" /> </StackPanel> <Grid Grid.Row="3" RowDefinitions="Auto,*" RowSpacing="8"> <TextBlock Text="{Binding Message}" /> <ListBox Grid.Row="1" ItemsSource="{Binding VisibleProducts}"> <ListBox.ItemTemplate> <DataTemplate x:DataType="vm:ProductItem"> <Grid ColumnDefinitions="160,100" Margin="4"> <TextBlock Text="{Binding Name}" /> <TextBlock Grid.Column="1" Text="{Binding Price}" /> </Grid> </DataTemplate> </ListBox.ItemTemplate> </ListBox> </Grid> </Grid> </Window>

第三部分:跟着敲代码

从这里开始动手。每一步都先运行一次,不要一次改完所有文件才运行。

第 1 步:创建数据模型

先在 ViewModels/MainWindowViewModel.cs 里放一个商品类型。

public sealed class ProductItem { public string Name { get; set; } = ""; public decimal Price { get; set; } }

解释:

  • Name 是商品名称。
  • Price 是价格。
  • 这个类只是数据,不负责界面显示。

第 2 步:准备全部数据

MainWindowViewModel 里面加入全部商品列表。

private readonly List<ProductItem> allProducts = Enumerable .Range(1, 5000) .Select(i => new ProductItem { Name = $"Product {i:0000}", Price = i * 1.5m }) .ToList();

解释:

  • Enumerable.Range(1, 5000) 会生成从 1 到 5000 的数字。
  • Select 把每个数字变成一个 ProductItem
  • Product {i:0000} 会把数字补成 4 位,例如 Product 0001
  • ToList() 把结果真正变成一个列表。

第 3 步:准备界面显示的数据

继续加入可见列表。

private const int PageSize = 100; public ObservableCollection<ProductItem> VisibleProducts { get; } = new();

解释:

  • PageSize 表示一次最多显示多少条。
  • VisibleProducts 是给界面绑定的集合。
  • 界面不会直接绑定 allProducts

第 4 步:加入搜索文字和提示文字

[ObservableProperty] private string searchText = ""; [ObservableProperty] private string message = "点击“加载首屏”显示前 100 条。";

解释:

  • SearchText 会绑定到输入框。
  • Message 会显示本次操作结果。
  • [ObservableProperty] 会自动生成能通知界面的属性。

第 5 步:写加载首屏命令

[RelayCommand] private void LoadFirstPage() { var watch = Stopwatch.StartNew(); VisibleProducts.Clear(); foreach (var product in allProducts.Take(PageSize)) { VisibleProducts.Add(product); } watch.Stop(); Message = $"首屏显示 {VisibleProducts.Count} / {allProducts.Count} 条,耗时 {watch.ElapsedMilliseconds}ms"; }

解释:

  • Clear() 先清空旧显示内容。
  • Take(PageSize) 只取前 100 条。
  • Add(product) 把商品加入界面正在显示的列表。
  • Stopwatch 记录这次刷新花了多久。
  • [RelayCommand] 会生成 LoadFirstPageCommand,按钮可以绑定它。

第 6 步:写搜索命令

[RelayCommand] private void Search() { string keyword = SearchText.Trim(); var watch = Stopwatch.StartNew(); IEnumerable<ProductItem> query = allProducts; if (!string.IsNullOrWhiteSpace(keyword)) { query = allProducts.Where(product => product.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase)); } List<ProductItem> result = query .Take(PageSize) .ToList(); VisibleProducts.Clear(); foreach (var product in result) { VisibleProducts.Add(product); } watch.Stop(); Message = $"搜索后显示 {VisibleProducts.Count} / {allProducts.Count} 条,耗时 {watch.ElapsedMilliseconds}ms"; }

解释:

  • Trim() 去掉输入前后的空格。
  • 空关键字表示不筛选,仍然显示前 100 条。
  • Contains 判断名称里是否包含关键字。
  • StringComparison.OrdinalIgnoreCase 表示不区分大小写。
  • 搜索结果仍然用 Take(PageSize) 控制显示数量。

第 7 步:窗口绑定输入框和按钮

先写顶部输入区。

<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8"> <TextBox Width="240" PlaceholderText="输入 Product 01 试试" Text="{Binding SearchText, Mode=TwoWay}" /> <Button Content="搜索" Command="{Binding SearchCommand}" /> <Button Content="加载首屏" Command="{Binding LoadFirstPageCommand}" /> </StackPanel>

解释:

  • TextBox.Text 绑定 SearchText
  • Mode=TwoWay 表示输入框改了,ViewModel 也会跟着改。
  • SearchCommand 来自 [RelayCommand] private void Search()
  • LoadFirstPageCommand 来自 [RelayCommand] private void LoadFirstPage()

第 8 步:窗口绑定列表

<ListBox Grid.Row="1" ItemsSource="{Binding VisibleProducts}"> <ListBox.ItemTemplate> <DataTemplate x:DataType="vm:ProductItem"> <Grid ColumnDefinitions="160,100" Margin="4"> <TextBlock Text="{Binding Name}" /> <TextBlock Grid.Column="1" Text="{Binding Price}" /> </Grid> </DataTemplate> </ListBox.ItemTemplate> </ListBox>

解释:

  • ItemsSource 决定列表显示哪一个集合。
  • 这里绑定的是 VisibleProducts,不是 allProducts
  • ItemTemplate 决定每一行长什么样。
  • 每一行只放两个 TextBlock,这样模板比较轻。

第 9 步:运行后这样验证

运行项目后按这个顺序检查:

  1. 打开窗口后,提示显示首屏 100 条。
  2. 列表能看到 Product 0001Product 0002 等数据。
  3. 输入 Product 01,点击搜索。
  4. 提示文字会更新耗时。
  5. 清空输入框,再点击搜索,列表回到前 100 条。
  6. PageSize 改成 20,重新运行,列表只显示 20 条。

小白重复敲写训练

大列表先从生成数据、绑定数据、减少单项内容三步练。

训练 1:生成一万条数据

public ObservableCollection<string> Items { get; } = new(Enumerable.Range(1, 10000).Select(i => $"Item {i}"));
<ListBox ItemsSource="{Binding Items}" />

滚动列表,观察是否还能正常响应。

训练 2:使用简单模板

<ListBox.ItemTemplate> <DataTemplate> <TextBlock Text="{Binding}" Margin="8,4" /> </DataTemplate> </ListBox.ItemTemplate>

改动任务:加入第二个 TextBlock,比较复杂度增加后的体验。

训练 3:分批添加

for (int i = 1; i <= 1000; i++) { Items.Add($"Item {i}"); if (i % 100 == 0) await Task.Delay(10); }

观察分批让界面有机会更新的效果。

每日小测

做完本页后,用这 5 题检查是否真的掌握。

1. 判断题

本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。

答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。

2. 填空题

本页主题是:大列表和虚拟化。今天至少要掌握的 3 个点是:

1. 为什么列表数据一多,窗口就容易卡 2. 什么是“数据很多”和“界面控件很多”的区别 3. 为什么不能一次把几千条都显示出来

答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。

3. 流程题

遇到本页相关功能时,先按什么顺序处理?

答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。

4. 找错误题

如果本页代码运行失败,第一步应该做什么?

答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。

5. 改需求题

在本页完整例子跑通后,至少改一个小需求。

可选改法:

  • 改一个字段名称。
  • 多加一个校验条件。
  • 多输出一行结果。
  • 把固定数据改成用户输入。
  • 把一次处理改成多条数据处理。

答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:为什么列表数据一多,窗口就容易卡。

上位机专项练习

设备或历史记录很多时必须虚拟化,只创建屏幕附近真正看得见的项目。

下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。

专项例子 1:虚拟化设备列表

<ListBox ItemsSource="{Binding Devices}" VirtualizationMode="Simple" Height="500" />

运行结果或界面效果:

滚动大量设备时只创建可见项

改动任务: 生成 10000 台模拟设备测试滚动。

专项例子 2:分页历史记录

int pageSize = 100; int pageNumber = 2; Reading[] page = allReadings .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToArray();

运行结果或界面效果:

第 2 页取得第 101 到 200 条

改动任务: 改成第 3 页每页 50 条。

专项例子 3:避免复杂单元格模板

<DataGridTextColumn Header="温度" Binding="{Binding Temperature, StringFormat={}{0:F1}}" />

运行结果或界面效果:

简单文字列比每格嵌套多层控件更轻

改动任务: 给压力列也用文本列。

第四部分:作业完整答案

作业要求:做一个大列表页面,内存里有 5000 条商品,界面一次最多显示 100 条,支持关键字搜索,并显示操作耗时。

答案文件 1:ViewModels/MainWindowViewModel.cs

using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.Linq; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ProductApp.ViewModels; public partial class MainWindowViewModel : ViewModelBase { private const int PageSize = 100; private readonly List<ProductItem> allProducts = Enumerable .Range(1, 5000) .Select(i => new ProductItem { Name = $"Product {i:0000}", Price = i * 1.5m }) .ToList(); public ObservableCollection<ProductItem> VisibleProducts { get; } = new(); [ObservableProperty] private string searchText = ""; [ObservableProperty] private string message = "点击“加载首屏”显示前 100 条。"; public MainWindowViewModel() { LoadFirstPage(); } [RelayCommand] private void LoadFirstPage() { var watch = Stopwatch.StartNew(); VisibleProducts.Clear(); foreach (var product in allProducts.Take(PageSize)) { VisibleProducts.Add(product); } watch.Stop(); Message = $"首屏显示 {VisibleProducts.Count} / {allProducts.Count} 条,耗时 {watch.ElapsedMilliseconds}ms"; } [RelayCommand] private void Search() { string keyword = SearchText.Trim(); var watch = Stopwatch.StartNew(); IEnumerable<ProductItem> query = allProducts; if (!string.IsNullOrWhiteSpace(keyword)) { query = allProducts.Where(product => product.Name.Contains(keyword, StringComparison.OrdinalIgnoreCase)); } List<ProductItem> result = query .Take(PageSize) .ToList(); VisibleProducts.Clear(); foreach (var product in result) { VisibleProducts.Add(product); } watch.Stop(); Message = $"搜索后显示 {VisibleProducts.Count} / {allProducts.Count} 条,耗时 {watch.ElapsedMilliseconds}ms"; } } public sealed class ProductItem { public string Name { get; set; } = ""; public decimal Price { get; set; } }

答案文件 2:Views/MainWindow.axaml

<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ProductApp.ViewModels" x:Class="ProductApp.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Width="760" Height="560" Title="Large List Demo"> <Grid RowDefinitions="Auto,Auto,Auto,*" Margin="20" RowSpacing="12"> <TextBlock Text="大列表页面" FontSize="24" FontWeight="Bold" /> <TextBlock Grid.Row="1" Text="程序里有 5000 条数据,但界面一次最多显示 100 条。" Foreground="#555555" /> <StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8"> <TextBox Width="240" PlaceholderText="输入 Product 01 试试" Text="{Binding SearchText, Mode=TwoWay}" /> <Button Content="搜索" Command="{Binding SearchCommand}" /> <Button Content="加载首屏" Command="{Binding LoadFirstPageCommand}" /> </StackPanel> <Grid Grid.Row="3" RowDefinitions="Auto,*" RowSpacing="8"> <TextBlock Text="{Binding Message}" /> <ListBox Grid.Row="1" ItemsSource="{Binding VisibleProducts}"> <ListBox.ItemTemplate> <DataTemplate x:DataType="vm:ProductItem"> <Grid ColumnDefinitions="160,100" Margin="4"> <TextBlock Text="{Binding Name}" /> <TextBlock Grid.Column="1" Text="{Binding Price}" /> </Grid> </DataTemplate> </ListBox.ItemTemplate> </ListBox> </Grid> </Grid> </Window>

验收结果

运行后必须看到这些结果:

  1. 窗口启动后显示前 100 条商品。
  2. 提示文字包含 100 / 5000
  3. 输入 Product 01 点击搜索,列表刷新为匹配结果。
  4. 搜索不会把 5000 条全部显示到界面。
  5. 每一行只显示商品名和价格,没有复杂控件。
  6. 修改 PageSize 后,显示数量会跟着变化。

为什么这个答案是对的

这个答案把大列表拆成了三层:

层级代码作用
全部数据allProducts保存 5000 条商品
过滤规则Search()按关键字筛选
界面数据VisibleProducts只给窗口显示最多 100 条

这样写以后,界面不会直接面对全部数据。后面无论换成分页、数据库、网络接口,窗口绑定部分都不用大改。