Lineamientos de la API asíncrona y no bloqueante de Android

Las APIs no bloqueantes solicitan que se realice el trabajo y, luego, ceden el control al subproceso de llamada para que pueda realizar otro trabajo antes de que se complete la operación solicitada. Estas APIs son útiles en los casos en que el trabajo solicitado podría estar en curso o podría requerir esperar a que se complete la E/S o la IPC, la disponibilidad de recursos del sistema muy disputados o la entrada del usuario antes de que se pueda continuar con el trabajo. Las APIs especialmente bien diseñadas proporcionan una forma de cancelar la operación en curso y detener el trabajo que se realiza en nombre del llamador original, lo que preserva el estado del sistema y la duración de la batería cuando ya no se necesita la operación.

Las APIs asíncronas son una forma de lograr un comportamiento no bloqueante. Las APIs asíncronas aceptan alguna forma de continuación o devolución de llamada que se notifica cuando se completa la operación o sobre otros eventos durante el progreso de la operación.

Existen dos motivos principales para escribir una API asíncrona:

  • Ejecutar varias operaciones de forma simultánea, en la que la operación N debe iniciarse antes de que finalice la operación N-1
  • Evitar bloquear un subproceso de llamada hasta que se complete una operación

Kotlin promueve en gran medida la concurrencia estructurada, una serie de principios y APIs creados sobre funciones de suspensión que desacoplan la ejecución síncrona y asíncrona del código del comportamiento de bloqueo de subprocesos. Las funciones de suspensión son no bloqueantes y síncronas.

Funciones de suspensión:

  • No bloquees su subproceso de llamada y, en cambio, cede su subproceso de ejecución como un detalle de implementación mientras esperas los resultados de las operaciones que se ejecutan en otro lugar.
  • Se ejecutan de forma síncrona y no requieren que el llamador de una API no bloqueante continúe ejecutándose de forma simultánea con el trabajo no bloqueante que inicia la llamada a la API.

En esta página, se detalla un nivel de referencia mínimo de expectativas que los desarrolladores pueden mantener de forma segura cuando trabajan con APIs asíncronas y sin bloqueo, seguido de una serie de recetas para crear APIs que cumplan con estas expectativas en los lenguajes Kotlin o Java, en la plataforma de Android o en las bibliotecas de Jetpack. En caso de duda, considera las expectativas de los desarrolladores como requisitos para cualquier superficie de API nueva.

Expectativas de los desarrolladores para las APIs asíncronas

Las siguientes expectativas se escriben desde el punto de vista de las APIs que no suspenden, a menos que se indique lo contrario.

Las APIs que aceptan devoluciones de llamada suelen ser asíncronas

Si una API acepta una devolución de llamada que no está documentada para que solo se llame en el lugar (es decir, que solo la llame el subproceso de llamada antes de que se muestre la llamada a la API), se supone que la API es asíncrona y que debe cumplir con todas las demás expectativas documentadas en las siguientes secciones.

Un ejemplo de una devolución de llamada que solo se llama de forma intercalada es una función de mapa o filtro de orden superior que invoca un asignador o un predicado en cada elemento de una colección antes de devolverlo.

Las APIs asíncronas deben devolver resultados lo más rápido posible

Los desarrolladores esperan que las APIs asíncronas sean no bloqueantes y que se muestren rápidamente después de iniciar la solicitud de la operación. Siempre debería ser seguro llamar a una API asíncrona en cualquier momento, y llamar a una API asíncrona nunca debería generar fotogramas con interrupciones ni errores de ANR.

La plataforma o las bibliotecas pueden activar muchos indicadores de operaciones y ciclo de vida a pedido, y esperar que un desarrollador tenga conocimiento global de todos los posibles sitios de llamada para su código es insostenible. Por ejemplo, se puede agregar un Fragment al FragmentManager en una transacción síncrona en respuesta a la medición y el diseño de View cuando se debe completar el contenido de la app para llenar el espacio disponible (como RecyclerView). Un LifecycleObserver que responde a la devolución de llamada del ciclo de vida onStart de este fragmento puede realizar de manera razonable operaciones de inicio únicas aquí, y esto puede estar en una ruta de código crítica para producir un fotograma de animación sin tirones. Un desarrollador siempre debe tener la certeza de que llamar a cualquier API asíncrona en respuesta a este tipo de devoluciones de llamada de ciclo de vida no causará un fotograma inestable.

Esto implica que el trabajo que realiza una API asíncrona antes de devolver un resultado debe ser muy ligero: como máximo, crear un registro de la solicitud y la devolución de llamada asociada, y registrarlo en el motor de ejecución que realiza el trabajo. Si el registro para una operación asíncrona requiere IPC, la implementación de la API debe tomar las medidas necesarias para satisfacer esta expectativa del desarrollador. Esto puede incluir uno o más de los siguientes elementos:

  • Cómo implementar un IPC subyacente como una llamada de binder unidireccional
  • Realizar una llamada de Binder bidireccional al servidor del sistema en la que completar el registro no requiere tomar un bloqueo muy disputado
  • Publicar la solicitud en un subproceso de trabajo en el proceso de la app para realizar un registro de bloqueo a través de IPC

Las APIs asíncronas deben devolver un valor nulo y solo arrojar excepciones para argumentos no válidos

Las APIs asíncronas deben informar todos los resultados de la operación solicitada a la devolución de llamada proporcionada. Esto permite que el desarrollador implemente una sola ruta de código para el éxito y el control de errores.

Las APIs asíncronas pueden verificar si hay argumentos nulos y arrojar NullPointerException, o bien verificar que los argumentos proporcionados estén dentro de un rango válido y arrojar IllegalArgumentException. Por ejemplo, para una función que acepta un float en el rango de 0 a 1f, la función puede verificar que el parámetro esté dentro de este rango y arrojar IllegalArgumentException si está fuera del rango, o se puede verificar que un String corto cumpla con un formato válido, como solo alfanumérico. (Recuerda que el servidor del sistema nunca debe confiar en el proceso de la app). Cualquier servicio del sistema debe duplicar estas verificaciones en el propio servicio del sistema.

Todos los demás errores se deben informar a la devolución de llamada proporcionada. Esto incluye, entre otros, lo siguiente:

  • Falla grave de la operación solicitada
  • Excepciones de seguridad por falta de autorización o permisos necesarios para completar la operación
  • Se excedió la cuota para realizar la operación
  • El proceso de la app no está lo suficientemente en primer plano como para realizar la operación.
  • Se desconectó el hardware requerido
  • Fallas en la red
  • Tiempos de espera
  • Cierre del binder o proceso remoto no disponible

Las APIs asíncronas deben proporcionar un mecanismo de cancelación

Las APIs asíncronas deben proporcionar una forma de indicar a una operación en ejecución que al llamador ya no le interesa el resultado. Esta operación de cancelación debe indicar dos cosas:

Se deben liberar las referencias fijas a las devoluciones de llamada proporcionadas por el llamador.

Las devoluciones de llamada proporcionadas a las APIs asíncronas pueden contener referencias fijas a gráficos de objetos grandes, y el trabajo en curso que contiene una referencia fija a esa devolución de llamada puede evitar que se recopilen esos gráficos de objetos como basura. Si se liberan estas referencias de devolución de llamada en la cancelación, es posible que estos gráficos de objetos sean aptos para la recolección de basura mucho antes que si se permitiera que el trabajo se ejecutara hasta completarse.

El motor de ejecución que realiza el trabajo para el llamador puede detener ese trabajo.

El trabajo iniciado por las llamadas a la API asíncronas puede generar un costo alto en el consumo de energía o en otros recursos del sistema. Las APIs que permiten que los llamadores indiquen cuándo ya no se necesita este trabajo permiten detenerlo antes de que pueda consumir más recursos del sistema.

Consideraciones especiales para las apps almacenadas en caché o congeladas

Cuando diseñes APIs asíncronas en las que las devoluciones de llamada se originan en un proceso del sistema y se entregan a las apps, ten en cuenta lo siguiente:

  1. Procesos y ciclo de vida de la app: Es posible que el proceso de la app receptora esté en estado almacenado en caché.
  2. Congelador de apps almacenadas en caché: Es posible que se haya congelado el proceso de la app receptora.

Cuando un proceso de la app entra en el estado almacenado en caché, significa que no aloja de forma activa ningún componente visible para el usuario, como actividades y servicios. La app se mantiene en la memoria en caso de que vuelva a ser visible para el usuario, pero, mientras tanto, no debería realizar ninguna tarea. En la mayoría de los casos, debes pausar el envío de devoluciones de llamada de la app cuando esta entra en el estado almacenado en caché y reanudarlo cuando la app sale de ese estado para no inducir trabajo en los procesos de la app almacenados en caché.

Una app en caché también puede estar inactiva. Cuando se inmoviliza una app, no recibe tiempo de CPU y no puede realizar ninguna tarea. Todas las llamadas a las devoluciones de llamada registradas de esa app se almacenan en búfer y se entregan cuando se reactiva la app.

Las transacciones almacenadas en búfer para las devoluciones de llamadas de la app pueden estar desactualizadas cuando la app se reactive y las procese. El búfer es finito y, si se desborda, la app del destinatario fallará. Para evitar sobrecargar las apps con eventos obsoletos o desbordar sus búferes, no envíes devoluciones de llamada de la app mientras su proceso esté inactivo.

En revisión:

  • Debes considerar pausar las devoluciones de llamada de la app de envío mientras se almacena en caché el proceso de la app.
  • DEBES pausar el envío de devoluciones de llamada de la app mientras el proceso de la app esté inactivo.

Seguimiento de estado

Para hacer un seguimiento de cuándo las apps entran o salen del estado almacenado en caché, haz lo siguiente:

mActivityManager.addOnUidImportanceListener(
    new UidImportanceListener() { ... },
    IMPORTANCE_CACHED);

Para hacer un seguimiento de cuándo se congelan o descongelan las apps, haz lo siguiente:

IBinder binder = <...>;
binder.addFrozenStateChangeCallback(executor, callback);

Estrategias para reanudar las devoluciones de llamada de la app de envío

Ya sea que pauses el envío de devoluciones de llamada de la app cuando esta entra en el estado almacenado en caché o en el estado inactivo, cuando la app salga del estado respectivo, debes reanudar el envío de las devoluciones de llamada registradas de la app una vez que esta salga del estado respectivo hasta que la app anule el registro de su devolución de llamada o el proceso de la app finalice.

Por ejemplo:

IBinder binder = <...>;
bool shouldSendCallbacks = true;
binder.addFrozenStateChangeCallback(executor, (who, state) -> {
    if (state == IBinder.FrozenStateChangeCallback.STATE_FROZEN) {
        shouldSendCallbacks = false;
    } else if (state == IBinder.FrozenStateChangeCallback.STATE_UNFROZEN) {
        shouldSendCallbacks = true;
    }
});

Como alternativa, puedes usar RemoteCallbackList, que se encarga de no entregar devoluciones de llamada al proceso de destino cuando está inactivo.

Por ejemplo:

RemoteCallbackList<IInterface> rc =
        new RemoteCallbackList.Builder<IInterface>(
                        RemoteCallbackList.FROZEN_CALLEE_POLICY_DROP)
                .setExecutor(executor)
                .build();
rc.register(callback);
rc.broadcast((callback) -> callback.foo(bar));

callback.foo() se invoca solo si el proceso no está inactivo.

Las apps suelen guardar las actualizaciones que reciben a través de devoluciones de llamada como una instantánea del estado más reciente. Considera una API hipotética para que las apps supervisen el porcentaje de batería restante:

interface BatteryListener {
    void onBatteryPercentageChanged(int newPercentage);
}

Considera la situación en la que se producen varios eventos de cambio de estado cuando una app está inactiva. Cuando se descongele la app, solo debes entregarle el estado más reciente y descartar otros cambios de estado obsoletos. Esta entrega debe ocurrir de inmediato cuando se descongele la app para que esta pueda "ponerse al día". Esto se puede lograr de la siguiente manera:

RemoteCallbackList<IInterface> rc =
        new RemoteCallbackList.Builder<IInterface>(
                        RemoteCallbackList.FROZEN_CALLEE_POLICY_ENQUEUE_MOST_RECENT)
                .setExecutor(executor)
                .build();
rc.register(callback);
rc.broadcast((callback) -> callback.onBatteryPercentageChanged(value));

En algunos casos, puedes hacer un seguimiento del último valor entregado a la app para que no sea necesario notificarle el mismo valor una vez que se descongele.

El estado se puede expresar como datos más complejos. Considera una API hipotética para que las apps reciban notificaciones sobre las interfaces de red:

interface NetworkListener {
    void onAvailable(Network network);
    void onLost(Network network);
    void onChanged(Network network);
}

Cuando pauses las notificaciones de una app, debes recordar el conjunto de redes y estados que la app había visto por última vez. Cuando se reanude, se recomienda notificar a la app las redes antiguas que se perdieron, las redes nuevas que están disponibles y las redes existentes cuyo estado cambió, en este orden.

No notifica a la app sobre las redes que estuvieron disponibles y luego se perdieron mientras se pausaron las devoluciones de llamada. Las apps no deben recibir un recuento completo de los eventos que ocurrieron mientras estaban congeladas, y la documentación de la API no debe prometer entregar flujos de eventos ininterrumpidos fuera de los estados explícitos del ciclo de vida. En este ejemplo, si la app necesita supervisar continuamente la disponibilidad de la red, debe permanecer en un estado de ciclo de vida que evite que se almacene en caché o se congele.

Durante la revisión, debes fusionar los eventos que ocurrieron después de pausar y antes de reanudar las notificaciones, y entregar el estado más reciente a las devoluciones de llamada de la app registrada de forma concisa.

Consideraciones para la documentación para desarrolladores

La entrega de eventos asíncronos puede demorarse, ya sea porque el remitente pausó la entrega durante un período, como se muestra en la sección anterior, o porque la app del destinatario no recibió suficientes recursos del dispositivo para procesar el evento de manera oportuna.

Desalentar a los desarrolladores a hacer suposiciones sobre el tiempo que transcurre entre el momento en que se notifica a su app sobre un evento y el momento en que realmente ocurrió el evento

Expectativas de los desarrolladores sobre la suspensión de APIs

Los desarrolladores que conocen la simultaneidad estructurada de Kotlin esperan los siguientes comportamientos de cualquier API de suspensión:

Las funciones de suspensión deben completar todo el trabajo asociado antes de devolver o arrojar un error

Los resultados de las operaciones no bloqueantes se muestran como valores de devolución de funciones normales, y los errores se informan a través de excepciones. (Esto suele significar que los parámetros de devolución de llamada son innecesarios).

Las funciones de suspensión solo deben invocar parámetros de devolución de llamada en su lugar

Las funciones de suspensión siempre deben completar todo el trabajo asociado antes de regresar, por lo que nunca deben invocar una devolución de llamada proporcionada ni otro parámetro de función, ni conservar una referencia a él después de que la función de suspensión haya regresado.

Las funciones de suspensión que aceptan parámetros de devolución de llamada deben conservar el contexto, a menos que se documente lo contrario.

Llamar a una función dentro de una función de suspensión hace que se ejecute en el CoroutineContext del llamador. Como las funciones de suspensión deben completar todo el trabajo asociado antes de devolver o arrojar un error, y solo deben invocar parámetros de devolución de llamada en su lugar, la expectativa predeterminada es que cualquier devolución de llamada de este tipo también se ejecute en el CoroutineContext de llamada con su dispatcher asociado. Si el propósito de la API es ejecutar una devolución de llamada fuera de la CoroutineContext de llamada, este comportamiento debe estar claramente documentado.

Las funciones de suspensión deben admitir la cancelación de trabajos de kotlinx.coroutines

Cualquier función de suspensión que se ofrezca debe cooperar con la cancelación de trabajos según lo define kotlinx.coroutines. Si se cancela el trabajo de llamada de una operación en curso, la función debe reanudarse con un CancellationException lo antes posible para que el llamador pueda limpiar y continuar lo antes posible. suspendCancellableCoroutine y otras APIs de suspensión que ofrece kotlinx.coroutines manejan esto automáticamente. Por lo general, las implementaciones de bibliotecas no deben usar suspendCoroutine directamente, ya que no admite este comportamiento de cancelación de forma predeterminada.

Las funciones de suspensión que realizan trabajo de bloqueo en un segundo plano (subproceso de IU o no principal) deben proporcionar una forma de configurar el dispatcher que se usa.

No se recomienda que una función bloqueante se suspenda por completo para cambiar de subproceso.

Llamar a una función de suspensión no debería generar la creación de subprocesos adicionales sin permitir que el desarrollador proporcione su propio subproceso o grupo de subprocesos para realizar ese trabajo. Por ejemplo, un constructor puede aceptar un CoroutineContext que se usa para realizar el trabajo en segundo plano de los métodos de la clase.

Las funciones de suspensión que aceptarían un parámetro CoroutineContext o Dispatcher opcional solo para cambiar a ese dispatcher y realizar un trabajo de bloqueo deberían exponer la función de bloqueo subyacente y recomendar que los desarrolladores que llamen usen su propia llamada a withContext para dirigir el trabajo a un dispatcher elegido.

Clases que inician corrutinas

Las clases que inician corrutinas deben tener un CoroutineScope para realizar esas operaciones de inicio. Respetar los principios de simultaneidad estructurada implica los siguientes patrones estructurales para obtener y administrar ese alcance.

Antes de escribir una clase que inicie tareas simultáneas en otro alcance, considera los siguientes patrones alternativos:

class MyClass {
    private val requests = Channel<MyRequest>(Channel.UNLIMITED)

    suspend fun handleRequests() {
        coroutineScope {
            for (request in requests) {
                // Allow requests to be processed concurrently;
                // alternatively, omit the [launch] and outer [coroutineScope]
                // to process requests serially
                launch {
                    processRequest(request)
                }
            }
        }
    }

    fun submitRequest(request: MyRequest) {
        requests.trySend(request).getOrThrow()
    }
}

Exponer un suspend fun para realizar trabajo simultáneo permite que la persona que llama invoque la operación en su propio contexto, lo que elimina la necesidad de que MyClass administre un CoroutineScope. La serialización del procesamiento de solicitudes se vuelve más simple, y el estado a menudo puede existir como variables locales de handleRequests en lugar de como propiedades de clase que, de lo contrario, requerirían una sincronización adicional.

Las clases que administran corrutinas deben exponer métodos de cierre y cancelación

Las clases que inician corrutinas como detalles de implementación deben ofrecer una forma de detener limpiamente esas tareas simultáneas en curso para que no filtren trabajo simultáneo no controlado en un alcance principal. Por lo general, esto implica crear un Job secundario de un CoroutineContext proporcionado:

private val myJob = Job(parent = `CoroutineContext`[Job])
private val myScope = CoroutineScope(`CoroutineContext` + myJob)

fun cancel() {
    myJob.cancel()
}

También se puede proporcionar un método join() para permitir que el código del usuario espere la finalización de cualquier trabajo simultáneo pendiente que realice el objeto. (Esto puede incluir el trabajo de limpieza que se realiza cuando se cancela una operación).

suspend fun join() {
    myJob.join()
}

Nombres de las operaciones de terminal

El nombre que se usa para los métodos que detienen de forma limpia las tareas simultáneas que posee un objeto y que aún están en curso debe reflejar el contrato de comportamiento de cómo se produce la detención:

Usa close() cuando las operaciones en curso puedan completarse, pero no se puedan iniciar operaciones nuevas después de que regrese la llamada a close().

Usa cancel() cuando las operaciones en curso se puedan cancelar antes de completarse. No se pueden iniciar operaciones nuevas después de que se muestre el resultado de la llamada a cancel().

Los constructores de clase aceptan CoroutineContext, no CoroutineScope

Cuando se prohíbe que los objetos se lancen directamente en un alcance principal proporcionado, la idoneidad de CoroutineScope como parámetro del constructor se descompone:

// Don't do this
class MyClass(scope: CoroutineScope) {
    private val myJob = Job(parent = scope.`CoroutineContext`[Job])
    private val myScope = CoroutineScope(scope.`CoroutineContext` + myJob)

    // ... the [scope] constructor parameter is never used again
}

El CoroutineScope se convierte en un wrapper innecesario y engañoso que, en algunos casos de uso, se puede construir solo para pasarlo como parámetro del constructor y, luego, descartarlo:

// Don't do this; just pass the context
val myObject = MyClass(CoroutineScope(parentScope.`CoroutineContext` + Dispatchers.IO))

Los parámetros de CoroutineContext tienen como valor predeterminado EmptyCoroutineContext

Cuando un parámetro CoroutineContext opcional aparece en una superficie de la API, el valor predeterminado debe ser el centinela Empty`CoroutineContext`. Esto permite una mejor composición de los comportamientos de la API, ya que un valor Empty`CoroutineContext` de un llamador se trata de la misma manera que aceptar el valor predeterminado:

class MyOuterClass(
    `CoroutineContext`: `CoroutineContext` = Empty`CoroutineContext`
) {
    private val innerObject = MyInnerClass(`CoroutineContext`)

    // ...
}

class MyInnerClass(
    `CoroutineContext`: `CoroutineContext` = Empty`CoroutineContext`
) {
    private val job = Job(parent = `CoroutineContext`[Job])
    private val scope = CoroutineScope(`CoroutineContext` + job)

    // ...
}