Асинхронная работа с потоками Java

Все приложения Android используют основной поток для обработки операций пользовательского интерфейса. Вызов длительных операций из этого основного потока может привести к зависаниям и зависанию системы. Например, если ваше приложение отправляет сетевой запрос из основного потока, пользовательский интерфейс вашего приложения замораживается до тех пор, пока оно не получит ответ сети. Если вы используете Java, вы можете создавать дополнительные фоновые потоки для обработки длительных операций, в то время как основной поток продолжает обрабатывать обновления пользовательского интерфейса.

В этом руководстве показано, как разработчики, использующие язык программирования Java, могут использовать пул потоков для настройки и использования нескольких потоков в приложении Android. В этом руководстве также показано, как определить код для запуска в потоке и как взаимодействовать между одним из этих потоков и основным потоком.

Библиотеки параллелизма

Важно понимать основы многопоточности и лежащие в ее основе механизмы. Однако существует множество популярных библиотек, которые предлагают абстракции более высокого уровня над этими концепциями и готовые к использованию утилиты для передачи данных между потоками. Эти библиотеки включают Guava и RxJava для пользователей языка программирования Java и Coroutines , которые мы рекомендуем пользователям Kotlin.

На практике вам следует выбрать тот, который лучше всего подходит для вашего приложения и вашей команды разработчиков, хотя правила многопоточности остаются прежними.

Обзор примеров

На основе Руководства по архитектуре приложений примеры в этом разделе выполняют сетевой запрос и возвращают результат в основной поток, где приложение затем может отобразить этот результат на экране.

В частности, ViewModel вызывает уровень данных в основном потоке, чтобы инициировать сетевой запрос. Уровень данных отвечает за перенос выполнения сетевого запроса из основного потока и отправку результата обратно в основной поток с помощью обратного вызова.

Чтобы перенести выполнение сетевого запроса из основного потока, нам нужно создать в нашем приложении другие потоки.

Создать несколько тем

Пул потоков — это управляемая коллекция потоков, которая параллельно выполняет задачи из очереди. Новые задачи выполняются в существующих потоках, когда эти потоки простаивают. Чтобы отправить задачу в пул потоков, используйте интерфейс ExecutorService . Обратите внимание, что ExecutorService не имеет ничего общего с Services , компонентом приложения Android.

Создание потоков требует больших затрат, поэтому пул потоков следует создавать только один раз при инициализации приложения. Обязательно сохраните экземпляр ExecutorService либо в классе Application , либо в контейнере внедрения зависимостей . В следующем примере создается пул потоков из четырех потоков, который мы можем использовать для запуска фоновых задач.

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

Существуют и другие способы настройки пула потоков в зависимости от ожидаемой рабочей нагрузки. Дополнительную информацию см. в разделе Настройка пула потоков .

Выполнить в фоновом потоке

Выполнение сетевого запроса в основном потоке приводит к тому, что поток ожидает или блокируется , пока не получит ответ. Поскольку поток заблокирован, ОС не может вызвать onDraw() , и ваше приложение зависает, что может привести к появлению диалогового окна «Приложение не отвечает» (ANR). Вместо этого давайте запустим эту операцию в фоновом потоке.

Сделать запрос

Во-первых, давайте взглянем на наш класс LoginRepository и посмотрим, как он выполняет сетевой запрос:

// 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() является синхронным и блокирует вызывающий поток. Для моделирования ответа на сетевой запрос у нас есть собственный класс Result .

Запустить запрос

ViewModel запускает сетевой запрос, когда пользователь нажимает, например, кнопку:

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

В предыдущем коде LoginViewModel блокирует основной поток при выполнении сетевого запроса. Мы можем использовать пул потоков, экземпляр которого мы создали, чтобы перенести выполнение в фоновый поток.

Обработка внедрения зависимостей

Во-первых, следуя принципам внедрения зависимостей , LoginRepository принимает экземпляр Executor , а не ExecutorService , поскольку он выполняет код, а не управляет потоками:

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

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

Метод Executor () принимает Runnable . Runnable — это интерфейс единого абстрактного метода (SAM) с методом run() , который выполняется в потоке при вызове.

Выполнять в фоновом режиме

Давайте создадим еще одну функцию под названием makeLoginRequest() , которая переносит выполнение в фоновый поток и пока игнорирует ответ:

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

Внутри метода execute() мы создаем новый Runnable с блоком кода, который мы хотим выполнить в фоновом потоке — в нашем случае это метод синхронного сетевого запроса. Внутренне ExecutorService управляет Runnable и выполняет его в доступном потоке.

Соображения

Любой поток в вашем приложении может работать параллельно с другими потоками, включая основной поток, поэтому вам следует убедиться, что ваш код является потокобезопасным. Обратите внимание, что в нашем примере мы избегаем записи в переменные, совместно используемые потоками, вместо этого передавая неизменяемые данные. Это хорошая практика, поскольку каждый поток работает со своим экземпляром данных, и мы избегаем сложности синхронизации.

Если вам необходимо разделить состояние между потоками, вы должны быть осторожны и управлять доступом из потоков с помощью механизмов синхронизации, таких как блокировки. Это выходит за рамки данного руководства. В общем, вам следует избегать совместного использования изменяемого состояния между потоками, когда это возможно.

Общайтесь с основной веткой

На предыдущем шаге мы проигнорировали ответ на сетевой запрос. Чтобы отобразить результат на экране, LoginViewModel должен знать об этом. Мы можем сделать это, используя обратные вызовы .

Функция makeLoginRequest() должна принимать обратный вызов в качестве параметра, чтобы она могла возвращать значение асинхронно. Обратный вызов с результатом вызывается всякий раз, когда сетевой запрос завершается или происходит сбой. В Котлине мы можем использовать функцию более высокого порядка. Однако в Java нам необходимо создать новый интерфейс обратного вызова, чтобы иметь ту же функциональность:

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 необходимо реализовать обратный вызов. В зависимости от результата он может выполнять разную логику:

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

В этом примере обратный вызов выполняется в вызывающем потоке, который является фоновым потоком. Это означает, что вы не можете изменять уровень пользовательского интерфейса или напрямую взаимодействовать с ним, пока не переключитесь обратно в основной поток.

Использовать обработчики

Вы можете использовать обработчик , чтобы поставить в очередь действие, которое будет выполнено в другом потоке. Чтобы указать поток, в котором будет выполняться действие, создайте Handler , используя Looper для потока. Looper — это объект, который запускает цикл сообщений для связанного потока. После создания Handler вы можете использовать метод post(Runnable) для запуска блока кода в соответствующем потоке.

Looper включает вспомогательную функцию getMainLooper() , которая извлекает Looper основного потока. Вы можете запустить код в основном потоке, используя этот Looper для создания Handler . Поскольку это то, что вы можете делать довольно часто, вы также можете сохранить экземпляр Handler в том же месте, где вы сохранили ExecutorService :

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

Хорошей практикой является внедрение обработчика в репозиторий, поскольку это дает вам больше гибкости. Например, в будущем вам может потребоваться передать другой Handler , чтобы планировать задачи в отдельном потоке. Если вы всегда обмениваетесь данными с одним и тем же потоком, вы можете передать Handler в конструктор репозитория, как показано в следующем примере.

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

В качестве альтернативы, если вам нужна большая гибкость, вы можете передать Handler каждой функции:

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

В этом примере обратный вызов, переданный в вызов makeLoginRequest репозитория, выполняется в основном потоке. Это означает, что вы можете напрямую изменить пользовательский интерфейс с помощью обратного вызова или использовать LiveData.setValue() для взаимодействия с пользовательским интерфейсом.

Настройка пула потоков

Вы можете создать пул потоков, используя одну из вспомогательных функций Executor с предопределенными настройками, как показано в предыдущем примере кода. Альтернативно, если вы хотите настроить детали пула потоков, вы можете создать экземпляр напрямую с помощью ThreadPoolExecutor . Вы можете настроить следующие детали:

  • Начальный и максимальный размер пула.
  • Сохраняйте время и единицу времени. Время поддержания активности — это максимальная продолжительность, в течение которой поток может оставаться бездействующим, прежде чем он закроется.
  • Входная очередь, содержащая Runnable задачи. Эта очередь должна реализовывать интерфейс BlockingQueue . Чтобы соответствовать требованиям вашего приложения, вы можете выбрать одну из доступных реализаций очереди. Дополнительные сведения см. в обзоре класса ThreadPoolExecutor .

Ниже приведен пример, в котором указывается размер пула потоков на основе общего количества ядер процессора, времени поддержания активности в одну секунду и очереди ввода.

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