Android

31 jan, 2017

Engenharia da arquitetura por trás do novo aplicativo do motorista Uber

Publicidade

Por que o Uber recomeçou

O Uber é baseado em um conceito simples: apertar um botão, conseguir um carro. O que começou como uma forma de solicitar um carro premium preto agora oferece uma gama de serviços, coordenando milhões de viagens por dia em centenas de cidades. Precisávamos redefinir nossa arquitetura móvel para refletir e suportar essa realidade para 2017 e o futuro.

Mas por onde começar? Bem, nós voltamos para onde começamos em 2009: com nada. Decidimos reescrever e redesenhar completamente nosso aplicativo do motorista. Não ser segurados por nossa extensa base de códigos e escolhas de design prévias nos deu liberdade onde, de outra maneira, teríamos feito ajustes. O resultado é o novo e elegante aplicativo que vocês veem hoje, que implementa uma nova arquitetura móvel tanto para iOS quanto para Android. Leia e entenda por que nós sentimos a necessidade de criar esse novo padrão de arquitetura, chamado Riblets, e como ele nos ajuda a atingir nossos objetivos.

Motivações: para onde?

Enquanto conectar motoristas com o transporte sob demanda permanece a ideia principal por trás do Uber, nosso produto evoluiu para algo muito maior, e nossa arquitetura móvel original não podia suportar. Os desafios de engenharia e débitos acumularam ao longo dos anos conforme melhorávamos o aplicativo do motorista com novas funcionalidades. Adições como o uberPOOL, corridas agendadas e visualizações de veículo promocional introduziram complexidade. Nosso módulo de viagem cresceu, ficando difícil de ser testado. Incorporar pequenas mudanças trazia a chance de quebrar outras partes do aplicativo, fazendo a experimentação ficar cheia de ajustes colaterais, inibindo o nosso ritmo de crescimento. Para manter a alta qualidade da experiência para todos os usuários do Uber, precisávamos de uma maneira de recapturar a simplicidade de onde começamos, enquanto consideramos onde estamos e para onde queremos ir no futuro.

O novo aplicativo tinha que ser simples tanto para os motoristas quanto para os engenheiros do Uber, que desenvolvem melhorias e novas funcionalidades diariamente. Para reescrever o aplicativo para esses grupos distintos, nossos dois principais objetivos foram aumentar a disponibilidade da nossa experiência principal do motorista, e permitir testes radicais dentro de um conjunto de trilhas do produto.

Confiabilidade é o principal

Do lado da engenharia, estamos nos esforçando para tornar a confiabilidade do Uber uma realidade em 99,99% do tempo para a experiência do nosso motorista. Atingir 99,99% de disponibilidade significa que temos somente o acumulativo de uma hora fora de operação por ano, ou uma queda a cada 10 mil execuções.

Para chegar lá, a nova arquitetura definiu e implementou um framework de código principal e opcional. Código principal – tudo o que é necessário para se inscrever, pegar, completar ou cancelar uma viagem – tem que rodar. Mudanças e adições ao código principal passam por um estrito processo de revisão. O código opcional passa por revisões menos estritas e pode ser desativado sem parar a execução do principal. Esse encorajamento do isolamento de código nos permite testar novas funcionalidades e automaticamente desativá-las se não estiverem funcionando corretamente, sem interferir na experiência das viagens.

Trilhos para o futuro

Precisamos de uma plataforma de onde centenas de times de programas e milhares de engenheiros possam rapidamente construir funcionalidades de qualidade e inovar no aplicativo do motorista sem comprometer a experiência principal. Então, demos à nossa nova plataforma compatibilidade cross-plataformas, na qual os engenheiros tanto de iOS quanto de Android podem trabalhar em uma base unificada.

Historicamente, entregar o melhor aplicativo em iOS e Android envolvia abordagens divergentes de arquitetura, design das bibliotecas e analytics. A nova arquitetura, no entanto, está empenhada em utilizar os mesmos melhores padrões e práticas em ambas as plataformas. Isso nos possibilita aproveitar as oportunidades de aprendizado em ambas as plataformas. Em vez de cometer o mesmo erro duas vezes por ter duas equipes separadas para cada plataforma, as lições de uma plataforma podem preventivamente resolver problemas da outra. Assim, os engenheiros iOS e Android podem colaborar mais facilmente e trabalhar em novas funcionalidades em paralelo.

Enquanto existem instâncias onde as plataformas podem e devem divergir (ex.: implementação de UI), tanto a plataforma iOS quanto Android iniciam de um ponto consistente. As plataformas compartilham:

  • Arquitetura principal;
  • Nomes das classes;
  • Relacionamentos de heranças entre as unidades lógicas de negócio;
  • Como a lógica de negócios é dividida;
  • Pontos de plugin (nomes, existência, estrutura etc.);
  • Cadeias de programação reativas;
  • Componentes de plataforma unificados.

Para atingirmos essa planta comum entre as plataformas, nossa nova arquitetura móvel exigia uma organização clara e separação da lógica de negócios, lógica de visualização, fluxo de dados e endereçamento. Tal arquitetura ajuda a vencer a complexidade, simplificar a testabilidade e, portanto, aumentar a produtividade da engenharia e confiabilidade do usuário. Inovamos em outros padrões de arquitetura para alcançar isso.

De MVC para Riblets

Com nossos dois objetivos em mente, nós examinamos onde nossa antiga arquitetura poderia ser melhorada e investigamos as opções para seguir em frente. A base de códigos que herdamos no início do Uber seguia o padrão MVC. Nós vimos outros padrões, particularmente o VIPER, que eventualmente utilizamos para criar o Riblets. A inovação principal com o Riblets é que o encaminhamento é realizado por lógica de negócios, ao contrário da visão lógica. Se você não tem familiaridade com o MVC e VIPER, leia alguns artigos sobre os padrões modernos de arquitetura iOS, então volte e veja os prós e contras de utilizá-los no Uber.

Onde começamos: MVC (Model, View, Controller)

O aplicativo do motorista anterior foi criado quase quatro anos atrás por alguns engenheiros. Enquanto o padrão MVC fazia sentido na época, ele não é gerenciável em larga escala. Como nosso aplicativo anterior e a equipe trabalhando nele cresceram para algumas centenas de pessoas, nós vimos como o MVC não poderia crescer conosco. Especificamente, havia duas grandes áreas problemas:

Primeiro, arquiteturas MVC maduras geralmente enfrentam os problemas de  controles de View muito grandes. A propósito, o RequestViewController, que começou com 300 linhas de código, tem mais de 3 mil linhas hoje devido a muitas responsabilidades: lógica de negócio, manipulação de dados, verificação de dados, lógica de rede, lógica de encaminhamento etc. Ficou difícil ler e modificar.

Em segundo lugar, as arquiteturas MVC têm um processo de atualização frágil com uma falta de testes. Nós testamos muito para liberar as novas funcionalidades para os usuários. Esses testes se resumem a declarações if-else. Sempre que existe uma classe com muitas funcionalidades, as declarações if-else são construídas umas sobre as outras e tornam quase impossível raciocinar, muito menos testar. Adicionalmente, conforme partes do código como RequestViewController e TripViewController crescem, fazer atualizações no aplicativo tornou-se um processo frágil. Imagine fazer uma mudança e testar cada combinação possível de declarações if-else. Como precisávamos dos testes para adicionar novas funcionalidades e aumentar o negócio do Uber, esse tipo de arquitetura não era escalável.

Pelo caminho: VIPER

Quando estávamos considerando alternativas ao MVC, fomos inspirados pela maneira como o VIPER poderia ser utilizado como uma aplicação de arquitetura limpa para o iOS. O Viper oferece algumas vantagens sobre o MVC. Primeiro, oferece mais abstração. O Presenter contém a lógica de apresentação que junta a lógica de negócio com a lógica de visão. O Interactor lida puramente com manipulação e verificação de dados. Isso inclui realizar chamadas de serviços para o backend manipular os estados, como pedido de sign in ou solicitar uma viagem. E, finalmente, o Router inicia as transições, como levar um usuário da home para a tela de confirmação. Em segundo lugar, com a abordagem VIPER, o Presenter e o Interactor são simples objetos, então podemos realizar testes unitários simples.

Mas também encontramos alguns pontos negativos no VIPER. Ele é específico para iOS, o que significa que teríamos que fazer muitas mudanças para Android. Sua lógica de aplicação orientada à visualização significa que os estados da aplicação são orientados por visualizações, pois a aplicação inteira está ligada à arvore de visualização. A lógica de negócio realizada pelo Interactor que deveria manipular os estados sempre tem que passar pelo Presenter, ou seja, escapando da lógica de negócio. E, finalmente, com a árvore de visualização e a árvore de negócio bem acopladas, é difícil implementar um nó que contenha apenas a lógica de negócio ou a lógica de visualização.

Enquanto o VIPER oferece melhoras significativas em relação ao padrão MVC que estávamos utilizando, ele não atende completamente às necessidades do Uber para uma plataforma escalável com modularidade clara. Então, voltamos à mesa de desenhos para ver como poderíamos desenvolver um padrão de arquitetura que tivesse os benefícios do VIPER, enquanto acomodava os contras. Nosso resultado foi o Riblets.

Riblets: arquitetura do aplicativo do motorista do Uber

Em nosso padrão de arquitetura, a lógica é similarmente dividida em partes menores, testáveis independentemente e cada uma tem um propósito único, seguindo o princípio de responsabilidade única. Nós utilizamos Riblets como essas peças modulares, e a aplicação inteira está estruturada como uma árvore de Riblets.

Riblets e seus componentes

Com Riblets, nós delegamos responsabilidades para seis componentes diferentes para abstrair ainda mais a lógica de negócios e de visualização.

O que diferencia Riblets de VIPER e MVC? O encaminhamento é guiado pela lógica de negócio em vez de pela lógica de visualização. Isso significa que a aplicação é orientada pelo fluxo da informação e das decisões sendo tomadas, em vez da visualização. No Uber, nem todas as partes da lógica de negócio são relacionadas a uma visualização que o usuário veja. Em vez de agrupar a lógica de negócio em um ViewController no MVC ou manipular os estados da aplicação através do Presenter no VIPER, podemos ter Riblets distintos para cada parte da lógica de negócio, nos dando agrupamentos lógicos que façam sentido e que são fáceis de se pensar sobre. Nós também desenhamos o padrão Riblet para ser agnóstico de plataformas para unificar o desenvolvimento Android e iOS.

Cada Riblet é feito de um Router, Interactor, e Builder com seu Componente (por isso o nome), e Presenters e Views opcionais. O Router e o Interactor tratam da lógica de negócios, enquanto o Presenter e o View tratam da lógica de visualização.

Vamos começar estabelecendo pelo que cada unidade desse Riblet é responsável, utilizando a seleção de produto Riblet como exemplo.

Builder

O Builder instancia todos os Riblets primários e define as dependências. Na seleção do produto Riblet, essa unidade define a dependência do fluxo de uma cidade (um fluxo de dados para uma determinada cidade).

Component

 O Component obtém e instancia as dependências de um Riblet. Isso inclui serviços, fluxos de dados e todo o resto que não seja uma unidade Riblet primária. O componente de seleção de produto obtém e instancia a dependência do fluxo de uma cidade, associa-a aos eventos corretos de rede, e injeta-a ao Interactor.

 Routers

Os Routers formam a árvore da aplicação anexando e desanexando Riblets filhas. Essas decisões são passadas pelo Interactor. Os Routers também orientam o ciclo de vida do Interactor ativando-o e desativando-o em certas trocas de estado. Os Routers contêm duas peças de lógica de negócios:

  1. Métodos que auxiliam a anexar e desanexar Routers
  2. Lógica de mudança de estados para determinar os estados de múltiplas filhas.

O Riblet de seleção de produtos não tem Riblets filhas. O Router de seu Riblet pai, o Riblet de confirmação, é responsável por anexar o Router de seleção de produto e adicionar sua visualização à hierarquia de visualização. Então, uma vez que um produto tenha sido selecionado, a seleção de produto do Router desativa o Interactor.

Interactors

Os Interactors realizam as lógicas de negócios. Isso inclui, por exemplo:

  • Realizar as chamadas de serviço para iniciar ações, como pedir um carro;
  • Realizar chamadas de serviço para buscar dados;
  • Determinar qual estado transitará para o próximo. Por exemplo, se o Interactor raiz nota que o token de autenticação de usuários está faltando, ele envia uma solicitação para seu Router para alterar para o “Welcome State”.

 View (Controller)

Views constroem e atualizam a interface do usuário, incluindo instanciar e posicionar os elementos da interface do usuário, tratar a interação do usuário, preencher os componentes da interface do usuário com dados, e as animações. A View do Riblet de Seleção de produto exibe os objetos recebidos do Presenter (opções de produto, preços, horário estimado que estará disponível, visibilidade do veículo no mapa) e envia de volta as ações do usuário(ex.: seleção do produto).

Presenter

Os Presenters gerenciam a comunicação entre os Interactors e as Views. Dos Interactors para as Views, ele traduz os modelos de negócios em objetos que as Views possam exibir. Na Seleção de Produto, isso inclui os dados de preço e visualização do veículo. Das Views para os Interactors, ele traduz os eventos de interação do usuário, como apertar um botão para selecionar um produto, nas ações apropriadas para os Interactors.

Juntando as peças

Os Riblets têm apenas um par de Router e Interactor, mas podem ter muitas partes de visualização. Os Riblets que apenas tratem da lógica de negócios e não possuem elementos de interface do usuário não possuem partes de visualização. Os Riblets então podem ser de visão única (um Presenter e uma View), visão múltipla (tanto com um Presenter e muitas Views, quanto com muitos Presenters e muitas Views), ou sem visão (nenhum Presenter e nenhuma View). Isso permite que a estrutura da árvore lógica seja diferente da árvore de visibilidade, que terá uma hierarquia mais horizontal. Isso ajuda a simplificar as transições de tela.

Por exemplo, o Riblet Ride é um Riblet sem visão que checa quando um usuário tem uma viagem ativa. Se o motorista tiver, ele anexa ao Riblet “Trip”, que vai exibir a viagem no mapa. Se não, anexa ao Riblet “Request”, que vai exibir uma tela que permita que o usuário solicite uma viagem. Riblets como o “Ride” sem uma lógica de visibilidade tem uma função importante dividindo a lógica de negócio que orienta nossas aplicações, sustentando o aspecto modular dessa nova arquitetura.

Como Riblets constroem a aplicação

Os Riblets criam a árvore da aplicação e frequentemente precisam se comunicar para atualizar as informações ou levar o usuário para o próximo estágio para conseguir sua viagem. Antes de vermos como eles se comunicam, vamos entender primeiro como os dados trafegam dentro de um Riblet.

Fluxo de dados dentro de um Riblet

Os Interactors possuem um estado para seu escopo e a lógica de negócios que orienta a aplicação. Essa unidade faz as chamadas de serviço para buscar as informações. Na nova arquitetura, os dados fluem em uma direção. Vão do serviço para o modelo de fluxo (model stream) e do modelo de fluxo para o Interactor. Os Interactors, agendadores e push notifications da rede podem solicitar aos serviços que façam alterações no modelo de fluxo. O modelo de fluxo produz modelos imutáveis. Isso reforça o requisito de que as classes Interactor devem usar a camada de serviço para alterar os estados da aplicação.

Fluxos de exemplo:

  • De um serviço backend para uma View: Uma chamada de serviço, como um status, busca dados do backend. Ele coloca os dados em um modelo de fluxo imutável. Um Interactor acompanhando esse fluxo nota esses novos dados e os passa para o Presenter. O Presenter formata os dados e envia para a View.
  • De uma View para um serviço backend: O usuário clica em um botão, como “Entrar”, e a View passa a interação para o Presenter. O Presenter chama um método “Entrar” no Interactor que resulta em uma chamada de serviço para realmente entrar. A chave recebida é publicada em um fluxo pelo serviço. Um Interactor acompanhando esse fluxo altera para o Riblet “Home”.

Comunicação entre os Riblets

Quando um Interactor toma uma decisão da lógica de negócios, ele pode precisar de informar outro Riblet dos eventos (ex.: conclusão) e enviar dados. Para isso, o Interactor tomando a decisão invoca uma interface que é conformada pelo Interactor de outro Riblet.

Tipicamente, se a comunicação está subindo a árvore de Riblets para o Interactor de um Riblet pai, a interface é definida com um listener. O listener é quase sempre implementado pelo Interactor do Riblet pai. Se a comunicação está descendo para um Riblet filho, a interface deverá ser definida com um encarregado, e implementada pelo Interactor do Riblet filho. Os encarregados destinam-se apenas para comunicações diretas síncronas entre as unidades Riblets, como um Interactor pai para o filho.

Especificamente para a comunicação de cima para baixo, o Riblet pai pode escolher expor um modelo de fluxo observável para o Interactor do Riblet filho. O Interactor do Riblet pai pode então enviar os dados para o Interactor do Riblet filho por esse fluxo, como uma alternativa à abordagem de encarregado. Na maioria das comunicações de cima para baixo para o envio de dados, esse deveria ser o método de comunicação preferido.

Por exemplo, quando um ProductSelectionInteractor hipotético determina que um produto foi selecionado, ele invoca um listener para passar a identificação visível do veículo. O listener é implementado por um ConfirmationInteractor. O ConfirmationInteractor então armazena  a identificação visível do veículo para que ela possa ser enviada em uma solicitação de serviço, invoca seu Router para desanexar e libera o Riblet ProductSelection.

Ao estruturar o fluxo de dados dentro dos Riblets dessa maneira, nós garantimos que os dados corretos virão na hora certa para nossa tela. Como os Riblets formam a árvore da aplicação baseados na lógica de negócio, nós podemos encaminhar as comunicações pela lógica de negócios (em vez da lógica de visualização). Isso faz sentido para o nosso negócio e ultimamente também ajuda a encorajar o isolamento de código, evitando que o desenvolvimento da aplicação cresça muito complexo.

De volta ao ponto de início

Quando nós definimos recomeçar o aplicativo do zero, queríamos focar na experiência do usuário principal aumentando a confiabilidade e estabelecendo os trilhos corretos para o desenvolvimento futuro do aplicativo. Criar uma nova arquitetura era essencial para alcançar esses objetivos.

Como aumentamos a disponibilidade para a experiência do usuário principal?

Os Riblets têm uma separação clara de responsabilidades, então os testes são mais diretos. Cada Riblet é testável independentemente. Com testes melhores, podemos ficar mais confiantes na confiabilidade do nosso aplicativo quando liberamos atualizações. Como cada Riblet tem uma responsabilidade única, foi fácil separar os Riblets e suas dependências entre código principal (diretamente necessário para acessar o aplicativo ou solicitar uma corrida) e opcional. Ao demandar revisões mais estritas do código principal, podemos ser mais confiantes na disponibilidade dos nossos fluxos principais.

Também habilitamos o roll back global dos fluxos principais para um estado de funcionamento garantido. Todo o código opcional está sob uma marcação de funcionalidades principais que pode ser desabilitada se partes dele apresentarem erro. No pior caso, podemos desabilitar todos os códigos opcionais e manter o padrão somente com o fluxo principal. Desde que tenhamos uma barra tão alta no código principal, podemos assegurar que nossos fluxos principais estão sempre funcionando.

Como estabelecemos os trilhos corretos para o futuro desenvolvimento do nosso aplicativo?

Os Riblets nos ajudam a limitar e separar as funcionalidades tanto quanto necessário. Essa clareza na separação da lógica de negócios e de visibilidade nos ajudarão a evitar que nossa base de códigos cresça de forma excessivamente complexo e se mantenha fácil de trabalhar. Como nossa nova arquitetura é agnóstica a plataformas, os engenheiros iOS e Android podem facilmente entender como cada um está desenvolvendo, aprender uns com os erros dos outros e trabalhar juntos para levar o Uber adiante. Os testes serão menos propensos a afetar colateralmente a experiência principal, pois os Riblets nos ajudam a separar códigos opcionais de códigos principais. Poderemos testar novas funcionalidades, desenvolvidas como plugins na arquitetura Riblet, sem nos preocupar com o fato de que eles possam deixar a experiência principal com erros.

Desde que os Riblets aumentaram a abstração e a separação de responsabilidades, e com um caminho claramente definido para o fluxo de dados e comunicação, continuar o desenvolvimento é fácil – essa arquitetura nos suportará por anos.

Prontos para seguir em frente

Nossa nova arquitetura nos coloca à frente. Essa reescrita mais recente significou refazer completamente nossa base de código do aplicativo, reimplementando o que existia antes, realizando pesquisa com os usuários, estudos de caso, testes A/B, e escrevendo novas funcionalidades como o feed. Além disso, quisemos realizar um lançamento global para colocar o novo aplicativo nas mãos dos usuários rapidamente, então nós consideramos as variações ao redor do mundo de design, funcionalidades, localização, aparelhos e perspectivas de testes. Apesar de o lançamento já ter passado, o trabalho com nossa nova arquitetura está apenas começando.

Existe uma grande quantidade de novas possibilidades para desenvolvermos sob nossa nova arquitetura – melhorar o novo feed, expandir nossa nova arquitetura para os aplicativos dos motoristas e o UberEATS, e até mesmo desenvolver para os desenvolvedores. Na verdade, passamos alguns meses desenvolvendo protótipos para ter certeza de que estávamos realizando as mudanças corretas. Agora pudemos nos sentir confiantes de que conseguimos a arquitetura certa para muito desenvolvimento no futuro. Se esse tipo de desenvolvimento te excita, venha fazer parte dessa história e melhorar a experiência Uber para todos na engenharia Android e iOS.

***

Este artigo é do Uber Engineering. Ele foi escrito por Vivian Tran e Yixin Zhu. A tradução foi feita pela Redação iMasters com autorização. Você pode conferir o original em: https://eng.uber.com/new-rider-app/.