绑定到 WPF DataGrid 时使用 Data Virtualization 并支持排序

Use Data Virtualization when binding to WPF DataGrid and support Sorting

我正在将大型 collection(250,000 多条记录)绑定到 DataGrid。为了使其表现良好,它必须同时使用 UI 虚拟化和数据虚拟化。经过一些研究,我想出了如何让这两个虚拟化工作。但是一旦我进行排序,通过单击 DataGrid 中的列 header,它就会放弃数据虚拟化并尝试将整个数据集读入内存。

相反,我希望它将排序命令传递给底层 collection,以便数据库在从磁盘检索数据之前执行排序。有办法吗?

我在这里回答我自己的问题,希望能帮助其他人处理同样的问题。这些信息分布在多篇文章中,Stack Overflow 社区对理解它提供了极大帮助。

首先,基础知识。 UI 虚拟化意味着控件(在本例中为 DataGrid)仅为在屏幕上可以看到的内容创建 UI objects(加上一些以实现快速滚动)。它内置于 DataGrid 中并默认启用。因此,您无需做太多事情就可以启用它。 See this article for details.

数据虚拟化意味着只读入屏幕上可见的相应数据。其余的留在数据库中。有很多关于数据虚拟化的参考资料,但我发现很难找到合适的文章。 This is the one from Microsoft.

就我而言,我正在进行 random-access 虚拟化。总结是我的 collection 应该实现 IList 和 INotifyCollectionChanged。或者,我也可以实现 IItemsRangeInfo 和 ISelectionInfo,如果它们有帮助的话。

到目前为止,还不错。我创建了一个测试 collection 来模拟对数据库中数据的随机访问。在这种情况下,它从索引中通过算法创建行数据,以便我可以使用任意大的虚拟 collection 进行测试,并消除数据库性能作为这些测试中的一个因素。实施 IList 和 INotifyCollectionChanged 有效。我可以创建具有十亿条记录的 collection 和具有 near-instantaneous 性能的 DataGrid 性能。可以抓住滚动条瞬间从头移动到尾

两个提示有助于使 collection 用于数据虚拟化。 IList 继承自 IEnumerable。对于较大的 random-access collection,您不希望任何调用者枚举 collection。但是,DataGrid 会在初始化期间调用 Enumerate 一次。您可以通过返回一个空 collection 来满足这一点。为此,我创建了一个空单例 collection class。

您不想调用的另一个 IList 方法是 CopyTo。我只是让该方法抛出 InvalidOperationException。

一切正常。但是,只要您单击列 header 执行排序,控件就会尝试制作整个 collection 的副本。有十亿条记录,我得到一个 out-of-memory 错误。似乎实施 IBindingList 应该解决这个问题,因为它提供了 DataGrid 需要的排序方法。但是,实现 IBindingList 会完全禁用数据虚拟化,导致控件在初始化期间尝试读取所有数据。

答案就在documentation for CollectionView中。当控件(如 DataGrid 或 ListView)绑定到 collection 时,它使用 CollectionView 作为中介。这个想法是有一个共享的 collection(MVVM 术语中的模型)并且排序和过滤是在 CollectionView 而不是 collection 本身中实现的。这样,如果相同的 collection 出现在多个控件中,排序一个不会影响其他控件。各种 CollectionView 实现通过制作绑定 collection 的影子副本并对影子进行排序来实现此目的。它在小型 collection 中运行良好,但对于数据虚拟化来说却是一场灾难。

数据绑定代码根据被绑定的collection接口清单选择视图。实现 IList 的 collection 被 ListCollectionView 绑定。如果 collection 也实现了 INotifyCollectionChanged,那么 ListCollectionView 将执行数据虚拟化(直到调用排序或过滤)。实现 IBindingListView 的 collection 受 BindingListCollectionView 的约束,后者 执行数据虚拟化。

要向 Data Virtualization 添加排序,您必须子class ListCollectionView,捕获排序请求,将它们传递给您的 collection class,并阻止 ListCollectionView 制作影子副本.尽管我必须参考 source code to ListCollectionView 才能弄明白,但这是非常容易的。这是代码:

class VirtualListCollectionView : ListCollectionView
{
    VirtualCollection m_collection;

    public VirtualListCollectionView(VirtualCollection collection)
        : base(collection)
    {
        m_collection = collection;
    }

    protected override void RefreshOverride()
    {
        m_collection.SetSortInternal(SortDescriptions);

        // Notify listeners that everything has changed
        OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));

        // The implementation of ListCollectionView saves the current item before updating the search
        // and restores it after updating the search. However, DataGrid, which is the primary client
        // of this view, does not use the current values. So, we simply set it to "beforeFirst"
        SetCurrent(null, -1);
    }
}

密钥正在覆盖 "RefreshOverride()"。那就是制作不需要的卷影副本的地方。相反,覆盖将排序要求传递给关联的 collection。自定义 class 上的特殊 "SetSortInternal()" 方法确实 而不是 生成 INotifyCollectionChanged 事件。这很重要,因为该事件会导致递归调用 RefreshOverride()。

接下来,您必须使用自定义 CollectionView class 而不是默认值来进行数据绑定。有两种方法可以实现这一点。一种是自己创建 VirtualListCollectionView(在 XAML 或代码隐藏中)并绑定到视图而不是 collection(通过将其分配给 DataGrid.ItemsSource)。另一种方法是在您的 collection 上实现 ICollectionViewFactory 并让它创建自己的视图。

在此框架中,CollectionView 将排序和过滤委托给底层 collection class(IList 实现)。因此,collection class 成为视图(或使用 MVVM 术语的 ModelView)的一部分,并且它们之间应该存在 1:1 关系。共享的collection(或使用 MVVM 术语的模型)是底层数据库。为了强调这一点,我尝试将两者合并到同一个 class 中。它可以完成,但它变得棘手,因为两个 classes 都实现了 IList。有两个 objects 更容易,每个都引用另一个。