Melhoria no desempenho por meio de linhas de execução

O uso adequado de linhas de execução no Android pode ajudar você a melhorar o desempenho 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 IU, é responsável por tudo o que acontece na tela. Entender como ela funciona pode ajudar a criar seu app para usar a linha de execução principal e ter o melhor desempenho possível.

Elementos internos

A linha de execução principal tem um design muito simples: a única função dela é executar blocos de uma fila de trabalhos seguros até que o app seja encerrado. O framework gera alguns desses blocos de trabalho a partir de diversos lugares. Esses lugares incluem callbacks associados 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 desenho. 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. Então, a linha de execução principal pode exibir o evento.

Enquanto ocorre uma animação ou atualização de tela, o sistema tenta executar um bloco de trabalho (que é responsável por desenhar a tela) a cada 16 ms mais ou menos, para renderizar suavemente a 60 quadros por segundo (vídeo em inglês). 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 deve 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 observará problemas, atrasos ou falta de capacidade de resposta da IU para a entrada. Se a linha de execução principal for bloqueada por aproximadamente cinco segundos, o sistema exibirá 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.

A movimentação de tarefas numerosas ou longas da linha de execução principal para que elas não interfiram na renderização suave e na rapidez da capacidade de resposta à entrada do usuário é o motivo mais importante para adotar o uso de linhas de execução no seu app.

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

Por padrão, os objetos de visualização do Android não são seguros para linha de execução (vídeo em inglês). O app deve criar, usar e destruir objetos de IU, todos na linha de execução principal. Se você tentar modificar ou inclusive 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 são divididos em duas categorias: referências explícitas e implícitas.

Referências explícitas

Muitas tarefas em linhas de execução não principais têm a meta final de atualizar objetos de IU. No entanto, se uma dessas linhas acessar um objeto na hierarquia de visualizações, o aplicativo poderá 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 serão 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 de worker pode conter uma referência a uma View, mas antes da conclusão do trabalho, a View é removida da hierarquia de visualizações. 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 vê 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 restar um bloco de trabalho em linha de execução que a referencie, direta ou indiretamente, o coletor de lixo não coletará a atividade até que o bloco de trabalho termine a execução.

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

Com cenários como esses, sugerimos que seu app não inclua referências explícitas a objetos de IU em tarefas de trabalho em linha 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ê deve 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 na criação do código com objetos em linha de execução pode ser vista no snippet de código a seguir:

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) {...}
      }
    }
    

A falha nesse snippet é que o código declara o objeto em 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 em 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 suas instâncias de classe sobrecarregadas como classes estáticas ou defini-las nos próprios arquivos, removendo assim a referência implícita.

Outra solução é declarar o objeto AsyncTask como uma classe aninhada estática (ou remover o qualificador interno no Kotlin). Essa ação elimina o problema de referência implícita devido à diferença entre uma classe aninhada estática e uma classe interna: a instância de uma classe interna requer que uma instância da classe externa seja instanciada e tem acesso direto aos métodos e campos da instância delimitadora. Por outro lado, uma classe aninhada estática não requer uma referência a uma instância de classe delimitadora. Portanto, ela não contém referências aos membros da classe externa.

Kotlin

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

Java

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

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, independentemente da criação ou destruição das atividades. Em alguns casos, essa persistência é desejável.

Imagine um caso em que uma atividade gera um conjunto de blocos de trabalho em 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 deve 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 será mais necessária.

Por outro lado, os pacotes de trabalho podem ter algum benefício não totalmente relacionado à IU. Nesse caso, faça a linha de execução persistir. 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 gerenciá-las corretamente, o app poderá sofrer contenção de memória e problemas de desempenho. A combinação de ViewModel com LiveData permite carregar dados e receber uma notificação quando eles mudarem, sem se preocupar com o ciclo de vida. Objetos ViewModel são uma solução para esse problema. Os ViewModels são mantidos em todas as mudanças de configuração, o que proporciona uma maneira fácil de fazer seus dados de visualização persistirem. Para saber mais sobre ViewModels, consulte Visão geral do ViewModel. Para ver mais informações sobre o LiveData, consulte Visão geral do LiveData. Se você também quer ver mais informações sobre a arquitetura do aplicativo, leia o Guia para a arquitetura do app.

Prioridade da linha de execução

Conforme descrito em Processos e ciclo de vida de um 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, sua linha de execução poderá interromper a linha de execução de IU e a RenderThread, o que faz o app elimine alguns frames. Se ela for muito baixa, as tarefas assíncronas, como o carregamento de imagens, poderão ficar mais lentas do que deveriam.

Sempre você criar uma linha de execução, chame setThreadPriority(). O programador de linhas de execução do sistema dá preferência às linhas de alta prioridade, equilibrando essas prioridades com a necessidade de finalmente 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 por meio da classe Process.

Por padrão, o sistema define a prioridade de uma linha de execução como a mesma prioridade 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 por meio de setThreadPriority().

A classe Process ajuda a reduzir a complexidade da atribuição de valores de prioridade por meio de um conjunto de constantes que seu app pode usar para definir prioridades de linha de execução. Por exemplo, THREAD_PRIORITY_DEFAULT representa o valor padrão de uma linha de execução. Seu app deve 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 ver uma lista de prioridades de linha de execução, consulte as constantes THREAD_PRIORITY na classe Process.

Para saber mais 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

O framework oferece as mesmas classes e primitivos Java para facilitar o uso de linhas de execução, como as classes Thread, Runnable e Executors. Para reduzir a carga cognitiva associada ao desenvolvimento de aplicativos em linha de execução para o Android, o framework disponibiliza um conjunto de auxiliares que podem ajudar no desenvolvimento, como AsyncTaskLoader e AsyncTask. Cada classe auxiliar tem um conjunto específico de nuances de desempenho que as tornam exclusivas para um determinado subconjunto de problemas de linha de execução. O uso de uma classe incorreta para a situação errada pode causar problemas de desempenho.

Classe AsyncTask

A classe AsyncTask é um primitivo simples e útil para apps que precisam mover rapidamente o trabalho da linha de execução principal para linhas de execução de worker. Por exemplo, um evento de entrada pode acionar a necessidade de atualizar a IU com um bitmap carregado. Um objeto AsyncTask pode descarregar o carregamento e a decodificação de bitmaps em uma linha de execução alternativa. Depois que esse processamento é concluído, o objeto AsyncTask pode gerenciar o recebimento do trabalho de volta na linha de execução principal para atualizar a IU.

Ao usar AsyncTask, lembre-se de alguns aspectos importantes de desempenho. Por padrão, um app envia todos os objetos AsyncTask que ele cria para uma única linha de execução. Portanto, eles são executados em série e, como na linha de execução principal, um pacote de trabalho especialmente longo pode bloquear a fila. Por esse motivo, sugerimos que você use AsyncTask apenas para processar itens de trabalho com menos de 5 ms de duração.

Objetos AsyncTask também são as causas mais comuns de problemas de referência implícita. Os objetos AsyncTask apresentam riscos relacionados a referências explícitas. No entanto, pode ser mais fácil resolvê-los nesse caso. Por exemplo, uma AsyncTask pode exigir uma referência a um objeto de IU para atualizá-lo corretamente depois que AsyncTask executa os callbacks relacionados na linha de execução principal. Nesse caso, use uma WeakReference para armazenar uma referência ao objeto de IU necessário e acessar o objeto quando a AsyncTask estiver operando na linha de execução principal. É preciso deixar claro que manter uma WeakReference a um objeto não torna o objeto seguro para linha de execução. A WeakReference oferece apenas um método para processar problemas relacionados a referências explícitas e coleta de lixo.

Classe HandlerThread

Embora uma AsyncTask seja útil, ela nem sempre é a solução certa (vídeo em inglês) para seu problema de linha de execução. Você pode precisar de uma abordagem mais tradicional para executar um bloco de trabalho em uma linha de execução mais longa, além de certa capacidade para gerenciar esse fluxo de trabalho manualmente.

Considere um desafio comum extrair 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 para o callback onPreviewFrame(), que é invocado na linha de execução de evento de que ele foi chamado. Se esse callback fosse invocado na linha de execução de IU, a tarefa de lidar com as enormes matrizes de pixels interferiria no trabalho de renderização e processamento de eventos. O mesmo problema ocorre com AsyncTask, que também executa tarefas em série e é suscetível a bloqueios.

Essa é uma situação em que uma linha de execução de gerenciador seria apropriada: uma linha de execução do gerenciador é realmente uma linha longa que seleciona o trabalho em uma fila e opera nele. Nesse exemplo, quando o app delega o comando Camera.open() a um bloco de trabalho na linha de execução do gerenciador, o callback onPreviewFrame() associado é direcionado à linha de execução do gerenciador, não às linhas de execução de UI ou AsyncTask. 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, AsyncTask e HandlerThread não são classes adequadas (vídeo em inglês). A natureza de linha de execução única da AsyncTask transformaria todo o trabalho em linha de execução em um sistema linear. Por outro lado, o uso da classe HandlerThread exigiria que o programador gerenciasse manualmente o balanceamento de carga entre um grupo de linhas de execução.

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 os números mínimo e máximo de linhas de execução. À medida que a carga de trabalho atribuída ao ThreadPoolExecutor aumenta, a classe usa as contagens mínima e máxima de linhas de execução iniciadas e considera a quantidade de trabalho pendente a ser concluído. Com base nesses fatores, ThreadPoolExecutor decide quantas linhas de execução devem 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 desempenho. 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 a capacidade de processar apenas um pequeno número de linhas de execução ao mesmo tempo. Tudo aquilo 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á diversas variáveis responsáveis por isso. Mas, escolher um valor (como 4 para iniciantes) e testá-lo 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 diversos 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. Se seu app puder reutilizar um conjunto de linhas de execução existente, o desempenho será beneficiado por meio da redução da contenção de memória e dos recursos de processamento.