Melhor performance usando linhas de execução

O uso adequado de linhas de execução no Android pode ajudar você a melhorar a performance do app. Esta página discute vários aspectos do trabalho com linhas de execução: trabalhar com a linha de execução de IU, ou principal, a relação entre ciclo de vida do app e prioridade da linha de execução, bem como métodos que a plataforma oferece para ajudar a gerenciar a complexidade da linha de execução. Em cada uma dessas áreas, esta página descreve possíveis armadilhas e as estratégias para evitá-las.

Linha de execução principal

Quando o usuário inicia seu app, o Android cria um novo processo do Linux junto com uma linha de execução. Essa linha de execução principal, conhecida também como linha de execução de interface, é responsável por tudo o que acontece na tela. Entender como ela funciona pode ajudar a projetar seu app para usar a linha de execução principal e ter a melhor performance possível.

Internos

A linha de execução principal tem um design muito simples: a única função dela é executar blocos de uma fila de trabalhos thread-safe até que o app seja encerrado. O framework gera alguns desses blocos de trabalho de diversos pontos. Esses pontos incluem callbacks associadas a informações de ciclo de vida, eventos do usuário, como entrada, ou eventos provenientes de outros apps e processos. Além disso, o app pode enfileirar os blocos explicitamente sozinhos, sem usar o framework.

Praticamente qualquer bloco de código executado pelo app (vídeo em inglês) é vinculado a um callback de evento, como entrada, inflação de layout ou exibição. Quando algo aciona um evento, a linha de execução em que o evento aconteceu o envia para fora e para a fila de mensagens da linha de execução principal. Depois, a linha de execução principal pode mostrar o evento.

Enquanto ocorre uma animação ou atualização de tela, o sistema tenta executar um bloco de trabalho (que é responsável por mostrar a tela) a cada 16 ms mais ou menos, para renderizar suavemente a 60 quadros por segundo. Para que o sistema atinja essa meta, a hierarquia da IU/visualização precisa ser atualizada na linha de execução principal. No entanto, quando a fila de mensagens da linha de execução principal contém tarefas que são muito numerosas ou muito longas para que a linha de execução principal conclua a atualização com rapidez suficiente, o app precisa mover esse trabalho para uma linha de execução de worker. Se a linha de execução principal não concluir a execução de blocos de trabalho em 16 ms, o usuário vai observar problemas, atrasos ou uma responsividade ruim da IU para entradas. Se a linha de execução principal for bloqueada por aproximadamente cinco segundos, o sistema vai mostrar a caixa de diálogo O app não está respondendo (ANR, na sigla em inglês), permitindo que o usuário feche o app diretamente.

O principal motivo para adotar a linha de execução no seu app é a movimentação de várias tarefas ou tarefas longas da linha de execução principal para que elas não interfiram na renderização suave e na rapidez da resposta à entrada do usuário.

Linhas de execução e referências de objetos de interface

Por padrão, os objetos de visualização do Android não são thread-safe. O app vai criar, usar e destruir objetos de IU, todos na linha de execução principal. Se você tentar modificar ou mesmo referenciar um objeto de IU em uma linha de execução que não a principal, isso pode resultar em exceções, falhas silenciosas, erros e outros comportamentos indefinidos.

Os problemas com referências se enquadram em duas categorias: referências explícitas e implícitas.

Referências explícitas

Muitas tarefas em linhas de execução que não a principal têm o objetivo final de atualizar objetos de IU. No entanto, se uma dessas linhas acessar um objeto na hierarquia de visualizações, o aplicativo pode se tornar instável. Isto é, se uma linha de execução de worker mudar as propriedades desse objeto ao mesmo tempo em que qualquer outra linha de execução o referenciar, os resultados vão ser indefinidos.

Por exemplo, imagine um app que tem uma referência direta a um objeto de IU em uma linha de execução de worker. O objeto na linha de execução do worker pode conter uma referência a uma View, mas antes da conclusão do trabalho, o View é removido da hierarquia de visualização. Quando essas duas ações ocorrem simultaneamente, a referência mantém o objeto View na memória e define propriedades nele. No entanto, o usuário nunca visualiza esse objeto, e o app o exclui quando a referência a ele desaparece.

Em outro exemplo, os objetos View contêm referências à atividade a que eles pertencem. Se essa atividade for destruída, mas ainda existir um bloco de trabalho em uma linha de execução que a referencie de forma direta ou indireta, o coletor de lixo não vai coletar a atividade até que o bloco de trabalho termine a execução.

Esse cenário pode causar problemas em situações em que o trabalho em uma linha de execução está em andamento enquanto ocorre algum evento de ciclo de vida da atividade, como uma rotação de tela. O sistema não vai executar a coleta de lixo até que o trabalho em andamento seja concluído. Dessa forma, pode haver dois objetos Activity na memória até que a coleta de lixo possa ocorrer.

Para cenários como esses, sugerimos que seu app não inclua referências explícitas a objetos de IU em tarefas de trabalho em linhas de execução. Sem essas referências, você evita esses tipos de vazamento de memória e a contenção de linhas de execução.

Em todos os casos, seu app só deve atualizar objetos de IU na linha de execução principal. Isso significa que você precisa criar uma política de negociação para que várias linhas de execução comuniquem o trabalho de volta à linha de execução principal, que atribui à atividade ou fragmento mais importante o trabalho de atualizar o objeto de IU real.

Referências implícitas

Uma falha comum no design do código com objetos em linha de execução pode ser vista no snippet de código abaixo:

Kotlin

class MainActivity : Activity() {
    // ...
    inner class MyAsyncTask : AsyncTask<Unit, Unit, String>() {
        override fun doInBackground(vararg params: Unit): String {...}
        override fun onPostExecute(result: String) {...}
    }
}

Java

public class MainActivity extends Activity {
  // ...
  public class MyAsyncTask extends AsyncTask<Void, Void, String>   {
    @Override protected String doInBackground(Void... params) {...}
    @Override protected void onPostExecute(String result) {...}
  }
}

O problema nesse snippet é que o código declara o objeto na linha de execução MyAsyncTask como uma classe interna não estática de alguma atividade ou uma classe interna no Kotlin. Essa declaração cria uma referência implícita para a instância Activity delimitadora. Como resultado, o objeto contém uma referência à atividade até a conclusão do trabalho da linha de execução, causando um atraso na destruição da atividade referenciada. Esse atraso, por sua vez, aumenta a demanda de memória.

Uma solução direta para esse problema seria definir as instâncias de classe sobrecarregadas como classes estáticas ou nos próprios arquivos, removendo assim a referência implícita.

Outra solução seria sempre cancelar e limpar tarefas em segundo plano no callback do ciclo de vida Activity apropriado, como onDestroy. No entanto, essa abordagem pode ser monótona e propensa a erros. Como regra geral, não coloque a lógica complexa e que não é da IU diretamente nas atividades. Além disso, AsyncTask foi descontinuada e não é recomendada para uso em novos códigos. Consulte Linhas de execução no Android para mais detalhes sobre os primitivos de simultaneidade disponíveis.

Ciclos de vida de linhas de execução e da atividade do app

O ciclo de vida do app pode afetar o funcionamento da linha de execução no seu aplicativo. Talvez seja necessário decidir se uma linha de execução deve ou não persistir depois que uma atividade é destruída. Conheça também a relação entre priorização da linha de execução e se uma atividade está sendo executada em primeiro ou segundo plano.

Linhas de execução persistentes

As linhas de execução persistem depois do ciclo de vida das atividades que as geram. As linhas de execução continuam sendo executadas sem interrupções, independente da criação ou destruição das atividades. No entanto, elas são finalizadas com o processo do app quando não há mais componentes ativos. Em alguns casos, essa persistência é desejável.

Imagine um caso em que uma atividade gera um conjunto de blocos de trabalho de linha de execução e, em seguida, é destruída antes que a linha de execução de worker possa executar os blocos. O que o app precisa fazer com os blocos que estão em andamento?

Se os blocos fossem atualizar uma IU que não existe mais, não haveria motivo para que o trabalho continuasse. Por exemplo, se o trabalho for carregar as informações do usuário de um banco de dados e atualizar visualizações, a linha de execução não é mais necessária.

Por outro lado, os pacotes de trabalho podem ter algum benefício que não é totalmente relacionado à IU. Nesse caso, é necessário manter a linha de execução. Por exemplo, os pacotes podem estar aguardando para fazer o download de uma imagem, armazená-la em cache no disco e atualizar o objeto View associado. Embora o objeto não exista mais, as ações de fazer o download e armazenar a imagem em cache ainda podem ser úteis, caso o usuário retorne à atividade destruída.

O gerenciamento manual de respostas do ciclo de vida para todos os objetos em linha de execução pode se tornar extremamente complexo. Se você não gerenciar corretamente as respostas, o app pode sofrer contenção de memória e problemas de desempenho. Combinar ViewModel com LiveData permite carregar dados e receber uma notificação quando eles mudarem, sem se preocupar com o ciclo de vida. Os objetos ViewModel são uma solução para esse problema. Os ViewModels são mantidos após todas as mudanças de configuração, o que proporciona uma maneira fácil de fazer os dados de visualização persistirem. Consulte os guias do ViewModel e do LiveData para mais informações sobre esses componentes. Se você também quiser mais informações sobre a arquitetura do app, leia o Guia para a arquitetura do app.

Prioridade da linha de execução

Conforme descrito em Processos e ciclo de vida do app, a prioridade que as linhas de execução do seu app recebem depende em parte de onde o app está no ciclo de vida. Ao criar e gerenciar linhas de execução no seu aplicativo, é importante definir a prioridade delas. Assim, as linhas de execução certas recebem as prioridades corretas no momento ideal. Se a prioridade for muito alta, a linha de execução do seu app vai poder interromper a linha de execução de interface e a RenderThread, o que faz com que o app elimine alguns frames. Se a prioridade for muito baixa, as tarefas assíncronas, como o carregamento de imagens, podem ficar mais lentas do que deveriam.

Sempre que você criar uma linha de execução, chame setThreadPriority(). O agendador de linhas de execução do sistema dá preferência às linhas de alta prioridade, equilibrando essas prioridades com a necessidade de concluir todo o trabalho. Em geral, as linhas de execução no grupo de primeiro plano recebem aproximadamente 95% (vídeo em inglês) do tempo total de execução do dispositivo, enquanto o grupo de segundo plano recebe cerca de 5%.

O sistema também atribui a cada linha de execução o próprio valor de prioridade usando a classe Process.

Por padrão, o sistema define a prioridade de uma linha de execução como as mesmas prioridades e associações de grupo da linha de execução gerada. No entanto, seu aplicativo pode ajustar explicitamente a prioridade da linha de execução usando setThreadPriority().

A classe Process ajuda a reduzir a complexidade da atribuição de valores de prioridade usando um conjunto de constantes que seu app pode usar para definir prioridades de linhas de execução. Por exemplo, THREAD_PRIORITY_DEFAULT representa o valor padrão de uma linha de execução. Seu app precisa definir a prioridade da linha de execução como THREAD_PRIORITY_BACKGROUND para as linhas que executam um trabalho menos urgente.

Seu app pode usar as constantes THREAD_PRIORITY_LESS_FAVORABLE e THREAD_PRIORITY_MORE_FAVORABLE como incrementos para definir prioridades relativas. Para conferir uma lista de prioridades de linhas de execução, consulte as constantes THREAD_PRIORITY na classe Process.

Para mais informações sobre o gerenciamento de linhas de execução, consulte a documentação de referência sobre as classes Thread e Process.

Classes auxiliares para linhas de execução

Para desenvolvedores que usam o Kotlin como linguagem principal, recomendamos o uso de corrotinas. As corrotinas oferecem vários benefícios, incluindo a criação de código assíncrono sem precisar de callbacks, além da simultaneidade estruturada para escopo, cancelamento e processamento de erros.

O framework também fornece as mesmas classes e primitivos Java para facilitar o uso de linhas de execução, como as classes Thread, Runnable e Executors, além de outras, como HandlerThread Para mais informações, consulte Linhas de execução no Android.

Classe HandlerThread

Uma linha de execução de gerenciador é efetivamente uma linha de execução de longa duração que capta o trabalho de uma fila e opera nele.

Considere um desafio comum com a extração de frames de visualização do objeto Camera. Quando você faz o cadastro para receber frames de visualização da câmera, eles são enviados ao callback onPreviewFrame(), que é invocado na linha de execução de evento em que ele foi chamado. Se esse callback fosse invocado na linha de execução de interface, a tarefa de lidar com as enormes matrizes de pixels iria interferir no trabalho de renderização e processamento de eventos.

Nesse exemplo, quando o app delega o comando Camera.open() a um bloco de trabalho na linha de execução de gerenciador, o callback onPreviewFrame() associado é direcionado à linha de execução de gerenciador, em vez da linha de execução de interface. Portanto, se você pretende fazer um trabalho de execução longa nos pixels, essa pode ser a solução mais indicada.

Quando seu app criar uma linha de execução usando HandlerThread, não se esqueça de definir a prioridade da linha de execução com base no tipo de trabalho realizado (vídeo em inglês). As CPUs só podem processar um pequeno número de linhas de execução ao mesmo tempo. A definição da prioridade ajuda o sistema a saber as maneiras certas de programar esse trabalho quando todas as outras linhas estão lutando por atenção.

Classe ThreadPoolExecutor

Certos tipos de trabalho podem ser reduzidos a tarefas distribuídas altamente paralelas. Uma dessas tarefas, por exemplo, é calcular um filtro para cada bloco 8x8 de uma imagem de 8 megapixels. Com o grande volume de pacotes de trabalho que isso cria, HandlerThread não é a classe mais apropriada para esse uso.

ThreadPoolExecutor é uma classe auxiliar que facilita esse processo. Essa classe gerencia a criação de um grupo de linhas de execução, define as prioridades correspondentes e gerencia a forma como o trabalho é distribuído entre esses linhas de execução. À medida que a carga de trabalho aumenta ou diminui, a classe inicia ou destrói mais linhas de execução para se ajustar à carga de trabalho.

Essa classe também ajuda o app a gerar um número ideal de linhas de execução. Quando ela cria um objeto ThreadPoolExecutor, o app define um número mínimo e máximo de linhas de execução. À medida que a carga de trabalho fornecida ao ThreadPoolExecutor aumenta, a classe considera as contagens mínima e máxima de linhas de execução inicializadas e considera a quantidade de trabalho pendente a ser feito. Com base nesses fatores, ThreadPoolExecutor decide quantas linhas de execução precisam estar ativas em um determinado momento.

Quantas linhas de execução você deve criar?

Em termos de software, seu código tem capacidade para criar centenas de linhas de execução. No entanto, isso pode gerar problemas de performance. Seu app compartilha recursos limitados da CPU com serviços em segundo plano, com o renderizador, o mecanismo de áudio, a rede etc. Na realidade, as CPUs têm conseguem processar apenas um pequeno número de linhas de execução ao mesmo tempo. Tudo que ultrapassa esse limite resulta em problemas de prioridade e programação (vídeo em inglês). Por isso, é importante criar apenas as linhas de execução necessárias para a carga de trabalho.

Na prática, há muitas variáveis responsáveis por isso. Mas, escolher um valor (como 4, para treinar) e testar com o Systrace é uma estratégia tão eficiente quanto qualquer outra. Você pode usar a abordagem de tentativa e erro para descobrir o número mínimo de linhas de execução que pode ser usado sem gerar problemas.

Outro fator a considerar ao definir o número de linhas de execução é lembrar que elas ocupam a memória. Cada linha de execução ocupa, no mínimo, 64 k de memória. Esse número aumenta rapidamente quando consideramos os vários apps instalados em um dispositivo, especialmente nas situações em que as pilhas de chamadas aumentam de maneira significativa.

Em geral, muitos processos do sistema e bibliotecas de terceiros iniciam os próprios conjuntos de linhas de execução. Caso seu app possa reutilizar um conjunto de linhas de execução existente, a performance vai melhorar ao reduzir a contenção de memória e os recursos de processamento.