WPF — виртуализация контекста данных при использовании служб с разбиением на страницы

Автор: Дин (Dean)

Многим приложениям WPF требуется обрабатывать очень большие объемы данных — пользователям действительно может понадобиться миллион строк в элементе управления GridView. Мы попробуем справиться с этим, используя "виртуализацию" данных, предоставляя их элементу управления "по мере необходимости".

Большинство элементов управления для списков в WPF (включая стандартные элементы ListView/GridView) скрывают в себе концепцию "окна просмотра". Окно просмотра — это виртуальное "окно" в используемую коллекцию данных, которому требуются только данные, отображаемые в данный момент. Поэтому, если коллекция содержит миллион строк, а высота "окна просмотра" составляет только 100 строк, то понадобится только 100 строк (но это должны быть "правильные" строки).

Кроме того, если коллекция данных, являющаяся используемым контекстом данных DataContext, реализует неуниверсальный интерфейс IList, окно просмотра будет оптимизировать свой доступ к коллекции, вызывая для перечисления строк IList.this[index], а не GetEnumerator().

Это означает, что при создании настраиваемой коллекции, реализующей неуниверсальный интерфейс IList, можно "виртуализировать" доступ к данным, что позволит выводить миллион строк в нашей таблице в мгновение ока.

Кроме того, этому сценарию "миллиона строк" может понадобиться получать данные от службы WCF (например), и метод возвращения данных службой, скорее всего, будет использовать разбиение на страницы, требуя для возвращения правильной страницы задать номер страницы и параметры размера страницы.

Хорошо, введение закончено, давайте обсудим ДЕЙСТВИТЕЛЬНО простое решение этой задачи.

Во-первых, я предполагаю, что используется ссылка на службу WCF с 2 методами службы.

  1. Метод, получающий общий размер коллекции.
  2. Метод, получающий в качестве параметров номер страницы и размер страницы и возвращающий строго типизированную коллекцию, представляющую одной "страницу" данных.

В качестве примера того, что я имею в виду, приведу пример контракта службы, которую я создал для этой статьи.

namespace WCFDataPaging.WCF
{
    [ServiceContract]
    public interface IService
    {
 
        [OperationContract]
        List<TestDataObject> GetPageData(int page, int pageSize);
 
        [OperationContract]
        int GetDataCount();
    }
}

и вот моя реализация службы:

public class MainService : IService
{
    public List<TestDataObject> GetPageData(int page, int pageSize)
    {
        return Utility.TestData.Skip(page*pageSize).Take(pageSize).ToList();
    }
 
    public int GetDataCount()
    {
        return Utility.TestData.Count;
    }
}

Теперь вспомогательный класс просто создает тестовую коллекцию размером около миллиона строк. В реальной жизни такие службы будут возвращать реальные данные из какого-нибудь хранилища данных.

Теперь мне понадобится класс коллекции на стороне WPF, который сможет выполнить всю необходимую виртуализацию:

public sealed class VirtualServiceCollection<T> : IList<T>, IList
{
    private readonly Func<int, int, T[]> dataFunction;
    private readonly Func<int> countFunction;
    private readonly int pageSize;
    private readonly List<T> data;
    private int currentPage;
 
    public VirtualServiceCollection(Func<int,int,T[]> dataFunction, Func<int> countFunction, int pageSize)
    {
        this.dataFunction = dataFunction;
        this.countFunction = countFunction;
        this.pageSize = pageSize;
        data = new List<T>(dataFunction(0, pageSize));
    }
 
    public IEnumerator<T> GetEnumerator()
    {
        var count = countFunction();
        for (var i = 0; i < count; i++)
            yield return this[i];
    }
 
    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }
 
    public void Add(T item)
    {
        throw new NotImplementedException();
    }
 
    public int Add(object value)
    {
        throw new NotImplementedException();
    }
 
    public bool Contains(object value)
    {
        throw new NotImplementedException();
    }
 
    void IList.Clear()
    {
        DoClear();
    }
 
    public int IndexOf(object value)
    {
        return data.IndexOf((T)value);
    }
 
    public void Insert(int index, object value)
    {
        throw new NotImplementedException();
    }
 
    public void Remove(object value)
    {
        throw new NotImplementedException();
    }
 
    void IList.RemoveAt(int index)
    {
        throw new NotImplementedException();
    }
 
    private T GetItem(int index)
    {
        var bot = currentPage * pageSize;
        var top = Math.Min(bot + pageSize, countFunction());
        if (index >= bot && index < top)
            return data[index - bot];
        currentPage = (int)Math.Floor(index / (double)pageSize);
        data.Clear();
        data.AddRange(dataFunction(currentPage, pageSize));
        return data[index - (currentPage * pageSize)];
    }
 
    object IList.this[int index]
    {
        get { return GetItem(index); }
        set { throw new NotImplementedException(); }
    }
 
    bool IList.IsReadOnly
    {
        get { return false; }
    }
 
    public bool IsFixedSize
    {
        get { return false; }
    }
 
    private void DoClear()
    {
        currentPage = 0;
        data.Clear();
    }
 
    void ICollection<T>.Clear()
    {
        DoClear();
    }
 
    public bool Contains(T item)
    {
        throw new NotImplementedException();
    }
 
    public void CopyTo(T[] array, int arrayIndex)
    {
        throw new NotImplementedException();
    }
 
    public bool Remove(T item)
    {
        throw new NotImplementedException();
    }
 
    public void CopyTo(Array array, int index)
    {
        throw new NotImplementedException();
    }
 
    int ICollection.Count
    {
        get { return countFunction(); }
    }
 
    public object SyncRoot
    {
        get { return this; }
    }
 
    public bool IsSynchronized
    {
        get { return false; }
    }
 
    int ICollection<T>.Count
    {
        get { return countFunction(); }
    }
 
    bool ICollection<T>.IsReadOnly
    {
        get { return false; }
    }
 
    public int IndexOf(T item)
    {
        return data.IndexOf(item);
    }
 
    public void Insert(int index, T item)
    {
        throw new NotImplementedException();
    }
 
    void IList<T>.RemoveAt(int index)
    {
        throw new NotImplementedException();
    }
 
    public T this[int index]
    {
        get { return GetItem(index); }
        set { throw new NotImplementedException(); }
    }
}

Одним из интересных моментов является получение конструктором двух делегатов Func — одного для получения размера коллекции, а другого для возвращения страницы.

Теперь пора все это запустить.

Сначала вот моя программная часть WPF:

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
        Loaded += DoLoaded;
    }
 
    private void DoLoaded(object sender, RoutedEventArgs e)
    {
        var s = new ServiceClient();
        var coll = new VirtualServiceCollection<TestDataObject>(s.GetPageData, s.GetDataCount, 100);
        DataContext = coll;
    }
}

Как можно видеть, мы передаем в нашу коллекцию два метода службы, получающие нужные нам данные.

И наконец — вот XAML-код

<Window x:Class="WCFDataPaging.WPF.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>
        <StackPanel Orientation="Horizontal">
            <TextBlock Text="Count : " />
            <TextBlock Text="{Binding Path=Count}" />
        </StackPanel>
        <ListView ItemsSource="{Binding}" Grid.Row="1">
            <ListView.View>
                <GridView>
                    <GridView.Columns>
                        <GridViewColumn Header="Name" DisplayMemberBinding="{Binding Name}" />
                        <GridViewColumn Header="ID" DisplayMemberBinding="{Binding ID}" />
                        <GridViewColumn Header="Start Date" DisplayMemberBinding="{Binding StartDate}" />
                        <GridViewColumn Header="Price" DisplayMemberBinding="{Binding Price}" />
                    </GridView.Columns>
                </GridView>
            </ListView.View>
        </ListView>
    </Grid>
</Window>

В верхней части окна находится текстовое поле для размера, а затем идут столбцы для нашего строго типизированного объекта.

И вот все это в действии:

Загрузка занимает около половины секунды, и прокрутка миллиона строк выполняется достаточно гладко. К сведению, первой частью данных в столбце имени (Name) в моей тестовой коллекции является номер строки, поэтому я могу проверить, как это работает :)