Programação assíncrona

Padrões para aplicativos MVVM assíncronos: Vinculação de dados

Stephen Cleary

O código assíncrono que usa as palavras-chave async e await está transformando o modo como os programas são escritos, e por um bom motivo. Embora async e await possam ser úteis para software de servidor, grande parte do foco atual é em aplicativos que possuem uma interface de usuário. Para tais aplicativos, essas palavras-chave podem gerar uma interface de usuário com mais capacidade de resposta. No entanto, ainda não está claro como usar async e await com padrões estabelecidos, como o MVVM (Model-View-ViewModel). Este artigo é o primeiro de uma série curta que levará em consideração padrões para combinar async e await com o MVVM.

Para ser claro, meu primeiro artigo sobre async, "Práticas recomendadas na programação assíncrona" (msdn.microsoft.com/magazine/jj991977), foi relevante para todos os aplicativos que usam async/await, cliente e servidor. Essa nova série é criada com base nas práticas recomendadas desse artigo e apresenta padrões especificamente para aplicativos MVVM do lado do cliente. No entanto, esses padrões são apenas padrões e podem não ser necessariamente as melhores soluções para um cenário específico. Se você encontrar uma maneira melhor, me conte!

No momento em que este artigo estava sendo escrito, as palavras-chave async e await tinham suporte em várias plataformas MVVM: desktop (Windows Presentation Foundation [WPF] no Microsoft .NET Framework 4 e superior), iOS/Android (Xamarin), Windows Store (Windows 8 e superior), Windows Phone (versão 7.1 e superior), Silverlight (versão 4 e superior), bem como PCLs (Bibliotecas de Classes Portáteis) destinadas a qualquer combinação dessas plataformas (como MvvmCross). Agora é o momento dos padrões "async MVVM" pronto para desenvolvimento.

Estou supondo que você esteja um pouco familiarizado com async e await e bastante familiarizado com o MVVM. Se não for esse o caso, há vários materiais introdutórios úteis disponíveis online. Meu blog (bit.ly/19IkogW) inclui uma introdução às palavras-chave async/await que lista recursos adicionais no fim e a documentação do MSDN sobre async é bem interessante (procure por "Programação assíncrona baseada em tarefa"). Para obter mais informações sobre o MVVM, recomendo quase tudo que foi escrito por Josh Smith.

Um aplicativo simples

Neste artigo, vou criar um aplicativo incrivelmente simples, como mostra a Figura 1. Quando o aplicativo é carregado, ele inicia uma solicitação HTTP e conta o número de bytes retornados. A solicitação HTTP pode ser concluída com sucesso ou com uma exceção, e o aplicativo será atualizado usando a vinculação de dados. O aplicativo é totalmente ágil na resposta o tempo todo.

The Sample Application
The Sample Application
The Sample Application
Figura 1 O aplicativo de exemplo

Em primeiro lugar, quero mencionar que sigo o padrão MVVM de modo não tão rígido nos meus próprios projetos, às vezes usando um Model de domínio adequado, mas com mais frequência usando um conjunto de serviços e objetos de transferência de dados (essencialmente uma camada de acesso a dados), em vez de um Model real. Também sou meio programático quando se trata do View; não me esquivo de algumas linhas de code-behind se a alternativa for dezenas de linhas de código para suporte a classes e XAML. Desse modo, quando falo sobre o MVVM, entenda que não estou usando nenhuma definição rígida específica do termo.

Uma das primeiras coisas que você precisa considerar ao introduzir async e await no padrão MVVM é identificar quais partes da sua solução precisam do contexto de threading de interface de usuário. As plataformas Windows são sérias quanto aos componentes de interface de usuário que estão sendo acessados apenas do thread da interface de usuário que os possuem. Obviamente, a exibição é totalmente associada ao contexto da interface de usuário. Também assumo uma postura de que nos meus aplicativos tudo que estiver vinculado à exibição pela vinculação de dados será associado ao contexto de interface de usuário. As versões recentes do WPF foram aliviados dessa restrição, permitindo algum compartilhamento de dados entre o thread de interface de usuário e threads de segundo plano (por exemplo, BindingOperations.EnableCollection­Synchronization). No entanto, o suporte para vinculação de dados entre threads não é garantido em toda plataforma MVVM (WPF, iOS/Android/Windows Phone, Windows Store), de modo que nos meus próprios projetos apenas trato tudo vinculado por dados para a interface de usuário como tendo afinidade com o thread da interface de usuário.

Consequentemente, eu sempre trato meus ViewModels como se eles estivessem ligados ao contexto de interface de usuário. Nos meus aplicativos, o ViewModel está mais estreitamente relacionado ao View do que ao Model, e a camada ViewModel é essencialmente uma API para todo o aplicativo. O View literalmente fornece apenas o shell dos elementos da interface de usuário no qual existe o aplicativo real. A camada ViewModel é conceitualmente uma interface de usuário que pode ser testada, completa com uma afinidade de thread da interface de usuário. Se seu Model for um modelo de domínio real (e não uma camada de acesso a dados) e houver vinculação de dados entre o Model e o ViewModel, o Model em si também terá afinidade com o thread da interface de usuário. Depois que você tiver identificado quais camadas têm afinidade com a interface de usuário, você poderá desenhar uma linha mental entre o "código afim de interface de usuário" (View e ViewModel e, possivelmente, o Model) e o "código independente de interface de usuário" (provavelmente o Model e definitivamente todas as outras camadas, como acesso a dados e serviços).

Além disso, todo código fora da camada View (isto é, das camadas ViewModel e Model, serviços, etc.) não deve depender de nenhum tipo associado a uma plataforma de interface de usuário específica. Qualquer uso direto do Dispatcher (WPF/Xamarin/Windows Phone/Silverlight), CoreDispatcher (Windows Store) ou ISynchronizeInvoke (Windows Forms) é uma péssima ideia. (SynchronizationContext é marginalmente melhor, mas muito pouco.) Por exemplo, existem muitos códigos na Internet que fazem algum trabalho assíncrono e usam o Dispatcher para atualizar a interface de usuário; uma solução mais tolerável e menos incômoda é usar await para trabalho assíncrono e atualizar a interface de usuário sem usar o Dispatcher.

Os ViewModels são a camada mais interessante porque têm afinidade com a interface de usuário, mas não dependem de um contexto de interface de usuário específico. Nessa série, combinarei async e MVVM de várias formas que evitam tipos de interface de usuário específicos, embora também sigam práticas recomendadas de async; este primeiro artigo se concentra na vinculação de dados assíncrona.

Propriedades assíncronas vinculadas por dados

O termo "propriedade assíncrona" é, na verdade, um oximoro. Os getters de propriedade devem ser executados imediatamente e recuperar valores atuais, e não disparar operações em segundo plano. Esse, provavelmente, é um dos motivos pelos quais a palavra-chave async não pode ser usada em um getter de propriedade. Se seu design estiver pedindo uma propriedade assíncrona, pense em algumas alternativas primeiro. Particularmente, a propriedade deveria ser de fato um método (ou um comando)? Se o getter da propriedade precisar disparar uma nova operação assíncrona toda vez que for acessado, ela não é bem uma propriedade. Os métodos assíncronos são diretos, e abordarei comandos assíncronos em outro artigo.

Neste artigo, vou desenvolver uma propriedade assíncrona vinculada por dados, isto é, uma propriedade vinculada por dados que atualizo com os resultados de uma operação assíncrona. Um cenário comum é quando um ViewModel precisa recuperar dados de alguma fonte externa.

Conforme expliquei anteriormente, para meu aplicativo de exemplo, vou definir um serviço que conte os bytes em uma página da Web. Para ilustrar o aspecto de capacidade de resposta de async/await, esse serviço também atrasará alguns segundos. Abordarei serviços assíncronos mais realistas em um artigo posterior; por enquanto, o "serviço" é apenas o método simples mostrado na Figura 2.

Figura 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)
  {
    // Artificial delay to show responsiveness.
    await Task.Delay(TimeSpan.FromSeconds(3)).ConfigureAwait(false);
    // Download the actual data and count it.
    using (var client = new HttpClient())
    {
      var data = await client.GetByteArrayAsync(url).ConfigureAwait(false);
      return data.Length;
    }
  }
}

Observe que isso é considerado um serviço, de modo que independe da interface de usuário. Como o serviço é independente da interface de usuário, ele usa ConfigureAwait(false) toda vez que faz uma espera (conforme discutido em meu outro artigo, "Práticas recomendadas na programação assíncrona").

Vamos adicionar um View e ViewModel simples que inicie uma solicitação HTTP na inicialização. O código de exemplo usa janelas do WPF com os Views que estão criando seus ViewModels na construção. Isso é apenas para simplificar. Os princípios e padrões do async discutidos nessa série de artigos se aplicam em todas as plataformas, estruturas e bibliotecas MVVM. O View, por enquanto, consistirá em uma única janela principal com um único rótulo. O XAML para o View principal apenas se vincula ao membro 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>

O code-behind da janela principal cria o ViewModel:

public partial class MainWindow
{
  public MainWindow()
  {
    DataContext = new BadMainViewModelA();
    InitializeComponent();
  }
}

Equívocos comuns

Você pode perceber que o tipo de ViewModel é chamado de BadMainViewModelA. Isso porque, primeiramente, vou observar alguns equívocos comuns relacionados aos ViewModels. Um erro comum é bloquear de modo síncrono a operação, desta forma:

public class BadMainViewModelA
{
  public BadMainViewModelA()
  {
    // BAD CODE!!!
    UrlByteCount =
      MyStaticService.CountBytesInUrlAsync("http://www.example.com").Result;
  }
  public int UrlByteCount { get; private set; }
}

Essa é uma violação da diretriz async que afirma "async o tempo todo", mas, às vezes, os desenvolvedores tentam isso se sentirem que não há opções. Se você executar esse código, você verá que ele funciona, até certo ponto. O código que usa Task.Wait ou Task<T>.Result em vez de await está bloqueando de forma síncrona essa operação.

Há alguns problemas com o bloqueio síncrono. O mais óbvio é o código que está usando uma operação assíncrona e bloqueando-a; ao fazer isso, ele perde todos os benefícios da assincronicidade. Se você executar o código atual, você verá que o aplicativo não faz nada por alguns segundos e, em seguida, a janela da interface de usuário aparece totalmente formada na exibição com seus resultados já preenchidos. O problema é o aplicativo que não responde, o que é inaceitável para muitos aplicativos modernos. O código de exemplo tem um atraso proposital para enfatizar essa falta de resposta; em um aplicativo do mundo real, esse problema pode passar despercebido durante o desenvolvimento e se mostrar apenas em cenários de cliente "incomuns" (como perda de conectividade de rede).

Outro problema com o bloqueio síncrono é mais sutil: o código é mais frágil. Meu serviço de exemplo usa ConfigureAwait(false) adequadamente, como um serviço deve usar. No entanto, isso é fácil de esquecer, especialmente se você (ou seus colegas de trabalho) não usam async regularmente. Considere o que poderia acontecer ao longo do tempo enquanto o código de serviço é mantido. Um desenvolvedor de manutenção pode esquecer um ConfigureAwait e, nesse ponto, o bloqueio do thread da interface de usuário se tornaria um deadlock do thread da interface de usuário. (Isso é descrito em mais detalhes no meu artigo anterior sobre práticas recomendadas do async.)

OK, você deve usar "async o tempo todo". No entanto, muitos desenvolvedores passam para a segunda abordagem falha, ilustrada na Figura 3.

Figura 3 BadMainViewModelB.cs

using System.ComponentModel;
using System.Runtime.CompilerServices;
public sealed class BadMainViewModelB : INotifyPropertyChanged
{
  public BadMainViewModelB()
  {
    Initialize();
  }
  // BAD CODE!!!
  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));
  }
}

Novamente, se você executar esse código, verá que ele funciona. A interface de usuário agora é mostrada imediatamente, com "0" no rótulo por alguns segundos antes de ser atualizado com o valor correto. A interface de usuário é ágil na resposta e tudo parecem bem. No entanto, o problema, nesse caso, é o tratamento de erros. Com um método async void, todos os erros levantados pela operação assíncrona levarão à pane do aplicativo, por padrão. Essa é outra situação que é fácil perder durante o desenvolvimento e que é mostrada apenas em condições "estranhas" nos dispositivos cliente. Mesmo alterando o código na Figura 3 de async void para async Task, pouco melhora o aplicativo; todos os erros seriam silenciosamente ignorados, deixando o usuário se perguntando o que aconteceu. Nenhum método de tratamento de erros é apropriado. E, embora seja possível lidar com isso capturando exceções da operação assíncrona e atualizando outras propriedades vinculadas por dados, isso resultaria em muitos códigos tediosos.

Uma abordagem melhor

De modo ideal, o que eu realmente quero é um tipo assim como Task<T> com propriedades para obter resultados ou detalhes do erro. Infelizmente, Task<T> não é uma vinculação de dados amigável por dois motivos: ele não implementa INotify­PropertyChanged e sua propriedade Result é bloqueada. No entanto, você pode definir um "Inspetor de tarefas" de tipo inferior, como o tipo na Figura 4.

Figura 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;
}

Vamos analisar o método principal NotifyTaskCompletion<T>.WatchTaskAsync. Esse método usa uma tarefa que representa a operação assíncrona e (de modo assíncrono) await para conclusão. Observe que await não usa ConfigureAwait(false); quero retornar ao contexto da interface de usuário antes de levantar as notificações de PropertyChanged. Esse método viola uma diretriz comum de codificação aqui: ele tem uma cláusula catch genérica vazia. Embora, nesse caso, seja exatamente o que quero. Não quero propagar exceções diretamente de volta ao loop da interface de usuário principal; quero capturar todas as exceções e definir propriedades para que o tratamento de erros seja realizado pela vinculação de dados. Quando a tarefa é concluída, o tipo gera notificações de PropertyChanged para todas as propriedades adequadas.

Um ViewModel atualizado usando NotifyTaskCompletion<T> se pareceria com isso:

public class MainViewModel
{
  public MainViewModel()
  {
    UrlByteCount = new NotifyTaskCompletion<int>(
      MyStaticService.CountBytesInUrlAsync("http://www.example.com"));
  }
  public NotifyTaskCompletion<int> UrlByteCount { get; private set; }
}

Esse ViewModel começará a operação imediatamente e criará um "inspetor" vinculado por dados para a tarefa resultante. O código de vinculação de dados do View precisa ser atualizado para se vincular explicitamente ao resultado da operação, desta forma:

<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>

Observe que o conteúdo do rótulo é vinculado por dados para NotifyTask­Completion<T>.Result, e não para Task<T>.Result. NotifyTaskCompletion<T>.Result é uma vinculação de dados amigável: ela não está bloqueando e notificará a vinculação quando a tarefa for concluída. Se você executar o código agora, achará que ele se comporta exatamente como no exemplo anterior: a interface de usuário é ágil na resposta e é carregada imediatamente (exibindo o valor padrão de "0") e, em seguida, é atualizada em alguns segundos com os resultados reais.

O benefício do NotifyTaskCompletion<T> é que ele tem muitas outras propriedades, de modo que você pode usar a vinculação de dados para mostrar indicadores de ocupado ou detalhes do erro. Não é difícil usar algumas dessas propriedades de conveniência para criar um indicador de ocupado e detalhes do erro completamente no View, como o código de vinculação de dados atualizado na Figura 5.

Figura 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>
    <!-- Busy indicator -->
    <Label Content="Loading..." Visibility="{Binding UrlByteCount.IsNotCompleted,
      Converter={StaticResource BooleanToVisibilityConverter}}"/>
    <!-- Results -->
    <Label Content="{Binding UrlByteCount.Result}" Visibility="{Binding
      UrlByteCount.IsSuccessfullyCompleted,
      Converter={StaticResource BooleanToVisibilityConverter}}"/>
    <!-- Error details -->
    <Label Content="{Binding UrlByteCount.ErrorMessage}" Background="Red"
      Visibility="{Binding UrlByteCount.IsFaulted,
      Converter={StaticResource BooleanToVisibilityConverter}}"/>
  </Grid>
</Window>

Com essa última atualização, que altera apenas o View, o aplicativo exibe "Carregando..." por alguns segundos (enquanto permanece ágil na resposta) e é atualizado para os resultados da operação ou para uma mensagem de erro que é exibida em um plano de fundo vermelho.

NotifyTaskCompletion<T> trata um caso de uso: quando você tem uma operação assíncrona e deseja vincular dados aos resultados. Esse é um cenário comum ao fazer buscas de dados ou ao carregar durante a inicialização. No entanto, ele não ajuda muito quando você tem um comando real que é assíncrono, por exemplo, "salvar o registro atual". (Vou considerar comandos assíncronos no meu próximo artigo.)

À primeira vista, parece que há muito mais trabalho para criar uma interface de usuário assíncrona, e isso é verdade até certo ponto. O uso adequado das palavras-chave async e await encoraja enfaticamente você a desenvolver uma experiência de usuário melhor. Quando você passa para uma interface de usuário assíncrona, acha que não pode mais bloquear a interface de usuário enquanto a operação assíncrona estiver em andamento. Você deve pensar sobre como a interface de usuário deve parecer durante o processo de carregamento e, resolutamente, projetar isso. Isso é mais trabalhoso, mas é trabalho que deve ser feito para a maioria dos aplicativos modernos. E é um motivo pelo qual plataformas mais novas, como o Windows Store, oferecem suporte apenas a APIs assíncronas: para encorajar os desenvolvedores a projetar uma experiência de usuário mais ágil nas respostas.

Conclusão

Quando uma base de código é convertida de síncrona para assíncrona, geralmente, os componentes de acesso a serviços ou dados mudam primeiro, e async cresce daí até a interface de usuário. Depois que tiver feito isso algumas vezes, traduzir um método de síncrono para assíncrono torna-se razoavelmente direto. Espero que essa conversão seja automatizada por ferramentas futuras. No entanto, quando async atinge a interface de usuário, é quando alterações reais são necessárias.

Quando a interface de usuário torna-se assíncrona, você deve solucionar situações em que os aplicativos não respondem, aprimorando o design da interface de usuário. O resultado final é um aplicativo mais moderno e ágil nas respostas. "Rápido e fluido" se quiser.

Este artigo apresentou um tipo simples que pode ser resumido como um Task<T> para vinculação de dados. Da próxima vez, vou analisar comandos assíncronos e explorar um conceito que é basicamente um “ICommand para async". E, no artigo final da série, concluirei considerando os serviços assíncronos. Lembre-se de que a comunidade continua desenvolvendo esses padrões. Fique à vontade para ajustá-los para suas necessidades particulares.

Stephen Cleary é marido, pai e programador que mora no norte de Michigan. Ele trabalha com multithreading e programação assíncrona há 16 anos e tem usado o suporte assíncrono no Microsoft .NET Framework desde o primeiro CTP. Seu site, incluindo seu blog, é stephencleary.com.

Agradecemos aos seguintes especialistas técnicos da Microsoft pela revisão deste artigo: James McCaffrey e Stephen Toub