Améliorer les performances grâce à l'exécution de threads

L'utilisation judicieuse des threads sur Android peut vous aider à améliorer les performances de votre application. Cette page décrit plusieurs aspects de l'utilisation des threads : l'utilisation de l'UI (ou thread principal) ; la relation entre le cycle de vie de l'application et la priorité du thread ; et les méthodes fournies par la plate-forme pour gérer la complexité des threads. Pour chacun de ces aspects, cette page décrit les écueils et les stratégies permettant de les éviter.

Thread principal

Lorsque l'utilisateur lance votre application, Android crée un processus Linux avec un thread d'exécution. Ce thread principal, également appelé thread d'interface utilisateur, est responsable de tout ce qui se passe à l'écran. Comprendre son fonctionnement peut vous aider à concevoir votre application afin qu'elle utilise le thread principal pour optimiser les performances.

Caractéristiques

Le thread principal présente une conception très simple : sa seule tâche consiste à prendre et à exécuter des blocs de tâches depuis une file d'attente sécurisée jusqu'à l'arrêt de l'application. Le framework génère certains de ces blocs à partir de divers endroits. Ces derniers incluent des rappels associés à des informations sur le cycle de vie, des événements utilisateur tels que des entrées ou des événements provenant d'autres applications et processus. De plus, l'application peut mettre en file d'attente des blocs de manière explicite, sans utiliser le framework.

Presque tous les blocs de code exécutés par votre application sont liés à un rappel d'événement, comme une entrée, un gonflement de mise en page ou un dessin. Lorsqu'un événement est déclenché, le thread où il se produit l'envoie de l'extérieur vers la file d'attente du message du thread principal. Le thread principal peut alors traiter l'événement.

Lorsqu'une animation ou une mise à jour d'écran est en cours, le système tente d'exécuter un bloc de tâches (qui est chargé de dessiner l'écran) toutes les 16 ms environ, afin de s'afficher correctement à 60 images par seconde. Pour que le système atteigne cet objectif, l'UI/la hiérarchie des vues doivent être mises à jour sur le thread principal. Toutefois, lorsque la file d'attente de messagerie du thread principal contient des tâches trop nombreuses ou trop longues pour que le thread principal termine la mise à jour assez rapidement, l'application doit déplacer cette tâche vers un thread de nœud de calcul. Si le thread principal ne parvient pas à terminer l'exécution des blocs de tâches dans un délai de 16 ms, l'utilisateur peut observer des problèmes d'accrochage ou de latence, ou un manque de réactivité de l'interface utilisateur. Si le thread principal se bloque pendant environ cinq secondes, le système affiche la boîte de dialogue L'application ne répond pas (ANR), ce qui permet à l'utilisateur de fermer directement l'application.

Le fait de déplacer des tâches longues ou nombreuses depuis le thread principal afin qu'elles n'interfèrent pas avec la fluidité d'affichage et la réactivité rapide aux entrées de l'utilisateur est l'une des principales raisons d'adopter l'exécution de threads dans votre application.

Threads et références d'objets UI

De par leur conception, les objets Android View ne sont pas sécurisés. Une application doit créer, utiliser et détruire des objets d'interface utilisateur, le tout dans le thread principal. Si vous essayez de modifier ou de référencer un objet UI dans un thread autre que le thread principal, il peut en résulter des exceptions, des échecs silencieux, des plantages et d'autres comportements incorrects non définis.

Les problèmes liés aux références appartiennent à deux catégories distinctes : les références explicites et les références implicites.

Références explicites

De nombreuses tâches ont pour objectif final de mettre à jour les objets de l'interface utilisateur. Toutefois, si l'un de ces threads accède à un objet de la hiérarchie des vues, l'application peut devenir instable. Si un thread de nœud de calcul modifie les propriétés de cet objet en même temps qu'un autre thread référence l'objet, les résultats ne sont pas définis.

Prenons l'exemple d'une application qui contient une référence directe à un objet UI d'un thread de nœud de calcul. L'objet du thread de nœud de calcul peut contenir une référence à un View. Mais avant la fin de la tâche, View est supprimé de la hiérarchie des vues. Lorsque ces deux actions se produisent simultanément, la référence conserve l'objet View en mémoire et définit ses propriétés. Toutefois, l'utilisateur ne voit jamais cet objet, et l'application le supprime une fois la référence supprimée.

Dans un autre exemple, les objets View contiennent des références à l'activité qui leur appartient. Si cette activité est détruite, mais qu'il reste un bloc de tâches organisées en threads qui y fait référence, directement ou indirectement, le récupérateur de mémoire ne collectera pas l'activité avant la fin de l'exécution de ce bloc.

Ce scénario peut être problématique dans des situations où une tâche organisée en threads est en cours pendant qu'un événement de cycle de vie d'activité, tel qu'une rotation d'écran, se produit. Le système ne pourra pas récupérer la mémoire tant que la tâche en cours ne sera pas terminée. Par conséquent, deux objets Activity peuvent être en mémoire jusqu'à ce que la récupération de mémoire puisse avoir lieu.

Dans ce genre de scénarios, nous vous recommandons de ne pas inclure de références explicites aux objets de l'interface utilisateur dans les tâches. Ce faisant, vous éviterez ce type de fuite de mémoire ainsi que les conflits au niveau des threads.

Dans tous les cas, votre application ne doit mettre à jour que les objets d'interface utilisateur dans le thread principal. Cela signifie que vous devez créer une règle de négociation autorisant plusieurs threads à communiquer avec le thread principal, qui délègue la mise à jour de l'objet d'UI à l'activité ou au fragment de niveau supérieur.

Références implicites

L'extrait de code ci-dessous illustre un défaut courant de conception de code avec des objets organisés en threads :

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

L'inconvénient de cet extrait est que le code déclare l'objet de threading MyAsyncTask en tant que classe interne non statique d'une activité (ou classe interne en Kotlin). Cette déclaration crée une référence implicite à l'instance Activity qui l'englobe. Par conséquent, l'objet contient une référence à l'activité jusqu'à la fin de la tâche organisée en threads, ce qui entraîne un délai dans la destruction de l'activité référencée. Ce retard exerce une pression supplémentaire sur la mémoire.

Une solution directe à ce problème consiste à définir vos instances de classe surchargées soit en tant que classes statiques, soit dans leurs propres fichiers, ce qui supprime la référence implicite.

Une autre solution consiste à toujours annuler et nettoyer les tâches en arrière-plan dans le rappel de cycle de vie Activity approprié, par exemple onDestroy. Cette approche peut toutefois s'avérer fastidieuse et sujette aux erreurs. En règle générale, vous ne devez pas placer directement une logique complexe et non basée sur l'interface utilisateur dans des activités. En outre, AsyncTask est désormais obsolète et son utilisation n'est pas recommandée dans un nouveau code. Pour en savoir plus sur les primitives de simultanéité disponibles, consultez Exécution de threads sur Android.

Threads et cycles de vie d'activité des applications

Le cycle de vie d'une application peut avoir une incidence sur son fonctionnement. Vous devrez peut-être décider qu'un thread doit ou non persister après la destruction d'une activité. Vous devez également connaître la relation entre la hiérarchisation des threads et savoir si une activité est exécutée au premier plan ou en arrière-plan.

Threads persistants

Les threads sont conservés pendant toute la durée de vie des activités qui les ont générées. Les threads continuent de s'exécuter sans interruption, même si des activités sont créées ou détruites. Toutefois, ils seront arrêtés en même temps que le processus applicatif une fois qu'il n'y aura plus de composants d'application actifs. Dans certains cas, cette persistance est souhaitable.

Prenons l'exemple d'une activité qui génère un ensemble de blocs de tâches organisées en threads, puis qui est détruite avant qu'un thread de calcul ne puisse exécuter les blocs. Comment l'application doit-elle gérer les blocs en cours ?

Si les blocs mettaient à jour une UI qui n'existe plus, il n'y a aucune raison que la tâche se poursuive. Par exemple, si la tâche consiste à charger des informations utilisateur à partir d'une base de données, puis à mettre à jour des vues, le thread n'est plus nécessaire.

En revanche, les paquets de tâches peuvent présenter certains avantages qui ne sont pas entièrement liés à l'interface utilisateur. Dans ce cas, vous devez conserver le thread. Par exemple, les paquets peuvent attendre de télécharger une image, la mettre en cache sur le disque et mettre à jour l'objet View associé. Bien que l'objet n'existe plus, les opérations de téléchargement et de mise en cache de l'image peuvent toujours être utiles, au cas où l'utilisateur revient à l'activité détruite.

La gestion manuelle des réponses du cycle de vie pour tous les objets de thread peut devenir extrêmement complexe. Si vous ne les gérez pas correctement, votre application peut présenter des conflits de mémoire et de performances. En combinant ViewModel et LiveData, vous pouvez charger des données et être informé des changements sans avoir à vous préoccuper du cycle de vie. Les objets ViewModel constituent une solution à ce problème. Les ViewModels sont conservés lors des modifications de configuration, ce qui permet de conserver facilement les données de vue. Pour en savoir plus sur les ViewModels, consultez le guide ViewModel. Pour en savoir plus sur LiveData, consultez le guide LiveData. Pour en savoir plus sur l'architecture des applications, consultez le guide de l'architecture des applications.

Priorité des threads

Comme décrit dans la section Processus et cycle de vie de l'application, la priorité reçue par les threads de votre application dépend en partie de son stade de cycle de vie. Lorsque vous créez et gérez des threads dans votre application, il est important de définir leur priorité afin que les bons threads obtiennent les priorités adéquates au bon moment. Si la valeur est trop élevée, votre thread peut interrompre le thread UI et le RenderThread, ce qui entraîne la perte d'images par votre application. Si la valeur est trop faible, vous pouvez ralentir les tâches asynchrones (telles que le chargement d'images).

Chaque fois que vous créez un thread, vous devez appeler setThreadPriority(). Le programmeur de threads du système donne la préférence aux threads présentant des priorités élevées, en équilibrant ces priorités avec la nécessité d'accomplir toutes les tâches à terme. En règle générale, les threads du groupe de premier plan obtiennent environ 95 % du temps d'exécution total de l'appareil, contre environ 5 % pour le groupe en arrière-plan.

Le système attribue également à chaque thread sa propre valeur de priorité, à l'aide de la classe Process.

Par défaut, le système définit la priorité d'un thread sur la même priorité et les mêmes appartenances aux groupes que le thread d'origine. Cependant, votre application peut ajuster explicitement la priorité des threads à l'aide de setThreadPriority().

La classe Process simplifie le processus d'attribution des valeurs de priorité en fournissant un ensemble de constantes que votre application peut utiliser pour définir les priorités des threads. Par exemple, THREAD_PRIORITY_DEFAULT représente la valeur par défaut d'un thread. Votre application doit définir la priorité du thread sur THREAD_PRIORITY_BACKGROUND pour les threads qui exécutent une tâche moins urgente.

Votre application peut utiliser les constantes THREAD_PRIORITY_LESS_FAVORABLE et THREAD_PRIORITY_MORE_FAVORABLE comme incrémenteurs pour définir des priorités relatives. Pour obtenir la liste des priorités des threads, consultez les constantes THREAD_PRIORITY dans la classe Process.

Pour en savoir plus sur la gestion des threads, consultez la documentation de référence sur les classes Thread et Process.

Classes d'aide pour les threads

Pour les développeurs qui utilisent Kotlin comme langage principal, nous recommandons l'utilisation de coroutines. Les coroutines offrent de nombreux avantages, y compris l'écriture de code asynchrone sans rappel, ainsi qu'une simultanéité structurée pour la surveillance, l'annulation et la gestion des exceptions.

Le framework fournit également les mêmes classes et primitives Java pour faciliter l'exécution de threads, comme les classes Thread, Runnable et Executors, ainsi que des classes supplémentaires telles que HandlerThread. Pour en savoir plus, consultez Exécution de threads sur Android.

Classe HandlerThread

Un thread handler est un thread de longue durée qui récupère le travail d'une file d'attente et l'exploite.

L'obtention d'images d'aperçu à partir de votre objet Camera constitue un enjeu courant. Lorsque vous vous souscrivez des images d'aperçu de la caméra, vous les recevez dans le rappel onPreviewFrame(), qui est appelé sur le thread d'événement à partir duquel il a été appelé. Si ce rappel était invoqué sur le thread UI, l'opération de gestion des très grands tableaux de pixels interfèrerait avec le rendu et le traitement des événements.

Dans cet exemple, lorsque votre application délègue la commande Camera.open() à un bloc de tâches dans le thread handler, le rappel onPreviewFrame() associé atterrit sur le thread handler, et non pas sur le thread UI. Cette solution est donc mieux adaptée si vous travaillez sur des pixels de longue durée.

Lorsque votre application crée un thread à l'aide de HandlerThread, pensez à définir la priorité du thread en fonction du type de tâche à effectuer. N'oubliez pas que les processeurs ne peuvent gérer qu'un petit nombre de threads en parallèle. Définir la priorité aide le système à savoir comment planifier cette tâche lorsque tous les autres threads se battent pour attirer l'attention.

Classe ThreadPoolExecutor

Certains types de tâches peuvent être réduits à des tâches distribuées hautement parallèles. Exemple : le calcul d'un filtre pour chaque bloc 8x8 d'une image de 8 mégapixels. Face au volume important de paquets de tâches que cela engendre, HandlerThread n'est pas la classe appropriée.

ThreadPoolExecutor est une classe d'assistance qui facilite ce processus. Elle gère la création d'un groupe de threads, définit leurs priorités et administre la répartition de la tâche entre ces threads. À mesure que la charge de travail augmente ou diminue, la classe démarre ou détruit davantage de threads pour s'adapter à la charge de travail.

Cette classe permet également à votre application de générer un nombre optimal de threads. Lorsqu'elle crée un objet ThreadPoolExecutor, l'application définit un nombre minimal et maximal de threads. À mesure que la charge de travail attribuée à ThreadPoolExecutor augmente, la classe prend en compte le nombre minimal et maximal de threads initialisés, ainsi que la quantité de travail en attente à effectuer. En fonction de ces facteurs, ThreadPoolExecutor décide du nombre de threads actifs à un moment donné.

Combien de threads devez-vous créer ?

Au niveau logiciel, votre code peut créer des centaines de threads, mais cela peut entraîner des problèmes de performances. Votre application partage des ressources de processeur limitées avec des services en arrière-plan, le moteur de rendu, le moteur audio, la mise en réseau, etc. Les processeurs ne peuvent gérer qu'un petit nombre de threads en parallèle. Le dépassement d'un certain seuil entraîne des problèmes de priorité et de planification. Il est donc important de ne créer que le nombre de threads nécessaires pour votre charge de travail.

En pratique, bien qu'un certain nombre de variables entrent en jeu, une stratégie fiable consiste à choisir une valeur (par exemple 4, pour commencer) puis à la tester avec Systrace. Vous pouvez procéder par tâtonnement afin d'identifier le nombre minimal de threads qu'il est possible d'utiliser sans rencontrer de problèmes.

Vous devez également tenir compte du fait que les threads consomment de la mémoire. Pour chaque thread, le coût minimal en mémoire est de 64 k. Cette valeur grimpe rapidement lorsque de nombreuses applications sont installées sur un appareil, en particulier dans les situations où les piles d'appels augmentent considérablement.

De nombreux processus système et bibliothèques tierces démarrent souvent leurs propres pools de threads. Si votre application peut réutiliser un pool de threads existant, cela peut améliorer les performances en réduisant les conflits au niveau de la mémoire et des ressources de traitement.