非阻塞 API 会请求执行工作,然后将控制权交还给调用线程,以便在请求的操作完成之前执行其他工作。在请求的工作可能正在进行或可能需要等待 I/O 或 IPC 完成、高争用度的系统资源可用或用户输入后才能继续工作的情况下,这些 API 非常有用。设计得特别好的 API 提供了一种方法来取消正在进行的操作,并停止代表原始调用方执行工作,从而在不再需要执行操作时保护系统运行状况和电池续航时间。
异步 API 是实现非阻塞行为的一种方式。异步 API 接受某种形式的接续或回调,以便在操作完成时或操作进行期间发生其他事件时收到通知。
编写异步 API 有两个主要原因:
- 并发执行多个操作,其中第 N 项操作必须在第 N-1 项操作完成之前启动。
- 避免在操作完成之前阻塞发起调用的线程。
Kotlin 强烈提倡结构化并发,这一系列原则和 API 基于挂起函数构建,可将代码的同步和异步执行与线程阻塞行为分离。挂起函数是非阻塞且同步的。
挂起函数:
- 请勿阻塞其调用线程,而是在等待其他位置执行的操作的结果时,将其执行线程作为实现细节让出。
- 同步执行,并且不需要非阻塞 API 的调用方继续与 API 调用发起的非阻塞工作并发执行。
本页详细介绍了开发者在使用非阻塞和异步 API 时可以放心遵循的最低基准预期,后面还提供了一系列方法,介绍了如何使用 Kotlin 或 Java 语言在 Android 平台或 Jetpack 库中编写符合这些预期的 API。如有疑问,请将开发者期望视为任何新 API 途径的要求。
开发者对异步 API 的期望
除非另有说明,否则以下预期是从非暂停型 API 的角度编写的。
接受回调的 API 通常是异步的
如果 API 接受的回调未记录为只能原地调用(即仅由调用线程在 API 调用本身返回之前调用),则假定该 API 是异步的,并且该 API 应满足以下部分中记录的所有其他预期。
仅在原位调用的回调示例是高阶映射或过滤函数,该函数会在返回之前对集合中的每个项调用映射器或谓词。
异步 API 应尽快返回
开发者希望异步 API 是非阻塞的,并在发起操作请求后快速返回。随时调用异步 API 应该始终是安全的,调用异步 API 绝不应导致帧卡顿或 ANR。
许多操作和生命周期信号可由平台或库按需触发,因此,要求开发者全面了解其代码的所有可能调用点是不可持续的。例如,当必须填充应用内容以填充可用空间(例如 RecyclerView
)时,可以通过同步事务在 FragmentManager
中添加 Fragment
以响应 View
测量和布局。响应此 fragment 的 onStart
生命周期回调的 LifecycleObserver
可以在此处合理执行一次性启动操作,并且这可能位于生成无卡顿动画帧的关键代码路径上。开发者应始终有信心,在响应此类生命周期回调时调用任何异步 API 不会导致画面卡顿。
这意味着,异步 API 在返回之前执行的工作必须非常轻量;最多只需创建请求和关联回调的记录,并将其注册到执行工作的工作引擎。如果注册异步操作需要 IPC,API 的实现应采取必要的措施来满足此开发者预期。这可能包括以下一项或多项:
- 将底层 IPC 实现为单向 binder 调用
- 向系统服务器发出双向 binder 调用,其中完成注册不需要获取高度争用锁
- 将请求发布到应用进程中的工作器线程,以通过 IPC 执行阻塞注册
异步 API 应返回 void,并且仅在参数无效时抛出异常
异步 API 应向提供的回调报告请求的操作的所有结果。这样,开发者就可以实现单个代码路径来处理成功和错误。
异步 API 可能会检查参数是否为 null 并抛出 NullPointerException
,或者检查所提供的参数是否在有效范围内并抛出 IllegalArgumentException
。例如,对于接受 0
到 1f
范围内的 float
的函数,该函数可以检查参数是否在此范围内,如果超出范围,则抛出 IllegalArgumentException
;或者,可以检查短 String
是否符合有效格式(例如仅限字母数字)。(请注意,系统服务器绝不应信任应用进程!任何系统服务都应在系统服务本身中复制这些检查。)
所有其他错误都应报告给提供的回调。包括但不限于:
- 请求的操作终止性失败
- 因缺少授权或缺少完成操作所需的权限而导致的安全异常
- 超出执行操作的配额
- 应用进程的“前台”状态不足以执行操作
- 所需硬件已断开连接
- 网络故障
- 禁言次数
- binder 终止或远程进程不可用
异步 API 应提供取消机制
异步 API 应提供一种方法,用于向正在运行的操作表明调用方不再关心结果。此取消操作应表明以下两点:
应释放对调用方提供的回调的硬引用
提供给异步 API 的回调可能包含对大型对象图的硬引用,而持有对该回调的硬引用的持续工作可以防止这些对象图被垃圾回收。通过取消时释放这些回调引用,这些对象图可能比允许工作运行到完成时更早符合垃圾回收条件。
为调用方执行工作的执行引擎可能会停止执行该工作
由异步 API 调用发起的工作可能会导致耗电量或其他系统资源费用高昂。允许调用方在不再需要此工作时发出信号的 API 允许在该工作耗用更多系统资源之前停止该工作。
缓存或冻结应用的特殊注意事项
设计异步 API 时,如果回调源自系统进程并传送到应用,请考虑以下事项:
当应用进程进入缓存状态时,这意味着它未主动托管任何用户可见的组件(例如 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
恢复,以便调用方能够尽快进行清理并继续操作。这由 suspendCancellableCoroutine
和 kotlinx.coroutines
提供的其他挂起 API 自动处理。库实现通常不应直接使用 suspendCoroutine
,因为它默认不支持此取消行为。
在后台(非主线程或界面线程)执行阻塞工作时,挂起函数必须提供配置所用调度程序的方法
不建议让阻塞函数完全挂起以切换线程。
调用挂起函数不应导致创建其他线程,也不应允许开发者提供自己的线程或线程池来执行该工作。例如,构造函数可以接受用于为类的方法执行后台工作的 CoroutineContext
。
仅接受可选 CoroutineContext
或 Dispatcher
参数以切换到该调度程序来执行阻塞工作且会暂停的函数应改为公开底层阻塞函数,并建议调用开发者使用自己的 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 Surface 中显示可选的 CoroutineContext
参数时,默认值必须为 Empty`CoroutineContext`
哨兵。这样可以更好地组合 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)
// ...
}