Windows Phone

Marcação de exclusão e o Zen da Assincronização do Windows Phone

Ben Day

Baixar o código de exemplo

Você já gravou um aplicativo e quase que imediatamente depois de terminar, desejou não tê-lo gravado dessa maneira? Essa é uma intuição de que há algo de errado com a arquitetura. As alterações que deveriam ser simples parecem quase impossíveis, ou pelo menos demoram muito mais do que deveriam. E depois vêm os bugs. Ah, e como há bugs! Você é um programador decente. Como você consegue gravar algo com tantos bugs? 

Parece familiar? Bem, isso aconteceu comigo quando eu gravei meu primeiro aplicativo para o Windows Phone, o NPR Listener. O NPR Listener dialoga com os serviços Web do National Public Radio (npr.org/api/index.php) para obter a lista de histórias disponíveis para seus programas e, em seguida, permite que os usuários ouçam essas histórias em seus dispositivos com Windows Phone. Quando o gravei pela primeira vez, estava fazendo vários desenvolvimentos no Silverlight e fiquei muito satisfeito com a forma como meus conhecimentos e habilidades foram tão bem transferidos para o Windows Phone. Terminei a primeira versão rapidamente e a encaminhei para o processo de certificação do Marketplace. O tempo todo eu estava pensando: "Bem, foi fácil". E então reprovei na certificação. Este é o caso que falhou: 

Etapa 1: Execute seu aplicativo.  

Etapa 2: Pressione o botão Iniciar para ir à página principal do seu telefone.

Etapa 3: Pressione o botão Voltar para retornar ao seu aplicativo.

Quando você pressiona o botão Voltar, seu aplicativo deve continuar sem erro e, idealmente, deve retornar o usuário à tela de onde ele saiu do seu aplicativo. No meu caso, o testador navegou até um programa do National Public Radio (como "All Things Considered"), clicou em uma das histórias atuais e pressionou o botão Iniciar para ir à tela inicial do dispositivo. Quando o testador pressionou o botão Voltar para retornar ao meu aplicativo, o aplicativo voltou e foi um festival de NullReferenceExceptions. Não foi legal.

Agora, vou contar para você um pouco sobre como desenvolvo meus aplicativos baseados em XAML. Para mim, tudo gira em torno do padrão Model-View-ViewModel e eu me concentro em uma separação quase fanática entre as páginas XAML e a lógica do meu aplicativo. Se for para ter algum código no codebehinds (*. xaml.cs) das minhas páginas, é melhor que haja um motivo muito bom. Muito disso é conduzido pela minha necessidade quase patológica de capacidade de teste de unidade. Os testes de unidade são fundamentais porque ajudam você a saber quando seu aplicativo está funcionando e, mais importante, facilitam refatorar seu código e alterar como o aplicativo funciona.

Então, se sou tão fanático por testes de unidade, por que recebi todas essas NullReferenceExceptions? O problema é que eu gravei meu aplicativo do Windows Phone como um aplicativo do Silverlight. Claro, Windows Phone é Silverlight, mas o ciclo de vida de um aplicativo do Windows Phone e de um aplicativo do Silverlight é completamente diferente. No Silverlight, o usuário abre o aplicativo, interage com ele até terminar e depois fecha o aplicativo. No Windows Phone, por sua vez, o usuário abre o aplicativo, trabalha com ele e vai e volta entre o sistema operacional e qualquer outro aplicativo sempre que quiser. Quando ele sai do aplicativo, o aplicativo é desativado ou "marcado para exclusão". Quando seu aplicativo é marcado para exclusão, ele não funciona mais, mas sua "pilha de retorno" de navegação — as páginas do aplicativo na ordem em que foram visitadas — ainda está disponível no dispositivo.

Você já deve ter notado no seu dispositivo com Windows Phone que você pode navegar em uma série de aplicativos e depois pressionar o botão Voltar repetidamente para voltar nesses aplicativos na ordem inversa. Essa é a pilha de retorno de navegação em ação, e cada vez que você entra em um aplicativo diferente, esse aplicativo é reativado dos dados persistentes marcados para exclusão. Quando seu aplicativo for marcado para exclusão, ele recebe uma notificação do sistema operacional informando que ele está prestes a ser desativado e deve salvar seu estado do aplicativo, para poder ser reativado mais tarde. A Figura 1 mostra alguns códigos simples para ativar e desativar seu aplicativo em App.xaml.cs.

Figura 1 Implementação de marcação para exclusão simples em App.xaml.cs

// Code to execute when the application is deactivated (sent to background).
// This code will not execute when the application is closing.
private void Application_Deactivated(object sender, DeactivatedEventArgs e)
{
  // Tombstone your application.
  IDictionary<string, object> stateCollection =
    PhoneApplicationService.Current.State;
  stateCollection.Add("VALUE_1", "the value");
}
// Code to execute when the application is activated
// (brought to foreground).
// This code will not execute when the application is first launched.
private void Application_Activated(object sender, ActivatedEventArgs e)
{
  // Un-tombstone your application.
  IDictionary<string, object> stateCollection =
    PhoneApplicationService.Current.State;
  var value = stateCollection["VALUE_1"];
}

Meu problema de NullReferenceException foi causado por uma completa falta de planejamento — e codificação — para lidar com esses eventos de marcação para exclusão. Isso, além de minha implementação ViewModel abrangente, rica e complexa, foi a receita para o desastre. Pense no que acontece quando um usuário clica no botão Voltar para entrar novamente no seu aplicativo. Esse usuário não acaba em uma página inicial, mas sim na última página que visitou em seu aplicativo. No caso do testador do Windows Phone, quando o usuário reativou meu aplicativo, ele entrou no aplicativo no meio e a interface do usuário supôs que o ViewModel estava preenchido e poderia suportar essa tela. Como o ViewModel não estava respondendo aos eventos de marcação para exclusão, quase toda referência de objeto era nula. Opa! Eu não fiz o teste de unidade nesse caso, fiz? (Kaboom!)

A lição aqui é que você precisa planejar sua interface do usuário e ViewModels para navegar para frente e para trás.

Adicionando marcação para exclusão após o fato

A Figura 2 mostra a estrutura do meu aplicativo original. Para fazer meu aplicativo passar na certificação, precisei lidar com esse caso do botão Iniciar/Voltar. Eu poderia implementar a marcação para exclusão no projeto do Windows Phone (Benday.Npr.Phone) ou poderia forçá-la no meu ViewModel (Benday.Npr.Presentation). Ambos envolviam alguns comprometimentos arquitetônicos desconfortáveis. Se eu adicionasse a lógica ao projeto Benday.Npr.Phone, minha interface do usuário saberia muito sobre como meu ViewModel funciona. Se eu adicionasse a lógica ao projeto ViewModel, seria preciso adicionar uma referência de Benday.Npr.Presentation a Microsoft.Phone.dll para obter acesso ao dicionário de valores de marcação de exclusão (PhoneApplicationService.Current.State) no namespace Microsoft.Phone.Shell. Isso poluiria meu projeto ViewModel com detalhes de implementação desnecessários e seria uma violação do princípio de separação de preocupações (SoC). 


Figura 2 Estrutura do aplicativo

Minha decisão final foi colocar a lógica no projeto do Phone, mas também criar algumas classes que sabem como serializar meu ViewModel em uma cadeia de caracteres em XML que eu poderia colocar no dicionário de valores de marcação de exclusão. Essa abordagem me permitiu evitar a referência do projeto de apresentação ao Microsoft.Phone.Shell e ainda me dava o código limpo que honrou o princípio da responsabilidade única. Denominei essas classes *ViewModelSerializer. A Figura 3 mostra parte do código necessário para transformar uma instância de StoryListViewModel em XML.

Figura 3 Código em StoryListViewModelSerializer.cs para transformar IStoryListViewModel em XML

private void Serialize(IStoryListViewModel fromValue)
{
  var document = XmlUtility.StringToXDocument("<stories />");
  WriteToDocument(document, fromValue);
  // Write the XML to the tombstone dictionary.
  SetStateValue(SERIALIZATION_KEY_STORY_LIST, document.ToString());
}
private void WriteToDocument(System.Xml.Linq.XDocument document,
  IStoryListViewModel storyList)
{
  var root = document.Root;
  root.SetElementValue("Id", storyList.Id);
  root.SetElementValue("Title", storyList.Title);
  root.SetElementValue("UrlToHtml", storyList.UrlToHtml);
  var storySerializer = new StoryViewModelSerializer();
  foreach (var fromValue in storyList.Stories)
  {
    root.Add(storySerializer.SerializeToElement(fromValue));
  }
}

Uma vez gravados esses serializadores, eu precisava adicionar lógica a App.xaml.cs para acionar essa serialização com base na tela atualmente exibida (veja a Figura 4).

Figura 4 Acionando os serializadores de ViewModel em App.xaml.cs

private void Application_Deactivated(object sender, 
  DeactivatedEventArgs e)
{
  ViewModelSerializerBase.ClearState();
  if (IsDisplayingStory() == true)
  {
    new StoryListViewModelSerializer().Serialize();
    new StoryViewModelSerializer().Serialize();
    ViewModelSerializerBase.SetResumeActionToStory();
  }
  else if (IsDisplayingProgram() == true)
  {
    new StoryListViewModelSerializer().Serialize();
    new ProgramViewModelSerializer().Serialize();
    ViewModelSerializerBase.SetResumeActionToProgram();
  }
  else if (IsDisplayingHourlyNews() == true)
  {
    new StoryListViewModelSerializer().Serialize();
    ViewModelSerializerBase.SetResumeActionToHourlyNews();
  }               
}

Finalmente consegui fazê-lo funcionar e o aplicativo foi certificado, mas, infelizmente, o código ficou lento, feio, frágil e cheio de bugs. O que eu deveria ter feito era projetar meu ViewModel para que tivesse menos estado que precisasse ser salvo e depois compilá-lo de forma a persistir como executado em vez de ter de fazer um evento gigante de marcação para exclusão no final. Como eu faria isso?

A escola dos controladores alucinados por programação assíncrona

Aqui vai uma pergunta para você: Você tem tendência para ser um "controlador alucinado"? Você tem problema para deixar as coisas para lá? Você prefere ignorar verdades evidentes e, por pura força de vontade, resolver problemas de forma a ignorar a realidade que é clara como o dia e está bem diante dos seus olhos? Sim … foi assim que eu administrei as chamadas assíncronas na primeira versão do NPR Listener. Especificamente, foi assim que eu abordei a rede assíncrona na primeira versão do aplicativo.

No Silverlight, nem todas as chamadas de rede devem ser assíncronas. Seu código inicia uma chamada de rede e retorna imediatamente. O resultado (ou uma exceção) é entregue algum tempo depois, por um retorno de chamada assíncrono. Isso significa que a lógica de rede sempre consiste em duas partes — a chamada de saída e a chamada de retorno. Essa estrutura tem consequências e é um segredinho sórdido no Silverlight que qualquer método que se baseia nos resultados de uma chamada de rede não pode retornar um valor e deve retornar nulo. Isso tem um efeito colateral: qualquer método que chama outro método que se baseia nos resultados de uma chamada de rede também deve retornar nulo. Como você pode imaginar, isso pode ser absolutamente brutal para arquiteturas em camadas porque as implementações tradicionais de padrões de design de n camadas, tais como Camada de Serviço, Adaptador e Repositório, dependem muito dos valores de retorno das chamadas de método.

Minha solução é uma classe chamada ReturnResult<T> (mostrada na Figura5), que serve como a cola entre o método que solicita a chamada de rede e o método que controla os resultados da chamada e fornece uma maneira para seu código retornar valores úteis. A Figura 6 mostra parte da lógica do padrão Repositório que faz uma chamada para um serviço do Windows Communication Foundation (WCF) e retorna uma instância de preenchida de IPerson. Usando esse código, você pode chamar LoadById(ReturnResult<IPerson>, int) e finalmente receber a instância preenchida de IPerson quando client_LoadBy­IdCompleted(object, LoadByIdCompleted­EventArgs) chama um dos métodos Notify. Isso basicamente permite que você crie um código similar ao que você teria se pudesse usar valores de retorno. (Para obter mais informações sobre ReturnResult<T>, consulte bit.ly/Q6dqIv.) 


Figura 5 ReturnResult<T>

Figura 6 Usando ReturnResult<T> para iniciar uma chamada de rede e retornar um valor do evento concluído

public void LoadById(ReturnResult<IPerson> callback, int id)
{
  // Create an instance of a WCF service proxy.
  var client = new PersonService.PersonServiceClient();
  // Subscribe to the "completed" event for the service method.
  client.LoadByIdCompleted +=
    new EventHandler<PersonService.LoadByIdCompletedEventArgs>(
      client_LoadByIdCompleted);
  // Call the service method.
  client.LoadByIdAsync(id, callback);
}
void client_LoadByIdCompleted(object sender,
  PersonService.LoadByIdCompletedEventArgs e)
{
  var callback = e.UserState as ReturnResult<IPerson>;
  if (e.Error != null)
  {
    // Pass the WCF exception to the original caller.
    callback.Notify(e.Error);
  }
  else
  {
    PersonService.PersonDto personReturnedByService = e.Result;
    var returnValue = new Person();
    var adapter = new PersonModelToServiceDtoAdapter();
    adapter.Adapt(personReturnedByService, returnValue);
    // Pass the populated model to the original caller.
    callback.Notify(returnValue);   
  }           
}

Quando eu terminei de gravar a primeira versão do NPR Listener, logo descobri que o aplicativo era lento (ou pelo menos parecia ser lento) porque eu não fiz armazenamento em cache. O que eu realmente precisava no aplicativo era uma maneira de chamar um serviço Web de NPR, obter uma lista de histórias para determinado programa e, em seguida, armazenar em cache esses dados para eu não ter de retornar ao serviço toda vez que precisasse recuperar essa tela. Adicionar essa funcionalidade, no entanto, foi bastante difícil porque eu estava tentando fingir que não existem as chamadas assíncronas. Basicamente, por ser alucinado por controle e tentar negar a estrutura essencialmente assíncrona do meu aplicativo, eu estava limitando minhas opções. Eu estava lutando contra a plataforma e, portanto, contorcendo a arquitetura do meu aplicativo.

Em um aplicativo síncrono, as coisas começam a acontecer na interface do usuário e o fluxo de controle passa pelas camadas do aplicativo, retornando dados conforme a pilha esvazia. Tudo acontece dentro de uma pilha de chamadas, onde o trabalho é iniciado, os dados são processados e um valor de retorno é devolvido para a pilha. Em um aplicativo assíncrono, o processo é mais parecido com quatro chamadas todas vagamente conectadas: a interface do usuário solicita que algo aconteça; o processamento pode ou não acontecer; se o processamento acontecer e a interface do usuário tiver assinado o evento, o processamento notifica a interface do usuário que uma ação foi concluída; e a interface do usuário atualiza a exibição com os dados da ação assíncrona.

Já posso me imaginar dando palestras para alguns jovens arrogantes sobre como era difícil a época anterior à assincronia e espera. “Na minha época, tínhamos de gerenciar nossa própria rede lógica assíncrona e os retornos de chamada. Era brutal, e nós gostávamos! Agora saiam do meu território!” Bem, na verdade, nós não gostávamos tanto assim. Era brutal.

Esta é outra lição: lutar contra a arquitetura subjacente da plataforma sempre lhe causará problemas.

Regravando o aplicativo usando armazenamento isolado

Eu gravei o aplicativo pela primeira vez para o Windows Phone 7 e fiz apenas uma pequena atualização para o Windows Phone 7.1. Em um aplicativo cujo propósito inteiro é transmitir áudio, era sempre uma decepção os usuários não conseguirem ouvir áudio enquanto navegavam em outros aplicativos. Quando o Windows Phone 7.5 foi lançado, eu queria aproveitar os novos recursos de mainstream em segundo plano. Eu também queria acelerar o aplicativo e eliminar um monte de chamadas de serviço Web desnecessárias adicionando algum tipo de cache de dados locais. Quando comecei a pensar na implementação desses recursos, porém, as limitações e a fragilidade da minha marcação para exclusão, ViewModel e implementação assíncrona ficaram cada vez mais aparentes. Estava na hora de corrigir meus erros anteriores e regravar completamente o aplicativo.

Tendo aprendido minhas lições na versão anterior do aplicativo, decidi que iria começar pela concepção da "capacidade de marcação para exclusão" e também abraçar completamente a natureza assíncrona do aplicativo. Como eu queria adicionar o cache de dados locais, comecei a procurar usar o armazenamento isolado. Armazenamento isolado é um local no seu dispositivo onde o aplicativo pode ler e gravar dados. Trabalhar com ele é semelhante a trabalhar com o sistema de arquivos em qualquer aplicativo comum do .NET.

Armazenamento isolado para cache e operações de rede simplificadas

Uma enorme vantagem do armazenamento isolado é que essas chamadas, diferente das chamadas de rede, não precisam ser assíncronas. Isso significa que eu posso usar uma arquitetura mais convencional que se baseia em valores de retorno. Quando percebi isso, comecei a pensar em como separar as operações que precisam ser assíncronas das que podem ser síncronas. As chamadas de rede precisam ser assíncronas. As chamadas de armazenamento isolado podem ser síncronas. Então, e se eu sempre gravar os resultados das chamadas de rede no armazenamento isolado antes de fazer qualquer análise? Isso me permite carregar dados sincronicamente e me dá uma maneira barata e fácil de fazer o cache de dados locais. O armazenamento isolado me ajuda a resolver dois problemas de uma só vez.

Comecei pela reformulação de como fazer minhas chamadas de rede, abraçando o fato de que elas são uma série de etapas vagamente associadas em vez de apenas uma grande etapa síncrona. Por exemplo, quando quero obter uma lista de histórias para determinado programa do NPR, eu faço isto (veja a Figura 7):

  1. O ViewModel assina um evento StoryListRefreshed no StoryRepository.
  2. O ViewModel chama o StoryRepository para solicitar uma atualização da lista de histórias para o programa atual. Essa chamada é concluída imediatamente e retorna nula.
  3. O StoryRepository emite uma chamada de rede assíncrona para um serviço Web NPR REST para obter a lista de histórias para o programa.
  4. Em algum momento, o método de retorno de chamada é acionado e o StoryRepository agora tem acesso aos dados do serviço. Os dados retornam do serviço como XML e, em vez de transformar isso em objetos preenchidos que são retornados ao ViewModel, o StoryRepository grava imediatamente o XML no armazenamento isolado.
  5. O StoryRepository aciona um evento StoryListRefreshed.
  6. O ViewModel recebe o evento StoryListRefreshed e chama o GetStories para obter a lista mais recente de histórias. O GetStories lê o XML da lista de histórias em cache do armazenamento isolado, converte-o em objetos que o ViewModel precisa e retorna os objetos preenchidos. Esse método pode retornar objetos preenchidos porque é uma chamada síncrona que lê do armazenamento isolado.


Figura 7 Diagrama da sequência de atualização e carregamento da lista de histórias

O ponto importante aqui é que o método RefreshStories não retorna dados. Ele apenas solicita a atualização dos dados de histórias armazenados em cache. O método GetStories pega os dados XML atualmente em cache e os converte em objetos IStory. Como o GetStories não precisa chamar serviços, é extremamente rápido, e a tela da lista de histórias é preenchida rapidamente e o aplicativo parece muito mais rápido do que a primeira versão. Se não houver dados armazenados em cache, o GetStories simplesmente retorna uma lista vazia de objetos IStory. Esta é a interface do IStoryRepository:

public interface IStoryRepository
{
  event EventHandler<StoryListRefreshedEventArgs> StoryListRefreshed;
  IStoryList GetStories(string programId);
  void RefreshStories(string programId);
    ...
}

Um ponto adicional sobre a ocultação dessa lógica atrás de uma interface é que ela torna o código limpo nos ViewModels e dissocia o esforço de desenvolvimento dos ViewModels da lógica de armazenamento e serviço. Essa separação torna o código mais fácil para o teste de unidade e muito mais fácil de manter.

Armazenamento isolado para marcação para exclusão contínua

Minha implementação de marcação para exclusão na primeira versão do aplicativo pegava os ViewModels e os convertia em XML que era armazenado no dicionário de valores de marcação para exclusão do telefone, PhoneApplicationService.Current.State. Gostei da ideia do XML, mas não gostei dessa persistência do ViewModel ser de responsabilidade da camada de interface do usuário do aplicativo do telefone em vez da própria camada do ViewModel. Também não gostei que a camada da interface do usuário esperava até o evento Deactivate da marcação para exclusão para manter todo meu conjunto de ViewModels. Quando o aplicativo está em execução, apenas alguns valores realmente precisam ser mantidos, e eles mudam muito gradualmente conforme o usuário muda de tela para tela. Por que não gravar os valores no armazenamento isolado enquanto o usuário navega no aplicativo? Dessa forma o aplicativo estará sempre pronto para ser desativado e a marcação para exclusão não é tão importante. 

Além disso, em vez de manter todo o estado do aplicativo, porque não salvar apenas o valor selecionado em cada página? Os dados são armazenados em cache localmente, então eles já devem estar no dispositivo, e eu posso facilmente recarregar os dados do cache sem alterar a lógica do aplicativo. Isso diminui o número de valores que precisam ser mantidos de centenas na versão 1 para talvez quatro ou cinco na versão 2. São bem menos dados para se preocupar, e tudo é muito mais simples.

A lógica para todo o código de persistência para leitura e gravação de ou para o armazenamento isolado é encapsulada em uma série de objetos de repositório. Para as informações relacionadas ao objeto de história, haverá uma classe StoryRepository correspondente. A Figura 8 mostra o código para obter uma ID de história, transformá-la em um documento XML e salvá-la no armazenamento isolado.

Figura 8 Lógica StoryRepository para salvar a ID da história atual

public void SaveCurrentStoryId(string currentId)
{
  var doc = XmlUtility.StringToXDocument("<info />");
  if (currentId == null)
  {
    currentId = String.Empty;
  }
  else
  {
    currentId = currentId.Trim();
  }
  XmlUtility.SetChildElement(doc.Root, "CurrentStoryId", currentId);
  DataAccessUtility.SaveFile(DataAccessConstants.FilenameStoryInformation, doc);
}

Envolver a lógica de persistência dentro de um objeto de repositório mantém a lógica de armazenamento e recuperação separada de qualquer lógica ViewModel e oculta os detalhes de implementação das classes ViewModel. A Figura 9 mostra o código na classe StoryListViewModel para salvar a ID da história atual quando a seleção de história muda. 

Figura 9 StoryListViewModel salva a ID da história atual quando o valor muda

void m_Stories_OnItemSelected(object sender, EventArgs e)
{
  HandleStorySelected();
}
private void HandleStorySelected()
{
  if (Stories.SelectedItem == null)
  {
    CurrentStoryId = null;
    StoryRepositoryInstance.SaveCurrentStoryId(null);
  }
  else
  {
    CurrentStoryId = Stories.SelectedItem.Id;
    StoryRepositoryInstance.SaveCurrentStoryId(CurrentStoryId);
  }
}

E este é o método de carga StoryListViewModel, que inverte o processo quando o StoryListViewModel precisa se repreencher no disco:

public void Load()
{
  // Get the current story Id.
  CurrentStoryId = StoryRepositoryInstance.GetCurrentStoryId();
  ...
  var stories = StoryRepositoryInstance.GetStories(CurrentProgramId);
  Populate(stories);
}

Planeje com antecedência

Neste artigo, apresentei algumas das decisões arquitetônicas e erros que cometi em meu primeiro aplicativo para o Windows Phone, o NPR Listener. Lembre-se de planejar para a marcação para exclusão e de abraçar — em vez de lutar — a assincronia em seus aplicativos para o Windows Phone. Se você quiser conferir o código das versões do antes e depois do NPR Listener, poderá baixá-lo do site archive.msdn.microsoft.com/mag201209WP7.

Benjamin Day é consultor e instrutor especializado em práticas recomendadas de desenvolvimento de software utilizando ferramentas de desenvolvimento da Microsoft com ênfase no Visual Studio Team Foundation Server, Scrum e Windows Azure. Ele é um MVP do Microsoft Visual Studio ALM, um treinador de Scrum certificado pela Scrum.org e palestrante em conferências como TechEd, DevTeach e Visual Studio Live! Quando não está desenvolvimento software, Day costuma correr e andar de caiaque para equilibrar seu amor por queijo, carnes curadas e champanhe. Entre em contato com ele pelo seu site benday.com.

Agradecemos aos seguintes especialistas técnicos pela revisão deste artigo: Jerri Chiu e David Starr