Desenvolvimento

3 abr, 2017

Reescrevendo o app Android de motorista da Uber Engenharia com hierarquias de escopo profundo

Publicidade

Quando reescrevemos os aplicativos de motorista do Uber para iOS e Android em 2016, subdividimos o aplicativo em uma profunda hierarquia de escopos de injeção de dependência. Isso permite que mais recursos sejam gravados sem o conhecimento um do outro e reduz a quantidade de estado obsoleto no aplicativo, aumentando assim a velocidade de engenharia e facilitando o crescimento.

Enquanto as frameworks do iOS possuem ViewControllers que sempre suportaram padrões compostos, os frameworks do AOSP (Android Open Source Project) não têm tradicionalmente suportado controladores aninhados em profundidade ou  mesmo os escopos. Como resultado, escrever aplicativos Android com hierarquias de escopo profundo é difícil e incomum. O aplicativo Rider da Uber é um exemplo de como essa dificuldade pode valer a pena de ser superada para resolver desafios estruturais.

A UX do aplicativo de motorista do Uber contém estados que compartilham objetos comuns, como o mapa; por exemplo, a visualização da tela inicial, a visualização de seleção do produto e a visualização do terminal do aeroporto, mostrada na Figura 1:

Figura 1: Três estados de aplicativo: Home (esquerda), Seleção do Produto (centro) e Aeroporto (direita).

A existência de objetos compartilhados entre telas diferentes significa que o aplicativo não pode ser composto de um conjunto distinto de Atividades (por exemplo, HomeActivity, AirportActivitye ProductSelectionActivity), a menos que objetos compartilhados sejam armazenados como singletons em escopo global. Para resolver isso, um padrão é necessário para controlar como os objetos são compartilhados entre telas e subtelas.

Em suma, precisávamos de um padrão de escopo efetivo para suportar o novo aplicativo de motorista do Uber para Android.

Então, como fizemos isso? Neste artigo, discutimos:

  1. O padrão de escopo superficial que usamos no aplicativo de motorista antigo e seus problemas.
  2. O padrão de escopo profundo que usamos no app de motorista reescrito e suas melhorias.
  3. Frameworks arquiteturais diferentes e como eles suportam hierarquias de escopo profundo.

O aplicativo de motorista antigo do Uber: uma hierarquia de escopo de dois níveis

Em 2016, tornou-se evidente que tínhamos ultrapassado o já existente aplicativo de motorista do Uber, já que ele não conseguia mais acompanhar a escala e a velocidade que precisávamos para manter e crescer operações.

Figura 2: O antigo aplicativo de motorista do Uber com o controle deslizante de seleção de veículos, que foi retirado em novembro de 2016.

A maior parte do antigo aplicativo de motorista está contida dentro de uma única atividade, chamada LoggedInActivity, que se comporta como um controlador com um único escopo. Há várias telas distintas dentro de LoggedInActivity que executam comportamentos drasticamente diferentes. Portanto, LoggedInActivity é composto de uma camada adicional de classes de subcontrolador que manipula diferentes UI e lógica de negócios. Alguns desses subcontroladores são mostrados abaixo na Figura 3:

Figura 3: Controlador do antigo aplicativo de motorista e hierarquia de escopo.

Todos os subcontroladores como CommuteController, PricingController e AirportController vivem no mesmo LoggedInScope, e todos os objetos de injeção de dependência podem ser compartilhados entre todos os subcontroladores. Veja o trecho dagger abaixo:

@LoggedInScope

@Component(modules = LoggedInActivityModule.class, dependencies = AppComponent.class)

public interface LoggedInComponent {

   void inject(LoggedInActivity activity);

}

Considere os controladores como o AirportController (consulte a tela do meio na Figura 1): esse AirportController existe na memória para toda a duração de LoggedInActivity. Essa condição tem várias desvantagens:

  1. Acoplamento: Outros controladores, como o CommuteController, podem ler e escrever a partir de objetos utilitários do AirportController, desde que eles compartilhem um escopo. Isso inevitavelmente leva ao acoplamento entre controladores e recursos não relacionados.
  2. Estado obsoleto: Todos os objetos utilitários usados pelo AirportController existem na memória após o AirportController ter terminado de exibir-se na tela. Isso força os engenheiros a escrever a lógica de reset propensa a erros para objetos utilitários do AirportController quando AirportController está oculto e, em seguida, mais tarde exibido novamente na tela.
  3. Combinatória de estados: Uma vez que objetos e controladores permanecem na memória durante todo o LoggedInScope, as classes precisam saber como elas devem se comportar durante cada subestado LoggedIn. Isso exige que os engenheiros escrevam classes maiores com mais lógica de estado propensa a erros.
  4. Difícil de atualizar e testar: Ao adicionar um novo subestado, os engenheiros precisam considerar como dezenas de controladores, como AirportController, e classes utilitárias devem se comportar nesse novo estado. O aplicativo de motorista do Uber tem um número surpreendentemente elevado de recursos como consequência de operar em centenas de cidades diferentes com diferentes restrições e otimizações, de modo que uma hierarquia de escopo de dois níveis rapidamente se torna incontrolável.

Hierarquias de escopo de três níveis são melhores?

Com duas camadas de escopos, o antigo aplicativo de motorista é um exemplo drástico do que acontece quando sua hierarquia de escopo é muito superficial. Infelizmente, criar uma camada adicional de escopos dando a cada controlador seu próprio escopo não consegue resolver a maioria dos problemas descritos acima.

Os objetos muitas vezes precisam ser compartilhados entre dois ou três controladores. Com apenas três camadas de escopos, esses objetos compartilhados precisam ser armazenados no LoggedInScope. Ao longo do tempo, a terceira camada de escopo torna-se “fina”, já que muitos objetos são refatorados na segunda camada de escopos.

Claramente, adicionar uma terceira camada de escopos é uma melhoria. Mas isso ainda causa uma segunda camada de escopo “gorda” com muitos dos mesmos problemas.

Novo aplicativo: hierarquia de escopo profundo

Dado que as hierarquias de escopo de dois e três níveis têm problemas importantes, não nos limitamos a um número definido de camadas de escopo quando estávamos desenvolvendo o novo aplicativo. Em vez disso, criamos novas camadas de escopo intermediárias sempre que isso fosse útil. Por exemplo, o escopo PreRequest é usado para armazenar objetos que precisam ser compartilhados por todos os estados de tela PreRequest, como Home, ProductSelection e RefinementSteps.

Esse padrão resulta em uma profunda hierarquia de escopos (ver Figura 4), fornecendo dois benefícios de alto nível:

  1. Nenhum dado ou visualização são compartilhados entre irmãos na árvore de escopo. Os objetos que precisam ser compartilhados são armazenados em nodes intermediários, portanto, os escopos de folha são bem encapsulados.
  2. Como nenhum dado interno é compartilhado entre escopos como Seleção de Porta do Aeroporto, Refinamento de Localização e Seleção de Produto, nenhum dos dados do Aeroporto precisa estar na memória após o fluxo de Seleção de Porta do Aeroporto estar completo. Como resultado, a nova hierarquia de controlador do aplicativo pode mapear 1:1 para sua hierarquia de escopo.

Figura 4: A nova hierarquia do controlador do aplicativo de motorista pode mapear 1:1 para sua hierarquia de escopo.

Os problemas no antigo aplicativo de motorista causados pela hierarquia de escopo de dois níveis desaparecem quando subdividimos o aplicativo em um conjunto de pequenos escopos com tempo de vida curto (Figura 4). Considere como esse novo escopo e hierarquia afetam o recurso Airport, abaixo:

  1. Menos acoplamento: A lógica do Aeroporto não pode alcançar nenhuma memória de escopos irmãos ou primos. Como resultado, o desenvolvimento de recursos pode ser feito independentemente dentro de Home, Seleção de Produtos, Aeroporto e Refinamento de Localização.
  2. Menos estados obsoletos: O escopo Airport Selection está somente na memória quando sua lógica está sendo executada. Portanto, não há necessidade de escrever a lógica de reset propensa a erros ao ocultar a UI do aeroporto.
  3. Menos combinatórias de estado: A maioria dos objetos não mais vive ao longo de toda a duração do escopo LoggedIn. Por exemplo, a lógica de Seleção de Produto não precisa tomar nenhuma decisão sobre seu comportamento quando dentro do estado de Seleção de Aeroporto, porque nenhuma lógica da Seleção de Produto existe na memória durante a seleção da porta do aeroporto.
  4. Mais fácil de atualizar e testar: Ao adicionar um novo subestado à aplicação, os engenheiros não precisam testar seu impacto em tantos recursos existentes devido à falta de estados combinatórios.

Frameworks arquiteturais comuns

Existem muitas maneiras diferentes de criar hierarquias de escopo profundo, por isso tivemos que avaliar todas as nossas opções antes de ficarmos com uma. Discutimos as arquiteturas consideradas antes de decidirmos reescrever o aplicativo de motorista usando RIBs (também conhecido como Riblets), nosso framework arquitetural interno, abaixo:

MVC e VIPER

O código de base herdado da engenharia do antigo aplicativo de motorista seguiu o padrão MVC (Model-View-Controller). Padrões de textbook comuns como MVC ou VIPER são gerais o suficiente para que eles possam suportar hierarquias de escopo profundamente aninhadas, mas as hierarquias de controlador normalmente são ditadas pela visualização aninhada. Isso é inconveniente para hierarquias de escopo profundo, pois muitos escopos não criam nenhuma exibição.

Portanto, não seguimos com MVC ou VIPER.

Flow Apps

O Flow foi projetado primeiramente para a finalidade de suportar níveis múltiplos de escopos aninhados. Como você pode criar escopos sem visão que não contenham nada, exceto objetos compartilhados (por exemplo, um LoggedInScope), o Flow era uma opção forte. Mas outros fatores (por exemplo, sua falta de uma estrutura iOS correspondente) nos impediu de usá-lo.

Portanto, não “seguimos com o fluxo”.

Conductor Apps

Frameworks como o Conductor não suportam explicitamente escopo ou escopo aninhado. Você pode adicionar escopos a cada controlador se estiver disposto a superar:

  • Nenhuma aplicação dos padrões DI: Isso é importante se você for usar muitas camadas de escopo.
  • Visualizações redundantes: Conductor força cada controlador a criar uma visualização, levando a visualizações redundantes ao usar hierarquias de escopo profundamente aninhadas.

Dadas essas restrições, resistimos à escolha de Conductor.

Scoop Apps

Outros aplicativos também contêm objetos de visualização compartilhados e dados de negócio entre suas telas. Por exemplo, o Scoop foi baseado em uma versão inicial do Flow para formalizar um padrão de controlador que pode compartilhar visualizações como mapas sem criar estado global.

O framework Scoop enfatiza fortemente os escopos. Com ele, os escopos estão correlacionados com a pilha de navegação: ir mais fundo na pilha de navegação aninha um escopo abaixo do escopo atual. Por exemplo, ao fazer a transição de HomeController para ConfirmationController, objetos podem ser compartilhados entre eles, dando ao ConfirmationController acesso para o escopo do HomeController.

O projeto do Scoop fornece padrões convenientes de navegação e animação ao custo de incentivar um maior acoplamento de controlador para controlador e de controlador para atividade, padrões que estávamos determinados a evitar.

Portanto, não escolhemos esta opção.

O que tem para o jantar: RIBs

Como nenhuma das opções pré-existentes atendeu às exigências da Uber, criamos nosso próprio framework arquitetural para nosso novo aplicativo de motorista: RIBs, que detalhamos pouco depois de sua estreia. Ao contrário do Scoop, a pilha de navegação e os escopos são desacoplados, e os únicos objetos compartilhados entre Home e Confirmation existem dentro de um escopo PreRequest intermediário. Com RIBs, usando um padrão de escopo aninhado para o nosso aplicativo de motorista, é fácil por causa de duas decisões de projeto:

  1. O boilerplate do escopo é gerado para RIBs: Criamos um plugin IntelliJ interno que gera o RIB e Dagger 2 componente/subcomponente boilerplate sempre que um engenheiro cria um novo RIB. Como resultado, o escopo aninhado é uma norma de baixa fricção.
  2. Os escopos não precisam ser acoplados a visualizações (?): Com uma hierarquia de escopo/controlador profundamente aninhada, esta funcionalidade é útil. Muitos escopos de folha vão querer mutar visualizações a partir de escopos intermediários em vez de criar suas próprias visualizações, e muitos escopos intermediários conterão somente lógica de negócios em vez de visualizações.

Escopos profundos FTW!

As hierarquias de escopo profundo possibilitam aplicativos como o nosso aplicativo de motorista, com suas telas densas e objetos compartilhados entre subtelas, aumentar a separação de preocupações, reduzir as possibilidades de dados obsoletos e aumentar a velocidade do desenvolvedor.

Uma vez que um aplicativo contém uma hierarquia de escopo profundo com controladores altamente desacoplados, torna-se mais fácil adicionar tecnologias poderosas como a análise estática para detectar vazamentos de memória, programação baseada em plug-in e várias otimizações de desempenho.

Se esse tipo de trabalho o anima, venha fazer parte desta história e melhorar a experiência do aplicativo de motorista do Uber para todos, através de engenharia iOS e Android. Há muito mais a fazer com o RIBs, e escreveremos mais sobre a arquitetura de desenvolvimento dos aplicativos da Uber para o restante de 2017.

***

Este artigo é do Uber Engineering Team. Ele foi escrito por Brian Attwell. A tradução foi feita pela Redação iMasters com autorização. Você pode conferir o original em: https://eng.uber.com/deep-scope-hierarchies/.