使用 XAML 进行虚拟化数据






4.91/5 (19投票s)
创建一个可扩展的 ObservableCollection,支持分页、异步以及(首次)完整的写入操作
代码链接
二进制文件 - nuget: https://nuget.net.cn/packages/VirtualizingObservableCollection/
源代码 - Github: http://github.com/anagram4wander/VirtualizingObservableCollection
使用 XAML 进行虚拟化数据
概述
观察者设计模式多年来一直是几乎所有现代软件平台的核心组件之一。事实上,观察者模式是 MVC/MVVM 设计模式的核心。在 .NET 中,ObservableCollection 是涉及数据绑定的任何系统(几乎任何非琐碎的应用程序)中最常用的对象之一。
尽管 ObservableCollection 非常出色,但它确实存在一些局限性:
- 它不实现任何形式的数据虚拟化,这使得处理大型或无边界数据集变得困难。
市面上有一些框架和工具提供了某种形式的虚拟化 ObservableCollection,但通常这些工具存在一些限制,例如仅与同一框架的控件配合使用,或者需要特定的编码模式才能正常工作。它们不是通用解决方案。此外,据我们所知,目前还没有实际支持虚拟化读/写的虚拟化 ObservableCollection,而是使用“读并重新加载”的模式,这在大数据集上可能非常低效。
那么,该怎么办?我们自己创建了一个——VirtualizingObservableCollection,它具有以下功能:
- 实现了与 ObsevableCollection<T> 相同的接口和方法,因此您可以在任何地方使用 ObservableCollection<T>——无需更改现有控件。
- 支持真正的多用户读/写而无需重置(最大限度地提高了大型并发场景的性能)。
- 自动管理内存,永远不会耗尽内存,无论数据集有多大(对于移动设备尤其重要)。
- 原生支持异步——非常适合网络连接缓慢和偶尔连接的模型。
- 开箱即用,但足够灵活和可扩展,可以根据您的需求进行自定义。
- 数据访问性能曲线极佳,与常规 ObservableCollection 一样快——使用它的成本可以忽略不计。
- 可在任何 .NET 项目中使用,因为它是在可移植代码库 (PCL) 中实现的。
感兴趣吗?继续阅读。
首先,简单介绍一下 ObservableCollection
我们都见过将 ObservableCollections 与 ItemControls(例如 ListBoxes 和 ComboBoxes)结合使用的各种示例,例如:
<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——但当您想要的数据在第 231 页时,用户必须按“下一页”按钮 231 次,这非常糟糕。
- 限制返回的行数。这不太实用,我们如何告诉用户列表已达到限制。
- 扩展第 2 点,我们可以限制列表,然后在末尾有一个“加载更多”选项。这更好,并且是我们在移动设备上经常看到的 UI 模式。但同样,如果数据在第 250 页,用户将不得不按“加载更多”249 次。
以上是当今使用的三种最常见的解决方案。还有第四种,它更为复杂一些。
- 在集合内部创建一个分页系统,使其看起来像完整的数据集,但它会按需在后台加载块(或页面)。这样,无论您拥有百个数据项还是一亿个数据项,UI 都相同。
这种方法的问题在于,到目前为止,已实现的可用解决方案都是只读的(除了 Telerik 等框架提供商,它们以自己的特殊方式支持分页和 CRUD(创建、读取、更新、删除)操作。但这些通常仅限于它们自己的控件)。
如果我们想用更改更新集合,我们实际上必须重置/刷新整个列表并重新获取数据。我们找到的每个分页集合都遵循相同的模式(有关更多详细信息,请参阅本文末尾)。
如果只有一个用户,或者数据不经常更改,这可能是可以接受的。然而,在多用户和/或高数据频率的情况下,当许多用户更改底层数据存储(例如 SQL 数据库)时,或者数据变化非常频繁(例如近乎实时监控系统)时,这会成为一个严重的问题,因为确保数据最新的唯一方法是重新加载,这会导致您的视图闪烁,因为它们正在重新加载,列表滚动回顶部,并且您当前的选择丢失了,更不用说重新获取所有数据页面的成本了。
这是一个不太理想的解决方案。您可以通过使用行为来绕过这个问题,这些行为会截获“重置”并对视觉效果、滚动位置和选择进行巧妙处理,以营造无缝的印象,但这些是我们多年来一直在使用的丑陋的 hack。更好的方法是解决根本问题。
这就是 VirtualizingObservableCollection——一个分页的 ObservableCollection,您可以像普通 ObservableCollection 一样对其进行更新。这是解决此问题的真正方法。
在哪里获取
最新软件包可以在 nuget 上找到。Install-Package VirtualizingObservableCollection。完整源代码可在 github 上获取,我们将发布一个全面的示例。
如何使用
VirtualizingObservableCollection 是在 PCL 中实现的,因此您可以在各种 .NET 解决方案中使用它。因此,它在后台执行的一些异步工作需要能够回调到调度程序线程。此外,页面回收系统需要定期运行,因此您需要设置某种计时器来安排这些操作。
设置 VirtualizationManager
这些都是平台特定的需求,因此您需要为 VirtualizationManager 编写(一小部分)样板代码。但是,您只需要做一次。
以下是 WPF 中的一个示例:
public MainWindow() { InitializeComponent(); //this routine only needs to run once, so first check to make sure the //VirtualizationManager isnt already initialized if (!VirtualizationManager.IsInitialized) { //set the VirtualizationManagers 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 }
设置好之后,我们需要创建一个填充数据的服务。我们不想一次性获取所有数据,所以我们将使用更像回调服务的东西,由我们的虚拟化集合按需调用。我们通过实现 IPagedSourceProvider<T> 接口来做到这一点。
以下是 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; }); } // Rest of the return goes here return new ContentPage { Content = new Label { Text = "Hello, Forms !", VerticalOptions = LayoutOptions.CenterAndExpand, HorizontalOptions = LayoutOptions.CenterAndExpand, }, }; } }
实现提供程序
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 不会在超出 delta 限制后释放。一篇关于 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 等消息传递系统完成,还是当其他人修改了底层数据源时,VirtualizedObservableCollection 都不会重置,因此您无需担心隐藏闪烁、重新滚动或缓存选择。
在使用共享数据库时使用 UpdateAt
当您使用延迟调用以及来自其他客户端(例如 SQL 数据库)的更新时,最好检查是否应将更新应用于您的虚拟集合副本。这是在页面级别完成的(因此 PagedSourceItemsPacket 中有 LoadedAt 成员——只需返回 DateTime.Now,或者更好的是将 TimeStamp 存储在数据库的行中,并返回 LoadedAt = (from I in Items select TimeStamp).Max()。
然后,传入您希望使用的 Expiry 实现,例如:
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 集合。
PaginationManager 存储的这些页面,默认情况下,每个页面都包含 '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] 等)相比并不频繁,而单增量查找经过优化,不需要递归或访问任何之前的 delta。
如果您想了解具体细节,查找全部包含在 PaginationManager 类中的 CalculateFromIndex 方法中。
我们还应该提到 IPageReclaimer<T> 接口,特别是 PageReclaimOnTouched 类,它是 PaginationManager 使用的默认实现。定期地,如果缓存中页面的总数超过 maxPages 限制,它将尝试通过按访问时间最晚的页面顺序移除溢出页面来移除和释放它们。
因此,我们得到了两全其美——巨大的集合加载速度快,高效的内存模型,小数据包(页面),并且能够编辑 ObservableCollection。
异步工作
如果您想使用(大部分)异步模式来实现它——只需实现 IPagedSourceProviderAsync,它引入了三个新方法:
Task<PagedSourceItemsPacket<SimpleDataItem>>——这基本上与 GetItemsAt 相同,参数也相同——只是它被设计为异步运行(并被调用)。
Task<int> GetCountAsync()——这基本上与 Count 属性相同,只是在这种情况下,计数是异步获取的。
GetPlaceHolder(int page, int offset)——GetPlaceholder 用于在分页数据仍在获取时返回一个对象,该对象将替代真实数据。
因此:对于我们的示例,我们可以实现类似以下的内容:
public class TesterSourceAsync : TesterSource, IPagedSourceProviderAsync<SimpleDataItem> { public async Task<PagedSourceItemsPacket<SimpleDataItem>> GetItemsAtAsync(int pageoffset, int count, bool usePlaceholder) { await Task.Delay(1000); // Just to slow it down ! return new PagedSourceItemsPacket<SimpleDataItem>() { LoadedAt = DateTime.Now, Items = (from items in SimpleDataSource.Instance.Items select items).Skip(pageoffset).Take(count) }; } public async Task<int> GetCountAsync() { await Task.Delay(1000); // Just to slow it down ! return SimpleDataSource.Instance.Items.Count; } public SimpleDataItem GetPlaceHolder(int index, int page, int offset) { return new SimpleDataItem() { Name = "Waiting [" + page + "/" +offset + "]" }; } }
何时使用异步,何时回退到非异步:
ItemSources 使用的所有正常获取操作都将使用异步版本,因此它报告的初始计数为零,一旦 GetCountAsync 完成,它就会发出重置,并更新计数。当请求数据项时,它会放置占位符,当 GetItemsAtAsync 完成时,它会用真实数据值替换这些项目。
在以下情况下,它会回退到非异步调用:
- 当您获取枚举器时——如果您发出“foreach(var item in myVOC)”——您将获得真实计数和真实数据(而不是占位符)。
- 当您发出“写入”操作并且数据未分页时,在这种情况下,它使用非异步 GetItemsAt(因此它会正确放置项目)。
关于 Alphachi Technology
您始终可以在 www.alphachitech.com 上找到有关我们所做工作的更多信息。
致谢
我必须感谢 bea 提供了解决此问题的灵感:http://www.zagstudio.com/blog/498#.VMrqjHmBH-g 和 http://www.zagstudio.com/blog/378#.VMrrlnmBH-g