Day 2 - 大列表和虚拟化
建议用时:180-210 分钟
你将学会什么
- 为什么列表数据一多,窗口就容易卡
- 什么是“数据很多”和“界面控件很多”的区别
- 为什么不能一次把几千条都显示出来
- 怎么用“总数据”和“可见数据”拆开解决问题
- 怎么用搜索和首屏数量减少界面压力
- 怎么用
Stopwatch记录加载耗时
本页不要先背概念。先看一个完整页面,知道“慢在哪里”和“怎么改”,再去下面拆原理,最后跟着敲完整代码。
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
学习衔接
上一页学习的是“性能认知”,今天继续学习“大列表和虚拟化”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
今天的最低通过线
第一次学习不要求背完整页。完成下面 3 项,就可以继续:
- 能用自己的话说明“大列表和虚拟化”解决什么问题。
- 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
- 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。
第一部分:先学原理和最小知识
这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。这里要解决的是:为什么大列表会慢,为什么要拆成 allProducts 和 VisibleProducts。
先把“数据”和“控件”分开
程序里的 5000 条商品,只是 5000 个数据对象。
窗口上的一行商品,不只是一个字符串。它可能会变成 ListBoxItem、Grid、多个 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
搜索的正确思路是:
- 用户输入关键字。
- ViewModel 从全部数据里筛选。
- 最多取 100 条。
- 刷新
VisibleProducts。 ListBox自动显示新的VisibleProducts。
不要直接去操作 ListBox.Items。
原因是:MVVM 里界面负责显示,ViewModel 负责数据和规则。规则写在 ViewModel 里,后面才容易测试、修改和复用。
大列表页面的判断顺序
遇到列表卡顿,按这个顺序排查:
- 数据是不是一次加载太多。
- 界面是不是一次显示太多。
- 单行模板是不是太复杂。
- 搜索和排序是不是每敲一个字都全量执行。
- 图片、文件、网络请求是不是放在界面线程里。
本页先解决前 3 个问题:控制显示数量、保持模板简单、记录耗时。
大列表常用操作速查
| 需求 | 写法 | 说明 |
|---|---|---|
| 保存全部数据 | allProducts | 不直接绑定全部 |
| 显示可见数据 | VisibleProducts | 绑定界面 |
| 截取前几条 | .Take(100) | 控制显示数量 |
| 搜索过滤 | .Where(...) | 从全部数据筛 |
| 重新加载 | RefreshVisibleProducts() | 条件变化后调用 |
| 计时 | Stopwatch | 判断刷新耗时 |
| 空状态 | IsEmpty | 搜索无结果时提示 |
大列表原则:
全部数据留在内存
界面只显示需要看的部分
搜索和筛选后再刷新 VisibleProducts第二部分:把知识组合成完整例子
这一页要做的是一个“大列表页面”。
程序里面准备 5000 条商品数据,但是窗口上不要一次塞 5000 条,而是只显示前 100 条。搜索时也是先在全部数据里找,再最多显示 100 条结果。
最终效果
你会得到一个窗口:
- 打开窗口后,列表显示 100 条商品。
- 顶部可以输入关键字搜索。
- 搜索后,下面列表只显示匹配结果的前 100 条。
- 界面会显示“本次显示多少条,总数据多少条,耗时多少毫秒”。
- 代码里能清楚区分“全部数据”和“界面正在显示的数据”。
完整 ViewModel
文件位置:
ViewModels/MainWindowViewModel.csusing 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 步:运行后这样验证
运行项目后按这个顺序检查:
- 打开窗口后,提示显示首屏 100 条。
- 列表能看到
Product 0001、Product 0002等数据。 - 输入
Product 01,点击搜索。 - 提示文字会更新耗时。
- 清空输入框,再点击搜索,列表回到前 100 条。
- 把
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>验收结果
运行后必须看到这些结果:
- 窗口启动后显示前 100 条商品。
- 提示文字包含
100 / 5000。 - 输入
Product 01点击搜索,列表刷新为匹配结果。 - 搜索不会把 5000 条全部显示到界面。
- 每一行只显示商品名和价格,没有复杂控件。
- 修改
PageSize后,显示数量会跟着变化。
为什么这个答案是对的
这个答案把大列表拆成了三层:
| 层级 | 代码 | 作用 |
|---|---|---|
| 全部数据 | allProducts | 保存 5000 条商品 |
| 过滤规则 | Search() | 按关键字筛选 |
| 界面数据 | VisibleProducts | 只给窗口显示最多 100 条 |
这样写以后,界面不会直接面对全部数据。后面无论换成分页、数据库、网络接口,窗口绑定部分都不用大改。