Asynchrones Arbeiten mit Java-Threads

Alle Android-Apps verwenden einen Hauptthread zur Verarbeitung von UI-Vorgängen. Wenn Vorgänge mit langer Ausführungszeit von diesem Hauptthread aus aufgerufen werden, kann es zu Einfrieren und Nichtreaktion kommen. Wenn Ihre Anwendung beispielsweise eine Netzwerkanfrage vom Hauptthread aus sendet, bleibt die Benutzeroberfläche Ihrer Anwendung eingefroren, bis sie die Netzwerkantwort erhält. Wenn Sie Java verwenden, können Sie zusätzliche Hintergrundthreads erstellen, um lang andauernde Vorgänge zu verarbeiten, während der Hauptthread weiterhin UI-Aktualisierungen verarbeitet.

In diesem Leitfaden erfahren Sie, wie Entwickler, die die Programmiersprache Java verwenden, einen Thread-Pool verwenden können, um in einer Android-App mehrere Threads einzurichten und zu verwenden. Außerdem erfahren Sie, wie Sie Code definieren, der in einem Thread ausgeführt wird, und wie zwischen einem dieser Threads und dem Hauptthread kommuniziert wird.

Nebenläufigkeitsbibliotheken

Es ist wichtig, die Grundlagen von Threading und die zugrunde liegenden Mechanismen zu verstehen. Es gibt jedoch viele beliebte Bibliotheken, die eine höhere Abstraktion dieser Konzepte und gebrauchsfertige Dienstprogramme für die Weitergabe von Daten zwischen Threads ermöglichen. Zu diesen Bibliotheken gehören Guava und RxJava für Nutzer der Programmiersprache Java sowie Coroutinen, die wir Nutzern von Kotlin empfehlen.

In der Praxis sollten Sie diejenige auswählen, die für Ihre Anwendung und Ihr Entwicklungsteam am besten geeignet ist, wobei die Regeln für das Threading unverändert bleiben.

Beispiele – Übersicht

Basierend auf dem Leitfaden zur Anwendungsarchitektur wird bei den Beispielen in diesem Thema eine Netzwerkanfrage gestellt und das Ergebnis an den Hauptthread zurückgegeben. Dort kann die Anwendung dieses Ergebnis dann auf dem Bildschirm anzeigen.

Insbesondere ruft ViewModel die Datenschicht im Hauptthread auf, um die Netzwerkanfrage auszulösen. Die Datenschicht ist dafür zuständig, die Ausführung der Netzwerkanfrage aus dem Hauptthread zu verschieben und das Ergebnis mithilfe eines Callbacks zurück an den Hauptthread zu senden.

Um die Ausführung der Netzwerkanfrage aus dem Hauptthread zu verschieben, müssen wir weitere Threads in der Anwendung erstellen.

Mehrere Threads erstellen

Ein Thread-Pool ist eine verwaltete Sammlung von Threads, die Aufgaben parallel aus einer Warteschlange ausführen. Neue Aufgaben werden in vorhandenen Threads ausgeführt, wenn diese inaktiv werden. Verwenden Sie die Schnittstelle ExecutorService, um eine Aufgabe an einen Threadpool zu senden. ExecutorService hat nichts mit Diensten zu tun, der Android-Anwendungskomponente.

Das Erstellen von Threads ist teuer. Daher sollten Sie einen Threadpool nur einmal während der Initialisierung Ihrer Anwendung erstellen. Speichern Sie die Instanz von ExecutorService entweder in der Application-Klasse oder in einem Abhängigkeitsinjektions-Container. Im folgenden Beispiel wird ein Thread-Pool aus vier Threads erstellt, mit denen Hintergrundaufgaben ausgeführt werden können.

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

Es gibt andere Möglichkeiten, einen Thread-Pool abhängig von der erwarteten Arbeitslast zu konfigurieren. Weitere Informationen finden Sie unter Thread-Pool konfigurieren.

In einem Hintergrundthread ausführen

Bei einer Netzwerkanfrage an den Hauptthread wird der Thread gewartet oder blockiert, bis er eine Antwort erhält. Da der Thread blockiert ist, kann das Betriebssystem onDraw() nicht aufrufen und Ihre Anwendung bleibt hängen, was zu einem Dialogfeld „App antwortet nicht“ (ANR) führt. Lassen Sie uns diesen Vorgang stattdessen in einem Hintergrundthread ausführen.

Anfrage stellen

Sehen wir uns zuerst die Klasse LoginRepository an und sehen wir uns an, wie sie die Netzwerkanfrage sendet:

// 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() ist synchron und blockiert den aufrufenden Thread. Um die Antwort auf die Netzwerkanfrage zu modellieren, verwenden wir eine eigene Result-Klasse.

Anfrage auslösen

Die ViewModel löst die Netzwerkanfrage aus, wenn der Nutzer auf eine Schaltfläche tippt:

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);
    }
}

Mit dem vorherigen Code blockiert LoginViewModel den Hauptthread, wenn die Netzwerkanfrage gestellt wird. Mit dem von uns instanziierten Thread-Pool verschieben wir die Ausführung in einen Hintergrundthread.

Abhängigkeitsinjektion handhaben

Zuerst verwendet LoginRepository gemäß den Prinzipien der Abhängigkeitsinjektion eine Instanz von Executor anstelle von ExecutorService, da er Code ausführt und keine Threads verwaltet:

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

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

Die Methode execute() des Executors verwendet ein Runnable. Ein Runnable ist eine Schnittstelle für eine abstrakte Methode (Single Abstrakte Methode) mit einer run()-Methode, die beim Aufrufen in einem Thread ausgeführt wird.

Im Hintergrund ausführen

Lassen Sie uns eine weitere Funktion namens makeLoginRequest() erstellen, die die Ausführung in den Hintergrundthread verschiebt und die Antwort vorerst ignoriert:

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

In der Methode execute() erstellen wir eine neue Runnable mit dem Codeblock, den wir im Hintergrundthread ausführen möchten – in unserem Fall die synchrone Netzwerkanfragemethode. Intern verwaltet der ExecutorService den Runnable und führt ihn in einem verfügbaren Thread aus.

Wissenswertes

Jeder Thread in der Anwendung kann parallel zu anderen Threads ausgeführt werden, einschließlich des Hauptthreads. Daher sollten Sie dafür sorgen, dass Ihr Code Thread-sicher ist. Beachten Sie, dass wir in unserem Beispiel nicht in Variablen schreiben, die zwischen Threads gemeinsam genutzt werden, und übergeben stattdessen unveränderliche Daten. Dies ist eine bewährte Methode, da jeder Thread mit seiner eigenen Dateninstanz arbeitet und die Komplexität der Synchronisierung vermieden wird.

Wenn Sie den Status zwischen Threads teilen müssen, müssen Sie vorsichtig sein, um den Zugriff von Threads mithilfe von Synchronisierungsmechanismen wie Sperren zu verwalten. Dies wird in diesem Leitfaden nicht behandelt. Im Allgemeinen sollten Sie nach Möglichkeit vermeiden, einen änderbaren Status zwischen Threads zu teilen.

Mit dem Hauptthread kommunizieren

Im vorherigen Schritt haben wir die Antwort auf die Netzwerkanfrage ignoriert. Damit das Ergebnis auf dem Bildschirm angezeigt wird, muss LoginViewModel darüber Bescheid wissen. Dazu können wir Callbacks verwenden.

Die Funktion makeLoginRequest() sollte einen Callback als Parameter verwenden, damit ein Wert asynchron zurückgegeben werden kann. Der Callback mit dem Ergebnis wird immer dann aufgerufen, wenn die Netzwerkanfrage abgeschlossen ist oder ein Fehler auftritt. In Kotlin können wir eine höhere Reihenfolge verwenden. In Java müssen wir jedoch eine neue Callback-Schnittstelle erstellen, um die gleiche Funktionalität zu haben:

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

Der ViewModel muss den Callback jetzt implementieren. Sie kann je nach Ergebnis unterschiedliche Logiken ausführen:

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
                }
            }
        });
    }
}

In diesem Beispiel wird der Callback im aufrufenden Thread ausgeführt, bei dem es sich um einen Hintergrundthread handelt. Das bedeutet, dass Sie die UI-Ebene erst dann ändern oder direkt mit ihr kommunizieren können, wenn Sie zurück zum Hauptthread wechseln.

Handler verwenden

Sie können einen Handler verwenden, um eine Aktion in die Warteschlange zu stellen, die für einen anderen Thread ausgeführt werden soll. Erstellen Sie das Handler mit einem Looper für den Thread, um den Thread anzugeben, für den die Aktion ausgeführt werden soll. Ein Looper ist ein Objekt, das die Nachrichtenschleife für einen verknüpften Thread ausführt. Nachdem Sie einen Handler erstellt haben, können Sie mit der Methode post(Runnable) einen Codeblock im entsprechenden Thread ausführen.

Looper enthält die Hilfsfunktion getMainLooper(), die den Looper des Hauptthreads abruft. Sie können Code im Hauptthread ausführen. Dazu verwenden Sie Looper, um ein Handler zu erstellen. Da dies sehr häufig der Fall ist, können Sie auch eine Instanz von Handler dort speichern, wo Sie ExecutorService gespeichert haben:

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

Es empfiehlt sich, den Handler in das Repository zu injizieren, da Sie dadurch mehr Flexibilität haben. In Zukunft möchten Sie beispielsweise einen anderen Handler übergeben, um Aufgaben in einem separaten Thread zu planen. Wenn Sie immer zum selben Thread zurückkehren, können Sie den Handler an den Repository-Konstruktor übergeben, wie im folgenden Beispiel gezeigt.

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

Wenn Sie mehr Flexibilität wünschen, können Sie alternativ eine Handler an jede Funktion übergeben:

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);
            }
        });
    }
}

In diesem Beispiel wird der an den makeLoginRequest-Aufruf des Repositorys übergebene Callback im Hauptthread ausgeführt. Das bedeutet, dass Sie die UI direkt über den Callback ändern oder LiveData.setValue() für die Kommunikation mit der UI verwenden können.

Threadpool konfigurieren

Sie können einen Thread-Pool mit einer der Executor-Hilfsfunktionen mit vordefinierten Einstellungen erstellen, wie im vorherigen Beispielcode gezeigt. Wenn Sie die Details des Threadpools anpassen möchten, können Sie alternativ direkt mit ThreadPoolExecutor eine Instanz erstellen. Sie können die folgenden Details konfigurieren:

  • Anfängliche und maximale Poolgröße.
  • Keep alive Time und Zeiteinheit. Die Keep-Alive-Zeit ist die maximale Dauer, die ein Thread inaktiv sein kann, bevor er herunterfährt.
  • Eine Eingabewarteschlange, die Runnable Aufgaben enthält. Diese Warteschlange muss die BlockingQueue-Schnittstelle implementieren. Je nach Anforderungen Ihrer Anwendung können Sie aus den verfügbaren Warteschlangenimplementierungen auswählen. Weitere Informationen finden Sie in der Klassenübersicht für ThreadPoolExecutor.

In diesem Beispiel wird die Größe des Threadpools anhand der Gesamtzahl der Prozessorkerne, einer Keep-Alive-Zeit von einer Sekunde und einer Eingabewarteschlange angegeben.

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