使用虚拟化列表的VirtualizingStackPanel

4
我正在开发一个应用程序,该应用程序应该显示从其他地方(比如数据库)加载的大量项目,并以列表/网格形式呈现。
由于始终将所有项目保存在内存中似乎是一种浪费,因此我正在寻找虚拟化列表部分的方法。VirtualizingStackPanel似乎就是我所需要的 - 然而,尽管它似乎很好地虚拟化了项的UI,但我不确定如何虚拟化底层的项目列表的部分。
作为一个小样例,请考虑一个WPF应用程序,其主窗口如下:
<Window x:Class="VSPTest.Window1"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Title="VSPTest" Height="300" Width="300">
    <Window.Resources>
        <DataTemplate x:Key="itemTpl">
            <Border BorderBrush="Blue" BorderThickness="2" CornerRadius="5" Margin="2" Padding="4" Background="Chocolate">
                <Border BorderBrush="Red" BorderThickness="1" CornerRadius="4" Padding="3" Background="Yellow">
                    <TextBlock Text="{Binding Index}"/>
                </Border>
            </Border>
        </DataTemplate>
    </Window.Resources>
    <Border Padding="5">
        <ListBox VirtualizingStackPanel.IsVirtualizing="True" ItemsSource="{Binding .}" ItemTemplate="{StaticResource itemTpl}" VirtualizingStackPanel.CleanUpVirtualizedItem="ListBox_CleanUpVirtualizedItem">
            <ListBox.ItemContainerStyle>
                <Style TargetType="{x:Type ListBoxItem}">
                    <Setter Property="HorizontalContentAlignment" Value="Stretch"/>
                </Style>
            </ListBox.ItemContainerStyle>
        </ListBox>
    </Border>
</Window>

提供列表的代码应该像这样:

提供列表的后台代码应该是这个样子:

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;

namespace VSPTest
{
    public partial class Window1 : Window
    {
        private class DataItem
        {
            public DataItem(int index)
            {
                this.index = index;
            }

            private readonly int index;

            public int Index {
                get {
                    return index;
                }
            }

            public override string ToString()
            {
                return index.ToString();
            }
        }

        private class MyTestCollection : IList<DataItem>
        {
            public MyTestCollection(int count)
            {
                this.count = count;
            }

            private readonly int count;

            public DataItem this[int index] {
                get {
                    var result = new DataItem(index);
                    System.Diagnostics.Debug.WriteLine("ADD " + result.ToString());
                    return result;
                }
                set {
                    throw new NotImplementedException();
                }
            }

            public int Count {
                get {
                    return count;
                }
            }

            public bool IsReadOnly {
                get {
                    throw new NotImplementedException();
                }
            }

            public int IndexOf(Window1.DataItem item)
            {
                throw new NotImplementedException();
            }

            public void Insert(int index, Window1.DataItem item)
            {
                throw new NotImplementedException();
            }

            public void RemoveAt(int index)
            {
                throw new NotImplementedException();
            }

            public void Add(Window1.DataItem item)
            {
                throw new NotImplementedException();
            }

            public void Clear()
            {
                throw new NotImplementedException();
            }

            public bool Contains(Window1.DataItem item)
            {
                throw new NotImplementedException();
            }

            public void CopyTo(Window1.DataItem[] array, int arrayIndex)
            {
                throw new NotImplementedException();
            }

            public bool Remove(Window1.DataItem item)
            {
                throw new NotImplementedException();
            }

            public IEnumerator<Window1.DataItem> GetEnumerator()
            {
                for (int i = 0; i < count; i++) {
                    yield return this[i];
                }
            }

            System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
            {
                return this.GetEnumerator();
            }
        }

        public Window1()
        {
            InitializeComponent();

            DataContext = new MyTestCollection(10000);
        }

        void ListBox_CleanUpVirtualizedItem(object sender, CleanUpVirtualizedItemEventArgs e)
        {
            System.Diagnostics.Debug.WriteLine("DEL " + e.Value.ToString());
        }
    }
}

因此,这显示了一个具有ListBox的应用程序,该应用程序强制使用IsVirtualizing附加属性虚拟化其项目。它从数据上下文获取其项目,为此提供了自定义的IList<T>实现,该实现会在需要时(通过索引器检索)创建10000个数据项。

出于调试目的,每次创建项目时都会输出文本ADD #(其中#等于项目索引),并且在项目超出视图并且其UI由虚拟堆栈面板释放时,使用CleanUpVirtualizedItem事件输出DEL#

现在,我的愿望是,我的自定义列表实现按请求提供项目-在这个最小的示例中,通过即时创建它们,在实际项目中通过从数据库加载它们。不幸的是,VirtualizingStackPanel似乎不会以这种方式行事-相反,它会在程序启动时调用列表的枚举器并首先检索所有10000个项目!

因此,我的问题是:如何使用VirtualizingStackPanel实际虚拟化数据(即不加载所有数据),而不仅仅是减少GUI元素的数量?

  • 是否有任何方法告诉虚拟堆栈面板有多少总项目,并告诉它按需要按索引访问它们,而不是使用枚举器? (例如,如果我记得正确,则Delphi Virtual TreeView组件的工作方式)
  • 是否有任何巧妙的方法可以捕获项目实际进入视图的事件,以便至少可以正常存储每个项目的唯一键,并且仅在请求时加载其余项目数据? (尽管这似乎是一个hacky解决方案,因为我仍然必须出于没有真正原因而提供完整长度的列表,除了满足WPF API之外。)
  • 是否有另一个WPF类更适合这种虚拟化?

编辑:根据dev hedgehog的建议,我创建了一个自定义的ICollectionView实现。其中一些方法仍然被实现为抛出NotImplementedException,但在打开窗口时调用的那些方法不会抛出异常。

然而,似乎对于该集合视图调用的第一件事就是GetEnumerator方法,再次枚举所有10000个元素(通过调试输出可证明,其中我对每1000个项打印一条消息),这正是我试图避免的。

以下是重现此问题的示例:

Window1.xaml

<Window x:Class="CollectionViewTest.Window1"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Title="CollectionViewTest" Height="300" Width="300"
    >
    <Border Padding="5">
        <ListBox VirtualizingStackPanel.IsVirtualizing="True" ItemsSource="{Binding .}">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <Border BorderBrush="Blue" BorderThickness="2" CornerRadius="5" Margin="2" Padding="4" Background="Chocolate">
                        <Border BorderBrush="Red" BorderThickness="1" CornerRadius="4" Padding="3" Background="Yellow">
                            <TextBlock Text="{Binding Index}"/>
                        </Border>
                    </Border>
                </DataTemplate>
            </ListBox.ItemTemplate>
            <ListBox.ItemContainerStyle>
                <Style TargetType="{x:Type ListBoxItem}">
                    <Setter Property="HorizontalContentAlignment" Value="Stretch"/>
                </Style>
            </ListBox.ItemContainerStyle>
        </ListBox>
    </Border>
</Window>

Window1.xaml.cs

using System;
using System.ComponentModel;
using System.Collections;
using System.Collections.Specialized;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Windows;

namespace CollectionViewTest
{
    public partial class Window1 : Window
    {
        private class DataItem
        {
            public DataItem(int index)
            {
                this.index = index;
            }

            private readonly int index;

            public int Index {
                get {
                    return index;
                }
            }

            public override string ToString()
            {
                return index.ToString();
            }
        }

        private class MyTestCollection : IList<DataItem>
        {
            public MyTestCollection(int count)
            {
                this.count = count;
            }

            private readonly int count;

            public DataItem this[int index] {
                get {
                    var result = new DataItem(index);
                    if (index % 1000 == 0) {
                        System.Diagnostics.Debug.WriteLine("ADD " + result.ToString());
                    }
                    return result;
                }
                set {
                    throw new NotImplementedException();
                }
            }

            public int Count {
                get {
                    return count;
                }
            }

            public bool IsReadOnly {
                get {
                    throw new NotImplementedException();
                }
            }

            public int IndexOf(Window1.DataItem item)
            {
                throw new NotImplementedException();
            }

            public void Insert(int index, Window1.DataItem item)
            {
                throw new NotImplementedException();
            }

            public void RemoveAt(int index)
            {
                throw new NotImplementedException();
            }

            public void Add(Window1.DataItem item)
            {
                throw new NotImplementedException();
            }

            public void Clear()
            {
                throw new NotImplementedException();
            }

            public bool Contains(Window1.DataItem item)
            {
                throw new NotImplementedException();
            }

            public void CopyTo(Window1.DataItem[] array, int arrayIndex)
            {
                throw new NotImplementedException();
            }

            public bool Remove(Window1.DataItem item)
            {
                throw new NotImplementedException();
            }

            public IEnumerator<Window1.DataItem> GetEnumerator()
            {
                for (int i = 0; i < count; i++) {
                    yield return this[i];
                }
            }

            System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
            {
                return this.GetEnumerator();
            }
        }

        private class MyCollectionView : ICollectionView
        {
            public MyCollectionView(int count)
            {
                this.list = new MyTestCollection(count);
            }

            private readonly MyTestCollection list;

            public event CurrentChangingEventHandler CurrentChanging;

            public event EventHandler CurrentChanged;

            public event NotifyCollectionChangedEventHandler CollectionChanged;

            public System.Globalization.CultureInfo Culture {
                get {
                    return System.Globalization.CultureInfo.InvariantCulture;
                }
                set {
                    throw new NotImplementedException();
                }
            }

            public IEnumerable SourceCollection {
                get {
                    return list;
                }
            }

            public Predicate<object> Filter {
                get {
                    throw new NotImplementedException();
                }
                set {
                    throw new NotImplementedException();
                }
            }

            public bool CanFilter {
                get {
                    return false;
                }
            }

            public SortDescriptionCollection SortDescriptions {
                get {
                    return new SortDescriptionCollection();
                }
            }

            public bool CanSort {
                get {
                    throw new NotImplementedException();
                }
            }

            public bool CanGroup {
                get {
                    throw new NotImplementedException();
                }
            }

            public ObservableCollection<GroupDescription> GroupDescriptions {
                get {
                    return new ObservableCollection<GroupDescription>();
                }
            }

            public ReadOnlyObservableCollection<object> Groups {
                get {
                    throw new NotImplementedException();
                }
            }

            public bool IsEmpty {
                get {
                    throw new NotImplementedException();
                }
            }

            public object CurrentItem {
                get {
                    return null;
                }
            }

            public int CurrentPosition {
                get {
                    throw new NotImplementedException();
                }
            }

            public bool IsCurrentAfterLast {
                get {
                    throw new NotImplementedException();
                }
            }

            public bool IsCurrentBeforeFirst {
                get {
                    throw new NotImplementedException();
                }
            }

            public bool Contains(object item)
            {
                throw new NotImplementedException();
            }

            public void Refresh()
            {
                throw new NotImplementedException();
            }

            private class DeferRefreshObject : IDisposable
            {
                public void Dispose()
                {
                }
            }

            public IDisposable DeferRefresh()
            {
                return new DeferRefreshObject();
            }

            public bool MoveCurrentToFirst()
            {
                throw new NotImplementedException();
            }

            public bool MoveCurrentToLast()
            {
                throw new NotImplementedException();
            }

            public bool MoveCurrentToNext()
            {
                throw new NotImplementedException();
            }

            public bool MoveCurrentToPrevious()
            {
                throw new NotImplementedException();
            }

            public bool MoveCurrentTo(object item)
            {
                throw new NotImplementedException();
            }

            public bool MoveCurrentToPosition(int position)
            {
                throw new NotImplementedException();
            }

            public IEnumerator GetEnumerator()
            {
                return list.GetEnumerator();
            }
        }

        public Window1()
        {
            InitializeComponent();
            this.DataContext = new MyCollectionView(10000);
        }
    }
}

VirtualizingStackPanel与数据虚拟化无关。您需要在数据层面上自己实现它。 - Federico Berasategui
@HighCore:我明白了 - 所以我将不得不编写自己的类似于“ItemsControl”的UI控件? - O. R. Mapper
当你说相当大的数量时,有多大?你想在视图超出范围后丢弃项目吗?往返数据库代价高昂。多次运行查询是昂贵的。你的内存不足了吗?我不明白将负载移动到数据库和网络以节省内存的做法。 - paparazzo
@Blam:可能是10000个项目,也可能是一百万个,甚至更多。最终,这取决于用户的筛选参数。但可以确定的是,特别是在有这么多项目的情况下,只有几十个项目可能会被查看。因此,在UI基本上只需要知道它们的总数(用于滚动)时,加载所有其他项目绝对没有意义。特别是,我不认为有必要在不必要地加载成千上万个项目时冒着阻塞UI的风险,因为这些项目可能永远不会被显示(但在用户的感知中只是“存在”)。 - O. R. Mapper
......因为只需向下滚动列表即可找到它们(如果尝试的话)。 - O. R. Mapper
显示剩余4条评论
4个回答

4

您想要 Data Virtualization,目前您拥有的是 UI Virtualization

您可以在这里了解更多关于数据虚拟化的信息。


3
为了解决VirtualizingStackPanel尝试枚举整个数据源的问题,我在http://referencesource.microsoft.com上查看源代码(https://referencesource.microsoft.com/#PresentationFramework/src/Framework/System/Windows/Controls/VirtualizingStackPanel.cs)。

以下是简化版:

  • 如果指定了VirtualizingStackPanel.ScrollUnit="Pixel",则需要确保其ItemTemplate中显示/虚拟化的所有项都具有相同的大小(高度)。即使只相差一个像素,也可能导致加载整个列表。

  • 如果显示的项的高度不完全相同,则必须指定VirtualizingStackPanel.ScrollUnit="Item"

我的发现:

VirtualizingStackPanel源代码中存在几个“雷区”,会通过索引运算符[]尝试迭代整个集合。其中之一是在测量循环期间,它尝试更新虚拟化容器的大小以使滚动视图准确。如果在此周期内添加的任何新项在Pixel模式下的大小不同,则会迭代整个列表进行调整,从而导致问题。

另一个“雷区”与选择有关并触发了硬刷新。这对于网格更为适用——但在幕后,它使用的是继承自VirtualizingStackPanelDataGridRowPresenter。因为它希望在刷新之间保持选择同步,所以尝试枚举所有内容。这意味着我们需要禁用选择(请记住,单击行会触发选择)。

我通过派生自己的网格并重写OnSelectionChanged来解决此问题:

protected override void OnSelectionChanged(SelectionChangedEventArgs e)
{
    if(SelectedItems.Count > 0)
    {
        UnselectAll();
    }
    e.Handled = true;
}

似乎还有其他需要注意的问题,但我尚未能够可靠地触发它们。真正的“解决”方法是使用宽松的约束条件为生成容器大小自己编写VirtualizingStackPanel。毕竟,对于大型数据集(百万级别以上),滚动条的准确性并不那么重要。如果我有时间去做这件事,我会在我的答案中更新gist/github存储库。

在我的测试中,我使用了一个可用的数据虚拟化解决方案:https://github.com/anagram4wander/VirtualizingObservableCollection


1

虽然问题发布已经很久了,但对于某些人可能仍然有用。在解决完全相同的问题时,我发现您的ItemsProvider(在您的情况下是MyTestCollection)必须实现非泛型IList接口。只有这样,VirtualizingStackPanel才能通过[]运算符访问单个项,而不是通过GetEnumerator枚举它们。在您的情况下,只需添加以下内容即可:

    object IList.this[int index]
    {
        get { return this[index]; }
        set { throw new NotSupportedException(); }
    }

    public int IndexOf(DataItem item)
    {
        // TODO: Find a good way to find out the item's index
        return DataItem.Index;
    }

    public int IndexOf(object value)
    {
        var item = value as DataItem;
        if (item != null)
            return IndexOf(item);
        else
            throw new NullReferenceException();
    }

就我所知,所有其余IList的成员都可以保持未实现状态。


1

你已经接近成功了,只是VirtualizingStackPanel并没有调用列表的枚举器。

当你绑定ListBox.ItemsSource时,会自动创建一个ICollectionView接口来连接你实际的数据源和ListBox目标。这个接口是调用枚举器的关键。

如何解决?只需编写继承自ICollectionView接口的自己的CollectionView类即可。将它传递给ItemsSource,ListBox就知道你希望拥有自己的数据视图,而这正是你所需要的。然后一旦ListBox意识到你正在使用自己的视图,只需在请求时返回所需的数据即可。就这样。和ICollectionView愉快地玩耍吧 :)


这听起来是一个有前途的方法,然而,在实现了 ICollectionView 的最小版本(即添加方法实现,直到不再抛出 NotImplementedException)后,ICollectionView 实现的 GetEnumerator 方法被调用,仍然枚举全部 10000 项。此外,查看 ICollectionView ,我找不到任何成员可以检索任何特定项或它们的总数。 - O. R. Mapper
所以,似乎即使使用ICollectionView,任何显示的项都必须枚举完整列表。 - O. R. Mapper
我无法在没有代码的情况下做出准确的判断。然而,在我为TreeView编写数据虚拟化时,我使用了ICollectionView接口完成。应该可以运行。请再次检查或者将你正在使用的代码发布给我们。上传项目到网上。 - dev hedgehog
我已经编辑了我的问题,并提供了一个使用集合视图的示例。GetEnumerator被调用,所有10000个项目在启动应用程序时立即检索,而不是逐渐地随着它们进入视图。 - O. R. Mapper

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接