Асинхронный код, использующий ключевые слова async и await, трансформирует то, как пишутся программы, и по веской причине. Хотя async и await могут быть полезны для серверного ПО, основное внимание сейчас уделяется приложениям, имеющим UI. В случае таких приложений эти ключевые слова помогают создавать более «отзывчивый» UI. Однако то, как использовать async и await с такими устоявшимися шаблонами, как Model-View-ViewModel (MVVM), далеко не столь очевидно. Эта статья является первой из небольшой серии, в которой будут рассмотрены шаблоны для комбинирования async и await с MVVM.
Внесу ясность: моя статья по асинхронности — «Best Practices in Asynchronous Programming» (msdn.microsoft.com/magazine/jj991977) — относилась ко всем приложениям, применяющим async/await (как к клиентским, так и к серверным). В этой новой серии используются рекомендации из той статьи и обсуждаются шаблоны, специально предназначенные для MVVM-приложений на клиентской стороне. Однако эти шаблоны — просто шаблоны, и в конкретном сценарии они не обязательно могут оказаться лучшим решением. Если вы найдете вариант получше, дайте мне знать!
На момент написания этой статьи ключевые слова async и await поддерживаются широким кругом платформ MVVM: настольной (Windows Presentation Foundation [WPF] в Microsoft .NET Framework 4 и выше), iOS/Android (Xamarin), Windows Store (Windows 8 и выше), Windows Phone (версий 7.1 и выше), Silverlight (версий 4 и выше), а также библиотеками PCL (Portable Class Libraries), ориентированными на любую смесь этих платформ (например, MvvmCross). Теперь настало время разработки шаблонов «асинхронной MVVM».
Я предполагаю, что вы в какой-то мере знакомы с ключевыми словами async и await и довольно хорошо разбираетесь в MVVM. Если это не так, в сети можно найти много вводных материалов по этой тематике. В моем блоге (bit.ly/19IkogW) есть введение в async/await, в конце которого перечислены дополнительные ресурсы; кроме того, очень хорошо написана документация MSDN по асинхронности (ищите по словосочетанию «Task-based Asynchronous Programming»). Более подробную информацию о MVVM я советую искать практически во всем, что написано Джошем Смитом (Josh Smith).
Простое приложение
В этой статье я намерен создать невероятно простое приложение, показанное на рис. 1. При загрузке это приложение выдает HTTP-запрос и подсчитывает количество возвращенных байтов. HTTP-запрос может завершаться успешно или с исключением, и приложение будет обновлять свой UI, используя связывание с данными. При любых обстоятельствах это приложение остается полностью отзывчивым.
Рис. 1. Приложение-пример
Но сначала я хочу упомянуть, что буду следовать шаблону MVVM в своих проектах весьма вольно, иногда используя соответствующий предметной области Model, но чаще применяя вместо самого Model набор сервисов и объектов передачи данных (data transfer objects, DTO) (по сути, это уровень доступа к данным). Я также буду довольно прагматично относиться к View и не стану чураться нескольких строк отделенного кода (codebehind), если в ином случае придется писать десятки строк кода во вспомогательных классах и XAML. Поэтому, когда я говорю о MVVM, понимайте это так, что я не использую ни одно из его строгих определений.
Размышляя о введении async и await в шаблон MVVM, первым делом продумайте, как идентифицировать, каким частям вашего решения нужен контекст UI-потока. Платформы Windows разрешают обращаться к UI-компонентам только из UI-потока, который владеет ими. Очевидно, что представление полностью увязывается с контекстом UI. И я подтверждаю, что в моих приложениях все, что сопоставляется с представлением через привязку данных, связано с контекстом UI. В недавних версиях WPF это ограничение ослабили, разрешив кое-какой обмен данными между UI-потоком и фоновыми потоками (например, BindingOperations.EnableCollectionSynchronization). Однако поддержка межпоточного связывания данных не гарантируется на каждой платформе MVVM (WPF, iOS/Android/Windows Phone, Windows Store), поэтому в своих проектах я просто интерпретирую все, что в UI связывается с данными, как имеющее привязку к UI-потоку.
В итоге я всегда обрабатываю свои ViewModel так, будто они связаны с контекстом UI. В моих приложениях ViewModel теснее связан с View, чем с Model, и уровень ViewModel фактически является API для всего приложения. View буквально предоставляет не более чем оболочку для UI-элементов, в которых находится собственно код приложения. Уровень ViewModel концептуально является тестируемым UI вкупе с привязкой к UI-потоку. Если ваш Model — это модель предметной области (а не уровень доступа к данным) и существует привязка данных между Model и ViewModel, то сам Model тоже имеет привязку к UI-потоку. Как только вы идентифицировали, какие уровни имеют привязку к UI, вы сможете мысленно провести линию между «кодом, привязанным к UI» (View и ViewModel, а также, возможно, Model), и «кодом, независимым от UI» (вероятно, Model и определенно все остальные уровни, такие как уровни сервисов и доступа к данным).
Более того, весь код вне уровня View (т. е. уровни ViewModel и Model, сервисы и т. д.) не должен зависеть от какого-либо типа, связанного с конкретной платформой UI. Любая попытка прямого использования Dispatcher (WPF/Xamarin/Windows Phone/Silverlight), CoreDispatcher (Windows Store) или ISynchronizeInvoke (Windows Forms) — плохая идея. (SynchronizationContext немного лучше, но тоже не особо.) Например, в Интернете полно кода, который асинхронно выполняет какую-то работу, а потом использует Dispatcher для обновления UI; более портируемое и менее трудоемкое решение — применять await для асинхронной работы и обновлять UI без использования Dispatcher.
ViewModel — самый интересный уровень, так как они имеют привязку к UI, но не зависят от конкретного контекста UI. В этой серии статей я буду комбинировать async и MVVM такими способами, чтобы избежать применения специфических UI-типов, в то же время следуя рекомендациям по работе с async; в первой статье мы сосредоточимся на асинхронном связывании с данными.
Асинхронное свойство для связывания с данными
Термин «асинхронное свойство» — это на самом деле оксюморон. Аксессоры get свойства должны выполняться и выдавать текущее значение немедленно, не запуская фоновые операции. По-видимому, это одна из причин, по которой ключевое слово async нельзя использовать в аксессоре get свойства. Если у вас возникнет потребность в создании асинхронного свойства, сначала продумайте альтернативы. В частности, действительно ли свойство должно быть методом (или командой)? Если аксессор get свойства должен при каждом обращении запускать новую асинхронную операцию, тогда это вообще не свойство. Используйте достаточно простые асинхронные методы, а об асинхронных командах я расскажу в другой статье.
В этой статье я собираюсь создать асинхронное свойство, связываемое с данными, т. е. свойство, которое я буду обновлять результатами асинхронной операции. Один из распространенных сценариев — ViewModel требуется получать данные из некоего внешнего источника.
Как я уже объяснил, в приложении-примере я намерен определить сервис, который подсчитывает количество байтов в веб-странице. Чтобы продемонстрировать такой аспект async/await, как отзывчивость, этот сервис будет создавать задержку на несколько секунд. Более реалистичные сервисы мы рассмотрим в следующей статье, а пока «сервис» будет состоять всего из одного метода, показанного на рис. 2.
Рис. 2. MyStaticService.cs
using System;
using System.Net.Http;
using System.Threading.Tasks;
public static class MyStaticService
{
public static async Task<int> CountBytesInUrlAsync(string url)
{
// Искусственная задержка, чтобы показать отзывчивость
await Task.Delay(TimeSpan.FromSeconds(3)).ConfigureAwait(false);
// Скачиваем реальные данные и ведем подсчет байтов
using (var client = new HttpClient())
{
var data = await client.GetByteArrayAsync(url).ConfigureAwait(false);
return data.Length;
}
}
}
Заметьте, что это считается сервисом и поэтому независимым от UI. А поскольку сервис независим от UI, он использует ConfigureAwait(false) всякий раз, когда выполняет await (как обсуждалось в моей другой статье «Best Practices in Asynchronous Programming»).
Давайте добавим простые View и ViewModel, чтобы при запуске выдавать HTTP-запрос. В коде примера используются WPF-окна с представлениями, создающими свои ViewModel при конструировании. Это сделано только для упрощения; принципы и шаблоны асинхронности, обсуждаемые в этой серии статей, применимы ко всем платформам, инфраструктурам и библиотекам MVVM. На данный момент View будет содержать одно основное окно с единственной надписью. XAML для основного View просто связывается с членом UrlByteCount:
<Window x:Class="MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid>
<Label Content="{Binding UrlByteCount}"/>
</Grid>
</Window>
Отделенный код для основного окна создает ViewModel:
public partial class MainWindow
{
public MainWindow()
{
DataContext = new BadMainViewModelA();
InitializeComponent();
}
}
Распространенные ошибки
Вероятно, вы заметили, что тип ViewModel называется BadMainViewModelA. Это потому, что я намерен для начала рассмотреть пару распространенных ошибок, связанных с ViewModel. Одна из них — синхронное блокирование на операции, например:
public class BadMainViewModelA
{
public BadMainViewModelA()
{
// ПЛОХОЙ КОД!!!
UrlByteCount =
MyStaticService.CountBytesInUrlAsync("http://www.example.com").Result;
}
public int UrlByteCount { get; private set; }
}
Это нарушение правила «асинхронность от начала до конца» («async all the way»), но иногда разработчики пытаются идти по этому пути, если им кажется, что других вариантов у них нет. Если вы запустите этот код, то увидите, что он работает — до определенной степени. Код, использующий Task.Wait или Task<T>.Result вместо await, синхронно блокируется на запущенной операции.
С синхронной блокировкой есть несколько проблем. Наиболее очевидная — код теперь выполняет асинхронную операцию и блокируется на ней; из-за этого вы теряете все преимущества асинхронности. Выполняя текущий код, вы увидите, что приложение ничего не делает в течение нескольких секунд, а затем полностью сформированное UI-окно, заполненное результатами, выпрыгивает на экран. Проблема в том, что приложение в этот период не отвечает, что неприемлемо во многих современных программах. В пример кода была умышленно введена задержка, чтобы подчеркнуть его «неотзывчивость»; в реальном приложении эта проблема может остаться незамеченной при разработке и проявиться только в «необычных» сценариях (например, при потере соединения с сетью).
Другая проблема с синхронным блокированием тоньше: код становится более хрупким. Мой пример сервиса должным образом использует ConfigureAwait(false) — именно так, как это должен делать любой сервис. Однако об этом легко забыть, особенно если вы (или ваши коллеги) нерегулярно пользуетесь асинхронными операциями. Рассмотрим, что может случиться со временем, когда код сервиса потребует обновления. Разработчик, занимающийся сопровождением кода, может забыть о ConfigureAwait, и в этот момент блокирование UI-потока может превратиться во взаимоблокировку UI-потока. (Подробнее это описано в моей предыдущей статье, на которую я уже не раз ссылался.)
Итак, вы должны использовать «асинхронность от начала и до конца». Однако многие разработчики выбирают и второй неправильный подход, проиллюстрированный на рис. 3.
Рис. 3. BadMainViewModelB.cs
using System.ComponentModel;
using System.Runtime.CompilerServices;
public sealed class BadMainViewModelB : INotifyPropertyChanged
{
public BadMainViewModelB()
{
Initialize();
}
// ПЛОХОЙ КОД!!!
private async void Initialize()
{
UrlByteCount = await MyStaticService.CountBytesInUrlAsync(
"http://www.example.com");
}
private int _urlByteCount;
public int UrlByteCount
{
get { return _urlByteCount; }
private set { _urlByteCount = value; OnPropertyChanged(); }
}
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChangedEventHandler handler = PropertyChanged;
if (handler != null)
handler(this, new PropertyChangedEventArgs(propertyName));
}
}
И вновь, если вы запустите этот код, вы обнаружите, что он работает. UI теперь появляется немедленно — с «0» в метке на несколько секунд, пока значение не обновится на полученный результат. UI остается «отзывчивым», и все вроде бы отлично. Но проблема в этом случае кроется в обработке ошибок. В случае асинхронного void-метода любые ошибки, возникшие при асинхронной операции, по умолчанию приведут к краху приложения. Это еще одна ситуация, которую легко пропустить при разработке и которая проявится только в «необычных» условиях на клиентском устройстве. Даже замена в коде на рис. 3 асинхронного void-метода на асинхронный Task-метод едва ли улучшит ситуацию; все ошибки будут «молча» игнорироваться, оставляя пользователя в недоумении по поводу того, что произошло. Ни один из таких методов обработки ошибок не годится. И хотя здесь можно вывернуться перехватом исключений от асинхронной операции и обновлением других свойств, связанных с данными, это приведет к тому, что вам придется писать уйму скучного кода.
Более эффективный подход
В идеале, мне нужен тип точно такой же, как Task<T> со свойствами для получения результатов или подробной информации об ошибке. К сожалению, Task<T> недружественно относится к связыванию с данными по двум причинам: он не реализует INotifyPropertyChanged, а его свойство Result является блокирующим. Однако вы можете определить своего рода «сторожа Task» («Task watcher»), например нечто вроде типа, показанного на рис. 4.
Рис. 4. NotifyTaskCompletion.cs
using System;
using System.ComponentModel;
using System.Threading.Tasks;
public sealed class NotifyTaskCompletion<TResult> : INotifyPropertyChanged
{
public NotifyTaskCompletion(Task<TResult> task)
{
Task = task;
if (!task.IsCompleted)
{
var _ = WatchTaskAsync(task);
}
}
private async Task WatchTaskAsync(Task task)
{
try
{
await task;
}
catch
{
}
var propertyChanged = PropertyChanged;
if (propertyChanged == null)
return;
propertyChanged(this, new PropertyChangedEventArgs("Status"));
propertyChanged(this, new PropertyChangedEventArgs("IsCompleted"));
propertyChanged(this, new PropertyChangedEventArgs("IsNotCompleted"));
if (task.IsCanceled)
{
propertyChanged(this, new PropertyChangedEventArgs("IsCanceled"));
}
else if (task.IsFaulted)
{
propertyChanged(this, new PropertyChangedEventArgs("IsFaulted"));
propertyChanged(this, new PropertyChangedEventArgs("Exception"));
propertyChanged(this,
new PropertyChangedEventArgs("InnerException"));
propertyChanged(this, new PropertyChangedEventArgs("ErrorMessage"));
}
else
{
propertyChanged(this,
new PropertyChangedEventArgs("IsSuccessfullyCompleted"));
propertyChanged(this, new PropertyChangedEventArgs("Result"));
}
}
public Task<TResult> Task { get; private set; }
public TResult Result { get { return (Task.Status == TaskStatus.RanToCompletion) ?
Task.Result : default(TResult); } }
public TaskStatus Status { get { return Task.Status; } }
public bool IsCompleted { get { return Task.IsCompleted; } }
public bool IsNotCompleted { get { return !Task.IsCompleted; } }
public bool IsSuccessfullyCompleted { get { return Task.Status ==
TaskStatus.RanToCompletion; } }
public bool IsCanceled { get { return Task.IsCanceled; } }
public bool IsFaulted { get { return Task.IsFaulted; } }
public AggregateException Exception { get { return Task.Exception; } }
public Exception InnerException { get { return (Exception == null) ?
null : Exception.InnerException; } }
public string ErrorMessage { get { return (InnerException == null) ?
null : InnerException.Message; } }
public event PropertyChangedEventHandler PropertyChanged;
}
Давайте подробно рассмотрим основной метод NotifyTaskCompletion<T>.WatchTaskAsync. Этот метод принимает задачу, представляющую асинхронную операцию, и (асинхронно) ожидает ее окончания. Заметьте, что в await не используется ConfigureAwait(false); я хочу возвращаться в контекст UI, прежде чем генерировать уведомления PropertyChanged. Здесь этот метод нарушает общие правила кодирования: в нем находится пустой универсальный блок catch. Однако в данном случае это как раз то, что мне нужно. Я не хочу распространять исключения напрямую в основной цикл UI; мне требуется захватывать любые исключения и так устанавливать свойства, чтобы обработка ошибок происходила через связывание с данными. Когда задача завершена, этот тип генерирует уведомления PropertyChanged для всех необходимых свойств.
Обновленный ViewModel, использующий NotifyTaskCompletion<T>, выглядел бы так:
public class MainViewModel
{
public MainViewModel()
{
UrlByteCount = new NotifyTaskCompletion<int>(
MyStaticService.CountBytesInUrlAsync("http://www.example.com"));
}
public NotifyTaskCompletion<int> UrlByteCount { get; private set; }
}
Этот ViewModel будет немедленно начинать операцию, а затем создавать связанного с данными «сторожа» для конечной задачи. Код связывания с данными View нужно обновить, чтобы привязка к результату операции осуществлялась явным образом, например:
<Window x:Class="MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid>
<Label Content="{Binding UrlByteCount.Result}"/>
</Grid>
</Window>
Заметьте, что содержимое метки (надписи) связано через привязку к данным с NotifyTaskCompletion<T>.Result, а не Task<T>.Result. Дело в том, что NotifyTaskCompletion<T>.Result дружественно к связыванию с данными: оно не является блокирующим и уведомит привязку о завершении задачи. Запустив теперь код, вы увидите, что он ведет себя, как предыдущий пример: UI остается отзывчивым и загружается немедленно (отображая значение по умолчанию — 0), а затем обновляется в течение нескольких секунд реальными результатами.
Преимущество NotifyTaskCompletion<T> в том, что у него есть и много других свойств, поэтому вы можете использовать привязку к данным для отображения разнообразных индикаторов и подробных сведений об ошибках. С помощью этих свойств нетрудно создать индикатор «занятости» и секцию для вывода подробных сведений об ошибке полностью в View, как это сделано, например, в обновленном коде связывания с данными на рис. 5.
Рис. 5. MainWindow.xaml
<Window x:Class="MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter"/>
</Window.Resources>
<Grid>
<!-- Индикатор "занятости" -->
<Label Content="Loading..." Visibility="{Binding UrlByteCount.IsNotCompleted,
Converter={StaticResource BooleanToVisibilityConverter}}"/>
<!-- Результаты -->
<Label Content="{Binding UrlByteCount.Result}" Visibility="{Binding
UrlByteCount.IsSuccessfullyCompleted,
Converter={StaticResource BooleanToVisibilityConverter}}"/>
<!-- Информация об ошибке -->
<Label Content="{Binding UrlByteCount.ErrorMessage}" Background="Red"
Visibility="{Binding UrlByteCount.IsFaulted,
Converter={StaticResource BooleanToVisibilityConverter}}"/>
</Grid>
</Window>
После этого обновления, которое изменяет только View, приложение отображает «Loading…» в течение нескольких секунд (не переставая отвечать на действия пользователя), а затем обновляет метку либо результатами операции, либо сообщением об ошибке, выводимой на красном фоне.
NotifyTaskCompletion<T> обрабатывает один сценарий использования: когда у вас есть асинхронная операция и вы хотите обеспечить получать результаты через связывание с данными. Это распространенный сценарий при поиске данных или загрузке при запуске программы. Однако от него мало толку, когда вы имеете дело с асинхронной командой, например «сохранить текущую запись». (Об асинхронных командах мы поговорим в следующей статье.)
На первый взгляд, кажется, что создание асинхронного UI требует уймы работы, и в какой-то мере это верно. Правильное использование ключевых слов async и await значительно упрощает проектирование более эффективных UI. Переходя к асинхронному UI, вы обнаружите, что больше не можете блокировать UI на время выполнения асинхронной операции. Вы должны продумывать, как будет выглядеть UI в процессе загрузки, и проектировать его с учетом этого состояния. Это требует больше работы, но она необходима для большинства современных приложений. И одна из причин, по которой более новые платформы вроде Windows Store поддерживают только асинхронные API, — вдохновить разработчиков на проектирование более отзывчивых UI.
Заключение
Преобразуя кодовую базу из синхронной в асинхронную, обычно начинают с изменения сервисов или компонентов доступа к данным, и уже отсюда асинхронность распространяется на UI. Проделав такое несколько раз, вы поймете, что преобразование метода из синхронного в асинхронный становится достаточно прямолинейным процессом. Я ожидаю (и надеюсь), что в будущем инструментарии такие преобразования будут выполняться автоматически. Однако, когда асинхронность затрагивает UI, здесь уже требуются настоящие изменения.
Когда UI становится асинхронным, вы должны выявлять ситуации, где ваше приложение блокируется, и улучшать дизайн его UI. В конечном счете вы получите более отзывчивое и более современное приложение. Если хотите, «быстрое и гибкое».
В этой статье вы увидели простой тип, который можно охарактеризовать как Task<T> для связывания с данными. В следующий раз я рассмотрю асинхронные команды и исследую концепцию, которая, по сути, представляет собой «ICommand для асинхронности». Затем, в финальной статье из этой серии мы обсудим асинхронные сервисы. Учитывайте, что сообщество все еще разрабатывает эти шаблоны, так что не стесняйтесь подстраивать их под свои потребности.