среда, 16 декабря 2009 г.

Будьте отзывчивыми!

Тестирование это инструмент для повышения качества программного обеспечения. Но "качественное" ПО - понятие многогранное и очень субъективное. Одним из таких критериев качества для конечного пользователя может быть "отзывчивый" пользовательский интерфейс.

Возьмем для примера простую форму:



Нажатие на каждую кнопку приводит к вызову соответсвующего метода у некоторого сервиса. Обычная такая форма, а вот и обычное для такого случая решение:


    public interface ISlowService
    {
        void Method1();
        void Method2();
        void Method3();
    }

    public class Presenter
    {
        public IView View { get; set; }

        public void ExecuteMethod1()
        {
            _service.Method1();
            View.AddMessage("Method1 complete.");
        }

        public void ExecuteMethod2()
        {
            _service.Method2();
            View.AddMessage("Method2 complete.");
        }

        public void ExecuteMethod3()
        {
            _service.Method3();
            View.AddMessage("Method3 complete.");
        }
    }

Запускаем, пробуем. Нажмем кнопку Method1, после чего понажимаем остальные кнопки, попробуем изменить размер окна, развернуть его или даже закрыть. Не работает?! Оказывается, что после нажатия на кнопку Method1, наша форма на одну минуту "замирает", потому что "выполняется" метод сервиса. "Так не бывает!" - скажете вы. Отчего ж... Одна минута - стандартный таймаут операции для WCF сервисов. Мало ли что там могло случиться на стороне сервиса. А пользователь ничего, подождет... Это и есть "обычное" решение такого рода задач.

Попробуем немного улучшить "качество" нашего кода. Решений и подходов для такого рода задач много, некоторые из них построенны на использовании фонового потока для выполнения задач. Но всегда ли так необходимо явно создавать еще один поток в нашем приложении? Посмотрите на этот скриншот:



У вас есть идеи чем могут заниматься 45 потоков в Outlook'е или 29 в Skype или в среднем по 30 "на брата" в инстансах IE? У меня нет таких разумных идей. Да, конечно, эти потоки в большинстве своем бездействуют, переключения между ними "стоят копейки" (в век гигагерцев то), однако если они бездействуют, то зачем их вообще создавать? Попробуем воспользоваться уже существующими в приложении потоками.

Немного изменим наш код:

        private delegate void ExecuteMethodDelegate();

        private void OnCallback(IAsyncResult ar)
        {
            var result = (AsyncResult) ar;
            var caller = (ExecuteMethodDelegate) result.AsyncDelegate;
            caller.EndInvoke(ar);

            var message = (string) ar.AsyncState;
            View.AddMessage(message);
        }

        public override void ExecuteMethod1()
        {
            ExecuteMethodDelegate d = Service.Method1;
            d.BeginInvoke(OnCallback, "Method1 complete.");
        }

        public override void ExecuteMethod2()
        {
            ExecuteMethodDelegate d = Service.Method2;
            d.BeginInvoke(OnCallback, "Method2 complete.");
        }

        public override void ExecuteMethod3()
        {
            Service.Method3();
            View.AddMessage("Method3 complete.");
        }

Мы воспользуемся асинхронными делегатами, которые в свою очередь используют потоки из ThreadPool, который уже существует и чей размер управляется managed runtime.

Запускаем, проверяем. После нажатия на кнопку Method1 мы можем нажимать на другие кнопки, нажатие на кнопку Method3 сразу же добавит строчку в список, делать с окном что угодно - разворачивать, минимизировать и даже закрыть окно и приложение. Однако, по истечении одной минуты ожидания, вместо строчки "Method1 complete." мы получим что то подобное:


Доступ к UI элементу из другого потока... Какие у нас есть варианты решения это проблемы?

  • Связка Control.InvokeRequired + Control.Invoke


        public void AddMessage(string message)
        {
            if (InvokeRequired)
            {
                Invoke(new MethodInvoker(() => AddMessage(message)));
                return;
            }

            listBox1.Items.Add(message);
        }


        Решение работает, однако если в нашем IView будет много методов/свойств, все они "обрастут" такими преамбулами, что не есть хорошо. Плюс, что если кто-то добавит новый метод, и забудет добавить преамбулу? Это может привести к неожиданным ошибкам с непонятной стратегией нахождения/тестирования. Нам нужно некоторое "централизованное" решение.


  • Попробовать "собрать" весь этот механизм внутри Presenter'а:
        private void OnCallback(IAsyncResult ar)
        {
            var result = (AsyncResult) ar;
            var caller = (ExecuteMethodDelegate) result.AsyncDelegate;
            caller.EndInvoke(ar);

            var message = (string) ar.AsyncState;

            var control = (Control) View;
            if (control.InvokeRequired)
                control.Invoke(new MethodInvoker(() => View.AddMessage(message)));
            else
                View.AddMessage(message);
        }


Один факт того, что Presenter'у нужно знать что его View это Control, останавливает меня от использования этого решения.
  • Использовать SynchronizationContext. Данный класс незаслужено игнорируется программистами, а зря...
        class UserState
        {
            public SynchronizationContext SynchronizationContext { get; set; }
            public string Message { get; set; }
        }

        private void OnCallback(IAsyncResult ar)
        {
            var result = (AsyncResult) ar;
            var caller = (ExecuteMethodDelegate) result.AsyncDelegate;
            caller.EndInvoke(ar);

            var userState = (UserState) ar.AsyncState;
            userState.SynchronizationContext.Post(obj => View.AddMessage(userState.Message), null);
        }

Имхо, элегантное решение, лишенное недостатков предыдущих двух. Мы получили "отзывчивый" интерфейс не затратив на это много сил. Попробуйте, не правда ли намного лучше, чем самое первое "обычное" решение? Так что мешает так делать всегда?...

Конечно и у данного решения есть недостатки, ограничивающие область его применения. Например невозможность отмены текущей выполняющейся операции. Это важно для ситуаций, когда операция ожидаемо будет продолжаться долго, например: копирование файлов, инсталяция приложения, расчет каких то сложных данных... Для таких случаев UI приложения должен содержать кнопку "Cancel" или какой то ее аналог. Но таких задач все же меньше, чем "кнопка, нажатие на которую приводит обращению к сервису far far away".

Сольюшн с примером можно взять здесь.

1 комментарий:

  1. Ну для начала надо сказать, что не всегда он есть вообще. То есть вполне может быть, что SyncronizationContext.Current == null. В случае UI потока он есть и организован на базе message loop. Образно говоря, тот поток, который хочет выполнить что-то в контексте UI потока, ставит ему в очередь сообщение с указанием того, что надо выполнить, а UI поток, получив это сообщение, выполняет.

    ОтветитьУдалить

Wider Two Column Modification courtesy of The Blogger Guide