概述
请参阅: http://alphachitech.wordpress.com/2015/01/31/virtualizing-observable-collection/ 以获取持续更新。
首先:此实现的源代码有数千行代码,因此我们将其打包成了一个不错的 nuget 包,而本文将重点介绍如何使用它。
观察者设计模式多年来一直是几乎所有现代软件平台的核心支柱。事实上,观察者模式是 MVC/MVVM 设计模式的核心。在 .NET 中,ObservableCollection 是任何涉及数据绑定的系统(几乎所有非微不足道的应用程序都是如此)中最常用的对象之一。
尽管 ObservableCollection 非常出色,但它也存在一些局限性:
- 它不实现任何形式的数据虚拟化,使得处理大型或无限制的数据集变得困难。
- 它在加载更改时会重置,使得处理动态数据集和多用户场景变得困难。
市面上有一些框架和工具提供了某种形式的虚拟化 ObservableCollection,但通常这些工具存在限制,例如只与同一框架的控件一起工作,或者需要特定的编码范式才能正常运行。它们并非通用解决方案。此外,据我们所知,还没有真正支持虚拟化读/写的虚拟化 ObservableCollection,而是使用“读取和重新加载”模式,这对于大型数据集来说效率可能非常低。
那么,如何解决这个问题呢?我们自己做了一个——VirtualizingObservableCollection,它具有以下功能:
- 实现与 ObservableCollection<T> 相同的接口和方法,因此您可以在任何使用 ObservableCollection<T> 的地方使用它——无需更改现有控件。
- 支持真正的多用户读/写而无需重置(最大限度地提高大规模并发场景的性能)。
- 它自行管理内存,因此无论数据集有多大,都不会耗尽内存(对于移动设备尤其重要)。
- 原生异步工作——非常适合慢速网络连接和偶尔连接的模型。
- 开箱即用,效果出色,并且足够灵活和可扩展,可根据您的需求进行自定义。
- 具有如此出色的数据访问性能曲线,使其几乎与常规 ObservableCollection 一样快——使用它的成本微不足道。
- 它在任何 .NET 项目中都可以使用,因为它是在可移植代码库 (PCL) 中实现的。
感兴趣吗?请继续阅读。
首先,简单介绍一下 ObservableCollection
我们都见过使用 ObservableCollection 与各种 ItemControls(如 ListBox 和 ComboBox)的无数示例,例如:
<ListBox x:Name="lb" ItemsSource=”{Binding Path=MyData}” Grid.Row="0"> <ListBox.ItemTemplate> <DataTemplate> <TextBlock Text="{Binding Path=Name}" /> </DataTemplate> </ListBox.ItemTemplate> </ListBox>
ViewModel 包含类似以下内容的:
public class SimpleViewModel { private ObservableCollection<SimpleDataItem> _MyData = null; public ObservableCollection<SimpleDataItem> MyData { get { if(_MyData == null) { _MyData = new ObservableCollection<SimpleDataItem>(); foreach (var o in SimpleDataSource.Instance.Items) { _MyData.Add(o); } } return _MyData; } } }
您只需从数据源中填充 ObservableCollection 的数据,即可开始工作。完美,对吧?
嗯,没那么快。如果 ObservableCollection 的大小在可控范围内,这是一个很好的解决方案,但在大多数大型系统中,数据可能包含数千甚至数十万行。加载如此大量的数据会严重拖慢应用程序的速度。如果调用的是远程服务器,情况会更糟——简单的加载可能需要几分钟甚至几个小时。CPU、内存和带宽有限的移动设备使问题更加复杂。
显然,对于这些情况(我们几乎所有的项目似乎都涉及到的情况),这不是一个可行的解决方案。
那么,人们今天如何处理大数据集呢?通常,我们会看到几种常见的解决方案:
- 在 UI 中实现“下一页/上一页”系统。这样,我们只加载列表中的项目数量。这是最常见的大型数据 UI——但当数据位于第 250 页时,这个问题就很严重了——用户必须按“下一页”按钮 249 次。
- 限制返回的行数。这并不可用,我们如何告诉用户列表已达到限制。
- 扩展第 2 点,我们可以限制列表,然后在末尾添加一个“加载更多”选项。这更好,并且是一种我们经常在移动设备上看到的 UI 模式。但同样,如果数据位于第 250 页,用户仍然需要按“加载更多”249 次。
这是今天使用的三种最常见的解决方案。还有第四种,它更复杂一些:
- 在集合内部创建分页系统,使其看起来像完整的数据集,但它会在后台按需加载块(或页面)。这样,无论您有百十条数据项还是数百万条数据项,UI 都相同。
这种方法的问题在于,到目前为止,已实现的可用解决方案都是只读的(除了 Telerik 等框架提供商,他们以自己的特殊方式支持分页和 CRUD(创建、读取、更新和删除)操作。但这些通常只在其自己的控件上。
如果我们想用数据集中的更改更新集合,我们实际上必须重置/刷新整个列表并重新获取数据。我们找到的每个分页集合都遵循相同的模式(有关更多详细信息,请参阅本文末尾)。
如果只有一名用户,或者数据更改不频繁,这可能是可以接受的。但是,在多用户和/或高数据频率场景中,当许多用户更改底层数据存储(例如 SQL 数据库)时,或者数据更改非常频繁(例如近实时监控系统),这就会成为一个严重的问题,因为确保拥有最新数据的唯一方法是重新加载,这会导致您的视觉效果在重新加载时闪烁,滚动列表回到顶部,并丢失当前选择,更不用说重新获取所有分页数据的成本了。
这不是理想的解决方案。您可以通过使用行为来拦截“重置”,并对视觉效果、滚动位置和选择进行一些巧妙的操作,从而给人一种无缝的感觉,但这些是我们多年来一直在使用的丑陋的解决方法。更好的方法是解决根本问题。
这就是 VirtualizingObservableCollection 的作用——一个可分页的 ObservableCollection,并且您可以像普通 ObservableCollection 一样更新它。这是一个真正解决此问题的方法。
在哪里获取
最新的包可在 Nuget 上找到(程序包管理器控制台命令:Install-Package VirtualizingObservableCollection)。我们将很快发布源代码以及全面的示例。
如何使用
VirtualizingObservableCollection 是用可移植类库实现的,因此您可以在各种 .NET 解决方案中使用它。因此,它在后台的一些异步工作需要能够回调到调度程序线程。此外,页面回收系统需要定期运行,因此您需要设置某种计时器来设置它们。
设置 VirtualizationManager
这两者都是特定于平台的需要,因此您需要编写(少量)样板代码供 VirtualizationManager 使用。但是,您只需要执行一次。
这是 WPF 中的一个示例:
public MainWindow() { InitializeComponent(); //this routine only needs to run once, so first check to make sure the //VirtualizationManager isn’t already initialized if (!VirtualizationManager.IsInitialized) { //set the VirtualizationManager’s UIThreadExcecuteAction. In this case //we’re using Dispatcher.Invoke to give the VirtualizationManager access //to the dispatcher thread, and using a DispatcherTimer to run the background //operations the VirtualizationManager needs to run to reclaim pages and manage memory. VirtualizationManager.Instance.UIThreadExcecuteAction = (a) => Dispatcher.Invoke(a); new DispatcherTimer( TimeSpan.FromSeconds(1), DispatcherPriority.Background, delegate(object s, EventArgs a) { VirtualizationManager.Instance.ProcessActions(); }, this.Dispatcher).Start(); } //the rest of the constructor goes here }
这是 Xamarin.Forms 中的相同内容:
public class App { public static Page GetMainPage() { if(!VirtualizationManager.IsInitialized) { VirtualizationManager.Instance.UIThreadExcecuteAction = (a) => Device.BeginInvokeOnMainThread(a); Device.StartTimer( TimeSpan.FromSeconds(1), () => { VirtualizationManager.Instance.ProcessActions(); return true; }); } return new ContentPage { Content = new Label { Text = "Hello, Forms!", VerticalOptions = LayoutOptions.CenterAndExpand, HorizontalOptions = LayoutOptions.CenterAndExpand, }, }; } }
现在我们已经设置好了,我们需要创建一个填充数据的服务。我们不想一次性获取所有数据,因此我们将使用更像回调服务的东西,在需要时由我们的虚拟化集合调用。我们通过实现 IPagedSourceProvider<T> 接口来实现这一点。
实现提供程序
IPagedSourceProvider<T> 定义了三个方法(其中两个是可选的)和一个属性:
- public PagedSourceItemsPacket<T> GetItemsAt(int pageoffset, int count, bool usePlaceholder)
这会返回一个项目列表(在 IEnumerable<T> Items 属性中),从项目 pageoffset 开始,最多包含 count 个项目(忽略 usePlaceholder 参数,该参数由系统内部使用)。
注意 - pageoffset 是 Enum 中的项目数量,而不是页码。这是为了允许页面大小不同(有关详细信息,请参阅“工作原理”部分)。
- public int IndexOf(SimpleDataItem item)
这会返回特定项目的索引。此方法是可选的——如果不需要使用 IndexOf,可以将其返回 -1。如果您不需要定位到特定项目,它不是必需的,但如果您需要选择项目,则建议实现此方法。 - public void OnReset(int count)
当在提供程序上调用 Reset 时,会运行此回调。实现此功能也是可选的。如果您在重置发生时不需要执行任何特定操作,可以使此方法体为空。 - public int Count
这是一个整数,表示数据集中的项目总数。
所以,让我们看看我们超级简单的例子:
public class TesterSource : IPagedSourceProvider<SimpleDataItem> { public PagedSourceItemsPacket<SimpleDataItem> GetItemsAt(int pageoffset, int count, bool usePlaceholder) { return new PagedSourceItemsPacket<SimpleDataItem>() { LoadedAt = DateTime.Now, Items = (from items in SimpleDataSource.Instance.Items select items).Skip(pageoffset).Take(count) }; } public int Count { get { return SimpleDataSource.Instance.Items.Count; } } public int IndexOf(SimpleDataItem item) { return SimpleDataSource.Instance.Items.IndexOf(item); } public void OnReset(int count) { } }
最后,在 VirtualizingObservableCollection 中使用提供程序:
设置好 IPagedSourceProvider 后,我们可以创建一个 VirtualizingObservableCollection,并像使用普通 ObservableCollection 一样使用它:
public class SimpleViewModel { private VirtualizingObservableCollection<SimpleDataItem> _MyDataVirtualized = null; public VirtualizingObservableCollection<SimpleDataItem> MyDataVirtualized { get { if (_MyDataVirtualized == null) { _MyDataVirtualized = new VirtualizingObservableCollection<SimpleDataItem>( new PaginationManager<SimpleDataItem>(new TesterSource())); } return _MyDataVirtualized; } } }
从上面的示例可以看出,我们实际上创建了几个对象。让我们解开它,看看它们是如何关联的:
- IPagedSourceProvider<T> (TesterSource) 访问底层数据源,提供有关数据源的重要信息,如项目数量。
- PaginationManager<T> 包装 IPagedSourceProvider<T> 并对其进行管理。分页、内存管理和数据插入的大部分繁重工作都在这里完成。
- VirtualizingObservableCollection<T> 包装 PaginationManager<T>,您可以在任何使用 ObservableCollection<T> 的地方使用它。
PaginationManager 构造函数有许多可选参数,使其更具灵活性:
public PaginationManager( IPagedSourceProvider<T> provider, IPageReclaimer<T> reclaimer = null, IPageExpiryComparer expiryComparer = null, int pageSize = 100, int maxPages = 100, int maxDeltas = -1, int maxDistance = -1, string sectionContext = "" )
- IPageReclaimer<T> reclaimer
默认实现使用 PageReclaimOnTouched<T>,它根据页面上次访问时间删除页面。但是,有时您可能希望使用不同的页面回收策略。在这种情况下,您可以将 IPageReclaimer<T> 替换为您自己的。 - IPageExpiryComparer expiryComparer
这是一个非常有用的系统,它允许您将页面的更新戳与更新本身的更新戳进行比较(这样它就不会将更新重新应用于已包含该更新的页面)。有一个使用 DateTime 的实现 DateBasedPageExpiryComparer。 - int pageSize
页面的默认大小为 100 个项目。您可以将其更改为任何正整数。 - int maxPages
默认情况下,当内存中存在 100 页时,VirtualizingObservableCollection 将开始回收页面。您可以将其更改为任何正整数。 - int maxDelta
默认值为 -1,表示 VirtualizingObservableCollection 在超过增量限制后不会释放。一篇关于 VirtualizingObservableCollection 内部工作原理的后续文章将详细介绍此参数。 - int maxDistance
默认值为 -1,表示 VirtualizingObservableCollection 不会考虑距离。一篇关于 VirtualizingObservableCollection 内部工作原理的后续文章将详细介绍此参数。 - string sectionContext
默认是一个空字符串。一篇关于 VirtualizingObservableCollection 内部工作原理的后续文章将详细介绍此参数的作用。
一旦我们有了集合,我们就可以随心所欲地进行操作,例如:
public void InsertAt(int index, SimpleDataItem newItem) { SimpleDataSource.Instance.Items.Insert(index, newItem); this.MyDataVirtualized.Insert(index, newItem); } public void RemoveAt(int index) { SimpleDataSource.Instance.Items.RemoveAt(index); this.MyDataVirtualized.RemoveAt(index); }
是的,这些是在数据虚拟化 ObservableCollection 上的实际 **写入** 操作,一旦执行了编辑,无论是本地执行、通过 SignalR 等消息系统执行,还是当其他人修改了底层数据源时,VirtualizingObservableCollection 都不会重置,因此您不必担心隐藏闪烁、重新滚动或缓存选择。
在共享数据库时使用 UpdateAt
当您使用延迟调用和来自其他客户端(如 SQL 数据库)的更新时,最好检查更新是否应应用于您的虚拟集合副本。这在页面级别进行(因此 PagedSourceItemsPacket 有 LoadedAt 成员)——只需返回 DateTime.Now,或者更好的是,将时间戳存储在数据库的行中,并返回 LoadedAt = (from I in Items select TimeStamp).Max();。
然后,传入您希望使用的过期实现,例如:
new VirtualizingObservableCollection<SimpleDataItem>( new PaginationManager<SimpleDataItem>( new TesterSource(), expiryComparer: DateBasedPageExpiryComparer.DefaultInstance));
为了做到这一点,当您收到来自其他客户端的更新时(例如来自 SignalR),请使用扩展的 CRUD 操作,例如 InsertAt(int index, T newItem, object UpdatedAt),其中 UpdatedAt 是插入操作的 DateTime。这样,您的副本将始终与数据库完美同步。
了解其工作原理
在我们真正理解它是如何工作之前,我们需要理解 ListBox 等控件是如何调用接口的。
当您有一个具有 ItemsSource 的控件时,它通常使用 IList<T>(特别是 T this[in index] getter 来填充项目),并偶尔使用 IndexOf。
通过使用稀疏页面数组,我们只缓存有限数量的元素,并在页面缓存已满时释放它们。这样,集合看起来可以任意大——但我们只保留一小部分数据,因为它被遍历了。这就是分页管理器中的 Dictionary<int page, ISourcePage<T> _Pages 集合。
这些由 PagingManager 存储的页面(最初)包含“pageSize”大小的项目(默认 100 个)。这通常是所有只读虚拟化集合的工作方式。我们认识到,我们需要一种方法来更改已应用 CRUD 操作的页面的页面大小。但我们需要一种有效的方法来确定给定索引实际位于哪个页面以及该页面的偏移量。
此实现的关键是使用递归查找的组合来获取页面和偏移量,而不是通过将每个页面之前的长度相加来计算页面。我们还引入了优化路径,即控件正在请求下一个项目或当前项目。
让我们稍微解释一下。
我们维护一个 Dictionary<int page, PageDelta> _Delta。这意味着我们可以计算逻辑调整:例如:
var adjustment = (from deltas in _Deltas.Values where deltas.PageNumber < page select deltas.Delta).Sum();
所以,基本上我们只存储大小不符合标准的页面。
现在,这并不像简单地应用 (page * pageSize) + adjustment 那样。根据调整是正数还是负数,我们必须进行递归,如果调整是正数,则要考虑到它有效偏移到的页面的任何调整。这是性能上的一点小代价,但这些初始查找与单增量查找(基本上是 [100], [101] 等)相比并不频繁,后者经过优化,不需要递归或访问任何之前的增量。
如果您想了解具体细节,查找功能都包含在 PaginationManager 类中的 CalculateFromIndex 方法中。
我们还应该提一下 IPageReclaimer<T> 接口,更具体地说,是 PageReclaimOnTouched 类(这是 PaginationManager 使用的默认实现)。定期地,如果缓存中保存的总页数超过 maxPages 限制,它将尝试通过删除溢出的页面来移除和释放它们,首先删除“最旧”的页面,其中最后访问的页面是“最旧”的。
因此,我们得到了两全其美——加载速度快的大型集合、高效的内存模型、小数据包,以及编辑 ObservableCollection 的能力。
待办事项
异步:我已经开始了这种模式,但仍然需要使计数异步工作,并解决在获取页面之前插入到页面中的问题。这可能需要几周时间来实现,因此如果您有任何需求或见解,请随时在 Nuget 上给我发送消息。
关于 Alpha Chi Technology
您始终可以在 www.alphachitech.com 上了解更多关于我们正在做的事情。
致谢
我必须感谢 Bea Stollnitz 为解决这个问题提供了灵感(http://www.zagstudio.com/blog/498 和 http://www.zagstudio.com/blog/378)。