Desenvolvimento

14 fev, 2019

Construindo uma interface de mapa escalável e confiável para motoristas

100 visualizações
Publicidade

Este artigo é o sexto de uma série sobre como a equipe de engenharia móvel da Uber desenvolveu a versão mais recente do nosso aplicativo de motorista, codinome Carbon, um dos principais componentes de nosso negócio de compartilhamento de viagens.

Entre outros novos recursos, o aplicativo permite que nossa população de mais de três milhões de motoristas parceiros encontre tarifas, obtenha direções e acompanhe seus ganhos.

Começamos a projetar o novo aplicativo em conjunto com o feedback de nossos motoristas parceiros em 2017 e começamos a implementá-lo para produção em setembro de 2018.

Quando a Uber decidiu por reescrever completamente o nosso aplicativo de motorista, nós usamos esse momento como uma oportunidade para dar um passo atrás e ver como poderíamos melhorar algumas das principais tecnologias do aplicativo.

Uma das tecnologias em que nos concentramos foi a exibição do mapa, que se tornou difícil de trabalhar e foi implicada em muitos erros que os motoristas haviam encontrado.

exibição do mapa do nosso aplicativo de motorista anterior sofreu com os problemas típicos do software legado, projetado com um objetivo em mente e a aquisição de novos recursos ao longo do tempo.

Esses recursos ocasionalmente conflitavam em seu controle sobre a exibição do mapa, resultando em uma experiência inferior para os usuários.

Carbon, a reescrita do nosso aplicativo de motorista, nos deu uma oportunidade para reprojetar o mapa, e nós criamos um framework que é mais fácil de trabalhar e mais confiável do que a implementação anterior.

Definir o mapa como uma camada de plano de fundo e criar três componentes distintos para regular a exibição provou ser uma solução elegante que pode escalar para o crescimento e nos permite implementar facilmente novos recursos, permitindo-nos atender melhor aos nossos motoristas parceiros.

Conflitos herdados

Quando lançamos nosso aplicativo de motorista anterior em 2013, sua arquitetura era muito mais simples. Por exemplo, a interface de usuário do nosso mapa exibia apenas a localização do motorista junto com o local de embarque ou desembarque.

Dado esse conjunto limitado de recursos, fazia sentido ter uma única classe gerenciando o mapa; no nosso caso, foi chamado de MapViewController.

Figura 1: A exibição do mapa do aplicativo do motorista em 2013 mostrou apenas a localização do motorista junto com os pontos de embarque e desembarque.

Com o tempo, adicionamos muitos recursos novos relacionados a mapas ao aplicativo, como navegação no aplicativo, localizador de posto de gasolina, compartilhamento de local do passageiro e filtragem de destino do motorista.

Cada um desses recursos adicionou código adicional ao MapViewController, introduzindo complexidade que tornou mais difícil refiná-lo ou adicionar ainda mais recursos.

Em 2015, tentamos simplificar o código dividindo sua lógica em dois novos controladores de visualização: OffTripMapViewController e OnTripMapViewController. Essa divisão ajudou um pouco, mas também introduziu novos problemas.

Por exemplo, para evitar que o aplicativo recriasse o mapa toda vez que um motorista entrasse ou saísse da viagem, passamos a visualização do mapa de um lado para outro entre os dois controladores de visualização.

Infelizmente, às vezes o tempo estava desativado e enviamos o mapa para o controlador de visualização incorreto. Sempre que isso acontecesse, o mapa desapareceria totalmente, como mostra a Figura 2 abaixo, causando confusão para os motoristas:

Figura 2: Ocasionalmente, a visualização de mapa desapareceu em nosso aplicativo de motorista anterior porque foi movida para o controlador de visualização incorreto.

Outro problema ocorreu quando alguns recursos precisavam existir tanto dentro quanto fora da viagem.

Por exemplo, a navegação no aplicativo permitia que os motoristas navegassem durante a viagem, mas também queríamos que os motoristas pudessem navegar para os destinos preferidos quando fora da viagem.

Acabamos duplicando uma grande quantidade de código de navegação para que funcionasse nos dois controladores de visualização.

Esse problema ilustrava um problema fundamental com a arquitetura: o acesso ao mapa era segmentado pelo estado do aplicativo, mas os recursos necessários para usar o mapa tinham tempos de vida que não se encaixavam perfeitamente nesses segmentos.

Figura 3: A arquitetura do mapa na versão anterior do aplicativo do motorista exigia a duplicação de algum código de recurso e dava muitas responsabilidades aos controladores de visualização

A nova arquitetura do mapa

Para o novo aplicativo de motorista, criamos uma nova arquitetura de mapa para impedir que esses tipos de erros aparecessem. Fizemos do mapa uma visualização em segundo plano que persiste durante toda a vida útil do aplicativo, para que não precisemos mais movê-lo de um controlador para outro.

Figura 4: Com a nossa nova arquitetura de mapa, o código de recurso não precisa mais ser duplicado e é distribuído em pedaços pequenos.

Três componentes fornecem acesso ao mapa: o Layer Manager, o Padding Provider e o Camera Director. Esses objetos são disponibilizados para qualquer parte do código, portanto, os recursos do mapa podem ser gravados de maneira descentralizada.

Com o novo aplicativo, não há mais a necessidade de colocar a lógica do mapa em controladores de visualização dedicados, e cada recurso relacionado ao mapa pode ter sua própria vida útil.

O Gerenciador de Camadas: criando caixas de proteção para elementos de mapa

Os recursos do aplicativo do motorista geralmente querem desenhar elementos no mapa, como marcadores ou sobreposições. Na versão anterior do aplicativo do motorista, o código do recurso desenhava elementos chamando funções na própria exibição do mapa.

Essa abordagem funcionou bem, desde que o código fosse perfeitamente bem-comportado, mas na prática nem sempre foi o caso.

Às vezes, os recursos inadvertidamente se esqueciam de remover os marcadores de mapa, deixando elementos perdidos no mapa que persistiam até que o aplicativo fosse relançado, um exemplo que pode ser visto na Figura 5.

Outras vezes, os recursos removiam elementos de outros porque queriam uma tela em branco para o mapa; o outro recurso não fazia ideia de que seus elementos haviam sido removidos e se comportavam como se o motorista ainda conseguisse ver esses elementos no mapa.

Figura 5: Às vezes, no aplicativo anterior, um recurso poderia deixar elementos como marcadores de postos de gasolina no mapa enquanto o motorista iniciava uma viagem, criando confusão visual desnecessária.

Implementado em nosso novo aplicativo de motorista, o Gerenciador de Camadas resolve esses problemas fornecendo uma caixa de proteção para cada recurso de mapa.

Em vez de adicionar elementos diretamente à visualização do mapa, os recursos criam suas próprias camadas de mapa e os registram no Gerenciador de Camadas.

A interface da camada do mapa permite que os engenheiros adicionem e removam elementos do mapa, mas não fornece acesso a nenhuma outra camada. Os recursos não podem mais interferir nos elementos do mapa que eles não possuem.

Em alguns casos, porém, um recurso realmente requer que o mapa esteja limpo de todos os outros elementos. Por exemplo, quando um motorista aceita uma nova solicitação de viagem, queremos que a tela de envio mostre apenas os elementos do mapa relevantes para o embarque proposto, para manter a interface do usuário limpa e fácil de seguir.

Nestes casos, os recursos podem solicitar ao Gerenciador de Camadas o registro de uma camada de mapa exclusiva. Isso oculta temporariamente todas as outras camadas do mapa; uma vez que a camada exclusiva é não registrada, as outras camadas podem retornar.

O Gerenciador de Camadas também possui uma proteção embutida para proteger contra recursos esquecendo-se de limpar seus elementos. Na arquitetura RIBs da Uber, a lógica de negócios normalmente reside nas classes Interactor.

Ao registrar uma camada de mapa, é obrigatório fornecer um interactor com o qual vincular a vida útil da camada, de modo que, quando um interactor é desativado, a camada de mapa seja automaticamente removida juntamente com todos os seus elementos.

É muito menos provável que os elementos de mapas dispersos persistam, uma vez que não conseguem viver mais tempo do que o recurso que os criou.

A interface básica do Gerenciador de Camadas mantém as camadas do mapa segregadas para que não entrem em conflito entre si, conforme ilustrado abaixo:

class MapLayerManager {
   func add(layer: MapLayer, interactorScope: InteractorScope, exclusive: Bool)
   func remove(layer: MapLayer)
}

class MapLayer {
   func add(marker: MapMarker)
   func remove(marker: MapMarker)

   func add(polygon: MapPolygon)
   func remove(polygon: MapPolygon)

   func add(polyline: MapPolyline)
   func remove(polyline: MapPolyline)

   func add(tileOverlay: MapTileOverlay)
   func remove(tileOverlay: MapTileOverlay)
}

O provedor de preenchimento: Manipulando o cromo do aplicativo na parte superior do mapa

Ter o mapa como uma visualização de plano de fundo significa que o cromo do aplicativo, em outras palavras, elementos como painéis e botões, geralmente o cobrirá.

O mapa precisa considerar o cromo em nosso aplicativo para que ele possa mostrar ao motorista as ruas e os locais de que precisam. Seria lamentável se, por exemplo, o pino de embarque ou a localização atual do motorista fossem obscurecidos por um painel opaco.

Figura 6: O painel inferior contribui com o preenchimento do mapa para garantir que a rota completa do motorista esteja visível.

Como não há uma maneira interna de o aplicativo saber que o mapa está obscuro, criamos o Provedor de Preenchimento como um componente que rastreia a parte visível do mapa. Qualquer cromo que cubra parcialmente o mapa se registra com o Provedor de Preenchimento como fonte de preenchimento.

Esses elementos cromados informam ao provedor quanto eles se estendem (em relação às bordas da tela) nos limites do mapa. O Provedor de Preenchimento agrega todas as suas origens e produz um fluxo observável das inserções da borda geral.

Qualquer código que queira mostrar uma determinada região do mapa para o usuário usa esse fluxo para garantir que a região desejada seja visível.

Assim como nas camadas de mapa, cada fonte de preenchimento de mapa precisa estar vinculada à vida útil do recurso que a criou. No entanto, em vez de vincular a um Interactor (que contém lógica de negócios), as fontes de preenchimento são vinculadas ao controlador de visualização de modo que, quando a visualização desaparecer, o preenchimento do mapa também desaparecerá automaticamente.

O Provedor de Preenchimento permite que as fontes de preenchimento sejam registradas, controlando quais partes do mapa podem ser cobertas e facilitando uma experiência de navegação aprimorada para os motoristas. Sua interface básica é mostrada abaixo:

class MapPaddingProvider {
   var edgeInsetsStream: Observable<EdgeInsets> { get }

   func add(paddingSource: MapPaddingSource,
            viewControllerScope: ViewControllerScope)
   func remove(paddingSource: MapPaddingSource)
}

class MapPaddingSource {
   var edgeInsets: EdgeInsets { get set }
}

O Diretor da Câmera: incentivando a cooperação entre recursos

A câmera do mapa é um termo que descreve o ponto no espaço 3D, um ponto de vista/uma posição estratégica, que está acima e olha para o mapa. É análoga a uma câmera de filme, em que o mapa é a cena que está sendo filmada e o usuário está olhando pelas lentes da câmera.

Os recursos geralmente mudam a câmera do mapa para mostrar uma região específica para o usuário, como quando um motorista é despachado e queremos mostrar o local de embarque.

Na versão anterior do aplicativo do motorista, qualquer recurso poderia alterar o ponto de vista da câmera alterando diretamente suas propriedades na visualização do mapa. Às vezes, os recursos querem atualizar a câmera continuamente, como durante a navegação no aplicativo, onde ela segue a localização do usuário enquanto dirige.

O problema com essa abordagem é que os recursos geralmente não tinham conhecimento de nenhum outro recurso que pudesse estar controlando a câmera ao mesmo tempo. Nesse caso, se dois recursos tentassem controlar a câmera do mapa ao mesmo tempo, o mapa entraria e sairia entre duas regiões, uma experiência desorientadora para o motorista.

O novo aplicativo do motorista resolve esses problemas com o Diretor da Câmera. Em vez de permitir que os recursos controlem livremente a câmera do mapa, o Diretor da Câmera fornece uma maneira de influenciar a câmera registrando uma regra de câmera. Uma regra de câmera tem uma interface para fornecer coordenadas de latitude/longitude que devem ser incluídas na tela.

A maioria dos recursos não precisa exibir uma região específica, mas pode ser programada para garantir que seus marcadores ou outros elementos estejam sendo mostrados ao usuário. O Diretor da Câmera agrega essas regras para criar uma câmera de mapa geral que inclua todas as coordenadas desejadas.

Essa abordagem baseada em regras permite que muitos recursos funcionem de maneira cooperativa, um resultado melhor do que a abordagem anterior, pela qual o ponto de vista da câmera saltaria de um local para outro e nenhuma garantia poderia ser dada sobre o que o usuário visualizaria.

No entanto, às vezes, a abordagem baseada em regras é insuficiente, pois não permite nenhum controle de câmera além da expansão da região do mapa visível.

Por exemplo, ao tocar em um marcador de mapa que mostra uma área de alta demanda de passageiro, queremos exibir uma folha de inspeção mostrando uma rota para essa área, conforme ilustrado na Figura 7, abaixo.

Nessa situação, a interface de usuário do mapa é colocada em um modo temporário em que ela se concentra em um marcador específico, e não queremos que ela inclua locais não relacionados na região do mapa visível. Para esses tipos de casos, podemos solicitar o controle de câmera exclusivo do Diretor da Câmera.

Isso fornece um objeto que nos permite definir uma câmera de mapa específica, mas apenas um recurso pode ter acesso exclusivo por vez e os recursos são notificados quando eles perdem o controle da câmera.

Figura 7: Como não queremos que outros recursos interfiram ao ajudar um motorista a navegar para uma área de alta demanda, damos a esse recurso um controle de câmera exclusivo.

Como o Gerenciador de Camadas (e semelhante ao Provedor de Preenchimento), o Diretor da Câmera requer que um RIBs Interactor se lique sempre que um recurso registra uma nova regra ou solicita acesso câmera exclusivo. Dessa forma, os recursos só podem afetar a câmera do mapa enquanto estiverem ativos.

A interface básica do Diretor da Câmera permite que os recursos definam seus requisitos de câmera, onde os requisitos são definidos usando regras e/ou acesso exclusivo, conforme ilustrado abaixo:

class MapCameraDirector {
   func add(cameraRule: MapCameraRule, interactorScope: InteractorScope)
   func remove(cameraRule: MapCameraRule)

   func requestCameraControl(interactorScope: InteractorScope) -> MapCameraHandle
   func relinquishCameraControl(handle: MapCameraHandle)
}

class MapCameraRule {
   var boundingLocations: [LocationCoordinate2D] { get set }
}

class MapCameraHandle {
   var isActiveStream: Observable<Bool> { get }
   func set(camera: MapCamera, duration: TimeInterval)
}

Avançando

No novo aplicativo do motorista, eliminamos a capacidade de recursos individuais controlarem diretamente o mapa, o que pode levar a conflitos e elementos visuais perdidos, como observamos no passado.

Com o novo framework, os recursos acessam o mapa com mais segurança por meio da mediação do Gerenciador de Camadas, do Provedor de Preenchimento e do Diretor da Câmera.

Também aproveitamos a arquitetura RIBs, que requer que os engenheiros escrevam recursos de maneira que, através do uso de interações e da vinculação de controladores de exibição, não deixem elementos visuais no mapa quando não estiverem em uso.

Fornecer esses guarda-corpos resultou em uma arquitetura de mapa mais robusta e escalável. Além disso, o desenvolvimento de recursos de mapa foi mais fácil, com menos necessidade de adicionar alternativas para outros recursos.

Em última análise, o benefício mais importante é para os nossos motoristas parceiros – com o novo aplicativo do motorista, eles terão uma experiência mais confiável, embarcando e desembarcando os passageiros.

Índice dos artigos na série dos aplicativos de motorista da Uber

Interessado em desenvolver aplicativos móveis usados ​​por milhões de pessoas todos os dias? Considere se juntar à nossa equipe com desenvolvedor Android ou iOS!

***

Este artigo é do Uber Engineering. Ele foi escrito por Chris Haugli. A tradução foi feita pela Redação iMasters com autorização. Você pode conferir o original em: https://eng.uber.com/building-a-scalable-and-reliable-map-interface-for-drivers/.