实现带分页数据的虚拟列表
本文通过使用分页数据进行虚拟列表技术,解决了加载和显示大量数据时的性能问题。
摘要
对于大量数据的列表,在 UI 中以合理的性能加载和显示数据始终是一个难题。本文讨论了使用虚拟列表和分页技术来解决性能问题。它特别解决了 UI 中的虚拟列表与后端数据存储访问层中的分页技术的结合。性能从 o(n) 得到了显著提升到 o(1)。
本文是数据显示性能优化系列的第一部分。
所有代码示例都使用 WPF 和 C# 提供。UI 中的示例用户控件是 WPF 的 ListBox
。
Content
在示例应用程序中,您可以单击以下三个按钮,每个按钮将使用不同的技术加载 10,000 名员工。最后一个按钮演示了显示员工的传统方法,基本上,它一次性加载所有员工并将它们显示在 listbox 中。第二个按钮演示了通过使用虚拟列表技术进行的员工惰性加载。基本上,只加载将要在 listbox 中看到的员工;如果用户向下滚动 listbox,则会加载其他员工。第三个按钮演示了通过将 UI 中的虚拟列表技术与后端数据访问层中的分页技术相结合来进行员工惰性加载。将为每个按钮显示加载时间。
本文主要讨论第三种解决方案,即通过将 UI 中的虚拟列表技术与后端数据访问层中的分页技术相结合来进行员工惰性加载。
在此示例中,最后一个按钮大约需要 11 秒才能加载和显示员工,而第一个和第二个按钮加载员工所需的时间不到 1 秒。
在用户单击按钮后,将显示员工列表 UI。用户可以向下滚动滚动条,直到看到 10,000 名员工。所有三个按钮都显示相同的员工列表 UI。
第一种方法:一次显示所有记录
在此方法中,使用传统的获取数据的方式,并将数据显示在 listbox 中。所有数据一次性获取并显示在 listbox 中。在我们的示例中,我们硬编码了 10,000 条记录,每条记录加载需要一秒钟。加载数据需要 10 秒,渲染和显示数据在 listbox 中大约需要 1 秒。
让我们逐步了解从 UI 到数据访问层的代码。
在 Employees.xaml UI 中,定义了一个 list box,它查找 ViewModel 中的 Employees
属性。
<ListBox x:Name="listBox1"
ItemsSource="{Binding Employees}"
VerticalAlignment="Center"/>
在 ViewModel 类中,定义了 Employees
属性,并使用 EmployeeService
来获取员工列表。
public List<string> Employees
get { return EmployeeService.GetEmployees(); }
}
目前我们只是在 EmployeeService
中模拟员工数据。员工总数被硬编码为 10,000,加载一名员工需要 1 毫秒。
public const int NumberOfEmployees = 10000;
//get all at once
public static List<string> GetEmployees()
{
return PrepareData(0, NumberOfEmployees);
}
private static List<string> PrepareData(int startIndex, int count)
{
var employees = new List<string>();
for (int index = startIndex; index < count; index++)
{
employees.Add(index.ToString());
Thread.Sleep(TimeSpan.FromMilliseconds(1));
}
return employees;
}
使用此方法,加载所有员工数据需要 10 秒,listbox 显示大约需要 11 秒的等待时间。用户将看到一个空白屏幕,不知道将显示什么。如此缓慢的性能是不可接受的。列表越大,速度越慢。
可以使用两种技术来解决此问题:分页 vs. 虚拟列表。
使用分页,我们不会一次性在 UI 中显示所有员工。相反,我们将逐页显示数据。通常,您会看到与每页关联的页码,并且可以单击页码直接跳转到该页。一个典型的例子是谷歌搜索结果。
使用虚拟列表,员工将显示为列表,您仍然可以滚动滚动条来查看列表。但是,在内部,只获取当前页面中显示的员工。如果用户向下滚动列表,则会在需要时获取其他员工。即,我们不需要提前一次性加载所有员工。一个典型的例子是 Google Picasa。
本文将只关注在 UI 中使用虚拟列表,而不会讨论 UI 中的分页。
第二种方法:在虚拟列表中显示所有记录
在此方法中,UI 将与上一个示例完全相同,唯一的不同是 ListBox
绑定到 VirtualListEmployees
而不是 Employees
。VirtualListEmployees
是 VirtualList
类型。VirtualList
不会一次性加载所有员工,而是 VirtualList
足够智能,只加载 UI 中显示的数据,通常是适合一屏的 20 到 30 名员工。如果您向下滚动 listbox,VirtualList
会足够智能地按需加载所需的员工。
来自 VirtualListEmployees.xaml 的代码片段
<ListBox x:Name="listBox1"
ItemsSource="{Binding VirtualListEmployees}"
VerticalAlignment="Center" />
来自 ViewModel.cs 的代码片段
public VirtualList<string> VirtualListEmployees
{
get { return new VirtualList<string>(new EmployeeObjectGenerator()); }
}
VirtuaList<T>
实现 IList<T>
和 IList
接口,这里的主要思想是创建一个具有预定义项目数量的列表。但是,列表的内容是空的。内容只有在 UI 需要时才会被加载。VirtualList.cs 源自 CodeProject.com,我在其上进行了一些修改。
VirtualList
构造函数分配一个空数组,其大小由 IObjectGeenrator
接口中的数据计数定义。
public VirtualList(IObjectGenerator<T> generator)
{
int maxItems = generator.Count;
_generator = generator;
_cachedItems = new T[maxItems];
}
数据只有在 UI 需要显示数据时才会被获取,并且数据会被缓存到数组中以提高性能。
public T this[int index]
{
get {
// Cache item if it isn't cached already
if (!IsItemCached(index))
CacheItem(index);
return _cachedItems[index];
}
}
public void CacheItem(int index)
{
_cachedItems[index] = _generator.CreateObject(index);
}
IObjectGenerator
和接口实现者用于确定员工总数,其中可以预先分配一个包含空值的数组。它还使用 CreateObject
方法在需要时才加载员工。
在 EmployeeObjectGenerator.cs 中,它在构造函数中获取员工总数,并且还要求 EmployeeService
根据索引获取员工。
public EmployeeObjectGenerator()
{
_count = EmployeeService.NumberOfEmployees;
}
public string CreateObject(int index)
{
return EmployeeService.GetEmployee(index);
}
EmployeeService
是我们的后端数据访问类,它用于检索员工数据。
在我们的示例中,没有连接数据库或文本。我们模拟了员工服务。员工姓名将与索引值相同。Thread.Sleep
用于模拟加载员工对象的 K_SU。在示例中,我们假设加载一名员工需要 1 毫秒。我们将员工数量硬编码为一万。
public const int NumberOfEmployees = 10000;
public static string GetEmployee(int index)
{
Thread.Sleep(TimeSpan.FromMilliseconds(1));
return index.ToString();
}
通过这个 VirtualList
实现,加载和显示 listbox 中的员工需要不到一秒钟的时间。性能从 o(n) 得到了显著提升到 o(1)。
但是,当前的 EmployeeService
模拟未能反映数据访问策略。我们的大多数数据访问层都使用数据库,而通过索引的 GetEmployee(int index)
方法无法与数据库一起使用,因为我们无法通过索引直接访问数据库数据。一种方法是使用查询查找所有满足搜索条件的员工,然后缓存它,并在缓存之上使用按索引的 GetEmployee(int index)
方法。这种方法的问题在于它会产生查询的性能损失。如果我们一次性查找所有员工,即使只查找匹配搜索条件的员工,速度也会显著变慢。速度将再次变为 o(n)。
第三种方法:在 UI 的虚拟列表中显示所有记录,并在数据访问层中使用分页技术
解决方案是使用分页技术从数据存储中获取数据,然后使用虚拟列表来管理分页数据。也就是说,如果需要第 x 页的员工,则按需加载第 x 页。如果需要第 Y 页的员工,则只按需加载第 Y 页。VirtualList
将位于所有页面之上,并将管理所有页面,无论它们是否已加载。
这个想法在下图中有展示。我们有一个包含 n 个页面的虚拟列表。每个页面仅在需要时加载。CurrentCursor
用于定义当前加载的页面索引,每个页面都有固定的页面大小。
让我们检查从 UI 到数据库访问级别的源代码。
UI 和 ViewModel 代码与上一个示例几乎相同。唯一的变化是它映射到 PagedVirtualListEmplyees
属性。这是来自 PagedVirtualListEmployees.xaml 的代码片段。
<ListBox x:Name="listBox1" ItemsSource="{Binding PagedVirtualListEmployees}"/>
来自 ViewModel.cs 的代码片段
public VirtualList<string> PagedVirtualListEmployees
{
get { return new VirtualList<string>(new PagedEmployeeObjectGenerator()); }
}
PagedEmployeeObjectGenerator
将所有获取员工信息委托给 PagedEmployeeRepository
。这是代码片段
public PagedEmployeeObjectGenerator()
{
_repository = new PagingEmployeeRepository();
}
public string CreateObject(int index)
{
return _repository.GetAt(index);
}
PagedEmployeeRepository
管理缓存的虚拟列表和分页。Count
始终首先被调用,以了解员工总数,从而分配一个数组缓存,它还使用第一页的值初始化数组缓存。这是代码片段
public int NumberOfEmployees = 10000;
public int PageCursor { get; set; }
private int _pageSize = 40;
public int Count()
{
var employees = DoLoadPage();
_cache = new string[NumberOfEmployees];
UpdateCache(employees);
return NumberOfEmployees;
}
DoLoadPage
将在 PageCursor
上加载页面,并且在 PageCursor
上的页面加载后,将调用 UpdateCache
将值复制到数组缓存。
一旦用户向下滚动,就会调用 GetAt(index)
方法。如果什么都没有加载(即缓存值为 null),那么我们需要从索引获取 PageCursor
,以便知道哪个页面尚未加载,然后调用 LoadPage()
来加载该页面并将值填充到缓存中。否则,它将返回缓存的员工。
public string GetAt(int index)
{
if (_cache[index] == null)
{
PageCursor = index / PageSize;
LoadPage();
}
return _cache[index];
}
使用此 PagedVirtualList
方法,无论列表大小如何,所有值都将在 UI 中一秒钟内显示。性能在 o(1) 以下。
结论
Voilà,就是这样。现在您可以使用分页虚拟列表在 o(1) 以下加载和显示大型列表。这标志着数据显示性能优化系列的第一篇文章。在第二篇文章中,我将讨论如何从列表中选择一个项目并将其删除,以及如何全选和取消全选。在第三篇文章中,我将讨论如何多次搜索虚拟列表并多次选择和删除。希望您喜欢这篇文章。