Trabajo asíncrono con subprocesos de Java

Todas las apps para Android usan un subproceso principal para controlar las operaciones de la IU. Llamar a operaciones de larga duración desde este subproceso principal puede generar bloqueos y falta de respuesta. Por ejemplo, si tu app realiza una solicitud de red desde el subproceso principal, su IU se inmoviliza hasta que recibe la respuesta de red. Si usas Java, puedes crear subprocesos en segundo plano adicionales para controlar operaciones de larga duración mientras el subproceso principal continúa controlando las actualizaciones de la IU.

En esta guía, se muestra cómo los desarrolladores que usan el lenguaje de programación Java pueden utilizar un conjunto de subprocesos para configurar y usar varios subprocesos en una app para Android. También se muestra cómo definir código para ejecutar en un subproceso y cómo establecer una comunicación entre uno de estos subprocesos y el subproceso principal.

Bibliotecas de simultaneidad

Es importante comprender los conceptos básicos de los subprocesos y sus mecanismos subyacentes. Sin embargo, existen muchas bibliotecas populares que ofrecen abstracciones de nivel superior sobre estos conceptos y utilidades listas para usar a fin de pasar datos entre subprocesos. Estas bibliotecas incluyen Guava y RxJava para los usuarios del lenguaje de programación Java y corrutinas, que recomendamos para los usuarios de Kotlin.

En la práctica, debes elegir la que funcione mejor para tu app y tu equipo de desarrollo, aunque las reglas de los subprocesos son las mismas.

Resumen de ejemplos

Según la Guía de arquitectura de apps, en los ejemplos de este tema, se realiza una solicitud de red y se muestra el resultado al subproceso principal, donde la app puede mostrar ese resultado en la pantalla.

Específicamente, ViewModel llama a la capa de datos del subproceso principal para activar la solicitud de red. La capa de datos se encarga de quitar la ejecución de la solicitud de red del subproceso principal y publicar el resultado en él mediante una devolución de llamada.

Para quitar la ejecución de la solicitud de red del subproceso principal, debemos crear otros subprocesos en nuestra app.

Cómo crear varios subprocesos

Un conjunto de subprocesos es una colección administrada de subprocesos que ejecuta tareas en paralelo desde una cola. Las tareas nuevas se ejecutan en los subprocesos existentes a medida que quedan inactivos. Para enviar una tarea a un conjunto de subprocesos, usa la interfaz ExecutorService. Ten en cuenta que ExecutorService no tiene nada que ver con Servicios, el componente de la aplicación para Android.

Crear subprocesos es costoso, por lo que debes crear un conjunto de subprocesos solo una vez cuando se inicializa tu app. Asegúrate de guardar la instancia de ExecutorService en tu clase Application o en un contenedor de inserción de dependencias. En el siguiente ejemplo, se crea un conjunto de subprocesos que podemos usar para ejecutar tareas en segundo plano.

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

Existen otras formas de configurar un conjunto de subprocesos según la carga de trabajo esperada. Consulta Cómo configurar un conjunto de subprocesos para obtener más información.

Ejecución en un subproceso en segundo plano

Cuando haces una solicitud de red en el subproceso principal, este espera, o se bloquea, hasta que recibe una respuesta. Como el subproceso está bloqueado, el SO no puede llamar a onDraw() y tu app se bloquea, lo que podría generar un diálogo de Aplicación no responde (ANR). En su lugar, ejecutemos esta operación en un subproceso en segundo plano.

Realiza la solicitud

Primero, veamos nuestra clase LoginRepository y cómo realiza la solicitud de red:

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

La clase makeLoginRequest() es síncrona y bloquea el subproceso de llamada. Para modelar la respuesta de la solicitud de red, tenemos nuestra propia clase Result.

Activa la solicitud

ViewModel activa la solicitud de red cuando el usuario presiona, por ejemplo, un botón:

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

Con el código anterior, LoginViewModel bloquea el subproceso principal cuando realiza la solicitud de red. Podemos usar el conjunto de subprocesos del que creamos una instancia para mover la ejecución a un subproceso en segundo plano.

Cómo controlar la inserción de dependencias

Primero, según los principios de inyección de dependencias, LoginRepository toma una instancia del ejecutor en lugar de ExecutorService porque ejecuta código y no administra subprocesos:

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

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

El método execute() del Ejecutor toma un Runnable. Un Runnable es una interfaz de método abstracto único (SAM) con un método run() que se ejecuta en un subproceso cuando se invoca.

Ejecución en segundo plano

Ahora, creemos otra función llamada makeLoginRequest() que pase la ejecución al subproceso en segundo plano y, por el momento, ignore la respuesta:

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

Dentro del método execute(), creamos una Runnable nueva con el bloque de código que queremos ejecutar en el subproceso en segundo plano; en nuestro caso, el método de solicitud de red síncrona. De forma interna, ExecutorService administra el Runnable y lo ejecuta en un subproceso disponible.

Consideraciones

Cualquier subproceso de tu app puede ejecutarse en paralelo a otros subprocesos, incluido el principal, por lo que debes asegurarte de que tu código sea seguro para los subprocesos. Ten en cuenta que, en nuestro ejemplo, evitamos escribir en variables compartidas entre subprocesos y, en su lugar, pasamos datos inmutables. Esta es una práctica recomendada, ya que cada subproceso trabaja con su propia instancia de datos, y evitamos la complejidad de la sincronización.

Si necesitas compartir el estado entre subprocesos, debes tener cuidado de administrar el acceso desde los subprocesos mediante mecanismos de sincronización como los bloqueos. Esto está fuera del alcance de esta guía. En general, debes evitar compartir el estado mutable entre subprocesos siempre que sea posible.

Cómo comunicarse con el subproceso principal

En el paso anterior, ignoramos la respuesta a la solicitud de red. Para mostrar el resultado en la pantalla, LoginViewModel debe conocerlo. Para ello, podemos usar devoluciones de llamada.

La función makeLoginRequest() debe tomar una devolución de llamada como parámetro para que pueda mostrar un valor de forma asíncrona. Se llama a la devolución de llamada con el resultado cada vez que se completa la solicitud de red o se produce una falla. En Kotlin, podemos usar una función de orden superior. Sin embargo, en Java, tenemos que crear una nueva interfaz de devolución de llamada para tener la misma funcionalidad:

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

Ahora, el ViewModel debe implementar la devolución de llamada. Puede usar una lógica diferente según el resultado:

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

En este ejemplo, la devolución de llamada se ejecuta en el subproceso de llamada, que es un subproceso en segundo plano. Esto significa que no puedes modificar ni comunicarte directamente con la capa de IU hasta que vuelvas al subproceso principal.

Cómo usar controladores

Puedes usar un Handler para poner en cola una acción que se realizará en un subproceso diferente. Para especificar el subproceso en el que se ejecutará la acción, crea el Handler con un Looper para el subproceso. Un Looper es un objeto que ejecuta el bucle de mensajes para un subproceso asociado. Una vez que hayas creado un Handler, puedes usar el método post(Runnable) para ejecutar un bloque de código en el subproceso correspondiente.

Looper incluye una función auxiliar, getMainLooper(), que recupera el objeto Looper del subproceso principal. Puedes ejecutar código en el subproceso principal con este Looper para crear un Handler. Como es algo que puedes hacer con bastante frecuencia, también puedes guardar una instancia del Handler en el mismo lugar en el que guardaste el ExecutorService:

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

Se recomienda insertar el controlador en el repositorio, ya que brinda más flexibilidad. Por ejemplo, en el futuro, es posible que quieras pasar un Handler diferente para programar tareas en un subproceso independiente. Si siempre te comunicas con el mismo subproceso, puedes pasar el Handler al constructor del repositorio, como se muestra en el siguiente ejemplo.

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

Como alternativa, si deseas más flexibilidad, puedes pasar un Handler a cada función:

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

En este ejemplo, la devolución de llamada que se pasó a la llamada makeLoginRequest del repositorio se ejecuta en el subproceso principal. Eso significa que puedes modificar directamente la IU desde la devolución de llamada o usar LiveData.setValue() para comunicarte con la IU.

Cómo configurar un conjunto de subprocesos

Puedes crear un conjunto de subprocesos usando una de las funciones auxiliares Executor con configuraciones predefinidas, como se muestra en el código de ejemplo anterior. De manera alternativa, si deseas personalizar los detalles del conjunto de subprocesos, puedes crear una instancia usando directamente ThreadPoolExecutor. Puedes configurar los siguientes detalles:

  • Tamaño inicial y máximo del conjunto.
  • Tiempo de mantenimiento de conexión y unidad de tiempo. El tiempo de mantenimiento de actividad es la duración máxima que un subproceso puede permanecer inactivo antes de cerrarse.
  • Una cola de entrada que conserve tareas del objeto Runnable. Esta cola debe implementar la interfaz BlockingQueue. Para cumplir con los requisitos de tu app, puedes elegir entre las implementaciones de cola disponibles. Si deseas obtener más información, consulta la descripción general de la clase para ThreadPoolExecutor.

El siguiente es un ejemplo que especifica el tamaño del conjunto de subprocesos según la cantidad total de núcleos del procesador, un tiempo de espera de un segundo y una cola de entrada.

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