Desenvolvimento

12 abr, 2017

Impulsionando UberEATS com React Native e Uber Engenharia

Publicidade

Com o UberEATS, nosso objetivo é fazer pedidos de alimentos dos seus restaurantes favoritos tão perfeitos quanto solicitar um passeio com uberX ou uberPOOL. Assim como lançar qualquer novo produto, construir uma rede de entrega de alimentos veio com a sua parte de triunfos de engenharia e surpresas. Embora saboroso, esse novo passageiro cheio de sabor (alimento!) vem com sua parte justa de desafios. Por exemplo, ele não pode especificar sua rota preferida ou conversar com o motorista, e ele requer mais etapas no pegar e entregar. Neste artigo, nós focamos em um desafio em particular: como a Uber Engenharia lidou com a introdução de um terceiro para o que tinha sido anteriormente um mercado de dois lados.

Felizmente, conseguimos que o UberEATS funcionasse rapidamente, alavancando grande parte da pilha de tecnologia existente da Uber. Uma viagem se tornou uma entrega. Os condutores parceiros tornaram-se parceiros de entrega, e os pilotos tornaram-se consumidores. Mas não houve nenhuma parte análoga ao restaurante, porque nos últimos cinco anos a suposição era que só haveria somente duas pessoas envolvidas em uma única viagem, não três pessoas e uma pizza de queijo, pedido de Pad Thai ou fajita de frango.

Construindo o painel do restaurante

Figura 1: O mercado do UberEATS inclui três partes: restaurantes, parceiros de entrega e consumidores. Essa nova dinâmica transforma o modelo tradicional de dois lados da Uber radicalmente.

Os restaurantes precisam de uma maneira de se comunicar tanto com os parceiros de entrega quanto com os consumidores. No mínimo, as partes precisam retransmitir:

  1. A colocação de uma nova ordem
  2. A aceitação de um pedido
  3. A chegada de um parceiro de entrega
  4. A conclusão de um pedido

Essas quatro demandas básicas deram origem ao Restaurant Dashboard, uma aplicação web single-page React/Flux acessado por meio de dispositivos tablet.

Figura 2: Um Restaurant Dashboard mostrando um pedido ativo.

Remodelando o Restaurant Dashboard para as próximas 50 cidades

Desde o lançamento inicial do aplicativo autônomo em Toronto, em dezembro de 2015, continuamos trabalhando na criação de uma interface fácil e confiável para os restaurantes usarem para coordenar as entregas. Ao longo de vários meses, tornou-se claro para nós que, a fim de continuar a melhorar o Restaurant Dashboard, uma remodelagem completa seria necessária.

Nosso aplicativo web apenas forneceu acesso limitado ao dispositivo, o que provou ser um problema significativo porque restringia nossa capacidade de comunicar informações importantes aos restaurantes. Um exemplo disso é que um usuário deve interagir com uma página web antes que as notificações baseadas em som possam dar o alerta. Os restaurantes estão fervilhando de atividade, de modo que o som é uma forma extremamente importante para notificar os funcionários do restaurante sobre a colocação de um novo pedido ou sobre quando um parceiro de entrega chegou para pegar um pedido. Para resolver esse problema, exibimos um modal cada vez que a página foi carregada, a fim de forçar a interação do usuário. Enquanto isso nos deu permissão implícita para tocar som, ele fez isso às custas da experiência do usuário.

Figura 3: Restaurant Dashboard mostrando um modal para forçar a interação do usuário e, portanto, permitir o som.

Também precisávamos construir alguns recursos que simplesmente não eram viáveis em um navegador web ou estavam disponíveis apenas em um formato altamente restrito. Por exemplo, a impressão de recibos físicos é um dado para muitos restaurantes, mas os navegadores web só permitem a função para aqueles que usam impressoras compatíveis com AirPrint. Essa limitação foi uma grande fonte de confusão e frustração igualmente para restaurantes e engenheiros. Percebemos que, para superar esse obstáculo, precisaríamos de acesso ao hardware, o que nos permitiria comunicar diretamente com impressoras usando SDKs nativos fornecidos por comerciantes de impressoras.

Avaliando React Native

Embora seja prematuro chamar React Native de a solução mágica do desenvolvimento de aplicativos móveis, ele parece se encaixar no caso de uso do UberEATS muito bem. Desde que a encarnação original do Restaurant Dashboard foi construída para a web, a nossa equipe tinha uma grande experiência usando React, mas limitada à exposição de iOS/Android. Houve também uma riqueza de conhecimento sobre como o componente de restaurante do serviço funcionou, o qual tínhamos acumulado trabalhando no UberEATS desde a sua concepção. Essas considerações fizeram do React Native, que fornece uma plataforma para desenvolvimento móvel na linguagem da web, uma opção atraente. Ele nos forneceu os utensílios que precisávamos para “cozinhar” o aplicativo que queríamos próximo da perfeição.

O suporte multiplataforma também foi uma grande preocupação para nós. Atualmente, a Uber trabalha em estreita colaboração com os restaurantes para encontrar dispositivos tablet e instalar o aplicativo Restaurant Dashboard, mas essa prática pode se tornar menos sustentável à medida que o UberEATS continua se expandindo. O lado parceiro motorista da Uber passou por uma mudança semelhante quando nos mudamos para um modelo BYOD (traga seu próprio dispositivo). Ao estruturar o aplicativo UberEATS de uma maneira agnóstica na plataforma, temos a opção de expandir mais tarde para o Android e dar suporte para ambas as plataformas avançando.

Para que o React Native fosse uma opção viável para nós, também foi importante que ele funcionasse dentro de nossa infraestrutura móvel existente e suportasse os tipos de recursos que haviam originariamente solicitado nossa mudança para um aplicativo nativo. Para isso, criamos um aplicativo de ‘demonstração’ adaptado para verificação de recursos críticos. Isso incluiu nossa capacidade de extrair dependências nativas de outras equipes da Uber para testar funcionalidades, incluindo relatórios de falhas, autenticação de usuários e análises. Como esses recursos abrangiam tanto a camada nativa Objective-C quanto a camada JavaScript interpretada, também foi um teste útil de nossa capacidade fornecer recursos que exigem integração entre esses dois ambientes muito diferentes.

No geral, a demonstração foi capaz de entregar o nosso resultado desejado. Bibliotecas como relatórios de falhas, que poderiam operar independentemente da lógica de negócios do nosso aplicativo, funcionaram perfeitamente. Transitar na camada de JavaScript para recursos como disparo de eventos de análise também provou ser surpreendentemente simples. Em retrospectiva, essa falta de uma barreira técnica provavelmente nos levou a confiar demais nas bibliotecas nativas, e essa tensão entre a funcionalidade nativa e de JavaScript iria encaixar muitas de nossas decisões arquitetônicas posteriores.

Criando um caminho de migração

A meta inicial era construir a quantidade mínima necessária de andaimes necessários para que o Restaurant Dashboard funcionasse nativamente. Para isso, criamos um sistema nativo de navegação e autenticação, juntamente com um WebView apontando para o nosso aplicativo web existente.

Figura 4: O diagrama acima mostra a interação entre o nativo e o armazenamento web Restaurant Dashboard Flux.

As solicitações de rede do WebView foram alteradas usando NSURLProtocol para ter os cabeçalhos de autenticação necessários. Hooks adicionais foram adicionados à janela, o que nos permitiu atualizar o fluxo de armazenamento Restaurant Dashboard baseado na web, injetando JavaScript no WebView. Isso nos deu muita flexibilidade em termos de migração gradual da funcionalidade.

Ter esse produto viável mínimo (minimal viable product – MVP) efetivamente na paridade de recursos nos permitiu iniciar rapidamente testes em restaurantes reais. Isso também desbloqueou alguns “ganhos rápidos” em termos de funcionalidade nativa. Integramos com vários SDKs de impressoras nativas para expandir a gama de impressoras compatíveis além daquelas suportadas pelo AirPrint. Também desativamos o modo de suspensão, algo que só exige uma linha de código nativo, mas que era impossível de fazer a partir da web.

O resto da aplicação poderia, então, ser migrado para o React Native, pedaço por pedaço. Sempre que possível, pretendemos fazer com que essas migrações façam parte de um trabalho mais amplo de recursos, em vez de reescrevê-las por uma questão de reescrever.

Definindo a arquitetura

Conforme mencionado anteriormente, o React Native funde o desenvolvimento web e o do celular, permitindo que criemos recursos nativamente ou em JavaScript. Com essa funcionalidade, também vêm os padrões e conceitos das comunidades móveis e comunidades web, respectivamente. Esse caldeirão de ideias nos dá mais opções, mas também apresenta novos desafios em termos de escolher a abstração correta.

Nós finalmente arquitetamos o UberEATS da mesma maneira que faríamos com um aplicativo web React/Redux regular, evitando padrões e módulos do iOS sempre que possível. Felizmente para nossas necessidades e preferências, conceitos web e tecnologias em geral traduzem muito bem ao desenvolvimento nativo.

Um exemplo dessa fácil tradução para a web é a funcionalidade de roteamento do aplicativo. Na web, o Restaurant Dashboard usa a popular biblioteca react-router que permite que as rotas sejam definidas declarativamente, da mesma forma que o View. No entanto, esse sistema assume a existência de URLs que tendem a ser insuficientes fora do navegador. React Native fornece uma biblioteca de navegação imperativa, que se assemelha à interface fornecida pelo UINavigationController.

Por uma questão de velocidade, mantivemos inicialmente a biblioteca react-router com o objetivo de substituir o framework de roteamento, uma vez que um MVP estava ativo e funcionando. O problema da URL não existente é facilmente resolvido replicando a API HTML5 History dentro do JavaScript, que para todos os efeitos é apenas uma pilha.

Quando chegou a hora de migrar para fora do react-router para uma das bibliotecas React Native como Navigator ou NavigationExperimental, as novas implementações não pareciam oferecer vantagens convincentes sobre a nossa solução atual. Acontece que vanilla react-router é apenas uma maneira realmente incrível de fazer roteamento, independentemente se você está no navegador ou nativo.

Outra lição importante do processo de portabilidade foi que é altamente vantajoso minimizar a interação entre iOS e JavaScript e concentrar a lógica na camada de JavaScript. Fazer isso tem uma série de benefícios significativos, tais como:

  • Menor mudança de contexto entre JavaScript e Objective-C
  • Maior portabilidade (através da diminuição do código específico da plataforma)
  • Escopo reduzido para erros

Quando começamos a trabalhar no projeto, desenvolvemos uma API simples para comunicação com a camada nativa. Embora tenhamos apreciado as vantagens de manter essa camada fina, subestimamos o quanto de código poderia ser mantido na camada React Native. Recursos como analytics e login são basicamente apenas chamadas de rede e poderiam ter sido implementados em JavaScript com relativa facilidade, enquanto que o código originalmente escrito em Objective-C precisará ser portado para Java para suportar o Android. Mais provável, porém, aproveitaremos a oportunidade para reescrever essas bibliotecas em JavaScript para que elas possam ser compartilhadas entre plataformas.

Atualizações de envio automáticas

Os aplicativos React Native são inicializados por uma pequena quantidade de código Objective-C/Java que, então, carrega o pacote de JavaScript. O pacote é fornecido com o aplicativo, bem como qualquer outro recurso. Como sugerimos, se a lógica de negócios permanece concentrada no pacote, o aplicativo pode ser atualizado carregando um arquivo JavaScript diferente no lançamento, o que é um processo simples. Na camada nativa, o aplicativo pode alterar o arquivo usado pela ponte do React Native e solicitar que ele seja recarregado.

Para manter nossa lógica de plataforma de atualização agnóstica, optamos por levá-la um passo adiante e criar um wrapper nativo ao redor da ponte, permitindo que o pacote de JavaScript em si determine qual pacote é carregado.

Figura 5: Restaurant Dashboard pode armazenar até três pacotes de JavaScript a qualquer momento.

O Restaurant Dashboard verifica periodicamente novos pacotes e os baixa automaticamente. Tanto o código nativo quanto o código do pacote seguem a versão semântica, atribuindo identificação exclusiva a cada nova implantação, e uma alteração é considerada quebrada se ela alterar a interface de comunicação Native-JavaScript. Por exemplo, renomear o módulo do Analytics para AnalyticsV2 seria considerada uma alteração de quebra porque as chamadas existentes do pacote JavaScript para o Analytics disparariam uma exceção.

É claro que, mesmo com a maior atenção para a versão semântica, uma atualização incorreta ainda é possível. No contexto do UberEATS, uma atualização incorreta refere-se a uma atualização de pacote que provoca a quebra do Restaurant Dashboard antes que a lógica de gestão do pacote tenha a oportunidade de ser executada. O momento da quebra tornaria impossível corrigir o problema empurrando um novo pacote. Atualizações que causam esse tipo de instabilidade acontecerão eventualmente, por isso é importante ter um sistema resiliente que possa detectar e se recuperar de construções instáveis.

Uma maneira de evitar a implantação de atualizações incorretas é tratar cada versão como uma experiência, o que permite uma implantação gradual e, se necessário, uma reversão das atualizações.

Figura 6: O processo de reversão do Restaurant Dashboard determina qual o pacote a ser carregado.

Para que o processo de reversão funcione corretamente, o Restaurant Dashboard precisa reconhecer que ele tem um pacote ruim e, em seguida, recarregar um pacote ‘seguro’ (ou seja, um pacote que sabemos estar livre de erros, assim como o pacote fornecido originalmente com o aplicativo), caso contrário, ele não será capaz de descobrir para qual versão do software ele deve rolar de volta. Conseguimos isso recarregando automaticamente o pacote JavaScript original fornecido com o aplicativo e, em seguida, carregando um de dois pacotes enviados: o pacote seguro mais recente ou o pacote mais recente. Se o pacote mais recente pode ser carregado, ele se gradua para ser o pacote seguro. Caso não exista um pacote seguro, o original permanece em uso sem atualizações.

Esse método de atualização do Restaurant Dashboard tem significativamente menos atrito do que uma atualização regular de aplicativos para dispositivos móveis, pois novas construções podem ser lançadas conforme necessário, reduzindo o tempo necessário para enviar um novo recurso de uma questão de semanas para dias. As atualizações são baixadas em segundo plano e carregadas uma vez que são concluídas, evitando a interação do usuário. Essa falta de interação imediata do usuário permite que as atualizações sejam propagadas mais rapidamente e que a maioria dos dispositivos possa ser mantida na construção mais recente. O mesmo mecanismo também nos permite rapidamente reverter as construções ruins, minimizando a interrupção para os parceiros do restaurante.

Enquanto enviar atualizações dessa maneira não substituiu completamente as versões normais do aplicativo (que ainda necessitam ocasionalmente de alterações no código nativo do iOS ou Android), isso reduziu sua frequência. À medida que a camada nativa amadurece com o projeto, esperamos que essa tendência continue.

Teste e checagem de tipagem

Dentro da Uber Engenharia, as equipes se movem rapidamente e os projetos web tendem a ser enviados à medida que as mudanças são enviadas para o repositório, em vez de esperar por um trem de construção. Isso permanece em total contraste com os processos de liberação de várias semanas normalmente associados a aplicativos móveis. Quando contemplamos a mudança para uma aplicação nativa durante o desenvolvimento do Restaurant Dashboard, estávamos preocupados que a estabilidade do aplicativo pudesse sofrer devido a essa reviravolta apertada; afinal, se você quebrar no intérprete do React Native, você quebra na vida real. Mesmo com o envio de pacote fornecendo uma maneira de reduzir esse risco, quebrar está longe de ser ideal.

Testes unitários e renderização rasa em particular já estão por aí há algum tempo, mas recentemente houve um movimento crescente na comunidade JavaScript para incorporar a verificação de digitação estática através de Flow  ou TypeScript.

Ao atualizar o aplicativo desta vez, decidimos verificar a digitação com Flow, uma decisão que nos deu confiança adicional na correção de nossa lógica de negócios. Na verdade, ele provou ser uma ferramenta inestimável para testar código e capturar erros antes que eles alcancem a produção.

Um exemplo simples do poder do Flow reside nas funções redutoras da verificação de digitação. Conforme detalhado abaixo, um redutor toma o estado atual e uma ação como entrada e, por sua vez, espera-se retornar um novo estado como saída:

Como lidar com os efeitos colaterais

O uso do Flow para type checking nos permite verificar que o nosso estado mantém sua forma correta após esse processo e é um crédito à comunidade Flow que novas versões continuaram a encontrar possíveis fontes de erros em nosso aplicativo. Além disso, a sobrecarga mínima associada à tipagem opcional significa que ela não interfere na iteração e desenvolvimento rápidos.

O Restaurant Dashboard usa Redux para gerenciar o fluxo de dados. O Redux fornece-nos uma maneira simples e previsível de modelar o estado do aplicativo seguindo alguns princípios-chave:

  1. Todo o estado está no banco, que é um objeto imutável único
  2. As Views tomam o armazenamento como entrada e entregam componentes React Native
  3. A View pode enviar ações, que são solicitações para modificar o armazenamento
  4. Os Redutores tomam a ação e o estado atual como entrada, retornando um novo armazenamento

Geralmente, é necessário alterar o armazenamento em resposta a ações assíncronas, como solicitações de rede. O Redux não prescreve uma maneira de fazer isso, mas uma abordagem comum é usar Thunks, um middleware para Redux que permite que as ações sejam funções que retornam uma promessa e enviam ações adicionais ao longo do caminho.

Figura 7: No Restaurant Dashboard, os dados fluem através de um aplicativo Redux.

Nossa abordagem inicial foi usar Thunks, mas rapidamente nos deparamos com problemas à medida que nossa lógica de aplicativo (e efeitos colaterais) se tornava mais complicada. Especificamente, encontramos dois padrões de efeitos colaterais que não se encaixavam naturalmente no modelo Thunk:

  1. Atualizações periódicas para o estado do aplicativo
  2. Coordenação entre os efeitos colaterais

Sagas, um modelo de efeito colateral alternativo para aplicações Redux, alavanca funções geradoras ES6 (ECMAScript 6) para fornecer uma opção menos complicada. Em vez de estender o conceito de uma ação, eles são modelados como um segmento separado que pode acessar o armazenamento, ouvir ações Redux e enviar novas. Em um esforço para evitar problemas relacionados ao Thunk, o UberEATS.com recentemente migrou em sua totalidade para Sagas, dando-nos a confiança de que poderiam escalar e que estavam maduros o suficiente para nossas necessidades.

Uma área onde Sagas realmente se destacam é na gestão de mudanças periódicas no estado do aplicativo, como recuperar uma nova lista de ordens ativas. Isso é possível usando Thunks, mas está longe de ser elegante. Por exemplo, o componente poderia periodicamente enviar uma ação para buscar ordens; alternativamente, o Thunk poderia chamar a si mesmo recursivamente. Além dos problemas de implementação, no entanto, nem ter um componente com lógica timer – nem um Thunk independente que mantenha acionando a si mesmo – se encaixa perfeitamente no modelo Redux.

Sagas fornecem uma maneira limpa de resolver esse problema, uma vez que nos permite criar uma tarefa long-living que periodicamente busca novos pedidos e envia uma ação para atualizar o banco.

Um problema relacionado a ter tarefas de longa execução é manter a comunicação entre elas, mostrada abaixo:

Com base no exemplo de pedidos de busca acima, os pedidos só devem ser recuperados e o banco só deve ser atualizado quando existir uma sessão de usuário válida. A falha em fazer cumprir essa regra pode levar a erros não óbvios, como uma condição de corrida entre o restaurante se desconectando e seus pedidos sendo atualizados. Isso, por sua vez, poderia revelar casos de borda provocando quebras ou sugestões estranhas da interface do usuário, pois o código para pedidos recebidos poderia muito razoavelmente fazer a suposição de que um restaurante inexistente existe.

Se proteger contra essas questões é relativamente simples, mas identificar possíveis condições de corrida e adicionar as verificações necessárias é demorado e propenso a erros. Mais importante ainda, nosso código de pedido não deve se preocupar com o estado da sessão do usuário, já que são duas preocupações distintas.

Sagas fornecem uma maneira simples de ouvir ações relacionadas à sessão e iniciar ou parar a tarefa em segundo plano para buscar pedidos. Por exemplo, quando vemos um evento de login, devemos abrir uma tarefa para buscar pedidos periodicamente e cancelar a tarefa se um logout for visto. Isso pode ser expresso concisamente como uma Saga, abaixo:

A tarefa bifurcada é outro gerador, que continuará a ser executado até que ele – ou seu pai – seja encerrado.

Na verdade, ocorre que esse padrão de tarefas de gating em ações específicas é bastante comum. Assim como os decoradores de componentes, podemos puxar essa lógica para uma função geradora de ordem superior, como mostrado abaixo:

A natureza de Sagas também simplifica o processo de teste. Com Sagas, o teste unitário de uma determinada peça de funcionalidade é tão simples como chamar a Saga relevante e realizar uma comparação profunda sobre o resultado.

Essa abordagem de ter muitos pequenos serviços se comunicando uns com os outros através da passagem de mensagens será familiar para muitos engenheiros de backend, mas geramos e consumimos ações Redux em vez de eventos Kafka. Do nosso ponto de vista no lado do desenvolvedor, tem sido fascinante observar esses padrões aplicados ao código do cliente.

Refletindo sobre a jornada do UberEATS

É quase impossível resumir em um único artigo toda a experiência de implantação de um aplicativo, particularmente de um que afetou significativamente a forma como os restaurantes interagem com o aplicativo UberEATS. De alguma forma, esperamos que este texto tenha fornecido algumas informações adicionais sobre o processo de pensamento da nossa equipe por trás da escolha de React Native para UberEATS, bem como algumas das etapas que realizamos para garantir uma experiência de usuário estável e robusta para nossos parceiros de restaurantes.

Enquanto o React Native ainda constitui apenas uma pequena porção do ecossistema de engenharia do UberEATS, nossa experiência em usá-lo para reconstruir o Restaurant Dashboard foi muito positiva. Desde a sua implementação no ano passado, o Painel do Restaurante renovado tornou-se uma ferramenta padrão para quase todos os restaurantes do UberEATS. Nesse ritmo, estamos otimistas sobre a capacidade do framework de continuar atendendo às nossas necessidades à medida que ampliamos e expandimos nosso mercado de usuários.

***

Este artigo é do Uber Engineering Team. Ele foi escrito por Chris Lewis. A tradução foi feita pela Redação iMasters com autorização. Você pode conferir o original em: https://eng.uber.com/ubereats-react-native/.