Tâches asynchrones avec des threads Java

Toutes les applications Android utilisent un thread principal pour gérer les opérations de l'interface utilisateur. L'appel d'opérations de longue durée à partir de ce thread principal peut entraîner des blocages et une absence de réponse. Par exemple, si votre application effectue une requête réseau à partir du thread principal, son UI est bloquée jusqu'à ce qu'elle reçoive la réponse réseau. Si vous utilisez Java, vous pouvez créer des threads d'arrière-plan supplémentaires pour gérer les opérations de longue durée tandis que le thread principal continue à gérer les mises à jour de l'interface utilisateur.

Ce guide explique comment les développeurs utilisant le langage de programmation Java peuvent utiliser un pool de threads pour configurer et utiliser plusieurs threads dans une application Android. Il explique également comment définir le code à exécuter sur un thread et comment communiquer entre l'un de ces threads et le thread principal.

Bibliothèques de simultanéité

Il est important de comprendre les principes de base des threads et leurs mécanismes sous-jacents. Toutefois, de nombreuses bibliothèques populaires proposent des abstractions de niveau supérieur sur ces concepts et des utilitaires prêts à l'emploi pour la transmission de données entre les threads. Ces bibliothèques incluent Guava et RxJava pour les utilisateurs du langage de programmation Java, et les coroutines, que nous recommandons pour les utilisateurs de Kotlin.

En pratique, vous devez choisir celui qui convient le mieux à votre application et à votre équipe de développement. Toutefois, les règles de threading restent les mêmes.

Présentation des exemples

Sur la base du guide de l'architecture des applications, les exemples de cet article effectuent une requête réseau et renvoient le résultat au thread principal, où l'application peut alors afficher ce résultat à l'écran.

Plus précisément, ViewModel appelle la couche de données sur le thread principal pour déclencher la requête réseau. La couche de données est chargée de déplacer l'exécution de la requête réseau en dehors du thread principal et de publier le résultat dans le thread principal à l'aide d'un rappel.

Pour retirer l'exécution de la requête réseau du thread principal, nous devons créer d'autres threads dans notre application.

Créer plusieurs threads

Un pool de threads est une collection gérée de threads qui exécute des tâches en parallèle à partir d'une file d'attente. Les nouvelles tâches sont exécutées sur des threads existants lorsque ceux-ci deviennent inactifs. Pour envoyer une tâche à un pool de threads, utilisez l'interface ExecutorService. Notez que ExecutorService n'a rien à voir avec Services, le composant d'application Android.

La création de threads est coûteuse. Vous ne devez donc créer un pool de threads qu'une seule fois lors de l'initialisation de votre application. Veillez à enregistrer l'instance de ExecutorService dans la classe Application ou dans un conteneur d'injection de dépendances. L'exemple suivant crée un pool de quatre threads que nous pouvons utiliser pour exécuter des tâches en arrière-plan.

public class MyApplication extends Application {
    ExecutorService executorService = Executors.newFixedThreadPool(4);
}

Il existe d'autres façons de configurer un pool de threads en fonction de la charge de travail attendue. Pour en savoir plus, consultez la section Configurer un pool de threads.

Exécuter dans un thread d'arrière-plan

L'envoi d'une requête réseau sur le thread principal entraîne l'attente ou le blocage du thread jusqu'à ce qu'il reçoive une réponse. Comme le thread est bloqué, le système d'exploitation ne peut pas appeler onDraw(), et votre application se fige, ce qui peut entraîner l'affichage d'une boîte de dialogue ANR (L'application ne répond pas). Exécutons plutôt cette opération sur un thread d'arrière-plan.

Envoyer la requête

Tout d'abord, examinons la classe LoginRepository et voyons comment elle envoie la requête réseau:

// Result.java
public abstract class Result<T> {
    private Result() {}

    public static final class Success<T> extends Result<T> {
        public T data;

        public Success(T data) {
            this.data = data;
        }
    }

    public static final class Error<T> extends Result<T> {
        public Exception exception;

        public Error(Exception exception) {
            this.exception = exception;
        }
    }
}

// LoginRepository.java
public class LoginRepository {

    private final String loginUrl = "https://example.com/login";
    private final LoginResponseParser responseParser;

    public LoginRepository(LoginResponseParser responseParser) {
        this.responseParser = responseParser;
    }

    public Result<LoginResponse> makeLoginRequest(String jsonBody) {
        try {
            URL url = new URL(loginUrl);
            HttpURLConnection httpConnection = (HttpURLConnection) url.openConnection();
            httpConnection.setRequestMethod("POST");
            httpConnection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
            httpConnection.setRequestProperty("Accept", "application/json");
            httpConnection.setDoOutput(true);
            httpConnection.getOutputStream().write(jsonBody.getBytes("utf-8"));

            LoginResponse loginResponse = responseParser.parse(httpConnection.getInputStream());
            return new Result.Success<LoginResponse>(loginResponse);
        } catch (Exception e) {
            return new Result.Error<LoginResponse>(e);
        }
    }
}

makeLoginRequest() est synchrone et bloque le thread d'appel. Pour modéliser la réponse à la requête réseau, nous disposons de notre propre classe Result.

Déclencher la requête

ViewModel déclenche la requête réseau lorsque l'utilisateur appuie, par exemple sur un bouton:

public class LoginViewModel {

    private final LoginRepository loginRepository;

    public LoginViewModel(LoginRepository loginRepository) {
        this.loginRepository = loginRepository;
    }

    public void makeLoginRequest(String username, String token) {
        String jsonBody = "{ username: \"" + username + "\", token: \"" + token + "\" }";
        loginRepository.makeLoginRequest(jsonBody);
    }
}

Avec le code précédent, LoginViewModel bloque le thread principal lors de l'envoi de la requête réseau. Nous pouvons utiliser le pool de threads que nous avons instancié pour déplacer l'exécution vers un thread d'arrière-plan.

Gérer l'injection de dépendances

Tout d'abord, conformément aux principes d'injection de dépendances, LoginRepository utilise une instance de l'exécuteur plutôt que ExecutorService, car il exécute le code et ne gère pas les threads:

public class LoginRepository {
    ...
    private final Executor executor;

    public LoginRepository(LoginResponseParser responseParser, Executor executor) {
        this.responseParser = responseParser;
        this.executor = executor;
    }
    ...
}

La méthode execute() de l'exécuteur utilise un Runnable. Un Runnable est une interface SAM (Single Abstract Method) avec une méthode run(), qui est exécutée dans un thread lorsqu'elle est appelée.

Exécuter en arrière-plan

Créons une autre fonction appelée makeLoginRequest() qui déplace l'exécution vers le thread d'arrière-plan et ignore la réponse pour le moment:

public class LoginRepository {
    ...
    public void makeLoginRequest(final String jsonBody) {
        executor.execute(new Runnable() {
            @Override
            public void run() {
                Result<LoginResponse> ignoredResponse = makeSynchronousLoginRequest(jsonBody);
            }
        });
    }

    public Result<LoginResponse> makeSynchronousLoginRequest(String jsonBody) {
        ... // HttpURLConnection logic
    }
    ...
}

Dans la méthode execute(), nous créons un Runnable avec le bloc de code que nous souhaitons exécuter dans le thread d'arrière-plan (dans notre cas, la méthode de requête réseau synchrone). En interne, ExecutorService gère Runnable et l'exécute dans un thread disponible.

Points à prendre en compte

Tout thread de votre application peut s'exécuter en parallèle d'autres threads, y compris le thread principal. Vous devez donc vous assurer que votre code est thread-safe. Notez que dans notre exemple, nous évitons d'écrire dans des variables partagées entre les threads, en transmettant à la place des données immuables. C'est une bonne pratique, car chaque thread fonctionne avec sa propre instance de données et nous évitons la complexité de la synchronisation.

Si vous devez partager un état entre threads, vous devez veiller à gérer l'accès des threads à l'aide de mécanismes de synchronisation tels que des verrous. Cela n'entre pas dans le cadre de ce guide. En général, vous devez éviter de partager un état modifiable entre les threads dans la mesure du possible.

Communiquer avec le thread principal

À l'étape précédente, nous avons ignoré la réponse à la requête réseau. Pour afficher le résultat à l'écran, LoginViewModel doit en être informé. Pour ce faire, nous pouvons utiliser des rappels.

La fonction makeLoginRequest() doit utiliser un rappel comme paramètre afin de pouvoir renvoyer une valeur de manière asynchrone. Le rappel avec le résultat est appelé chaque fois que la requête réseau se termine ou qu'un échec se produit. En langage Kotlin, nous pouvons utiliser une fonction d'ordre supérieur. Toutefois, en Java, nous devons créer une nouvelle interface de rappel pour avoir la même fonctionnalité:

interface RepositoryCallback<T> {
    void onComplete(Result<T> result);
}

public class LoginRepository {
    ...
    public void makeLoginRequest(
        final String jsonBody,
        final RepositoryCallback<LoginResponse> callback
    ) {
        executor.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    Result<LoginResponse> result = makeSynchronousLoginRequest(jsonBody);
                    callback.onComplete(result);
                } catch (Exception e) {
                    Result<LoginResponse> errorResult = new Result.Error<>(e);
                    callback.onComplete(errorResult);
                }
            }
        });
    }
  ...
}

ViewModel doit maintenant implémenter le rappel. Elle peut exécuter une logique différente en fonction du résultat:

public class LoginViewModel {
    ...
    public void makeLoginRequest(String username, String token) {
        String jsonBody = "{ username: \"" + username + "\", token: \"" + token + "\" }";
        loginRepository.makeLoginRequest(jsonBody, new RepositoryCallback<LoginResponse>() {
            @Override
            public void onComplete(Result<LoginResponse> result) {
                if (result instanceof Result.Success) {
                    // Happy path
                } else {
                    // Show error in UI
                }
            }
        });
    }
}

Dans cet exemple, le rappel est exécuté dans le thread appelant, qui est un thread d'arrière-plan. Cela signifie que vous ne pouvez pas modifier la couche d'interface utilisateur ni communiquer directement avec elle tant que vous ne rebasculez pas vers le thread principal.

Utiliser des gestionnaires

Vous pouvez utiliser un Handler pour mettre en file d'attente une action à effectuer sur un autre thread. Pour spécifier le thread sur lequel exécuter l'action, créez le Handler à l'aide d'un Looper pour le thread. Un Looper est un objet qui exécute la boucle de messages pour un thread associé. Une fois que vous avez créé un Handler, vous pouvez utiliser la méthode post(Runnable) pour exécuter un bloc de code dans le thread correspondant.

Looper inclut une fonction d'assistance, getMainLooper(), qui récupère l'Looper du thread principal. Vous pouvez exécuter du code dans le thread principal en utilisant ce Looper pour créer un Handler. Comme vous le faites souvent, vous pouvez enregistrer une instance de Handler à l'emplacement où vous avez enregistré l'ExecutorService:

public class MyApplication extends Application {
    ExecutorService executorService = Executors.newFixedThreadPool(4);
    Handler mainThreadHandler = HandlerCompat.createAsync(Looper.getMainLooper());
}

Nous vous recommandons d'injecter le gestionnaire dans le dépôt, car cela vous offre plus de flexibilité. Par exemple, vous souhaiterez peut-être transmettre ultérieurement un autre élément Handler pour planifier des tâches sur un thread distinct. Si vous communiquez toujours au même thread, vous pouvez transmettre Handler au constructeur de dépôt, comme illustré dans l'exemple suivant.

public class LoginRepository {
    ...
    private final Handler resultHandler;

    public LoginRepository(LoginResponseParser responseParser, Executor executor,
            Handler resultHandler) {
        this.responseParser = responseParser;
        this.executor = executor;
        this.resultHandler = resultHandler;
    }

    public void makeLoginRequest(
        final String jsonBody,
        final RepositoryCallback<LoginResponse> callback
    ) {
        executor.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    Result<LoginResponse> result = makeSynchronousLoginRequest(jsonBody);
                    notifyResult(result, callback);
                } catch (Exception e) {
                    Result<LoginResponse> errorResult = new Result.Error<>(e);
                    notifyResult(errorResult, callback);
                }
            }
        });
    }

    private void notifyResult(
        final Result<LoginResponse> result,
        final RepositoryCallback<LoginResponse> callback,
    ) {
        resultHandler.post(new Runnable() {
            @Override
            public void run() {
                callback.onComplete(result);
            }
        });
    }
    ...
}

Si vous souhaitez plus de flexibilité, vous pouvez également transmettre un Handler à chaque fonction:

public class LoginRepository {
    ...

    public void makeLoginRequest(
        final String jsonBody,
        final RepositoryCallback<LoginResponse> callback,
        final Handler resultHandler,
    ) {
        executor.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    Result<LoginResponse> result = makeSynchronousLoginRequest(jsonBody);
                    notifyResult(result, callback, resultHandler);
                } catch (Exception e) {
                    Result<LoginResponse> errorResult = new Result.Error<>(e);
                    notifyResult(errorResult, callback, resultHandler);
                }
            }
        });
    }

    private void notifyResult(
        final Result<LoginResponse> result,
        final RepositoryCallback<LoginResponse> callback,
        final Handler resultHandler
    ) {
        resultHandler.post(new Runnable() {
            @Override
            public void run() {
                callback.onComplete(result);
            }
        });
    }
}

Dans cet exemple, le rappel transmis à l'appel makeLoginRequest du Repository est exécuté sur le thread principal. Cela signifie que vous pouvez modifier directement l'UI à partir du rappel ou utiliser LiveData.setValue() pour communiquer avec l'UI.

Configurer un pool de threads

Vous pouvez créer un pool de threads à l'aide de l'une des fonctions d'assistance Executor avec des paramètres prédéfinis, comme indiqué dans l'exemple de code précédent. Si vous souhaitez personnaliser les détails du pool de threads, vous pouvez également créer une instance directement à l'aide de ThreadPoolExecutor. Vous pouvez configurer les informations suivantes:

  • Taille initiale et maximale du pool.
  • Valeur Keep alive et unité de temps. La durée de conservation est la durée maximale pendant laquelle un thread peut rester inactif avant de s'arrêter.
  • Une file d'attente d'entrée contenant des tâches Runnable Cette file d'attente doit implémenter l'interface BlockingQueue. Pour répondre aux exigences de votre application, vous pouvez choisir parmi les implémentations de file d'attente disponibles. Pour en savoir plus, consultez la présentation de la classe ThreadPoolExecutor.

Voici un exemple qui spécifie la taille du pool de threads en fonction du nombre total de cœurs de processeur, d'une durée de conservation d'une seconde et d'une file d'attente d'entrée.

public class MyApplication extends Application {
    /*
     * Gets the number of available cores
     * (not always the same as the maximum number of cores)
     */
    private static int NUMBER_OF_CORES = Runtime.getRuntime().availableProcessors();

    // Instantiates the queue of Runnables as a LinkedBlockingQueue
    private final BlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<Runnable>();

    // Sets the amount of time an idle thread waits before terminating
    private static final int KEEP_ALIVE_TIME = 1;
    // Sets the Time Unit to seconds
    private static final TimeUnit KEEP_ALIVE_TIME_UNIT = TimeUnit.SECONDS;

    // Creates a thread pool manager
    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
            NUMBER_OF_CORES,       // Initial pool size
            NUMBER_OF_CORES,       // Max pool size
            KEEP_ALIVE_TIME,
            KEEP_ALIVE_TIME_UNIT,
            workQueue
    );
    ...
}