Site menu Brincando com GPSzisses no iOS
e-mail icon
Site menu

Brincando com GPSzisses no iOS

e-mail icon

2016.03.26

Este artigo expressa a opinião do autor na época da sua redação. Não há qualquer garantia de exatidão, ineditismo ou atualidade nos conteúdos. É proibida a cópia na íntegra. A citação de trechos é permitida mediante referência ao autor e este sítio de origem.

Quando vou fazer meus passeios no meio do mato em busca de ruínas ou locais pouco conhecidos, quase sempre trata-se de locais sem cobertura de celular. Então faço uma pesquisa no Google Earth, anoto algumas coordenadas, imprimo um mapa topográfico do IBGE que oferece versões JPEG de média resolução para download, e uso o GPS do celular em campo. Mesmo com mapa e fuçando no Google Earth, é incrível como a orientação por terra pode ser desafiadora.

Longe de desgostar disso, eu tenho aproveitado essa dificuldade como parte da diversão do passeio. Dentro desse espírito, criei meu próprio aplicativo de GPS, o SimplestGPS para iPhone.

Figura 1: Tela principal do SimplestGPS

Ele é reconhecidamente feio, além do mais há inúmeros apps GPS gratuitos por aí. Mas o "faça você mesmo" tem sua graça. A idéia era fazer um GPS minimalista, que mostrasse apenas números num tamanho grande e fácil de ler. Com o tempo, foi irresistível adicionar mais recursos, como os "Targets", onde posso cadastrar e acompanhar a aproximação dos locais de visitação. Fazer continha de latitude e longitude estava um pouco além de ser algo "divertido", mesmo dentro do espírito DIY.

O SimplestGPS tem código-fonte aberto e é um exemplo bem didático de como usar as APIs de location do iOS. Também foi o primeiro app que portei para Swift (a versão original era em Objective-C) e faz uso de "segues" para mover-se entre telas, um recurso do Storyboard que é pra lá de bom.

Nas últimas semanas, trabalhando um pouquinho cada dia, adicionei mais um recurso: mostrar mapas na tela. Imprimir mapas é um saco, não tenho impressora colorida, e é preciso esquadro e calculadora para calcular a localização sobre o mapa com base no GPS. Embutir os mapas JPEG no app é a coisa óbvia a fazer. Tira um pouco do minimalismo original; mesmo assim o recurso foi implementado da forma mais "barebones" possível.

Figura 2: Tela de mapa do SimplestGPS. Os pontos azuis são targets cadastrados.

A implementação foi interessante por ser multidisciplinar, e pretendo compartilhar alguns detalhes.

Classes

Segue uma pequena lista de classes para ajudar num primeiro contato com o código no GitHub.

A maioria das classes tem o sufixo "2" porque são as versões Swift, que tiveram de coexistir com as versões Objective-C por certo tempo. As classes MapViewController e MapCanvasView são as que vamos discutir em mais profundidade.

MapCanvasView

Esta classe desenha o mapa na tela. Ela é a mais leve e rápida possível, e também "burra". Todos os cálculos de coordenadas ficam fora daqui.

A implementação óbvia, de que fiz uso no início, foi um override do método drawRect. Neste método, pode-se literalmente pintar cores e formas diretamente na tela. No caso, pintamos as imagens dos mapas, e em seguida pequenos círculos que representam a localização atual e os targets.

A API iOS torna isso muito fácil. Por exemplo, para pintar uma imagem, basta chamar imagem.drawInRect(), passando um CGRect com as coordenadas. Se queremos pintar a imagem de forma ampliada, basta passar coordenadas que extrapolam o tamanho da tela. Mesmo passando um retângulo muito maior que a tela, de modo a obter uma enorme ampliação, não há perda de performance, em relação ao caso "normal" onde toda a imagem aparece na tela.

As coordenadas do retângulo CGRect são simples o suficiente: o zero é o canto esquerdo superior. Os valores são expressos em CGFloat (ponto flutuante) porque a resolução é expressa em coordenadas lógicas. (A resolução física dos iPhones modernos é bem maior que a resolução lógica.)

Figura 3: Tela de mapa do SimplestGPS, ampliação máxima de uma carta 1:50.000. Se a carta fosse impressa com essa ampliação, teria mais de 10 metros quadrados.

Apesar da facilidade de uso e do fato de drawRect ser chamado apenas quando realmente é preciso mudar algo na tela, a performance não é grande coisa, principalmente quando diversas imagens precisam ser pintadas. Isso acontece porque a imagem reside na memória da CPU, e a cada "pintura" ela tem de ser transferida para a memória da GPU.

Sendo assim, mudamos a implementação, fazendo com que cada elemento (imagem, bolinha) seja uma subview criada em código, em vez de pintada diretamente. As imagens são carregadas em objetos UIImageView. De forma análoga ao drawRect, basta atualizar a propriedade frame de cada objeto para mudar a ampliação da mesma na tela.

O ganho de performance ocorre porque os objetos UIImageView permanecem na memória; apenas seus frame's são atualizados. Desta forma, as respectivas imagens já residem na GPU, e a simples manipulação do frame é muito rápida. A mesma técnica deve funcionar igualmente bem em outras plataformas, como Android.

Quão frequentemente drawRect é chamado? Pelo que vi, apenas uma vez quando o aplicativo é aberto, e enquanto ficar em uso. Se você abrir outro app, pode ou não acontecer do método ser chamado novamente quando voltar. Provavelmente as "pinturas" das apps inativos vão sendo descartadas para liberar memória conforme a necessidade.

Nos tempos antigos, nada disso faria diferença porque a CPU escrevia diretamente na memória de vídeo. Sou velho o suficiente para ter feito isso já, usando assembler, para tirar o último suco de um 8088 desenhando na tela.

MapViewController

Este controller lida com os eventos da tela de mapa (zoom, centragem, arrasto) e também com as transformações de coordenadas GPS para coordenadas da tela, que finalmente são alimentadas para o objeto MapCanvasView.

Não tenho experiência com desenvolvimento de jogos, nem com geometria esférica, então certamente meus esforços vão parecer pedestres para alguém mais entendido do assunto. Não usei matrizes, que são o pão-com-manteiga das transformações lineares. Fui me baseando no senso comum e em testes conforme desenvolvia. Minha abordagem foi usar coordenadas GPS (latitude e longitude) para quase todos os cálculos, convertendo para coordenadas de tela apenas no último instante, na hora de mandá-las para MapCanvasView.

O método repaint() é o "coração" do controller. Ele é chamado caso haja qualquer mudança de localização, ou em caso de intervenção do usuário, que pressionou botão ou arrastou a tela. A primeira coisa que o método faz é definir um retângulo de latitude e longitude que aparecerá na tela, com base no centro da tela (clat e clong) e no fator de zoom.

A faixa de longitudes do retângulo da tela leva em conta a proporção da tela no modo retrato, e também a latitude em clat, de modo que as distâncias estejam em escala correta sobre a tela. 1º de latitude é sempre equivalente a uma milha náutica (1852m), porém 1º de longitude muda conforme a latitude: uma milha no Equador, zero nos pólos. A proporção é data pelo cosseno da latitude: aqui onde moro, a latitude é -26º, portanto 1º de longitude nessa região equivale a aproximadamente 1665m (1852×cos(-26) = 1852×0.8987).

Com base nesse retângulo, todos os elementos (mapas, targets, localização atual) são testados. Os que estiverem "dentro" do retângulo são repassados para MapCanvasView a fim de serem desenhados. Quem estiver "fora" é suprimido. Conforme dito antes, esses testes são feitos sempre com base em latitude e longitude.

Figura 4: Tela de mapa do SimplestGPS, com zoom out quase no máximo. Duas cartas de 1:50.000 e uma de 1:250.000 são pintadas na tela. A carta 1:250.000 é corretamente pintada por baixo das demais, aparecendo apenas nas áreas que não possuem cartas 1:50.000.

Os valores clat e clong podem conter a localização atual (que muda o tempo todo conforme você se desloca), ou então uma localização arbitrária fixa, o que acontece quando o usuário arrasta o mapa (o que sinaliza que ele queria ver uma parte diferente do mapa, que deve ficar parada na tela). Essa lógica é implementada pelos métodos recenter() e do_centerme(), este último invocado quando o botão "Center me" é pressionado.

Essa lógica é pedestre porque ela não funciona para os pólos, onde os meridianos convergem. Para definir corretamente um retângulo sobre a Terra, seria preciso quatro coordenadas em vez de duas. Mas como eu não pretendo usar o SimplestGPS na Antártida, não é um problema premente. O aplicativo também presume que os mapas seguem a projeção de Mercator e possuem uma largura fixa em longitude, e novamente um mapa de região polar não poderia seguir este formato.

Nesse mesmo espírito, os métodos ins() e iins() presumem que "a Terra é plana", e testam respectivamente se um ponto ou um mapa estão dentro do retângulo da tela, usando testes simples de matemática intervalar.

Mesmo com esta simplificação, há um problema com a longitude, relacionada à linha internacional de data (longitude 180). Nessa região, a longitude muda de sinal. De leste para oeste: -178, -179, -180/+180, +179, +178... Para a matemática intervalar não desandar, lanço mão de um truque. Se o intervalo de longitudes estiver mais "próximo" da longitude 180 que da longitude 0 (método nearer_180) as longitudes negativas são convertidas para positivas: 182, 181, 180, 179, 178... usando o método offset_180(). É possível resolver o problema desta forma porque os intervalos de longitude (da tela ou dos mapas) são sempre menores que 180 graus, e nunca englobam as longitudes 0 e 180 ao mesmo tempo.

Desnecessário dizer que, se usássemos geometria esférica, os problemas acima não aconteceriam.

Outro problema relacionado a longitude é que, dependendo do cálculo, podemos acabar com longitudes "desnaturadas", ou seja, maiores que 180 ou menores que -180. As longitudes são "renormalizadas" pelo método normalize_longitude() em todas as situações em que isso é necessário.

Cenas dos próximos capítulos

A brincadeira com "pintura manual" de Views encorajou a implementar uma rosa-dos-ventos, inspirada nos aviônicos. A futura versão 3.0 do SimplestGPS deve abolir completamente a tela textual em favor do novo visual. As informações textuais continuarão a ser exibidas, mas de forma mais discreta.

Figura 5: Rosa-dos-ventos da próxima versão do SimplestGPS

A implementação de bússola e agulhas que parecem reais, com inércia, oscilação, etc. custou inúmeras visitas ao Stack Overflow e muita experimentação, mas o resultado final ficou bem interessante, principalmente se levar em conta o reduzido número de horas investido.

Gosto muito de aviônicos (instrumentos de painel de avião) porque o "UI/UX" dos mesmos é estudado há quase um século, para garantir segurança em vôo e sucesso em batalha, devendo ser efetivo mesmo quando o piloto está semi-consciente ou ferido. Cada instrumento é pensado para manter o piloto informado bastando uma rápida olhadela.

Nesse espírito, a rosa-dos-ventos foi projetada para mostrar rapidamente a direção de movimento (agulha vermelha), direção do alvo selecionado (agulha verde), direção dos demais alvos (em verde, letras pequenas), etc. A interface foi simplificada para 4 botões, um em cada canto da tela. O "botão" MOD seleciona o modo da rosa-dos-ventos (norte verdadeiro ou rotativa), modo do mapa (exibido, oculto ou exclusivo). Os botões TGT e TGD selecionam o alvo e o nível de detalhe dos alvos secundários. O botão PIN abre a lista de alvos ("pin" porque uma ação comum é anotar a localização atual como novo alvo, como se fosse um "pin" no mapa).

Figura 6: Rosa-dos-ventos da próxima versão do SimplestGPS

A implementação seguiu e estendeu as técnicas utilizadas na versão 2.0. Cada elemento da rosa-dos-ventos é uma sub-View transparente. Todas as Views estão na tela o tempo todo; de acordo com o modo, umas ou outras são escondidas ou reveladas setando a propriedade hidden.

Muitos elementos são desenhados dentro de drawRect com linhas, pontos, círculos, etc. Porém são criados na posição 0º, e dali em diante são apenas rotacionados setando-se a propriedade transform. Desta forma, o método drawRect, que é "caro", é invocado muito raramente. As transformações são executadas pela GPU, o que permite inclusive uma animação suave com pouco gasto de CPU.

A animação original fazia uso de um prosaico NSTimer, porém existe um timer específico para esse fim: CADisplayLink, que é sincronizado com a atualização da tela. É possível que o uso de UIKit Dynamics proporcionasse resultados ainda melhores e/ou com menos trabalho, já que esta última API implementa animações físicas. Vide este exemplo que simula um bloco de gelatina.

Ainda há muito espaço para melhoria de performance. A principal "falha" é usar UIView para cada elemento da bússola, quando poderia usar CALayer. Os "layers" são os objetos que de fato aparecem na tela; cada View tem o seu. O que UIView implementa a mais é a interação com o usário, reconhecimento de gestos, etc. Elementos visuais que não interagem individualmente com o usuário podem ser todos CALayer. Mas, para um aplicativo despretensioso, o UIKit baunilha provou ser muito competente, justificando a fama da Maçã.

e-mail icon