Oi Meu nome é Sasha, sou desenvolvedor iOS da equipe que cria o feed do VKontakte. Agora, mostrarei como otimizamos a exibição da interface e contornamos os problemas associados a isso.
Eu acho que você pode imaginar o que é fita VK. Esta é uma tela na qual você pode ver uma variedade de conteúdo: textos, imagens estáticas, gifs animados, elementos incorporados (vídeo e música). Tudo isso deve ser exibido sem problemas, daí as altas demandas no desempenho das soluções.
Agora vamos ver quais abordagens padrão para trabalhar com mapeamentos existem e quais limitações ou vantagens devem ser levadas em consideração.
Se você gosta de ouvir mais do que ler, a gravação em vídeo do relatório está aqui .

Conteúdo
- Descrição e cálculo do layout
1.1 Layout automático
1.2 Cálculo de frame
manual - Cálculo do tamanho do texto
2.1 Métodos padrão para calcular o tamanho de UILabel
/ UITextView
/ UITextField
2.2 Métodos NSAttributedString
/ NSString
2.3 Textkit
2.4 Coretext - Como o feed do VKontakte funciona?
- Como obter melhor desempenho
4.1 Por que problemas de desempenho
4.2 CATransaction.commit
4.3 Pipeline de renderização
4.4 Os lugares mais vulneráveis ao desempenho - Ferramentas de medição
5.1 Rastreio do sistema de metal
5.2 Corrigimos rebaixamentos de desempenho no código enquanto o aplicativo está sendo executado
- Como pesquisar problemas. Recomendações
- Conclusão
- Fontes de informação
1. Descrição e cálculo do layout
Primeiro, vamos relembrar como criar uma estrutura de interface visual ( layout ) usando ferramentas regulares. Para economizar espaço, ficaremos sem listagens - simplesmente listarei as soluções e explicarei seus recursos.
1.1 Layout automático
Talvez a maneira mais popular de criar uma interface no iOS seja usar o sistema de layout Auto Layout da Apple. É baseado no algoritmo Cassowary , indissociavelmente ligado ao conceito de restrições.
Por enquanto, lembre-se de que a interface implementada usando o Layout automático se baseia em restrições.
Características da abordagem:
- O sistema de restrição é transformado em um problema de programação linear .
- Cassowary resolve o problema de otimização resultante usando o método simplex . Este método possui complexidade assintótica exponencial. O que isso significa? À medida que o número de restrições no layout aumenta, na pior das hipóteses, os cálculos podem diminuir exponencialmente.
- Os valores de
frame
resultantes para o UIView
são a solução para o problema de otimização correspondente.
Benefícios do uso do Layout Automático:
- Em mapeamentos simples, é possível complexidade computacional linear .
- Ele se dá bem com todos os elementos padrão, pois é a tecnologia "nativa" da Apple.
UIView
funciona com o UIView
.- Disponível no Interface Builder, que permite descrever o layout em um Storyboard ou XIB.
- Garante uma solução atualizada, mesmo durante a transição. Isso significa que o valor do
frame
de cada UIView
sempre (!) Uma solução para a tarefa de layout real.
Os recursos do sistema são suficientes para a maioria dos monitores. Mas não é adequado para criar fitas com uma quantidade enorme de conteúdo heterogêneo. Porque
É importante lembrar que o Layout automático:
- Funciona apenas no segmento principal . Suponha que os engenheiros da Apple tenham escolhido o Mainstream como o ponto de sincronização da solução Auto Layout e os valores de quadro de todos os
UIView
. Sem isso, você teria que calcular o Layout automático em um segmento separado e sincronizar constantemente os valores com o segmento Principal. - Ele pode trabalhar lentamente em representações complexas , pois é baseado em um algoritmo de força bruta cuja complexidade, no pior caso, é exponencial.
- Disponível com iOS 6.0 . Agora, isso dificilmente é um problema, mas vale a pena considerar.
Conclusão: usando o Layout automático, é conveniente criar exibições sem ou com coleções, mas sem relações complexas entre elementos.
1.2 Cálculo de frame
manual
A essência da abordagem: calculamos todos os valores de frame
. Por exemplo, implementamos os métodos layoutSubviews
, sizeThatFits
. Ou seja, em layoutSubviews
mesmos organizamos todos os elementos filho, em sizeThatFits
calculamos o tamanho correspondente ao local desejado dos elementos e conteúdo filho.
O que isso dá? Podemos transferir cálculos complexos para o fluxo de segundo plano, e cálculos relativamente simples podem ser realizados no fluxo principal.
Qual é o problema? Você deve implementar os cálculos você mesmo, é fácil cometer um erro. Você também precisa garantir que a posição dos filhos e os resultados retornados em sizeThatFits
.
A auto-avaliação é justificada se:
- Descobrimos ou prevemos que encontraremos limitações de desempenho do Layout Automático.
- o aplicativo possui uma coleção complexa e há uma boa chance de o elemento desenvolvido cair em uma de suas células;
- queremos calcular o tamanho do elemento no segmento Background;
- exibimos elementos não padronizados na tela, cujo tamanho deve ser constantemente recontado, dependendo do conteúdo ou do ambiente.

Um exemplo Desenhe dicas de ferramentas que são dimensionadas automaticamente para se ajustarem ao conteúdo. A parte mais interessante desta tarefa é como calcular o tamanho visual do texto em cada dica de ferramenta.
2. Cálculo do tamanho do texto
Esse problema pode ser resolvido de pelo menos quatro maneiras, cada uma das quais se baseia em seu próprio conjunto de métodos. E cada um tem suas próprias características e limitações.
2.1 Métodos padrão para calcular o tamanho de UILabel
/ UITextView
/ UITextField
Os sizeThatFits
(usados por padrão em sizeToFit
) e intrinsicContentSize
(usado no Layout automático) retornam o tamanho preferido do conteúdo da exibição. Por exemplo, com a ajuda deles, podemos descobrir quanto espaço o texto escrito em UILabel
.
A desvantagem é que ambos os métodos funcionam apenas no segmento Principal - eles não podem ser chamados a partir do plano de fundo.
Quando os métodos padrão são úteis?
- Se já usamos
sizeToFit
ou Auto Layout. - Quando há elementos padrão no visor, e queremos obter o tamanho deles no código.
- Para qualquer exibição sem coleções complexas.
2.2 Métodos NSAttributedString / NSString
Observe os sizeWithAttributes
boundingRect
e sizeWithAttributes
. Eu não aconselho usá-los para ler o tamanho do conteúdo de UILabel
/ UITextView
/ UITextField
. Não encontrei nas informações da documentação que os métodos NSString
e os métodos de layout dos elementos UIView
sejam baseados no mesmo código (mesmas classes). Esses dois grupos de classes pertencem a estruturas diferentes: Foundation e UIKit, respectivamente. Talvez você já tenha que ajustar o resultado boundingRect ao tamanho UILabel
? Ou você se deparou com o fato de que os NSString
não levam em consideração o tamanho dos emoticons ? Estes são os problemas que você pode obter.
Também vou lhe dizer quais classes são responsáveis pelo desenho de texto em UILabel
/ UITextView
/ UITextField
, mas, por enquanto, UITextField
retornar aos métodos.
Usar boundingRect e sizeWithAttributes vale a pena se:
drawInRect
elementos de interface não padrão usando drawInRect
, drawAtPoint
ou outros métodos da NSAttributedString
NSString
/ NSAttributedString
.- Queremos considerar o tamanho dos elementos no fluxo de segundo plano. Novamente, isso é apenas ao usar os métodos de renderização apropriados.
- Desenhe em um contexto arbitrário, por exemplo, exiba uma linha na parte superior da imagem.
2.3 Textkit
Essa ferramenta consiste nas classes padrão NLayoutManager
, NSTextStorage
e NSTextContainer
. O layout UILabel
/ UITextView
/ UITextField
também UITextField
baseado neles.
O TextKit é muito conveniente quando você precisa descrever em detalhes a localização do texto e indicar em quais formas ele fluirá :

Usando o TextKit, você pode calcular o tamanho dos elementos da interface na fila de segundo plano, bem como o frame
linhas / caracteres . Além disso, a estrutura permite desenhar glifos e alterar completamente a aparência do texto no layout existente. Tudo isso funciona no iOS 7.0 e superior.
O TextKit é útil quando você precisa:
- exibir texto com layout complexo;
- desenhar texto em imagens;
- calcular os tamanhos de substrings individuais;
- conte o número de linhas;
- use os resultados dos cálculos em um
UITextView
.
Eu enfatizo novamente. Se você precisar calcular o tamanho do UITextView
, primeiro configuramos as instâncias das NSLayoutManager
, NSTextStorage
e NSTextContainer
e passamos essas instâncias para o UITextView
correspondente , onde serão responsáveis pelo layout. Somente assim garantimos total coincidência de todos os valores.
Não use o TextKit com UILabel
e UITextField
! Para eles (diferente do UITextView
), você não pode configurar o NSLayoutManager
, NSTextStorage
e NSTextContainer
.
2.4 Coretext
Esta é a ferramenta de texto de nível mais baixo no iOS. Dá o controle máximo sobre a renderização de fontes, caracteres, linhas, recuos. E ele, como o TextKit, permite calcular parâmetros tipográficos do texto, como linha de base e tamanho do quadro de linhas individuais.
Como você sabe, quanto mais liberdade, maior a responsabilidade. E, para obter bons resultados usando o CoreText, você precisa poder usar seus métodos.
O CoreText fornece segurança de thread para operações na maioria dos objetos. Isso significa que podemos chamar seus métodos a partir de diferentes threads. Para comparação, ao usar o TextKit, você mesmo deve pensar na sequência de chamadas de método.
O CoreText deve ser usado se:
- É necessária uma API de baixo nível extremamente simples para acesso direto aos parâmetros de texto. Devo dizer imediatamente que, para a grande maioria das tarefas, os recursos do TextKit são suficientes.
- Há muito trabalho a fazer com linhas individuais (
CTLine
) e caracteres / elementos. - O suporte é importante no iOS 6.0.
Para o feed do VKontakte, usamos o CoreText. Porque No momento em que implementamos as funções básicas de trabalhar com texto, o TextKit ainda não estava lá.
3. Como o feed do VKontakte funciona?
Brevemente sobre como recebemos dados do servidor, layout de formulário e displays.

Primeiro, considere as tarefas executadas na fila de segundo plano. Recebemos dados do servidor, processamos e descrevemos declarativamente a exibição subsequente. Nesse estágio, ainda não temos instâncias do UIView
, apenas definimos as regras e a estrutura da interface futura com nossa ferramenta declarativa, um pouco semelhante ao SwiftUI . Para calcular o layout, calculamos o frame
inteiro levando em consideração as restrições atuais, por exemplo, a largura da tela. Atualizamos o dataSource
atual ( dataSourceUpdate
). Aqui, na fila de plano de fundo, preparamos as imagens: executar descompactação (consulte a seção de desempenho para obter mais detalhes), desenhar sombras, arredondamentos e outros efeitos.
Agora vá para a fila principal. dataSourceUpdate
recebido no UITableView
, reutilizamos e processamos os eventos da interface, preenchemos as células.
Para descrever nosso sistema de layout, seria necessário um artigo separado, mas aqui vou listar suas principais características:
- Uma API declarativa é um conjunto de regras nas quais uma interface é construída.
- Componentes básicos formam uma árvore (
nodes
). - Cálculos simples em componentes básicos. Por exemplo, nas listas, calculamos apenas o deslocamento da
origin
, levando em consideração a largura / altura de todos os filhos. - Os elementos básicos não criam "contêineres" desnecessários do
UIView
na hierarquia. Por exemplo, o componente da lista não forma um UIView
adicional e não adiciona filhos a ele. Em vez disso, calculamos o deslocamento da origin
dos filhos em relação ao elemento pai (para a lista). - Gerenciamento de texto de baixo nível com CoreText.
Mas mesmo com essa abordagem, a exibição da fita pode não ser tranquila devido a problemas de desempenho. Porque
Cada célula possui uma hierarquia complexa de nodes
. E embora os elementos básicos não criem contêineres desnecessários, muitas UIView
ainda UIView
exibidas na faixa de opções. E ao preencher a hierarquia com “nós” (ver ligação) na fila principal, há trabalho extra que é difícil de evitar.
Tentamos transferir o maior número possível de tarefas para a fila de segundo plano e agora continuamos a fazê-lo. Além disso, existem operações intensivas em CPU e GPU que devem ser levadas em consideração e contornadas.
4. Como alcançar um melhor desempenho
A resposta mais simples é descarregar o thread principal, CPU e GPU. Para fazer isso, você precisa entender profundamente o trabalho dos aplicativos iOS. E, acima de tudo, identifique as fontes dos problemas.
4.1 Por que problemas de desempenho
Animação principal, RunLoop
e Scroll
Vamos lembrar como a interface é construída no iOS. No nível superior, há o UIKit , responsável por interagir com o usuário: manipulando gestos, despertando o aplicativo do modo de suspensão e coisas semelhantes. Para renderizar a interface, uma ferramenta de nível inferior é responsável - Core Animation (como no macOS). Esta é uma estrutura com seu próprio sistema de descrição de interface . Considere os conceitos básicos de construção de uma interface.
Para o Core Animation, toda a interface é CALayer
camadas do CALayer
. Eles formam uma Árvore de Renderização, gerenciada através de transações de transação CATransaction
.
Uma transação é um grupo de alterações, mais precisamente, informações sobre a necessidade de atualizar algo na interface exibida. Qualquer alteração no frame
ou em outros parâmetros da camada cai na transação atual. Se ainda não estiver, o próprio sistema cria uma transação implícita .
Várias transações formam uma pilha. Novas atualizações caem na transação principal da pilha.
Agora sabemos que, para atualizar a tela, precisamos formar transações com novos parâmetros para a árvore de camadas.

Quando e como criar transações? Em nosso aplicativo, os threads têm uma entidade chamada RunLoop
. Em termos simples, esse é um loop infinito, a cada iteração na qual a fila de eventos atual é processada.
No thread Principal, o RunLoop
necessário para processar eventos de várias fontes, como uma interface (gestos), timers ou, por exemplo, manipuladores para receber dados do NSStream
e NSPort
.

Como o Core Animation e o RunLoop
? Descobrimos acima que, ao alterar as propriedades de uma camada na Árvore de Renderização, o sistema cria transações implícitas, se necessário (portanto, não precisamos chamar CATransaction.begin
para redesenhar algo). Além disso, a cada iteração do RunLoop
sistema fecha automaticamente as transações abertas e aplica as alterações feitas ( CATransaction.commit
).
Preste atenção! O número de iterações RunLoop
não depende da taxa de atualização da tela. O ciclo não é sincronizado com a tela e funciona como " while()
sem fim while()
".
Agora vamos ver o que acontece nas iterações do RunLoop
no thread Principal durante a rolagem:
... if (dispatchBlocks.count > 0) { // MainQueue doBlocks() } ... if (hasPanEvent) { handlePan() // UIScrollView change content offset -> change bounds } ... if (hasCATransaction) { CATransaction.commit() } ...
Primeiro, os blocos adicionados à fila Principal por meio de dispatch_async
/ dispatch_sync
são executados. E até que sejam concluídos, o programa não continua com as seguintes tarefas.
Em seguida, o UIKit começa a processar o gesto de pan do usuário. Como parte do processamento desse gesto, o UIScrollView.contentOffset
muda e, como resultado, o UIScrollView.bounds
. Alterar os bounds
UIScrollView
(respectivamente e de seus descendentes UITableView
, UICollectionView
) atualiza a parte visível do conteúdo ( viewport
).
No final da iteração RunLoop
, se tivermos transações abertas, a commit
ou RunLoop
ocorrerá automaticamente.
Para verificar como isso funciona, coloque pontos de interrupção nos locais apropriados.
Aqui está a aparência do processamento de gestos:

E aqui está o CATransaction.commit
após o handlePan
:

Durante a desaceleração da rolagem, o UIScrollView
cria um timer CADisplayLink
para sincronizar o número de alterações no contentOffset
por segundo com a taxa de atualização da tela.

Percebemos que o CATransaction.commit
não ocorre no final da iteração RunLoop
, mas diretamente no processamento do timer do CADisplayLink
. Mas isso não importa:

4.2 CATransaction.commit
De fato, todas as operações dentro do CATransaction.commit
são executadas nas camadas do CALayer
. layoutSublayers
têm seus próprios métodos para atualizar o layout ( layoutSublayers
) e a imagem ( drawLayer
). A implementação padrão desses métodos resulta em chamadas de método delegadas . Ao adicionar uma nova instância do UIView
à hierarquia do UIView
, adicionamos implicitamente a camada correspondente à hierarquia da camada de Animação principal. Nesse caso, o UIView
por padrão um delegado de sua camada. Como você pode ver na pilha de chamadas, o UIView
como parte da implementação dos métodos delegados do CALayer
, executa seus métodos, que serão discutidos:

Como geralmente trabalhamos com a hierarquia do UIView
, a descrição continuará com exemplos do UIView
.
Durante o CATransaction.commit
, o layout de todo o UIView
marcado com setNeedsLayout
. Observe que mais uma vez nós mesmos não chamamos layoutSubviews
ou layoutIfNeeded
devido à sua execução adiada garantida no sistema dentro do CATransaction.commit
. Mesmo que em uma transação (entre chamadas para CATransaction.begin
e CATransaction.commit
) você altere o frame
várias vezes e chame setNeedsLayout
, cada alteração não será aplicada instantaneamente. As alterações finais só terão efeito depois de chamar CATransaction.commit
. Métodos relevantes do CALayer
: setNeedsLayout
, layoutIfNeeded
e layoutSublayers
.
Um grupo semelhante para desenho é formado pelos métodos setNeedsDisplay
e setNeedsDisplay
. Para CALayer
são setNeedsDisplay
, displayIfNeeded
e drawLayer
. CATransaction.commit
chama os métodos de renderização em todos os elementos marcados com setNeedsDisplay
. Esta etapa às vezes é chamada de desenho fora da tela.
Um exemplo Para especificidade e conveniência, UITableView
o UITableView
:
... // Layout UITableView.layoutSubviews() // , .. ... // Offscreen drawing UITableView.drawRect() // ...
O UIKit reutiliza as UICollectionView
UITableView
/ UICollectionView
no layoutSubviews
: chama o willDisplayCell
delegado willDisplayCell
e assim por diante. Durante o CATransaction.commit
, ocorre o desenho fora da tela: os métodos drawInContext
de todas as camadas ou o drawRect
todos os UIView
, marcados como setNeedsDisplay
, são setNeedsDisplay
. Percebo que quando desenhamos algo no drawRect
, isso acontece no encadeamento principal e precisamos urgentemente alterar a exibição das camadas para um novo quadro. É claro que essa solução pode ser muito ineficiente.
O que acontece a seguir no CATransaction.commit
? A árvore de renderização é enviada ao servidor de renderização.
4.3 Pipeline de renderização
Lembre-se de todo o processo de formação de um quadro de interface no iOS (pipeline de renderização [WWDC 2014 Session 419. Gráficos e animações avançados para aplicativos iOS)):

Não apenas o processo de nosso aplicativo é responsável pela formação do quadro - o Core Animation também trabalha em um processo de sistema separado chamado Render Server.
Como o quadro é formado. Nós (ou o sistema para nós) criamos uma nova transação ( CATransaction
) no aplicativo com uma descrição das alterações na interface, “confirmamos” e transferimos para o Render Server. Tudo, no lado da aplicação, o trabalho está feito. Em seguida, o servidor de renderização decodifica a transação (árvore de renderização), chama os comandos necessários no chip de vídeo, desenha um novo quadro e o exibe na tela.
Curiosamente, ao criar o quadro, um certo "multithreading" é usado. Se a taxa de atualização da tela for de 60 quadros por segundo, um novo quadro será formado no total, não em 1/60, mas em 1/30 de segundo. Isso ocorre porque enquanto o aplicativo está preparando um novo quadro, o Render Server ainda está processando o anterior:

Grosso modo, o tempo total da formação do quadro antes de ser exibido na tela consiste em 1/60 segundo em nosso processo para a formação da transação e 1/60 segundo no processo do Render Server durante o processamento da transação.
Eu gostaria de fazer a seguinte observação. Podemos paralelizar o desenho de camadas e renderizar o conteúdo da CGImage
UIImage
/ CGImage
no fluxo Background. Depois disso, no encadeamento principal, você precisa atribuir a imagem criada à propriedade CALayer.contents
. Em termos de desempenho, essa é uma abordagem muito boa. São os desenvolvedores que o usam Texture . Mas como podemos alterar o CALayer.contents
apenas no processo de gerar uma transação no processo do nosso aplicativo, temos apenas 1/60 de segundo a 60 quadros para criar e substituir uma nova imagem, em vez de 1/30 de segundo (levando em conta otimizações e paralelização do pipeline de renderização com o Render Server )
Além disso, o servidor de renderização ainda pode lidar com mesclagem (veja abaixo) e cache de camada de curto prazo [iOS Core Animation: Advanced Techniques. Nick Lockwood]. 1/60 CALayer.contents
, . .
: , .
4.4.
Main-thread

1. ( CATransaction.commit
) - UIView.layoutSubviews
UIView
(, CALayer
). , layoutSubviews
/ cellForRow
/ willDisplayCell
.
2. drawInContext
/ drawRect
. - Main- ( CATransaction.commit
) — . , .
3. . . CATransaction.commit
, , .
4. . UIImage
/ CGImage
.
5. . Main-thread , scroll. - , UI.
6. Main-. , RunLoop
Main- , , Main-. .
GPU

Blending . GPU ( Render Server GPU, ). , , Background-.
. , UIBlurEffect
, UIVibrancyEffect
, , (Render Pass). , , .
Offscreen rendering (Render Server)

Render Server . , , :
CALayer
, , Offscreen rendering. , UIVisualEffect
( , Render Server CPU, GPU).
, .
5.
, , Time Profiler. Metal System Trace — Time Profiler .
, ( ). , : , .
, Metal System Trace , . , Render Server. , Main-, — , .

- , :

Metal System Trace . 64- , iPhone 5s. , . , - , , UI.
5.2.
. , - - . , CADisplayLink
.
CADisplayLink
timestamp
— ( Render Server). CADisplayLink.timestamp
timestamp
. , (, 1/60 ) :
// CADisplayLink. link = [CADisplayLink displayLinkWithTarget:target selector:selector] [link addToRunLoop:[NSRunLoop mainRunLoop] forMode:UITrackingRunLoopMode] // CADisplayLink : diff = prevTimestamp - link.timestamp if (diff > 1/fps) { // freeze } prevTimestamp = link.timestamp
CADisplayLink
UITrackingRunLoopMode
, .
Rendering Pipeline:

UI-, . «» freezeFrameTimeRate
:
scrollTime // Scroll freezeFrameTime // , "", freezeFrameTimeRate = freezeFrameTime / scrollTime
, - UIView
. , «»:

, , « UIView
» . Porque , . , , , : CADisplayLink
, Render Server link.timetamp
, Render Server , . 60 UI-, Render Server. Render Server , .
, , , Render Server . Metal , Render Server. , , iOS, Render Server .
.
, , . , .
: — ! — .
Conclusão
— . , , .
, — . , .
, :
- Apple .
- Auto Layout .
- The Cassowary Linear Arithmetic Constraint Solving Algorithm .
- iOS Core Animation: Advanced Techniques. Nick Lockwood.
- WWDC 2014 Session 419. Advanced Graphics and Animations for iOS Apps.
