Site menu SimplestGPS 3.0 para iOS
e-mail icon
Site menu

SimplestGPS 3.0 para iOS

e-mail icon

2016.05.03

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.

O SimplestGPS virou uma espécie de animal de estimação meu. Sempre que posso dou um pouco de "alimento" a ele. E dessa forma ele vai crescendo. Considero que o app tem diversos aspectos interessantes de implementação, que valem a pena comentar. O código-fonte está no GitHub.

Da versão 2 para 3, a tela inicial minimalista foi completamente abolida em favor da "rosa-dos-ventos" inspirada em aviônicos, com animações suaves. A mesma tela inicial pode exibir mapas em formato PNG ou JPEG, transferidos ao celular via iTunes. O mapa também move-se suavemente na tela, chega a ser difícil notar. As animações a 60 quadros por segundo são realmente agradáveis.

As informações textuais ainda aparecem, na tela, porém mais discretamente. A tela de "targets", que mostrava diversas informações textuais sobre distância e azimute de pontos cadastrados, foi reduzida à função de cadastro, pois a rosa-dos-ventos mostra essas mesmas informações de forma mais bonita e legível.

Para quem nunca ouviu falar do app, ele foi construído para iOS (ainda não há versão para Android), e já escrevi antes sobre ele, no lançamento da versão 2.0.

Figura 1: Aplicativo em modo bússola, exibindo direção de movimento em vermelho, agulha verde mostrando o azimute do target

Figura 2: Aplicativo em modo mapa+bússola, mostrando o azimute do alvo Blumenau dentro da rosa-dos-ventos. O cadeado indica que o centro do mapa está travado na posição atual.

Figura 3: Aplicativo em modo mapa+bússola. A cruzeta vermelha indica a posição atual do celular, e só aparece quando o centro da tela não está travado na posição atual.

Figura 4: Aplicativo em modo mapa. A cruzeta vermelha indica a localização atual, e a cruzeta verde indica um dos targets (alvos) cadastrados. O fundo da tela indica a escala do mapa (a largura da tela equivale a 8604m).

Figura 5: Tabela de targets cadastrados. Faça pressão longa sobre um item existente para editá-lo.

Figura 6: Tela de cadastramento ou alteração de um target (alvo).

Classes do aplicativo

O diagrama a seguir mostra as classes do SimplestGPS e as relações entre elas. Há algumas classes com o sufixo "2", isso é relíquia da época em que o aplicativo foi migrado de Objective-C para Swift e as versões velha e nova da mesma classe conviviam no projeto.

Figura 7: Diagrama simplificado das classes do SimplestGPS e relacionamentos entre elas

Como quase toda a "ação" está concentrada na tela principal (MapViewController), a maioria das classes está "pendurada" nesse Controller, com destaque para MapCanvasView e todas as Views subordinadas. Há ainda muitas outras sub-Views subordinadas a MapCanvasView, mas são criadas programaticamente e pertencem a classes do UIKit padrão, e a Figura mostra apenas nossas próprias classes derivadas.

As classes CompassAnim e PositionAnim são auxiliares das Views nas animações de tela, que discutirei em detalhes mais à frente.

O aplicativo possui dois Models. A classe GPSModel2 é responsável pela localização em si, e conversões de unidade de medida. A classe MapModel é responsável por carregar mapas conforme o zoom da tela e a localização atual: por diversos motivos é a classe mais complexa do app.

As transições "Segue" são definidas no Storyboard, que é a ferramenta de desenho de telas do XCode. Os botões são conectados diretamente com a tela seguinte, ou a anterior, que devem abrir.

Figura 8: Conexão de um botão com um Segue no Storyboard do Xcode

Os Controllers apenas reagem às transições; por exemplo o TargetsViewController2 prepara o terreno para a edição do "target" selecionado pelo usuário, mas a edição em si acontece em TargetViewController2 (no singular). Essa parte do SimplestGPS (edição de targets) é extremamente burocrática, e não há muito que falar sobre ela.

Views empilhadas

A tela principal do SimplestGPS faz uso pesado de transparências, e de posicionamento de alguns elementos "por trás" de outros. O mapa fica atrás da bússola, as agulhas da bússola ficam por trás dos números de azimute, e assim por diante.

No UIKit, as regras são simples e claras. Primeira regra: as subviews adicionadas por último são "pintadas" por cima das irmãs. Não existe uma "coordenada Z" explícita como no CSS. Segunda regra: a visibilidade das subviews subordina-se à visibiidade da mãe. Suponha uma View principal, que contenha duas subviews A e B, que por sua vez contenham subsubviews A', A'', ... e B' e B'', etc. Se B é pintada na frente de A, isto implica que A', A'', etc. serão sempre pintadas atrás do que houver em B.

Esse modus operandi fica mais evidente quando é preciso adicionar ou remover subviews de forma dinâmica, que este app faz o tempo todo com "targets" e mapas.

Como os mapas sempre ficam por trás dos demais elementos de tela, foi mais fácil criar uma subview intermediária (map_plane) onde os mapas são "pendurados" como subsubviews. Como map_plane é a primeira subview adicionada à MapCanvasView, ela fica no fundo da tela, e o mesmo acontece com todos os mapas subordinados a ela.

Um outro complicador é que os mapas podem cobrir áreas variadas; os mapas mais "detalhados" deveriam ser pintados à frente dos menos detalhados. O método MapCanvasView.send_img() toma o cuidado de inserir novos mapas "por trás" dos mapas com prioridade maior que já estão na tela. Outra opção, utilizada antes porém menos otimizada, é simplesmente destruir todas as subviews e recriá-las quando for necessário alterar a ordem de pintura.

Coordenadas em UIView

As coordenadas de elementos de UI são expressas em "pontos", não em "pixels". O ponto (0,0) está no canto esquerdo superior. A começar pelo iPhone 4S Retina, a resolução física dos dispositivos iOS é um múltiplo da resolução em pontos, e esse múltiplo nem sequer é inteiro no iPhone 6+/6S+.

Na prática o desenvolvedor pode calcular pontos como se fossem pixels. A única grande diferença é que as coordenadas são expressas em números de ponto flutuante, e um posicionamento fracionário tem efeito prático na tela. Números inteiros devem ser evitados. O sistema não só tem pixels de sobra para suavizar posições fracionárias, como faz antialiasing.

Quando uma View — qualquer View, seja a principal ou subordinada — é criada, ela pede o parâmetro frame, que define o tamanho da View e sua localização dentro da View-mãe. As coordenadas são expressas no espaço da mãe. Por exemplo, um frame com x=50 e y=0 posiciona a view dentro da superview com uma margem esquerda de 50 pontos, e encostada na borda superior da superview. Essas coordenadas não são coordenadas absolutas de tela. Apenas a View de nível mais alto possui coordenadas de frame coincidentes com as dimensões da tela.

O tamanho da View pode ser manipulada facilmente, a qualquer tempo, alterando-se as propriedades frame, bounds e center. Frame especifica o tamanho e local ocupado pela view; bounds contém apenas o tamanho, e center contém apenas a localização do ponto central dentro da superview. Mudar uma propriedade altera as outras duas, então num primeiro momento parece ser uma questão de gosto atuar sobre uma ou outra.

Figura 9: Principais medidas de uma UIView em relação a sua Superview

Mas o correto é manipular bounds e center, porque frame é afetado de forma independente em caso de transformações. Se a View ocupa uma área retangular maior quando rotacionada, o frame reflete essa área, porém center e bounds preservam seu significado original. Falarei mais sobre transformações mais adiante.

Figura 10: Alteração do frame de uma UIView rotacionada

Não fizemos uso de Layers no SimplestGPS, apesar de ser recomendável. Uma View (descendente da classe UIView) é um elemento visual capaz de interagir com o usuário: receber toques, detectar gestos, etc. Um Layer (descendente da classe CALayer) é um elemento visual puro que não pode interagir.

Toda View possui um Layer (propriedade UIView.layer), e pode-se adicionar sublayers a um Layer, de forma análoga a uma View que pode possuir subviews. Na verdade, existem duas hierarquias paralelas no sistema visual do iOS: uma de Views, e outra de Layers. A hierarquia de Views define que elemento recebe toques e gestos, enquanto a hierarquia de Layers (que é automaticamente mantida se o aplicativo só usa Views) define a ordem de "pintura" na tela.

No caso do SimplestGPS, a forma mais correta de implementar MapCanvasView seria usar Layers para tudo: bússola, mapas, pontos, textos, etc. porque nenhum desses elementos interage individualmente com o usuário. Apenas a View de nível mais alto, que cobre a tela inteira, recebe gestos.

Usar subviews para elementos passivos da tela é "errado" mas não causa problemas de performance. Quem realmente "pinta" a tela é a Layer. O que causa problemas de performance é o excesso de Layers grandes, transparentes e sobrepostas — não importa se essas Layers estão individualmente associadas a Views. Para aplicações mais exigentes, como jogos, o jeito é usar GLKit, OpenGL ou Metal.

Performance de tela e uso de GPU

No tempo da onça, até meados dos anos 2000, o que se via na tela era resultado direto do conteúdo do framebuffer, que não era nada mais que uma memória RAM dedicada ao vídeo. Para atualizar o conteúdo da tela, a CPU escrevia diretamente nessa memória. Nessa época, o coração de uma View, fosse qual fosse a plataforma, era um método denominado paint(), draw() ou similar. A cada minúscula mudança da View, esse método era chamado para redesenhar a View, e esse desenho era transferido para o framebuffer.

A grande desvantagem disso era o consumo de CPU, que precisava trabalhar muito para manter a tela atualizada. (E nessa época não era comum um computador ter múltiplos cores como é hoje.) Por outro lado, era uma arquitetura muito simples e fácil de entender.

De uns dez anos para cá, o conteúdo da tela é gerado pela GPU, um processador dedicado à tarefa, que tem sua própria memória. Isso é positivo sob inúmeros aspectos, mas há alguns aspectos "novos" que o desenvolvedor tem de considerar. Algumas operações que têm custo fixo num framebuffer, têm custo bem diferente com GPU.

Por exemplo, "pintar" uma nova imagem custa muito caro, pois é uma operação realizada pela CPU, e a transferência do resultado para a memória da GPU, na forma de textura, também custa caro. O custo total é provavelmente maior que no framebuffer. (A opção seria usar OpenGL ES ou Metal para "pintar" a mesma imagem diretamente na GPU, que é o modus operandi de jogos em geral.)

Por outro lado, transformar essa imagem ou textura é extremamente barato, pois basta especificar a operação matemática a ser executada sobre ela. As GPUs são especificamente otimizadas para isso. Por "transformação" entenda: mover, escalonar, rotacionar, esticar, achatar, espelhar, entortar, esconder...

Traduzindo tudo isso para o iOS, é possível desenhar uma View do zero, implementando o método UIView.drawRect(). O SimplestGPS desenha a bússola dessa forma. Porém esse desenho é feito o mais raramente possível. O "giro" da bússola, das agulhas, etc. é realizado usando transformações. (Sim, eu tentei fazer o giro "repintando" completamente a View, e a lentidão é flagrante.)

O outro "problema" da GPU é ser um processador independente. Monitorar o uso de CPU não é suficiente, principalmente se o visual do seu app é algo diferente do trivial. É curioso constatar que seu aplicativo usa 0% de CPU, o celular permanece rápido e responsivo, mas por algum motivo obscuro o celular esquenta e gasta bateria loucamente...

O iOS tem a excelente ferramenta Instruments que permite monitorar todos os aspectos do funcionamento do aplicativo: CPU, uso de memória, I/O, e... GPU. Isto permitiu constatar que versões não-otimizadas do SimplestGPS rodavam a GPU a quase 100%.

Figura 11: Instruments monitorando o uso de GPU do aplicativo

Do ponto de vista da GPU, um app baseado em Views ou Layers (como é o SimplestGPS) não passa de uma pilha de texturas. Se as texturas não são modificadas freqüentemente (como em geral não são) o fardo mais pesado para a GPU é a sobreposição de diversas texturas semi-transparentes grandes.

É fácil cometer esse erro, porque nada indica que há problemas: o consumo de CPU não aumenta e a responsividade não é afetada. Além do mais, elementos transparentes são, literalmente, invisíveis: empilhar 30 Views totalmente transparentes sobre os elementos visíveis não tem qualquer conseqüência na tela, mas gera enorme consumo de GPU (e bateria).

O SimplestGPS "abusa" de transparências, eu pessoalmente sou meio brega e gosto delas. Mas fiz o possível para otimizar seu uso, reduzindo muito a carga da GPU:

1) Reduzir o tamanho das Views é muito vantajoso. Por exemplo, as agulhas da bússola tinham tamanho equivalente ao da bússola para facilitar as rotações. Fazê-las no tamanho estritamente suficiente economizou muito esforço à GPU.

2) Views (ou Layers) garantidamente opacas devem ser marcadas como tal, fazendo UIView.opaque = true. Assim a GPU "sabe" que texturas "por trás" dessa View não precisam ser consideradas. No caso do SimplestGPS, os mapas são considerados opacos.

3) A View principal MapCanvasView tinha fundo cinza-escuro e os mapas eram adicionados a uma subview transparente (MapCanvasView.map_plane) que cobria a tela inteira. Tornar map_plane opaco, com a mesma cor de fundo, economizou uma camada de transparência que afetava toda a tela e o celular "esfriou" muito só com essa mudança.

Transformações

Conforme dito antes, é muito barato fazer transformações sobre uma textura que já esteja na memória da GPU. Isso não é novidade para quem desenvolve jogos e lida com OpenGL ou similar. Mas é algo menos óbvio para desenvolvedores de sistemas com UI convencional.

Hoje em dia, seria difícil encontrar um computador sem GPU. Sendo assim, as transformações estão disponíveis em toda plataforma gráfica, inclusive CSS do HTML. Vale a pena tirar um tempinho para compreendê-las. O custo de usar uma transformação é praticamente zero, porque do ponto de vista da GPU a sua View é apenas uma textura adimensional, e já existe uma matriz de transformação (criada pela plataforma) que faz a textura aparecer no lugar esperado. "Aplicar uma transformação" apenas muda os valores dessa matriz preexistente.

Mesmo que você não pretenda fazer coisas girarem na tela, as transformações são uma "mão na roda" porque elas permitem manipular a aparência de um elemento sem mexer na sua implementação. Por exemplo, acontece às vezes de elementos implementados por terceiros terem tamanho fixo em pontos. Isso já me aconteceu em Android, em iOS, e também na Web. O único jeito de modificar o tamanho é aplicar uma transformada.

No caso do iOS, quando se aplica uma transformada a uma UIView, o pivô da transformada é o centro da View. Se tudo que você quer fazer é girar um elemento da tela em torno do seu centro, uma linha de código resolve:

map_plane!.transform = CGAffineTransformMakeRotation(_current_heading)

A linha acima gira os mapas conforme a direção de movimento detectada pelo GPS. A função gera uma matriz, mas você nem precisa tomar conhecimento disso... O único detalhe é que o argumento deve ser em radianos. O giro acima funciona porque o centro de map_plane coincide com o centro da tela, e queremos justamente que tudo gire em torno do centro da tela.

Por outro lado, se você quer que a View gire em torno de um outro eixo, é preciso mover ou transladar a View para esse eixo; girá-la; e transladá-la de volta para a posição original. Por exemplo:

Figura 12: Problemática da rotação de um elemento sobre outro, cujos centros não coincidem

Figura 13: Rotacionando um elemento sobre outro, usando translações para que tenham o mesmo centro virtual

if (xlate == nil) {
	self.xlate = CGPoint(x: pivot.x - view.center.x,
			     y: pivot.y - view.center.y)
}

var transform = CGAffineTransformMakeTranslation(xlate!.x, xlate!.y)
transform = CGAffineTransformRotate(transform,
				current * CGFloat(M_PI / 180.0))
transform = CGAffineTransformTranslate(transform,
				-xlate!.x, -xlate!.y)
view.transform = transform

O código acima gira os elementos da bússola, como os indicadores dos "targets". Eles devem girar em torno do centro da bússola (especificado em pivot), porém seu tamanho é muito menor, então seu centro (view.center) não coincide com o centro da bússola (pivot). O ponto xlate é meramente a diferença entre os dois centros.

Note que a propriedade view.transform é alimentada com apenas uma matriz. A beleza das transformações é que elas podem ser combinadas (por multiplicação de matrizes) e o custo de processamento da matriz final na GPU é sempre fixo.

Um detalhe com que briguei o tempo todo, certamente por inexperiência, foi a orientação do plano cartesiano. Na matemática, o ângulo "cresce" no sentido anti-horário e Y é positivo para cima. Nos exemplos de transformada acima, as translações parecem seguir a convenção matemática, porém a rotação é horária (congruente com UIView onde Y aumenta para baixo). Se você misturar transformações com cálculos manuais de rotação, mais fácil ainda cometer erros.

Uma transformação de posição (ou a concatenação de transformações) é expressa como uma matriz 3x3. Existem inúmeros materiais na Internet que explicam a matemática subjacente, que não vou repetir aqui.

Outra espécie de transformação é a que modifica a própria imagem. Por exemplo uma View com efeito "translúcido", que mostra uma versão borrada do que há embaixo dela. Esse tipo de transformação não é uma simples matriz. A forma mais eficiente de fazer isso é escrever um trecho de código GLSL a ser executado na própria GPU. O framework GPUImage é a opção mais conhecida para fazer esse tipo de coisa em iOS.

Animação

A forma mais eficiente de fazer animações no iOS é usar os recursos nativos. Eles são executados fora da thread principal e com alta prioridade. As animações nativas são tão eficientes que, por padrão, toda alteração feita diretamente sobre um Layer é automaticamente animada!

Porém os recursos nativos atendem melhor a animações com tempo determinado. No caso do SimplestGPS, as animações estão, potencialmente, correndo o tempo todo, sem nunca terminarem. Além do mais, pareceu-me mais fácil (e divertido) implementar a lógica das animações, em vez de tentar a todo custo adaptar as necessidades do app à API padrão de animação.

Assim sendo, implementei as animações e, no início, executava-as periodicamente utilizando um prosaico NSTimer. Essa abordagem não fica muito suave, mesmo fazendo 60 atualizações por segundo (60fps é o "santo Graal" de uma animação suave). A causa disso é a falta de sincronismo entre atualização de tela e atualização dos elementos.

A solução não poderia ser mais simples: a classe CADisplayLink oferece um timer especial, de alta prioridade e sincronizado com a atualização da tela. Usá-lo no lugar de um NSTimer melhora a animação da água para o vinho, fica praticamente tão boa quanto as animações nativas. Os detalhes de uso podem ser vistos na classe MapCanvasView.

Não é difícil imaginar que o código invocado 60 vezes por segundo por CADisplayLink deve ser o mais rápido possível. Do contrário a animação vai perder frames, e o consumo de CPU vai pra estratosfera.

No caso do SimplestGPS, a animação é deixada a cargo de duas classes específicas — CompassAnim e PositionAnim. Elas animam os elementos rotativos da bússola e os deslocamentos dos mapas, respectivamente. Em modo "Heading", o mapa também gira mas ele simplesmente segue a posição da bússola.

O ponto é que essas classes executam o mínimo de trabalho possível no método anim(), invocado periodicamente. Usando uma metáfora futebolística bem ao gosto do nosso ex-presidiário, digo, ex-presidente, a bola é deixada o mais redonda possível no curso normal do programa.

No próprio instanciamento de CADisplayLink é possível especificar que frames devem ser "pulados", ou seja, animar a cada 2 frames (30fps), 3 (20fps), 4 (15fps). Isto pode ser uma solução quando a animação é pesada demais. Mas é a última coisa a ser tentada, porque animações abaixo de 60fps perdem a suavidade e ficam sem graça. Para quem tiver a curiosidade de saber por que uma animação de computador precisa de 60fps enquanto um filme consegue ser suave a 24fps, dê uma olhada no segundo item deste artigo.

Aspectos de animação

Um aspecto da animação que certamente você enfrentará se desenvolver algo parecido com este app, é a necessidade de executar algum código simultaneamente com a animação — nem antes nem depois.

Por exemplo, você quer animar uma View do ponto A ao ponto B, mas no momento ela está no ponto C, embora escondida (UIView.hidden = true). É preciso fazê-la aparecer, porém só depois dela ser movida para o ponto A. Se ela for habilitada antes da animação ter chance de começar, a animação vai dar um pequeno "salto" do ponto C para o ponto A. Vai ser rápido, mas será perceptível e incômodo.

Desta forma, minhas classes de animação aceitam um bloco de código opcional, que é armazenado e executado:

location_anim!.set_rel(pointrel, block: {
         self.location_view!.hidden = (self.mode == self.MODE_COMPASS
                                   || self.mode == self.MODE_HEADING)
})

O código acima informa à animação que o ponto da localização atual deve ser movido para pointrel, e o bloco que modifica a propriedade hidden deve ser executado simultaneamente, no próximo ciclo de animação que ocorre em PositionAnim.anim().

Por exemplo, quando o aplicativo inicia, o GPS ainda não sabe a localização atual, e o pontinho vermelho fica na coordenada default (0,0). A animação "sabe" que a última posição é indefinida, e move o pontinho imediatamente para pointrel (já que não faz sentido animar a partir de uma posição indefinida). Se a propriedade hidden fosse mudada fora do bloco, o pontinho vermelho apareceria em (0,0) antes de ir para pointrel, dando um salto bem desagradável aos olhos.

Outro caso de simultaneidade necessária é o seguinte: o tamanho do mapa pode ser mudado a qualquer momento pela classe MapModel para economizar memória. Suponha um mapa de 4000x4000 pixels, cujo centro deva ficar exatamente no centro da tela. Por motivos que explicarei a seguir, a classe PositionAnim já faz a animação usando coordenadas relativas aos centros, então fazer

PositionAnim.set_rel(CGPoint(x: 0, y: 0), ...)

faz exatamente o que queremos: move o mapa para o meio da tela.

Figura 14: Posicionando um mapa na tela, cujo centro virtual coincide com o centro real e também com o centro da tela

Agora, imagine que o mapa seja cortado, e perca 1000 pixels de largura do lado esquerdo. Seu novo tamanho é 3000x4000. O centro do mapa foi deslocado 500 pontos para a direita. Para o mapa ficar "imóvel" na tela e o centro virtual permanecer em (0,0), o centro real precisa ser posicionado 500 pontos à direita. Essa mudança não pode ser animada, ela tem de ser imediata.

Figura 15: Posicionando o mesmo mapa na tela, centro virtual coincide com o centro da tela, mas o centro real foi deslocado pelo corte

Como essas translações acontecem o tempo todo, já que MapModel vive cortando a imagem aqui e ali para economizar memória, ela é prevista no próprio animador, sem precisar que seja feita no bloco de código:

PositionAnim.set_rel(CGPoint(x: 0, y: 0),
                     offset: CGPoint(x: -500, y: 0))

Faltou esclarecer porque PositionAnim anima uma coordenada relativa (i.e. considerando que 0,0 é o centro da tela) em vez da coordenada absoluta. No presente estado do SimplestGPS essa convenção não tem vantagem alguma; ela é "herança" de necessidades anteriores, que podem reaparecer no futuro.

Quando PositionAnim é instanciado pela superview da View a ser animada, recebe o parâmetro size, a partir do qual a classe calcula dois valores: supercenterx e supercentery. Esses valores serão readicionados na hora de mover a View.

Suponha agora que seja preciso rotacionar esse ponto. Por exemplo, quando o mapa gira na tela em função da direção GPS. Para o mapa aparecer correto, é preciso fazer duas operações: 1) rotacionar o mapa em torno do seu próprio eixo com transformação (essa é a parte fácil), e 2) recalcular sua posição na tela, "girando" o centro do mapa em torno do centro da tela, cálculo que tem de ser feito manualmente.

Figura 16: Cálculo correto da rotação de um objeto em torno do centro da tela. O centro do objeto deve ser girado usando o centro da tela como pivô.

No caso de um mapa em rotação, PositionAnim não deve animar o giro, porque ele é síncrono com a bússola. Apenas a movimentação relativa, que corresponde ao GPS em movimento, deve ser animada.

Temos então um problema chato, onde as coordenadas do mapa mudam o tempo todo sobre a tela, porém apenas um dos fatores que provocam essa mudança deve ser animado. Do contrário, o mapa seria movido na tela segundo "o caminho mais curto" que não pareceria nada natural.

Figura 17: Movimento real e rotação concomitantes: formas errada e correta de animar o indicador de localização sobre a tela

Como no SimplestGPS o pivô de rotação é sempre o centro da tela, a distância entre o centro da tela e o centro do mapa não é afetada por rotações. Além disso, girar uma coordenada em torno da origem (0,0) é muito mais fácil. Assim, 1) animamos a posição do mapa em relação ao centro da tela; e só no último momento 2) rotacionamos essa posição usando uma conversão cartesiana/polar/cartesiana e 3) adicionamos a posição absoluta (supercenterx,supercentery).

Esse esquema funcionava muito bem, porém um refactoring acabou movendo os mapas para a subview MapCanvasView.map_plane. Essa subview é rotacionada com todos os mapas a bordo dentro dela. Isso permitiu remover completamente a rotação de PositionAnim. Mas valeu a experiência.

Por seu turno, o animador CompassView executa uma tarefa adicional: faz a bússola ou agulha "cintilar", manipulando sua opacidade, quando sua posição é indefinida. Por exemplo, se o usuário está parado, o azimute de rumo (heading) é indefinido. A posição indefinida é sinalizada pelo valor NaN. Assim, delega-se ao animador a melhor forma de indicar esta definição ao usuário. (Testei outra forma: manter a agulha ou a bússola girando o tempo todo sem parar. Ficou bonitinho nos testes, porém era deveras incômodo em campo.)

Física em animação

Um problema bem conhecido das APIs de animação do iOS é a (falta de) continuidade, quando o destino de um objeto animado muda enquanto outra animação já está em andamento. Foi o principal motivo de ter criado classes próprias para animação.

Para as animações dos elementos da bússola (rosa-dos-ventos, agulhas, "targets"), o requisito informal era um comportamento semelhante a bússolas mecânicas, como aquelas cheias de óleo que os escoteiros usam. O movimento deve ser "líquido", suave, com inércia, sem saltos, e mesmo assim não demorar a indicar um novo rumo. Quando o rumo muda muito freqüentemente, a animação deve "suavizar" isso de modo a mostrar um rumo aproximadamente estável.

A forma óbvia, e universalmente adotada, de atingir este resultado, é simular diretamente a Física (dinâmica e cinemática) de um objeto, com alguns "atalhos" adicionados depois para melhorar a experiência geral.

Quando uma animação CompassView é criada, dois parâmetros fixos são passados: massa e atrito. O animador também controla internamente a posição atual, a posição-alvo e a velocidade corrente. Estes últimos valores são em graus, não em metros, pois a bússola só gira, não se move.

O método de animação CompassView.tick() recebe um valor dt, que representa o tempo corrido desde o último quadro de animação. O algoritmo desse método é descrito em partes:

let force = target - current
var force2 = abs(force)
if fast {
	force2 = pow(force2, 1.5)
}
let acceleration = force2 / mass * (force > 0 ? 1 : -1)

Tudo começa com "A Força", que é simplesmente a diferença entre a posição atual do objeto, e a posição em que ele deveria estar. Para obter a aceleração, a força é dividida pela massa.

Aqui já constatamos o primeiro "atalho" na simulação física, o "modo rápido" (fast) que adiciona um componente exponencial à força. O modo rápido é ativado quando o usuário altera o modo no app, caso em que é desejável que a bússola vá mais rapidamente para a nova posição. No funcionamento normal, é melhor que a bússola seja mais "preguiçosa" para evitar saltos desagradáveis.

speed -= speed * friction * dt
speed += acceleration * dt

A velocidade é reduzida pelo atrito (proporcional e sempre com o mesmo sinal da velocidade) e modificada pela aceleração (que pode ter sinal contrário). Quem é mais chegado em matemática pode encarar as fórmulas acima como equações diferenciais.

Note a multiplicação por dt que é o tempo do quadro de animação. É importante usar esse tempo em vez de um divisor fixo, pois isso garante uma animação "cinemática" mesmo quando há perda de quadros (caso em que dt é maior para cobrir o tempo perdido).

speed = max(speed, -MAX_SPEED)
speed = min(speed, MAX_SPEED)

Mais um "atalho". Uma velocidade máxima é um antídoto ao "modo rápido" que produz uma força exponencial. Por tentativa e erro, determinei que não é agradável quando a bússola muda mais rápido que 180º por segundo.

A forma mais correta talvez fosse adicionar um componente de arrasto além do atrito. Como o arrasto é proporcional ao quadrado da velocidade, conseguiria contrabalançar velocidades muito altas. Mas ficou suficientemente bom assim. (Talvez eu já tenha feito essa melhoria no código quando você estiver lendo isto.)

var effective_speed = speed
if effective_speed < 0 && effective_speed > -MIN_SPEED {
	effective_speed = -MIN_SPEED
} else if speed > 0 && effective_speed < MIN_SPEED {
	effective_speed = MIN_SPEED
}

Mais um truque: a adoção de uma velocidade mínima. Sem isso, a velocidade e o atrito tendem ambos a zero, e bússola nunca atinge o alvo. Chega cada vez mais perto, mas não atinge. E é desejável que atinja, porque aí a animação pode parar e deixar a CPU em paz.

Novamente, seria possível resolver o problema com uma simulação física mais elaborada, que levasse em conta o atrito estático. Mas o custo extra dessa melhoria não proporcionaria uma animação visualmente melhor.

Finalmente, a posição corrente é atualizada conforme a velocidade:

current += effective_speed * dt

Já a classe PositionAnim anima uma posição cartesiana, não uma posição angular.

Por um lado ela é muito mais simples que CompassAnim, porque ela não simula força nem aceleração. Os testes mostraram que não parece muito "natural" a localização ficar oscilando em torno do alvo. A implementação atual talvez pudesse ser substituída por uma animação nativa iOS, já que simplesmente move para a posição-alvo num tempo predefinido.

Por outro lado, há um complicador. A posição é bidimensional, então os cálculos de cinemática devem ser vetoriais. Por exemplo, quando o método CompassAnim.set_rel() é invocado, a velocidade de deslocamento para o alvo é calculada da seguinte forma:

let distance = hypot(target.x - current.x, target.y - current.y)
last_distance = distance
vspeed = CGVector(dx: (target.x - current.x) / SETTLE_TIME,
                  dy: (target.y - current.y) / SETTLE_TIME)

A distância até o alvo é calculada com Pitágoras (hypot()). Durante a animação, esses valores são utilizados:

current.x += vspeed.dx * dt
current.y += vspeed.dy * dt
            
let distance = hypot(target.x - current.x, target.y - current.y)
if distance > last_distance {
	// distance increasing; stop
	vspeed = CGVector(dx: 0, dy: 0)
	current = target
	last_distance = 0
} else {
	last_distance = distance
}

O animador era bem mais complexo antes do que é agora, porque todos os elementos físicos (força, aceleração, atrito) tinham de ser vetoriais. Dizendo de outra forma, cada grandeza tem a) valor e b) direção. A versão atual é tão simples que nem precisamos lidar com direções e ângulos, embora tenhamos usado a função hypot() para calcular o valor.

Como sempre, há um atalho no código acima: quando a distância começa a aumentar, significa que a animação passou do ponto, e deve ser interrompida. Isso não seria necessário se a simulação fosse física, pois a aceleração inverteria o sinal e traria o ponto de volta.

GPSModel2 e fórmulas de geodésica

O código que interfaceia com as APIs de location/GPS começou a vida dentro do Controller, causando aquele inchaço do Controller (no Android, seria a Activity) que acomete todo aplicativo. Sabe como é, a classe já está ali, foi criada automaticamente.

Quando adicionei o recurso de "targets" ao app, surgiu a necessidade de vários Controllers obterem informações do GPS, bem como armazenar os dados dos "targets", calcular a distância dos mesmos em relação à posição atual, etc. O código foi movido para sua própria classe, GPSModel2, e os Controllers registram-se como observadores do modelo quando aparecem na tela, a fim de receber atualizações.

O GPSModel2 toma conta de algumas funções burocráticas, como conversão de medidas e formatação de números. Em muitos casos os Controllers não querem saber qual a real latitude e longitude, eles só precisam do número formatadinho para mostrar na tela, e o Model atende a esse desejo, mantendo a simplicidade dos Controllers.

O GPSModel2 também implementa algumas funções de geodésica — algumas de forma correta, outras não.

A forma "certinha" usa geometria esférica. Por exemplo, o método GPSModel2,harvesine() calcula a distância entre dois pontos da Terra, e funciona mesmo se os pontos forem o Pólo Norte e o Pólo Sul. Os métodos que usam harvesine(), como por exemplo o GPSModel2.inside(), que determina se um ponto está dentro de um círculo definido por latitude, longitude e raio, são igualmente "corretos". O método GPSModel2.azimuth() determina o azimute (direção) que um ponto está em relação a outro, e também é "correto".

Outras funções são implementadas de forma "erradinha". Por exemplo, GPSModel2.map_inside() testa basicamente se uma área "retangular" sobrepõe/contém/está contida num círculo. Coloquei "retangular" entre aspas porque uma área da Terra delimitada por 2 coordenadas não é realmente retangular, é mais um trapézio com lados arredondados.

Não chega a ser um problema porque a) o erro é pequeno para áreas pequenas (o SimplestGPS nunca mostra mais que 1º de latitude na tela); b) o SimplestGPS só aceita mapas com projeção UTM no momento; e c) a projeção na tela também é UTM. A projeção UTM ("transversa de Mercator") projeta uma área delimitada por 2 coordenadas como um retângulo. Ao contrário do que Roberto Carlos cantou, nesse caso um erro conserta o outro :)

Se no futuro o SimplestGPS for melhorado a ponto de suportar mapas da região polar, há muito mais coisa a consertar; corrigir a implementação de GPSModel2.map_inside() seria o menor dos problemas. Por outro lado, nada me impede de fazer um refactoring que torne os métodos todos "certinhos" e isso não quebraria o resto do aplicativo. (Talvez eu já tenha feito isso quando você ler este texto.)

Mesmo usando essa simplificação retangular, caí várias vezes nas armadilhas da matemática intervalar e dos problemas com ponto flutuante. Quando dois retângulos coincidem exatamente e as medidas são expressas em números decimais, o risco é grande da comparação achar que são diferentes! A velha técnica, empregada em GPSModel2.contains_latitude() e contains_longitude() é adicionar um pequeno viés para garantir que retângulos coincidentes sejam detectados como tal:

class func contains_latitude(a: Double, b: Double,
                             c: Double, d: Double)
				-> Bool {
	let (_a, _b) = (min(a, b), max(a, b))
	let (_c, _d) = (min(c, d), max(c, d))
	return (_a + 0.0001) >= 
                _c && (_b - 0.0001) <= _d
}

Também vemos no método acima a tradicional escaramuça com os intervalos "fora de ordem", já que o teste intervalar presume os intervalos em ordem.

Ainda outro problema em lidar com coordenadas como se elas fossem planas, é a descontinuidade da longitude. Na "Linha de Data Internacional", a longitude muda abruptamente de +180º para -180º, para quem vai para leste. Também é fácil produzir longitudes "anormais" como +181º por acidente quando adiciona-se ou subtrai-se de uma longitude próxima de 180º.

Os métodos "certinhos" como harvesine() não têm problemas com isso porque usam funções trigonométricas. Os cossenos de 181º, -181º, 179º, -179º, 539º, etc. são todos iguais! Porém essas descontinuidades quebram os métodos "erradinhos" baseados em matemática intervalar.

Depois de testar algumas estratégias, cheguei a uma forma aceitável: lidar com longitudes na forma diferencial. O coração dessa estratégia é o método GPSModel2.longitude_minus(), que é "certinho" (179ºE − 179ºW retorna -2, porque a segunda longitude está mais a leste). Para testar intervalos de longitudes (por exemplo, a-b versus c-d), basta eleger uma das longitudes como "ponto zero" e considerar as demais como diferenças em relação à primeira:

        let da = 0.0
        let db = longitude_minusf(b, minus: a)
        let dc = longitude_minusf(c, minus: a)
        let dd = longitude_minusf(d, minus: a)

A forma realmente correta teria sido criar uma classe ou tipo, em vez de usar os tipos numéricos nativos. Isso permitiria fazer "adição" e "subtração" de forma correta e transparente; e mais importante, impediria qualquer mistura acidental de longitudes com números comuns.

MapModel e gerenciamento de mapas

Assim como o conteúdo de GPSModel2 nasceu dentro do Controller, o MapModel também nasceu dentro da classe GPSModel2, para depois ganhar vida própria. MapModel realiza apenas uma tarefa: fornece ao Controller uma lista de mapas que deve ser mostrada na tela, com suas "medidas" em latitude e longitude. Só isso; cabe ao Controller converter as medidas em coordenadas de tela, cabe à View mostrar as imagens de forma eficiente, etc.

Parece simples, mas na verdade foi a classe mais difícil e divertida de desenvolver e otimizar. Carregar os mapas na memória é simples, determinar quais mapas devem ser exibidos também é fácil uma vez que as funções geodésicas em GPSModel2 existam e funcionem. A grande questão é consumo de memória. Se a memória fosse infinita ou pelo menos abundante, seria possível manter todos os mapas na memória, e bastaria mandar para o Controller a lista dos mapas visíveis na tela. (Mandar todos os mapas para o Controller também funcionaria, pois os mapas invisíveis seriam transladados para fora da tela, mas aí o problema seria lentidão.)

Figura 18: Tela com mapas carregados, não carregados (verdes) e em carregamento (azuis).

O fato é que a memória de um dispositivo móvel ainda é pequena se comparada a um computador de mesa (um iPhone 6+ tem 1GB de RAM) e as imagens envolvidas são grandes: meus mapas cartográficos 1:50.000 têm em média 4000 pixels de lado, e o SimplestGPS pode mostrar quase 30 desses mapas no zoom máximo. Uma imagem de 4000x4000 pesa 64MB uma vez carregada; 30 delas pesariam quase 2GB — um peso proibitivo. Uma questão adicional, que não é transparente em APIs de nível mais alto mas certamente incomodaria, é a transferência de 2GB de texturas para a memória da GPU, cuja memória é tipicamente menor que a RAM principal.

Um problema adicional é que as plataformas móveis são bastante "ditatoriais" em relação à memória ocupada por um aplicativo, fechando-o assim que se comporta mal. Mas, assim como a essência de toda ditadura é a insegurança jurídica, também é difícil a um app saber antecipadamente quanta memória ele pode ocupar! O sistema manda uma notificação de "memória baixa", porém não dá nenhuma outra dica. E não raro o app é morto em seguida. Também acontece da notificação ser enviada mesmo quando o uso de memória é moderado.

No caso do SimplestGPS, a classe MapModel começa com uma estimativa de memória disponível, monitora o consumo (presumindo que a imagem ocupe 4 bytes por pixel) e atualiza a estimativa para 80% do consumo atual caso receba o sinal de "pouca memória" do iOS. Não é perfeito, mas é o melhor que pude fazer dadas as limitações descritas.

A estratégia de gerenciamento de imagens passou por inúmeras mudanças e melhorias. Na verdade ela já foi mais complicada do que é hoje. O coração é o método MapModel.get_maps(), invocado pelo Controller quando e.g. o GPS atualiza a posição atual. Segue uma descrição resumida:

Calcula o status de visibilidade dos mapas em relação à tela

Enquanto o uso de memória for maior que 25% do limite:
	Remove mapas fora da tela

Ordena os mapas segundo a prioridade de exibição

Se houver um mapa contido na tela que:
	a) esteja "recortado" muito pequeno; ou
	b) esteja reduzido demais e usando >50% da memória; ou
	c) esteja ampliado demais:
		Recarrega o mapa

Se o uso de memória for maior que 100%:
	Remove da memória o mapa de menor prioridade

Para todos os mapas visíveis na tela, começando pelo mais prioritário:
	Se não estiver carregado:
		Se o uso de memória for menor que 100%:
			Carrega mapa
		Do contrário:
			Usa uma pequena imagem vermelha, sinalizando a
			falta de memória para o usuário
	Adiciona à lista de imagens para o Controller
	Se este mapa cobre completamente a tela:
		Parar (basta esse mapa na tela)

Se lista de imagens for igual à lista anterior:
	Devolve "nulo" para o Controller saber que nada mudou
Do contrário:
	Devolve a nova lista ao Controller

As imagens são "recortadas" e escalonadas para que preencham a tela e possuam nitidez "Full HD" (1920 pixels de altura), considerando que a tela de um iPhone 6+ é Full HD. Isso garante o menor uso possível de memória, porém exige recarga quando a localização muda e/ou o usuário muda o zoom. É um cobertor curto, disputado entre gasto de CPU e consumo de memória — recarregar imagens custa muito processamento e também prejudica um pouco a usabilidade.

Originalmente, as imagens eram carregadas sempre inteiras, eram apenas reduzidas em tamanho para tratar o "pior caso", quando muitos mapas precisam aparecer na tela ao mesmo tempo e a memória escasseia. Porém, isso ainda não era suficiente, pois havia picos de uso de memória que causavam a morte do aplicativo. (Por exemplo, se a localização atual estiver perto da borda de um mapa, quatro mapas precisam ser exibidos para preencher a tela, causando um pico de 256MB.)

Na versão atual, o consumo de memória é basicamente uma constante, pois é função da resolução e tamanho da tela, não dos mapas. Considerando uma área de 1920x1920, o consumo de memória giraria sempre em torno de 16MB.

Porém o app carrega mapas suficientes para preencher uma área circular, uma vez que os mapas podem girar; e o raio é 50% maior que a tela para cobrir pequenos movimentos. E ladrilhos retangulares causam muito "desperdício" na hora de preencher uma área circular.

Considerando esses fatores, o consumo típico gira em torno de 50MB, desde que o limite de memória comporte esse valor. Se não comportar, o algoritmo descarta as imagens de menor prioridade.

Figura 19: Área circular que MapModel deve preencher com mapas de forma suficiente e eficiente

O único momento em que o tamanho bruto do mapa continua pesando é no momento da carga, pois ele precisa residir inteiro na memória, por um curto período de tempo. Para evitar que o sistema mate o app, a classe recusa-se a carregar uma imagem que faria a memória passar de 100%, mesmo que o consumo caia em seguida pelo recorte e reescalonamento. Dessa forma, o usuário tem de tomar um mínimo de cuidado com o tamanho dos mapas que transfere para o celular.

O algoritmo de controle de memória depende da prioridade dos mapas. A ordem em que eles são "pintados" na tela também depende disso. A prioridade é definida da seguinte forma:

1) Mapas mais "detalhados" têm maior prioridade. Por "detalhado" entenda-se o mapa com menor altura (em latitude). Presume-se que um mapa que cubra menos área seja mais detalhado.

2) Se dois mapas são igualmente "detalhados", aquele cujo centro esteja mais próximo do centro da tela é considerado mais relevante.

Esse critério é muito simples e trata o caso de uso típico onde há diversos mapas disponíveis, todos com o mesmo "tamanho". Também joga corretamente um mapa 1:250.000 para o fundo da tela, trazendo os mapas 1:50.000 para frente.

Figura 20: Mapas 1:50.000 sobrepondo mapa 1:250.000 que preenche uma lacuna de cobertura detalhada

O critério certamente falharia com ladrilhagens mais complexas, ou mapas de tamanhos patológicos (por exemplo, um mapa 1:50.000 com 10 pixels é obviamente menos detalhado que um mapa 1:250.000 com 10.000 pixels), mas não vi necessidade de ser tão paranóico.

Dentro da classe MapModel, a manipulação dos mapas individuais também migrou de um "espaguete" para a criação de uma classe MapDescriptor, que serve também a outras camadas do aplicativo, e com uma boa e velha máquina de estado para manter as diversas fases sob controle (e.g. evitar que o modelo tente carregar o mesmo mapa duas vezes). O descritor também controla a thread de carga e crop/redução da imagem (o único uso explícito de thread em todo o app).

e-mail icon