Android 异步和非阻塞 API 准则

非阻塞 API 会请求执行工作,然后将控制权返回给调用线程,以便在请求的操作完成之前执行其他工作。在以下情况下,这些 API 非常有用:所请求的工作可能正在进行中,或者可能需要等待 I/O 或 IPC 完成、高度争用的系统资源可用或用户输入,然后才能继续进行工作。设计精良的 API 提供了一种取消正在进行的操作的方法,并停止代表原始调用方执行的工作,从而在不再需要该操作时保持系统健康状况和电池续航时间。

异步 API 是实现非阻塞行为的一种方式。异步 API 接受某种形式的延续或回调,当操作完成时或在操作进行期间发生其他事件时,系统会通知该延续或回调。

编写异步 API 主要有以下两个原因:

  • 并发执行多项操作,其中第 N 项操作必须在第 N-1 项操作完成之前启动。
  • 避免阻塞调用线程,直到操作完成。

Kotlin 强烈提倡使用结构化并发,这是一系列基于挂起函数构建的原则和 API,可将代码的同步和异步执行与线程阻塞行为分离。挂起函数是非阻塞同步的。

挂起函数:

  • 不会阻塞其调用线程,而是在等待在其他位置执行的操作的结果时,让出其执行线程作为实现细节。
  • 同步执行,并且不需要非阻塞 API 的调用方与 API 调用启动的非阻塞工作并发执行。

本页详细介绍了开发者在使用非阻塞 API 和异步 API 时可以安全地保持的最低基准预期,然后提供了一系列配方,用于在 Android 平台或 Jetpack 库中以 Kotlin 或 Java 语言编写符合这些预期的 API。如有疑问,请将开发者的期望视为任何新 API 表面的要求。

开发者对异步 API 的预期

除非另有说明,否则以下预期均从非挂起 API 的角度撰写。

接受回调的 API 通常是异步的

如果某个 API 接受的回调未明确说明仅在本地调用(即仅由调用线程在 API 调用本身返回之前调用),则该 API 应被视为异步 API,并且应满足以下各部分中记录的所有其他预期。

仅在本地调用的回调的一个示例是高阶 map 或 filter 函数,该函数会在返回之前对集合中的每个项调用映射器或谓词。

异步 API 应尽快返回

开发者希望异步 API 不会阻塞,并且在启动操作请求后能够快速返回。随时调用异步 API 应该是安全的,并且调用异步 API 绝不应导致卡顿的帧或 ANR。

许多操作和生命周期信号都可以由平台或库按需触发,因此期望开发者全面了解其代码的所有潜在调用位置是不切实际的。例如,在同步事务中,可以向 FragmentManager 添加 Fragment,以响应 View 测量和布局(当必须填充应用内容才能填满可用空间时,例如 RecyclerView)。响应此 fragment 的 onStart 生命周期回调的 LifecycleObserver 可能会在此处合理地执行一次性启动操作,而这可能位于生成无抖动动画帧的关键代码路径上。开发者应始终确信,在响应这些生命周期回调时调用任何异步 API 都不会导致卡顿的帧。

这意味着异步 API 在返回之前执行的工作必须非常轻量级;最多是创建请求和关联回调的记录,并将其注册到执行工作的执行引擎。如果注册异步操作需要 IPC,API 的实现应采取一切必要措施来满足开发者的这一预期。这可能包括以下一项或多项:

  • 将底层 IPC 实现为单向 binder 调用
  • 向系统服务器发出双向 binder 调用,其中完成注册不需要获取高度竞争的锁
  • 将请求发布到应用进程中的工作线程,以通过 IPC 执行阻塞注册

异步 API 应返回 void,并且仅针对无效实参抛出异常

异步 API 应向所提供的回调报告所请求操作的所有结果。这样,开发者就可以实现用于成功和错误处理的单一代码路径。

异步 API 可能会检查实参是否为 null 并抛出 NullPointerException,或者检查所提供的实参是否在有效范围内并抛出 IllegalArgumentException。例如,对于接受 float(范围为 01f)的函数,该函数可能会检查参数是否在此范围内,如果超出范围,则抛出 IllegalArgumentException;或者,可能会检查简短的 String 是否符合有效格式(例如仅包含字母数字字符)。(请注意,系统服务器绝不应信任应用进程!任何系统服务都应在系统服务本身中复制这些检查。)

所有其他错误都应报告给所提供的回调。这包括但不限于:

  • 所请求的操作终止性失败
  • 缺少完成操作所需的授权或权限时的安全例外情况
  • 超出执行相应操作的配额
  • 应用进程不够“前台”,无法执行相应操作
  • 所需硬件已断开连接
  • 网络故障
  • 禁言次数
  • Binder 死亡或远程进程不可用

异步 API 应提供取消机制

异步 API 应提供一种方法来向正在运行的操作表明调用方不再关心结果。此取消操作应发出两个信号:

应释放对调用者提供的回调的硬引用

提供给异步 API 的回调可能包含对大型对象图的硬引用,而持有对该回调的硬引用的正在进行的工作可能会阻止对这些对象图进行垃圾回收。通过在取消时释放这些回调引用,这些对象图可能会比允许工作运行到完成时更早符合垃圾回收条件。

为调用方执行工作的执行引擎可能会停止该工作

由异步 API 调用启动的工作可能会消耗大量电量或其他系统资源。允许调用方在不再需要此工作时发出信号的 API 允许在工作消耗更多系统资源之前停止该工作。

缓存或冻结应用的特殊注意事项

在设计回调源自系统进程并传递给应用的回调异步 API 时,请考虑以下事项:

  1. 进程和应用生命周期:接收方应用进程可能处于缓存状态。
  2. 缓存的应用冻结器:接收方应用进程可能会被冻结。

当应用进程进入缓存状态时,这意味着它未主动托管任何用户可见的组件,例如 activity 和服务。应用会保留在内存中,以防再次对用户可见,但在此期间不应执行任何工作。在大多数情况下,当应用进入缓存状态时,您应暂停调度应用回调,并在应用退出缓存状态时恢复调度,以免在缓存的应用进程中引发工作。

缓存的应用也可能会被冻结。应用被冻结后,将无法获得任何 CPU 时间,也无法执行任何工作。对该应用注册的回调的所有调用都会被缓冲,并在应用解除冻结时传送。

当应用解除冻结并处理缓冲的事务时,这些事务可能已过时。缓冲区是有限的,如果溢出,会导致接收方应用崩溃。为避免应用因过时的事件而不堪重负或其缓冲区溢出,请勿在应用进程冻结时调度应用回调。

审核中:

  • 您应考虑在应用进程被缓存时暂停调度应用回调。
  • 当应用进程冻结时,您必须暂停调度应用回调。

状态跟踪

如需跟踪应用何时进入或退出缓存状态,请执行以下操作:

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

如需跟踪应用何时冻结或解冻,请执行以下操作:

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

恢复调度应用回调的策略

无论应用进入缓存状态还是冻结状态时,您是否暂停调度应用回调,当应用退出相应状态时,您都应在应用退出相应状态后恢复调度应用已注册的回调,直到应用取消注册其回调或应用进程终止。

例如:

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

或者,您也可以使用 RemoteCallbackList,这样在目标进程冻结时,系统就不会向其传送回调。

例如:

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()

应用通常会使用回调将收到的更新保存为最新状态的快照。假设有一个 API 可供应用用来监控剩余电量百分比:

interface BatteryListener {
    void onBatteryPercentageChanged(int newPercentage);
}

假设应用处于冻结状态时发生了多个状态更改事件。当应用解除冻结时,您应仅向应用传递最新状态,并舍弃其他过时的状态更改。当应用解除冻结时,应立即进行此传递,以便应用“赶上”进度。可通过以下方式实现:

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

在某些情况下,您可以跟踪传递给应用的最后一个值,这样应用在解冻后就不需要收到相同值的通知。

状态可以更复杂的数据形式表示。假设有一个 API,用于通知应用网络接口:

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

暂停向应用发送通知时,您应记住该应用上次看到的网络和状态集。恢复后,建议按以下顺序通知应用有关丢失的旧网络、可用的新网络以及状态已更改的现有网络。

在回调暂停期间,如果网络从可用变为不可用,则不通知应用。应用不应收到在冻结期间发生的完整事件记录,并且 API 文档不应承诺在明确的生命周期状态之外不间断地传送事件流。在此示例中,如果应用需要持续监控网络可用性,则必须保持在不会被缓存或冻结的生命周期状态。

在审核中,您应合并暂停通知后和恢复通知前发生的事件,并以简洁的方式将最新状态传递给注册的应用回调。

开发者文档注意事项

异步事件的传递可能会延迟,原因可能是发送方暂停传递了一段时间(如上一部分所示),也可能是接收方应用未获得足够的设备资源来及时处理事件。

阻止开发者对应用收到事件通知的时间与事件实际发生的时间之间的时间差做出假设。

开发者对暂停 API 的预期

熟悉 Kotlin 结构化并发的开发者希望任何挂起 API 都具有以下行为:

挂起函数应在返回或抛出之前完成所有关联的工作

非阻塞操作的结果会作为常规函数返回值返回,错误则通过抛出异常来报告。(这通常意味着回调参数是不必要的。)

挂起函数应仅就地调用回调形参

挂起函数应始终在返回之前完成所有相关工作,因此在挂起函数返回后,它们绝不应调用所提供的回调或其他函数参数,也不应保留对这些参数的引用。

除非另有说明,否则接受回调参数的挂起函数应保留上下文

在挂起函数中调用函数会导致该函数在调用方的 CoroutineContext 中运行。由于挂起函数应在返回或抛出之前完成所有关联的工作,并且应仅就地调用回调形参,因此默认预期是,任何此类回调也应使用其关联的调度程序在调用 CoroutineContext 上运行。如果 API 的目的是在调用 CoroutineContext 之外运行回调,则应明确记录此行为。

挂起函数应支持 kotlinx.coroutines 作业取消

提供的任何挂起函数都应与 kotlinx.coroutines 定义的作业取消机制配合使用。如果正在进行的操作的调用作业被取消,该函数应尽快恢复并返回 CancellationException,以便调用方尽快清理并继续。suspendCancellableCoroutinekotlinx.coroutines 提供的其他暂停 API 会自动处理此问题。库实现通常不应直接使用 suspendCoroutine,因为默认情况下它不支持这种取消行为。

在后台(非主线程或界面线程)执行阻塞工作的挂起函数必须提供一种配置所用调度器的方法

不建议阻塞函数完全挂起来切换线程。

调用挂起函数不应导致创建额外的线程,除非允许开发者提供自己的线程或线程池来执行该工作。例如,构造函数可以接受一个 CoroutineContext,用于为类的方法执行后台工作。

如果挂起函数接受可选的 CoroutineContextDispatcher 参数只是为了切换到该调度程序来执行阻塞性工作,那么该函数应改为公开底层阻塞性函数,并建议调用开发者使用自己的 withContext 调用将工作定向到所选的调度程序。

启动协程的类

启动协程的类必须具有 CoroutineScope 才能执行这些启动操作。遵循结构化并发原则意味着采用以下结构模式来获取和管理该范围。

在编写将并发任务启动到其他范围的类之前,请考虑替代模式:

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

公开 suspend fun 以执行并发工作,可让调用方在其自己的上下文中调用操作,从而无需让 MyClass 管理 CoroutineScope。请求的处理序列化变得更简单,状态通常可以作为 handleRequests 的局部变量存在,而不是作为类属性存在,否则需要额外的同步。

管理协程的类应公开关闭和取消方法

将协程作为实现详情启动的类必须提供一种方法来干净地关闭这些正在进行的并发任务,以免它们将不受控制的并发工作泄漏到父作用域中。通常,这会以创建所提供 CoroutineContext 的子 Job 的形式进行:

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

fun cancel() {
    myJob.cancel()
}

还可以提供 join() 方法,以允许用户代码等待对象执行的任何未完成的并发工作完成。(这可能包括通过取消操作执行的清理工作。)

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

终端操作命名

用于干净利落地关闭对象所拥有的仍在进行中的并发任务的方法的名称应反映关闭发生时的行为合同:

如果正在进行的操作可能会完成,但在调用 close() 返回后,可能不会开始任何新操作,请使用 close()

如果正在进行的操作可能会在完成之前被取消,请使用 cancel()。 在对 cancel() 的调用返回后,不得开始任何新操作。

类构造函数接受 CoroutineContext,而不是 CoroutineScope

当禁止对象直接启动到提供的父级范围时,CoroutineScope 作为构造函数参数的适用性会受到影响:

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

CoroutineScope 成为不必要且具有误导性的封装容器,在某些使用情形下,它可能仅为了作为构造函数参数传递而构建,最终却被舍弃:

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

CoroutineContext 参数默认为 EmptyCoroutineContext

当 API 界面中出现可选的 CoroutineContext 参数时,默认值必须为 Empty`CoroutineContext` sentinel。这样可以更好地组合 API 行为,因为来自调用方的 Empty`CoroutineContext` 值与接受默认值的方式相同:

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)

    // ...
}