«Оживление» пользовательского интерфейса

    image

    Приложение не отвечает?!


    Многие из тех, кто программирует WPF-приложения, наверное тысячи раз писали конструкцию вида:
    {Binding Items}

    Если получение элементов коллекции Items выполняется в основном потоке приложения и занимает некоторое время — мы получаем «мертвый» пользовательский интерфейс. Приложение некоторое время не будет отрисовывать изменения состояния и реагировать на пользовательский ввод. И если время обработки превысит некоторый лимит времени, определенный в оконной системе Windows — система пометит данное окно как не отвечающее на запросы: на изображение последнего успешного рендеринга окна наложиться белая маска и к заголовку добавиться специальный маркер (Not responding) ((Не отвечает) в русской локализации):
    image

    Да вы и сами наверное много раз наблюдали подобную картину, когда какое-нибудь приложение в результате сбоя или выполнения синхронной операции «уходит в себя». И, конечно же, знаете как это раздражает пользователя. Большинство будет биться с истерике, разбивая клавиатуру о монитор, будет с непониманием смотреть на экран, силясь понять что происходит с программой. Более «продвинутые» пользователи, не дожидаясь окончания длительной операции, закроют «провинившееся» окно с помощью менеджера задач.

    Решение №1: Асинхронный ObjectDataProvider


    Решение очень простое и идеально подойдет тем, кто использует в текущих проектах ObjectDataProvider в качестве источника данных.

    Шаг №1: Реализуем простой статический провайдер данных

    Провайдер представляет собой обычный статический класс с одним методом:
    // Emulates a long items getting process using some delay of getting of each item<br/>
    public static class AsyncDataProvider<br/>
    {    <br/>
        private const int _DefaultDelayTime = 300;<br/>
     <br/>
        public static ReadOnlyCollection<string> GetItems()<br/>
        {<br/>
            return GetItems(_DefaultDelayTime);<br/>
        }<br/>
     <br/>
        public static ReadOnlyCollection<string> GetItems(int delayTime)<br/>
        {            <br/>
            List<string> items = new List<string>();            <br/>
            foreach (string item in Enum.GetNames(typeof(AttributeTargets)).OrderBy(item => item.ToLower()))<br/>
            {<br/>
                items.Add(item);<br/>
                // Syntetic delay to emulate a long items getting process<br/>
                Thread.Sleep(delayTime);<br/>
            }<br/>
     <br/>
            return items.AsReadOnly();<br/>
        }        <br/>
    }


    Шаг №2: Объявляем асинхронный источник данных в XAML

    <Window.Resources><br/>
        <ObjectDataProvider x:Key="AsyncDataSource" <br/>
            IsAsynchronous="True"<br/>
            ObjectType="Providers:AsyncDataProvider" <br/>
            MethodName="GetItems" /><br/>
        <Converters:NullToBooleanConverter x:Key="NullToBooleanConverter" /><br/>
    </Window.Resources>


    Конвертер NullToBooleanConverter — это всего лишь вспомогательный объект, назначение которого можно прочесть в названии (его реализацию можно найти в прикрепленном к топику проекте). Вся магия заключается в аттрибуте IsAsynchronous="True" объекта ObjectDataProvider. Этот аттрибут отвечает за управление способом получения данных — если этот аттрибует установлен в "True" ядро WPF создаст для получения значения этого свойства фоновый объект Dispatcher и, таким образом, привязка будет выполнятся в фоновом потоке, не мешая основному потоку приложения обрабатывать пользовательский ввод.

    Шаг №3: Используем провайдер данных в коде

    <ListBox x:Name="ItemsListBox" <br/>
                ItemsSource="{Binding Source={StaticResource AsyncDataSource}, IsAsync=True}">        <br/>
        <ListBox.Style><br/>
            <Style TargetType="{x:Type ListBox}"><br/>
                <Style.Triggers><br/>
                    <Trigger Property="ItemsSource" Value="{x:Null}"><br/>
                        <Setter Property="Template" Value="{StaticResource WaitControlTemplate}" /><br/>
                    </Trigger><br/>
                </Style.Triggers><br/>
            </Style><br/>
        </ListBox.Style><br/>
    </ListBox>


    Обратите внимание — для списка использован триггер, позволяющий визуализировать для пользователя процесс получения данных. Это очень важно — если не информировать пользователя о какой-то длительной операции, он подумает что приложение просто не работает, поскольку ни одно действие над списком не будет доступно (при правильной обработке их состояний, конечно — об этом дальше).

    Шаг №4: Не забываем обрабатывать доступность действий

    <Button Grid.Column="1" <br/>
            Content="Edit"                     <br/>
            Width="70"                    <br/>
            IsEnabled="{Binding SelectedItem, ElementName=ItemsListBox, Converter={StaticResource NullToBooleanConverter}}"<br/>
            Click="EditButton_Click"/><br/>
     


    Шаг №5: In action

    Вот как выглядит главное окно после запуска со всеми нашими изменениями:
    Asynchronous items loading in action

    А вот так оно выглядит после того, как данные получены:
    Asynchronous items loading completed

    Кнопка Edit привязана к выделенному элементу через простой конвертер. Если выделенный элемент в основном списке ItemsListBox отсутствует — кнопка будет недоступна. А выделить элемент можно будет только после того, как асинхронный провайдер данных AsyncDataSource заполнит элементами список. Кнопка Close добавлена для визуализации возможности управления приложением — ничто не мешает нажать на нее во время процесса получения данных и закрыть главное окно. Приложение при этом исправно отреагирует на наш запрос и закроется, чего не произошло бы в том случае, если наш источник данных был бы синхронным.

    Решение №2 Асинхронный Binding


    Второе решение этой задачи использует паттерн M-V-VM (Model-View-ViewModel), наверное один из популярнейших сейчас паттернов построения модульных приложений для WPF и Silverlight. Обсуждение данного паттерна выходит за рамки данной статьи — при желании вы сможете легко найти о нем много информации в сети (если вам лень искать — загляните в раздел Ссылки в конце статьи).

    Шаг №1:

    Создадим модель представления для главного окна приложения:
    public class MainViewModel<br/>
    {<br/>
        private ICommand _commandClose;<br/>
     <br/>
        private ICommand _commandEdit;<br/>
     <br/>
        private ReadOnlyCollection<string> _items;<br/>
     <br/>
        public ReadOnlyCollection<string> Items<br/>
        {<br/>
            get<br/>
            {<br/>
                if (_items == null)<br/>
                {<br/>
                    _items = AsyncDataProvider.GetItems();                    <br/>
                }<br/>
     <br/>
                return _items;<br/>
            }<br/>
        }<br/>
     <br/>
        public ICommand CommandClose<br/>
        {<br/>
            get<br/>
            {<br/>
                if (_commandClose == null)<br/>
                {<br/>
                    _commandClose = new RelayCommand(=> OnClose());<br/>
                }<br/>
     <br/>
                return _commandClose;<br/>
            }<br/>
        }<br/>
     <br/>
        public ICommand CommandEdit<br/>
        {<br/>
            get<br/>
            {<br/>
                if (_commandEdit == null)<br/>
                {<br/>
                    _commandEdit = new RelayCommand(=> OnEdit(p), p => CanEdit);<br/>
                }<br/>
     <br/>
                return _commandEdit;<br/>
            }<br/>
        }<br/>
     <br/>
        public string SelectedItem<br/>
        {<br/>
            get;<br/>
            set;<br/>
        }<br/>
     <br/>
        private void OnClose()<br/>
        {<br/>
            App.Current.Shutdown();<br/>
        }<br/>
     <br/>
        private void OnEdit(object parameter)<br/>
        {<br/>
            MessageBox.Show(String.Format("Edtiting item: {0}", <br/>
                parameter != null ? parameter.ToString() : "Not selected"));<br/>
        }<br/>
     <br/>
        private bool CanEdit<br/>
        {<br/>
            get<br/>
            {<br/>
                return SelectedItem != null;<br/>
            }            <br/>
        }<br/>
    }


    Шаг №2: Немного изменим объявление привязки в коде XAML главного представления

    <ListBox x:Name="ItemsListBox" <br/>
                Grid.Row="0" <br/>
                ItemsSource="{Binding Items, IsAsync=True}"<br/>
                SelectedItem="{Binding SelectedItem}"><br/>
        <ListBox.Style><br/>
            <Style TargetType="{x:Type ListBox}"><br/>
                <Style.Triggers><br/>
                    <Trigger Property="ItemsSource" Value="{x:Null}"><br/>
                        <Setter Property="Template" Value="{StaticResource WaitControlTemplate}" /><br/>
                    </Trigger><br/>
                </Style.Triggers><br/>
            </Style><br/>
        </ListBox.Style><br/>
    </ListBox>


    В этом сценарии за асинхронность отвечает аттрибут, указанный в разметке привязки: "{Binding Items, IsAsync=True}". Как и в примере с ObjectDataProvider ядро WPF создаст отдельный фоновый диспетчер для получения значения привязки в отдельном асинхронном контексте.

    Отдельно стоит отметить что в этом сценарии нам не требуется прибегать к кодированию правил видимости элементов управления в XAML-коде представления главного окна. В приведенном выше коде модели представления за видимость кнопки Edit на форме отвечает свойство MainViewModel.CanEdit, которое является частью команды MainViewModel.CommandEdit. Более детально узнать о паттерне Команда (Command) вы можете, заглянув в раздел Ссылки. Здесь же будет уместно заметить лишь то, что нам не придется ничего делать вручную — обо всем позаботится класс CommandManager. От нас требуется лишь правильная реализация контракта ICommand, которую обеспечивает класс RelayCommand (с реализацией этого класса вы можете ознакомится в прилагаемом проекте).

    Домашнее задание


    Выполняется по желанию — проверять не буду, даже не просите :) Можно слегка усовершенствовать шаблон WaitControlTemplate превратив его в полноценный элемент управления, унаследованный от класса Border или от его предка Decorator, если есть желание сделать полноценный элемент управления по всем правилам. Поведение и логика этого элемента будут простыми:
    • Внутрь элемента можно добавить только ItemsControl или любого из его наследников (ListBox, ListView, TreeView etc) — контролировать это желательно на самом жестком уровне, вплоть до выбрасывания исключения если свойство Content не является ItemsControl
    • При изменении свойства Content элемент управления пытается привести содержимое к ItemsControl и получить значение привязки данных свойства ItemsSource
    • Если предыдущий шаг удался — перевести привязку в асинхронный режим
    • Визуализация элемента управления может быть частично построена на логике паттерна Заместитель (Proxy) — пока данные асинхронно загружаются элемент управления показывает свое содержимое (крутящийся индикатор загрузки и надпись с просьбой подождать), после завершения загрузки отображается содержимое свойства Content


    Резюме


    Главными клиентами разработчиков являются пользователи. И каждому разработчику время от времени полезно ставить себя на место простого пользователя, проводить полный цикл тестирования своего приложения и пытаться анализировать, что именно его (как пользователя) раздражает и что бы он (как пользователь) хотел бы улучшить.

    Исходный код


    AsyncBinding.zip

    Ссылки


    Приложения WPF с шаблоном проектирования модель-представление-модель представления
    Общие сведения о системе команд
    Asynchronous Data Binding in Windows Presentation Foundation
    Asynchronous WPF
    Оригинал главного изображения для топика взят отсюда
    Share post

    Similar posts

    Comments 10

    • UFO just landed and posted this here
        +1
        спасибо
        перенесите пожалуйста статью в блог (.NET или WPF), чтобы она смогла выйти на главную Хабра
          +4
          Перенес в блог WPF, поскольку изначально планировал разместить его там — только сейчас хватило кармы.
          +2
          While items ARE loading
            +1
            Спасибо, исправил. Исходники правда не стал перезаливать :)
            0
            Статья действительно отличная, спасибо
              +1
              backgroundworker разве для этого не подходит?
              Спасибо за статью.
                +1
                Подходит. Но это для того случая, если требуется выполнить некоторое действие в фоне. Здесь же основной акцент на Binding и то, что не приходится прилагать усилий — ядро WPF сделает все само. А с BackgroundWorker вам придется попыхтеть с реализацией правильной синхронизации. Да и работа с ним в рамках связывания данных не совсем простая — поверьте, приходилось сталкиваться.
                Вообще для реализации операций с потоками в UI лучше использовать более новую концепцию — класс Dispatcher. Дизайнеры .NET Framework рекомендуют использовать этот класс вместо BackgroundWorker, который считается устаревшей концепцией .NET Framework 2.0.
                    +1
                    Маленькое дополнение — надо было сразу об этом сказать. Dispatcher, на мой взгляд, лучше ложится в схему приложения, использующего паттерн MVVM. Это сугубо из личного опыта — мне гораздо удобнее стало, когда я перешел на использование Dispatcher. Поскольку там вы свободно можете выполнять код в потоке UI Dispatcher. При использовании BackgroundWorker у вас такой возможности нет. Да и информацию о прогрессе вы можете передать только в рамках навазываемого коллбэка private void ProgressChanged(object sender, ProgressChangedEventArgs e), что накладывает определенные ограничения.

              Only users with full accounts can post comments. Log in, please.