Animação e ajuste do iOS para eficiência

Publicados: 2022-03-11

Construir um ótimo aplicativo não é apenas sobre aparência ou funcionalidade, mas também sobre o desempenho dele. Embora as especificações de hardware dos dispositivos móveis estejam melhorando rapidamente, aplicativos com desempenho ruim, gaguejando a cada transição de tela ou rolando como uma apresentação de slides podem arruinar a experiência do usuário e se tornar um motivo de frustração. Neste artigo, veremos como medir o desempenho de um aplicativo iOS e ajustá-lo para eficiência. Para o propósito deste artigo, vamos construir um aplicativo simples com uma longa lista de imagens e textos.

Animação e ajuste do iOS para eficiência

Para fins de teste de desempenho, eu recomendaria o uso de dispositivos reais. Se você leva a sério a criação de aplicativos e otimizá-los para uma animação suave do iOS, os simuladores simplesmente não são suficientes. As simulações às vezes podem estar fora de sintonia com a realidade. Por exemplo, o simulador pode estar sendo executado no seu Mac, o que provavelmente significa que a CPU (Unidade Central de Processamento) é muito mais poderosa que a CPU do seu iPhone. Por outro lado, a GPU (Graphics Processing Unit) é tão diferente entre seu dispositivo e seu Mac que seu Mac realmente emula a GPU do dispositivo. Como resultado, as operações vinculadas à CPU tendem a ser mais rápidas em seu simulador, enquanto as operações vinculadas à GPU tendem a ser mais lentas.

Animando a 60 FPS

Um aspecto importante do desempenho percebido é garantir que suas animações sejam executadas a 60 FPS (quadros por segundo), que é a taxa de atualização da tela. Existem algumas animações baseadas em temporizador, que não discutiremos aqui. De um modo geral, se você estiver executando em algo maior que 50 FPS, seu aplicativo terá uma aparência suave e com bom desempenho. Se suas animações estiverem travadas entre 20 e 40 FPS, haverá uma gagueira perceptível e o usuário detectará uma “rugosidade” nas transições. Qualquer coisa abaixo de 20 FPS afetará severamente a usabilidade do seu aplicativo.

Antes de começarmos, provavelmente vale a pena discutir a diferença entre operações vinculadas à CPU e vinculadas à GPU. A GPU é um chip especializado otimizado para desenhar gráficos. Enquanto a CPU também pode, é muito mais lenta. É por isso que queremos descarregar o máximo de nossa renderização gráfica, o processo de geração de uma imagem de um modelo 2D ou 3D, para a GPU. Mas precisamos ter cuidado, pois quando a GPU fica sem poder de processamento, o desempenho relacionado aos gráficos degradará mesmo se a CPU estiver relativamente livre.

Core Animation é uma estrutura poderosa que lida com animação tanto dentro do seu aplicativo quanto fora dele. Ele divide o processo em 6 etapas principais:

  1. Layout: onde você organiza suas camadas e define suas propriedades, como cor e sua posição relativa

  2. Exibição: é onde as imagens de fundo são desenhadas em um contexto. Qualquer rotina que você escreveu em drawRect drawRect: ou drawLayer:inContext: é acessada aqui.

  3. Prepare: Neste estágio, Core Animation, já que está prestes a enviar contexto para o renderizador para desenhar, executa algumas tarefas necessárias, como descompactar imagens.

  4. Commit: Aqui o Core Animation envia todos esses dados para o servidor de renderização.

  5. Desserialização: As 4 etapas anteriores foram todas dentro do seu aplicativo, agora a animação está sendo processada fora do seu aplicativo, as camadas empacotadas são desserializadas em uma árvore que o servidor de renderização pode entender. Tudo é convertido em geometria OpenGL.

  6. Draw: Renderiza as formas (na verdade, triângulos).

Você deve ter adivinhado que os processos 1-4 são operações da CPU e 5-6 são operações da GPU. Na realidade, você só tem controle sobre os 2 primeiros passos. O maior assassino da GPU são as camadas semitransparentes, nas quais a GPU precisa preencher o mesmo pixel várias vezes por quadro. Além disso, qualquer desenho fora da tela (vários efeitos de camada, como sombras, máscaras, cantos arredondados ou rasterização de camada forçarão o Core Animation a desenhar fora da tela) também afetará o desempenho. Imagens muito grandes para serem processadas pela GPU serão processadas pela CPU muito mais lenta. Embora as sombras possam ser facilmente alcançadas definindo duas propriedades diretamente na camada, elas podem facilmente matar o desempenho se você tiver muitos objetos na tela com sombras. Às vezes vale a pena considerar adicionar essas sombras como imagens.

Medindo o desempenho da animação do iOS

Começaremos com um aplicativo simples com 5 imagens PNG e uma visualização de tabela. Neste aplicativo, carregaremos essencialmente 5 imagens, mas as repetiremos em 10.000 linhas. Adicionaremos sombras às imagens e aos rótulos ao lado das imagens:

 -(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; NSInteger index = (indexPath.row % [self.images count]); NSString *imageName = [self.images objectAtIndex:index]; NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; cell.customCellImageView.image = image; cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; return cell; }

As imagens são simplesmente recicladas enquanto os rótulos são sempre diferentes. O resultado é:

Ao deslizar o dedo verticalmente, é muito provável que você perceba gagueira à medida que a exibição rola. Neste ponto você pode estar pensando que o fato de estarmos carregando imagens no thread principal é o problema. Pode ser que se tivéssemos movido isso para o tópico em segundo plano, todos os nossos problemas seriam resolvidos.

Em vez de fazer suposições cegas, vamos experimentá-lo e medir o desempenho. É hora dos Instrumentos.

Para usar Instrumentos, você precisa mudar de “Executar” para “Perfil”. E você também deve estar conectado ao seu dispositivo real, nem todos os instrumentos estão disponíveis no simulador (outro motivo pelo qual você não deve otimizar o desempenho no simulador!). Usaremos principalmente os modelos “GPU Driver”, “Core Animation” e “Time Profiler”. Um fato pouco conhecido é que, em vez de parar e executar em um instrumento diferente, você pode arrastar e soltar vários instrumentos e executar vários ao mesmo tempo.

Agora que temos nossos instrumentos configurados, vamos medir. Primeiro vamos ver se realmente temos um problema com nosso FPS.

Caramba, acho que estamos conseguindo 18 FPS aqui. O carregamento de imagens do pacote no encadeamento principal é realmente tão caro e caro? Observe que a utilização do nosso renderizador está quase no limite. Assim é a nossa utilização de ladrilhos. Ambos estão acima de 95%. E isso não tem nada a ver com carregar uma imagem do pacote no encadeamento principal, então não vamos procurar soluções aqui.

Ajuste para eficiência

Existe uma propriedade chamada shouldRasterize, e as pessoas provavelmente recomendarão que você a use aqui. O que o shouldRasterize faz exatamente? Ele armazena em cache sua camada como uma imagem achatada. Todos aqueles desenhos de camadas caros precisam acontecer uma vez. Caso seu quadro mude com frequência, não há uso para um cache, pois ele precisará ser regenerado a cada vez de qualquer maneira.

Fazendo uma rápida alteração em nosso código, obtemos:

 -(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; NSInteger index = (indexPath.row % [self.images count]); NSString *imageName = [self.images objectAtIndex:index]; NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; cell.customCellImageView.image = image; cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; cell.layer.shouldRasterize = YES; cell.layer.rasterizationScale = [UIScreen mainScreen].scale; return cell; }

E medimos novamente:

Com apenas duas linhas, melhoramos nosso FPS em 2x. Estamos agora em média acima de 40 FPS. Mas ajudaria se tivéssemos movido o carregamento de imagens para um thread de segundo plano?

 -(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; NSInteger index = (indexPath.row % [self.images count]); NSString *imageName = [self.images objectAtIndex:index]; cell.tag = indexPath.row; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; dispatch_async(dispatch_get_main_queue(), ^{ if (indexPath.row == cell.tag) { cell.customCellImageView.image = image; } }); }); cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; // cell.layer.shouldRasterize = YES; // cell.layer.rasterizationScale = [UIScreen mainScreen].scale; return cell; }

Ao medir, vemos que o desempenho está em média em torno de 18 FPS:

Nada que valha a pena comemorar. Em outras palavras, não melhorou nossa taxa de quadros. Isso porque, embora entupir o thread principal seja errado, não foi nosso gargalo, a renderização foi.

Voltando ao melhor exemplo, onde tínhamos uma média acima dos 40FPS, a performance é notavelmente mais suave. Mas podemos realmente fazer melhor.

Verificando “Color Blended Layers” na Core Animation Tool, vemos:

“Color Blended Layers” é exibido na tela onde sua GPU está fazendo muita renderização. Verde indica a menor quantidade de atividade de renderização, enquanto vermelho indica a maior. Mas nós configuramos shouldRasterize para YES . Vale ressaltar que “Color Blended Layers” não é o mesmo que “Color Hits Green and Misses Red”. O último basicamente destaca as camadas rasterizadas em vermelho à medida que o cache é regenerado (uma boa ferramenta para ver se você não está usando o cache corretamente). A configuração de shouldRasterize como YES não tem efeito na renderização inicial de camadas não opacas.

Este é um ponto importante, e precisamos parar por um momento para pensar. Independentemente de shouldRasterize estar definido como YES ou não, para renderizar a estrutura precisa verificar todas as visualizações e mesclar (ou não) com base se as subvisualizações são transparentes ou opacas. Embora possa fazer sentido para o seu UILabel não ser opaco, talvez seja inútil e mate seu desempenho. Por exemplo, um UILabel transparente em um fundo branco provavelmente não tem valor. Vamos torná-lo opaco:

Isso resulta em melhor desempenho, mas nossa aparência do aplicativo mudou. Agora, como nosso rótulo e imagens são opacos, a sombra se moveu ao redor de nossa imagem. Ninguém provavelmente vai gostar dessa mudança, e se quisermos preservar a aparência original com desempenho de primeira qualidade, não estamos perdidos na esperança.

Para extrair alguns FPS extras preservando a aparência original, é importante revisitar duas de nossas fases de animação principal que negligenciamos até agora.

  1. Preparar
  2. Comprometer-se

Estes podem parecer estar completamente fora de nossas mãos, mas isso não é bem verdade. Sabemos que para uma imagem ser carregada ela precisa ser descompactada. O tempo de descompressão muda dependendo do formato da imagem. Para PNGs, a descompactação é muito mais rápida que JPEGs (embora o carregamento seja mais longo, e isso depende do tamanho da imagem também), então estávamos no caminho certo para usar PNGs, mas não estamos fazendo nada sobre o processo de descompactação, e essa descompactação está acontecendo no “ponto de desenho”! Este é o pior lugar possível onde podemos matar o tempo - no thread principal.

Existe uma maneira de forçar a descompressão. Poderíamos configurá-lo para a propriedade image de um UIImageView imediatamente. Mas isso ainda descompacta a imagem no thread principal. Existe alguma maneira melhor?

Há um. Desenhe-o em um CGContext, onde a imagem precisa ser descompactada antes de poder ser desenhada. Podemos fazer isso (usando a CPU) em um thread em segundo plano e atribuir limites conforme necessário com base no tamanho da nossa visualização de imagem. Isso otimizará nosso processo de desenho de imagem ao fazê-lo fora do encadeamento principal e nos poupará de cálculos de “preparação” desnecessários no encadeamento principal.

Já que estamos nisso, por que não adicionar sombras enquanto desenhamos a imagem? Podemos então capturar a imagem (e armazená-la em cache) como uma imagem estática e opaca. O código é o seguinte:

 - (UIImage*)generateImageFromName:(NSString*)imageName { //define a boudns for drawing CGRect imgVwBounds = CGRectMake(0, 0, 48, 48); //get the image NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; //draw in the context UIGraphicsBeginImageContextWithOptions(imgVwBounds.size, NO, 0); { //get context CGContextRef context = UIGraphicsGetCurrentContext(); //shadow CGContextSetShadowWithColor(context, CGSizeMake(0, 3.0f), 3.0f, [UIColor blackColor].CGColor); CGContextBeginTransparencyLayer (context, NULL); [image drawInRect:imgVwBounds blendMode:kCGBlendModeNormal alpha:1.0f]; CGContextSetRGBStrokeColor(context, 1.0, 1.0, 1.0, 1.0); CGContextEndTransparencyLayer(context); } image = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return image; }

E finalmente:

 -(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; // NSInteger index = (indexPath.row % [self.images count]); // NSString *imageName = [self.images objectAtIndex:index]; // // cell.tag = indexPath.row; // // dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ // NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; // UIImage *image = [UIImage imageWithContentsOfFile:filePath]; // // dispatch_async(dispatch_get_main_queue(), ^{ // if (indexPath.row == cell.tag) { // cell.customCellImageView.image = image; // } // }); // }); cell.customCellImageView.image = [self getImageByIndexPath:indexPath]; cell.customCellImageView.clipsToBounds = YES; // cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); // cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; cell.layer.shouldRasterize = YES; cell.layer.rasterizationScale = [UIScreen mainScreen].scale; return cell; }

E os resultados são:

Estamos agora com uma média acima de 55 FPS, e nossa utilização de renderização e utilização de tiler são quase metade do que eram originalmente.

Embrulhar

Caso você esteja se perguntando o que mais podemos fazer para gerar mais alguns quadros por segundo, não procure mais. UILabel usa WebKit HTML para renderizar texto. Podemos ir diretamente para CATextLayer e talvez brincar com as sombras lá também.

Você deve ter notado em nossa implementação acima, não estávamos fazendo o carregamento da imagem em um thread em segundo plano e, em vez disso, estávamos armazenando em cache. Como havia apenas 5 imagens, isso funcionou muito rápido e não pareceu afetar o desempenho geral (especialmente porque todas as 5 imagens foram carregadas na tela antes da rolagem). Mas você pode tentar mover essa lógica para um thread em segundo plano para obter um desempenho extra.

Ajustar a eficiência é a diferença entre um aplicativo de classe mundial e um amador. A otimização de desempenho, especialmente quando se trata de animação iOS, pode ser uma tarefa assustadora. Mas com a ajuda do Instruments, pode-se diagnosticar facilmente os gargalos no desempenho da animação no iOS.