Praca asynchroniczna z wątkami Javy

Wszystkie aplikacje na Androida używają wątku głównego do obsługi operacji UI. Wywoływanie długotrwałych operacji z tego wątku głównego może spowodować zawieszenie usługi i brak odpowiedzi. Jeśli na przykład aplikacja wysyła żądanie sieciowe z wątku głównego, jej interfejs użytkownika jest zablokowany, dopóki nie otrzyma odpowiedzi sieciowej. Jeśli używasz Javy, możesz utworzyć dodatkowe wątki w tle do obsługi długotrwałych operacji, a wątek główny będzie nadal obsługiwać aktualizacje interfejsu.

Ten przewodnik pokazuje, jak deweloperzy używający języka programowania Java mogą używać puli wątków do konfigurowania i używania wielu wątków w aplikacji na Androida. Dowiesz się z niego również, jak zdefiniować kod uruchamiany w wątku oraz jak komunikować się między jednym z tych wątków a wątkiem głównym.

Biblioteki równoczesności

Warto znać podstawy wątków i ich mechanizmy. Istnieje jednak wiele popularnych bibliotek, które oferują wyższe abstrakcje dotyczące tych pojęć i gotowe do użycia narzędzia do przekazywania danych między wątkami. Biblioteki te to m.in. Guava i RxJava dla użytkowników języka programowania Java oraz Coroutines, które zalecamy użytkownikom Kotlin.

W praktyce należy wybrać ten, który najlepiej sprawdza się w przypadku aplikacji i zespołu programistów, chociaż zasady tworzenia wątków pozostają takie same.

Przegląd przykładów

Zgodnie z przewodnikiem po architekturze aplikacji przykłady w tym temacie wysyłają żądanie sieciowe i zwracają wynik do wątku głównego, w którym aplikacja może wyświetlić wynik na ekranie.

W szczególności ViewModel wywołuje warstwę danych w wątku głównym, aby aktywować żądanie sieciowe. Warstwa danych odpowiada za przeniesienie wykonania żądania sieciowego z wątku głównego i opublikowanie wyniku z powrotem do wątku głównego za pomocą wywołania zwrotnego.

Aby przenieść wykonywanie żądania sieciowego z wątku głównego, musimy utworzyć inne wątki w naszej aplikacji.

Utwórz wiele wątków

Pula wątków to zarządzany zbiór wątków, który uruchamia zadania równolegle z kolejki. W istniejących wątkach będą wykonywane nowe zadania, gdy staną się one nieaktywne. Aby wysłać zadanie do puli wątków, użyj interfejsu ExecutorService. Pamiętaj, że ExecutorService nie ma nic wspólnego z Usługami – komponentem aplikacji na Androida.

Tworzenie wątków jest kosztowne, dlatego pulę wątków należy utworzyć tylko raz podczas inicjowania aplikacji. Pamiętaj, aby zapisać instancję ExecutorService w klasie Application lub w kontenerze wstrzykiwania zależności. Poniższy przykład pokazuje pulę wątków składających się z 4 wątków, których można używać do uruchamiania zadań w tle.

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

Istnieją inne sposoby konfigurowania puli wątków w zależności od oczekiwanego obciążenia. Więcej informacji znajdziesz w artykule Konfigurowanie puli wątków.

Wykonaj w wątku w tle

Wysłanie żądania sieciowego w wątku głównym powoduje, że wątek poczeka lub zablokuje go, aż otrzyma odpowiedź. Ponieważ wątek jest zablokowany, system operacyjny nie może wywołać funkcji onDraw(), a aplikacja zawiesza się, co może prowadzić do wyświetlenia okna błędu ANR (Application Not Responding). Wykonajmy ją w wątku w tle.

Prześlij prośbę

Przyjrzyjmy się najpierw klasie LoginRepository, aby zobaczyć, jak wysyła żądanie sieciowe:

// 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() jest synchroniczny i blokuje wątek wywołujący. Do modelowania odpowiedzi na żądanie sieciowe mamy własną klasę Result.

Wyślij żądanie

ViewModel wywołuje żądanie sieciowe, gdy użytkownik kliknie na przykład przycisk:

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

Za pomocą poprzedniego kodu LoginViewModel blokuje wątek główny podczas wysyłania żądania sieciowego. Możemy użyć puli wątków, którą utworzyliśmy w instancji, do przeniesienia wykonania do wątku w tle.

Obsługa wstrzykiwania zależności

Po pierwsze zgodnie z zasadami wstrzykiwania zależności LoginRepository przyjmuje wystąpienie elementu Executor zamiast ExecutorService, ponieważ wykonuje kod i nie zarządza wątkami:

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

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

Metoda execute() wykonawcy wymaga klasy Runnable. Runnable to interfejs pojedynczej metody abstrakcyjnej (SAM) z metodą run(), która jest wykonywana w wątku po wywołaniu.

Wykonuj w tle

Utwórzmy inną funkcję o nazwie makeLoginRequest(), która przenosi wykonanie do wątku w tle i na razie ignoruje odpowiedź:

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

W metodzie execute() tworzymy nowy blok kodu Runnable z blokiem kodu, który chcemy uruchamiać w wątku w tle – w naszym przypadku z synchroniczną metodą żądania sieci. ExecutorService zarządza wewnętrznie elementem Runnable i wykonuje go w dostępnym wątku.

co należy wziąć pod uwagę

Każdy wątek w aplikacji może działać równolegle do innych wątków, w tym wątku głównego, dlatego sprawdź, czy Twój kod jest w nim bezpieczny. Zwróć uwagę, że w naszym przykładzie unikamy zapisywania zmian w zmiennych udostępnianych między wątkami i przekazywania niezmiennych danych. To dobra metoda, bo każdy wątek korzysta z własnej instancji danych, a to pozwala uniknąć złożoności synchronizacji.

Jeśli musisz udostępniać stan między wątkami, pamiętaj, aby zarządzać dostępem z wątków za pomocą mechanizmów synchronizacji, takich jak blokady. Nie wchodzi to w zakres tego przewodnika. W miarę możliwości unikaj udostępniania zmiennych stanów między wątkami.

Komunikacja z wątkiem głównym

W poprzednim kroku zignorowaliśmy odpowiedź na żądanie sieciowe. Aby wyświetlić wynik na ekranie, LoginViewModel musi o nim wiedzieć. Możemy to zrobić, stosując wywołania zwrotne.

Funkcja makeLoginRequest() powinna przyjmować wywołanie zwrotne jako parametr, by asynchronicznie zwracać wartość. Wywołanie zwrotne z wynikiem jest wywoływane za każdym razem, gdy żądanie sieciowe zostanie ukończone lub wystąpi błąd. W Kotlin możemy użyć funkcji wyższego rzędu. W języku Java musimy jednak utworzyć nowy interfejs wywołania zwrotnego, który będzie miał taką samą funkcjonalność:

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 musi teraz wdrożyć wywołanie zwrotne. Działanie tej funkcji może być różne w zależności od wyniku:

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

W tym przykładzie wywołanie zwrotne jest wykonywane w wywołanym wątku, który jest wątkiem w tle. Oznacza to, że nie możesz modyfikować warstwy interfejsu ani komunikować się z nią bezpośrednio, dopóki nie przełączysz się z powrotem na wątek główny.

Używanie modułów obsługi

Aby dodać do kolejki działanie, które ma zostać wykonane w innym wątku, możesz użyć modułu obsługi. Aby określić wątek, w którym ma być uruchomione działanie, utwórz Handler za pomocą Zapętlacza wątku. Looper to obiekt, który uruchamia pętlę wiadomości w powiązanym wątku. Po utworzeniu Handler możesz użyć metody post(Runnable), aby uruchomić blok kodu w odpowiednim wątku.

Looper zawiera funkcję pomocniczą getMainLooper(), która pobiera Looper wątku głównego. Możesz uruchomić kod w wątku głównym, używając tego polecenia Looper do utworzenia Handler. Jest to rozwiązanie, które często powtarzasz, więc możesz też zapisać wystąpienie obiektu Handler w tym samym miejscu, w którym został zapisany ExecutorService:

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

Zalecamy wstrzyknięcie modułu obsługi do repozytorium, ponieważ zapewnia to większą elastyczność. W przyszłości możesz np. chcieć przekazywać inny Handler, aby zaplanować zadania w osobnym wątku. Jeśli zawsze komunikujesz się z powrotem w tym samym wątku, możesz przekazać Handler do konstruktora repozytorium, jak pokazano w poniższym przykładzie.

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

Jeśli potrzebujesz większej elastyczności, możesz przekazać obiekt Handler do każdej funkcji:

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

W tym przykładzie wywołanie zwrotne przekazane do wywołania makeLoginRequest repozytorium jest wykonywane w wątku głównym. Oznacza to, że możesz modyfikować interfejs bezpośrednio z poziomu wywołania zwrotnego lub używać LiveData.setValue() do komunikowania się z nim.

Konfigurowanie puli wątków

Pulę wątków możesz utworzyć za pomocą jednej z funkcji pomocniczych wykonawcy ze wstępnie zdefiniowanymi ustawieniami, jak pokazano w poprzednim przykładowym kodzie. Jeśli chcesz dostosować szczegóły puli wątków, możesz utworzyć instancję bezpośrednio za pomocą polecenia ThreadPoolExecutor. Możesz skonfigurować te szczegóły:

  • Początkowy i maksymalny rozmiar puli.
  • Utrzymanie czasu aktywności i jednostka czasu. Czas utrzymywania aktywności to maksymalny czas, przez jaki wątek może pozostawać nieaktywny, zanim zostanie wyłączony.
  • Kolejka wejściowa zawierająca zadania (Runnable). Ta kolejka musi implementować interfejs BlockQueue. Aby spełnić wymagania aplikacji, możesz wybrać jedną z dostępnych implementacji kolejek. Więcej informacji znajdziesz w omówieniu klasy funkcji ThreadPoolExecutor.

Oto przykład, który określa rozmiar puli wątków na podstawie łącznej liczby rdzeni procesora, czasu utrzymywania aktywności wynoszącego 1 sekundę i kolejki wejściowej.

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