воскресенье, 27 октября 2013 г.

Асинхронная загрузка данных с индикацией

Я как-то уже затрагивал тему асинхронной загрузки данных в своей статье TaskExtensions в помощь. Однако, используя такой подход, код со временем начинал обрастать огромным количеством callback'ов и командами. Чтобы оптимизировать данный процесс для своих нужд я сделал класс, которым и хочу поделиться. Ничего нового в нем нет, однако, он позволил сократить огромное количество кода и на мой взгляд является весьма интересным.
Суть класса такова: предоставить пользователю коллекцию динамических данных с выдачей уведомлений при обновлении, а также обеспечить механизм индикации загрузки данных, при этом выбор источника данных предоставить потребителю.
Самый классический сценарий для UI-девелопера: список объектов и кнопка "Обновить". Я решил совместить это в одной коллекции:

public sealed class AsyncObservableCollection<T> : ObservableCollection<T>, ICommand
{
    private readonly Func<Object, Task<IEnumerable<T>>> factory;

    public AsyncObservableCollection(Func<Object, Task<IEnumerable<T>>> factory)
    {
        this.factory = factory;
    }
}

Как видно - это обычная обобщенная коллекция, которая наследует интерфейс ICommand (не совсем стандартный подход). В качестве параметра конструктор класса принимает делегат, который по сути является фабрикой, экземпляр которого будет вызываться для получения списка объектов асинхронно. Тем самым я предоставил пользователю (потребителю) класса самому решать как и откуда он будет загружать данные.
Для обеспечения механизма индикации потребуется добавить несколько свойств (хочу внести ясность, что под индикацией я понимаю отображение любой информации пользователю о выполнении асинхронной операции, будь-то busy-индикатор или обычный текст в status bar):

private Int32 loading;
public Boolean IsLoading
{
    get { return loading != 0; }
}

public Boolean IsEnabled
{
    get { return !IsLoading; }
}

Назначение этих свойств, я думаю, понятно из названий, но вот почему они ссылаются на целое значение (а не на булево), я объясню это на коде реализации интерфейса ICommand:

private EventHandler canExecuteChangedDelegate;
event EventHandler ICommand.CanExecuteChanged
{
    add { canExecuteChangedDelegate += value; }
    remove { canExecuteChangedDelegate -= value; }
}

bool ICommand.CanExecute(object parameter)
{
    return IsEnabled;
}

void ICommand.Execute(object parameter)
{
    if (factory == null) return;
    if (Interlocked.CompareExchange(ref loading, 1, 0) == 0)
        InternalExecute(parameter);
}

Поскольку метод ICommand.Execute теоретически может вызываться несколько раз подряд и к тому же из разных потоков, а предыдущее обновление элементов списка возможно еще не завершилось, то здесь я выполняю сравнение значения переменной loading. Если loading == 0, то есть загрузка данных не выполняется, я меняю значение loading = 1 и запускаю обновление списка. Иначе, если loading == 1, то ничего не делаем, так как список находится в процессе обновления.
Операция сравнения и изменения значения переменной loading выполняется атомарно благодаря статическому методу Interlocked.CompareExchange (к сожалению перегрузки для работы с типом bool нет), который позволяет выполнять данные операции с переменными, общедоступными нескольким потокам.
Теперь приведу код метода InternalExecute, который обрабатывает индикацию выполнения и запускает задачу загрузки данных:

private void InternalExecute(Object parameter)
{
    if (Application.Current.CheckAccess())
    {
        OnCanExecuteChanged();
        Clear();
        ToAsync(factory(parameter), Callback, parameter);
    }
    else
    {
        Application.Current.Dispatcher.Invoke(DispatcherPriority.DataBind,
                            new Action<Object>(InternalExecute), parameter);
    }
}

private void OnCanExecuteChanged()
{
    OnPropertyChanged(new PropertyChangedEventArgs("IsLoading"));
    OnPropertyChanged(new PropertyChangedEventArgs("IsEnabled"));

    var handler = canExecuteChangedDelegate;
    if (handler == null) return;
    handler(this, EventArgs.Empty);
}

С помощью CheckAccess я проверяю, связан ли вызывающий поток с UI-потоком. Если да, то обновляю состояние индикации и запускаю задачу для загрузки данных. Если же нет, то выполняю этот же метод в контексте UI-потока. Все это сделано для того, чтобы безо всяких сюрпризов обновить свойства индикации, которые могут быть привязаны к элементам управления в пользовательском интерфейсе.
Про метод ToAsync я рассказывал в статье TaskExtensions в помощь, а вот про метод обратного вызова Callback расскажу немного подробней:

private void Callback(IAsyncResult asyncResult)
{
    var task = asyncResult as Task<IEnumerable<T>>;
    if (task == null) return;
    switch (task.Status)
    {
        case TaskStatus.RanToCompletion:
            // The task completed execution successfully.
            Action<T> action = Add;
            foreach (var entity in task.Result)
            {
                var obj = entity;
                Application.Current.Dispatcher.Invoke(
                    DispatcherPriority.DataBind, action, obj);
            }
            break;

        case TaskStatus.Faulted:
            // The task completed due to an unhandled exception.
            if (task.Exception != null) task.Exception.Handle(ExceptionHandle);
            break;
    }
    Thread.VolatileWrite(ref loading, 0);
    Application.Current.Dispatcher.Invoke(DispatcherPriority.DataBind,
                                                new Action(OnCanExecuteChanged));
}

Этот метод вызывается после выполнения пользовательского делегата загрузки данных, и проверяет состояние задачи. Если выполнение прошло без ошибок - добавляем элементы в коллекцию и в конце, с помощью статического метода Thread.VolatileWrite устанавливаем состояние индикации в начальное. Так же не забывайте обрабатывать ошибки, которые могли произойти в пользовательском делегате, так как это может быть причиной ошибки в дальнейшем (кстати, знаете почему?).
Ну вот основные моменты класса я описал. Ссылку на полный исходный код можно найти в конце статьи. Осталось показать как это применить на практике.
MainView:

<Window x:Class="WpfCSharpTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:s="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="clr-namespace:WpfCSharpTest"
        Title="MainWindow" Height="350" Width="525">
    <Window.Resources>
        <s:BooleanToVisibilityConverter x:Key="booleanToVisibilityConverter"/>
    </Window.Resources>
    <Window.DataContext>
        <vm:MainViewModel/>
    </Window.DataContext>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="36"/>
        </Grid.RowDefinitions>
        <ListBox ItemsSource="{Binding Items}">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <Grid>
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="100"/>
                            <ColumnDefinition Width="100"/>
                        </Grid.ColumnDefinitions>
                        <TextBlock Text="{Binding Name}" Grid.Column="0"/>
                        <TextBlock Text="{Binding Description}" Grid.Column="1"/>
                    </Grid>
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>
        <TextBlock Text="Загрузка данных..." VerticalAlignment="Center" HorizontalAlignment="Left" Grid.Row="1" Margin="10,0"
                   Visibility="{Binding Items.IsLoading, Converter={StaticResource booleanToVisibilityConverter}}"/>
        <TextBox x:Name="textBox" Text="100000" VerticalAlignment="Center" HorizontalAlignment="Center" Grid.Row="1"/>
        <Button Content="Обновить" Command="{Binding Items}" CommandParameter="{Binding Text, ElementName=textBox}"
                VerticalAlignment="Center" HorizontalAlignment="Right" Grid.Row="1" Margin="10,0"/>
    <Grid>
<Window>

MainViewModel:

class MainViewModel
{
    public MainViewModel()
    {
        Items = new AsyncObservableCollection<CustomItem>(Factory);
    }

    public AsyncObservableCollection<CustomItem> Items { get; private set; }

    private static Task<IEnumerable<CustomItem>> Factory(object parameter)
    {
        return Task<IEnumerable<CustomItem>>.Factory.StartNew(delegate(object o)
                            {
                                var count = Int32.Parse(o.ToString());
                                var list = new List<CustomItem>();
                                for (var i = 0; i < count; i++)
                                {
                                    list.Add(new CustomItem
                                            {
                                                Name = "Name " + i,
                                                Description = "Description " + i
                                            });
                                }
                                return list;
                            }, parameter);
    }
}

class CustomItem
{
    public String Name { get; set; }

    public String Description { get; set; }
}

Обратите внимание, что в качестве аргумента мы можем передать любое значение, которое нам потребуется. В примере передается количество элементов которое требуется получить. И еще один момент: привязка кнопки к команде выглядит немного странно Command = "{Binding Items}", все потому, что мой класс наследует интерфейс ICommand.

P.S. Чтобы "увидеть" динамическое обновление интерфейса, вы можете в методе обратного вызова Callback в цикле foreach сделать небольшую задержку, к примеру Thread.Sleep(100), для того, чтобы список успевал обновляться.

Исходный код

Комментариев нет:

Отправить комментарий