21、人脸识别与Kotlin协程:suspendCancellableCoroutine封装、Flow监听认证状态、协程取消处理
说实话,人脸识别这个功能,在Android原生API里是典型的回调地狱。你调用一个方法,传个callback进去,然后在callback里处理成功或失败。如果只是单个调用还好,一旦涉及到多个认证步骤、状态监听、页面生命周期管理……代码很快就变得没法看了。
我个人习惯是,只要遇到回调,第一反应就是把它包装成协程。Kotlin协程的suspendCancellableCoroutine简直就是为这种场景量身定做的。今天我们就来聊聊,怎么把人脸识别的回调API优雅地封装成挂起函数,再用Flow去监听认证状态,最后处理好协程取消的问题。
一、为什么需要suspendCancellableCoroutine?
先看原生的人脸识别调用方式:
val biometricManager = BiometricManager.from(context)
val authenticator = BiometricPrompt(
fragmentActivity,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
// 成功
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
// 错误
}
override fun onAuthenticationFailed() {
// 失败(可重试)
}
}
)
authenticator.authenticate(promptInfo)
这种写法有什么问题?你想想看,如果页面里有多个地方需要调用人脸识别,每个地方都得写一遍这个callback。而且callback里的代码和调用方的代码是分离的,逻辑跳来跳去,维护起来很痛苦。
我在项目中遇到过这样一个场景:用户在进行支付操作时,需要先验证人脸,然后才能继续。如果用回调的方式,支付逻辑就得写在callback里,如果验证失败还要重试……嗯,代码很快就变成了一坨意大利面。
所以,我的做法是:用suspendCancellableCoroutine把这个回调包装成一个挂起函数。
二、suspendCancellableCoroutine封装实战
先看代码,再解释:
suspend fun BiometricPrompt.authenticateSuspend(
promptInfo: BiometricPrompt.PromptInfo
): BiometricResult = suspendCancellableCoroutine { continuation ->
// 创建一个新的callback实例
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
continuation.resume(BiometricResult.Success(result))
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
continuation.resume(BiometricResult.Error(errorCode, errString.toString()))
}
override fun onAuthenticationFailed() {
// 注意:这里不resume,因为可以重试
// 我们通过Flow来通知UI
}
}
// 设置取消时的清理
continuation.invokeOnCancellation {
// 取消认证
this@authenticateSuspend.cancelAuthentication()
}
// 开始认证
this.authenticate(promptInfo, callback)
}
sealed class BiometricResult {
data class Success(val result: BiometricPrompt.AuthenticationResult) : BiometricResult()
data class Error(val code: Int, val message: String) : BiometricResult()
}
这里有几个关键点:
suspendCancellableCoroutine会创建一个可取消的协程上下文。当协程被取消时,invokeOnCancellation里的代码会被执行。- 为什么
onAuthenticationFailed不调用resume?因为人脸识别失败后,系统允许用户继续尝试,这时候协程不应该结束。我后面会用Flow来处理这种“中间状态”。 - 我曾经踩过一个坑:忘记在
invokeOnCancellation里调用cancelAuthentication()。结果用户退出页面后,人脸识别还在后台运行,弹窗一直不消失。嗯,这个教训挺深刻的。
三、用Flow监听认证状态
刚才说了,onAuthenticationFailed不能直接结束协程。那怎么通知UI“用户人脸没匹配上,请重试”呢?我的方案是用SharedFlow或StateFlow来发射状态。
看这个封装:
class BiometricAuthManager(private val context: Context) {
private val _authState = MutableSharedFlow<BiometricAuthState>(
replay = 1,
extraBufferCapacity = 1
)
val authState: SharedFlow<BiometricAuthState> = _authState.asSharedFlow()
suspend fun authenticate(promptInfo: BiometricPrompt.PromptInfo): BiometricResult {
return suspendCancellableCoroutine { continuation ->
val biometricPrompt = BiometricPrompt(
context as FragmentActivity,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
_authState.tryEmit(BiometricAuthState.Success)
continuation.resume(BiometricResult.Success(result))
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
val error = BiometricAuthState.Error(errorCode, errString.toString())
_authState.tryEmit(error)
continuation.resume(BiometricResult.Error(errorCode, errString.toString()))
}
override fun onAuthenticationFailed() {
// 只发射状态,不结束协程
_authState.tryEmit(BiometricAuthState.Failed)
}
}
)
continuation.invokeOnCancellation {
biometricPrompt.cancelAuthentication()
}
biometricPrompt.authenticate(promptInfo)
}
}
}
sealed class BiometricAuthState {
object Success : BiometricAuthState()
data class Error(val code: Int, val message: String) : BiometricAuthState()
object Failed : BiometricAuthState()
}
这样设计的好处是什么?
- 调用方可以同时拿到“最终结果”(通过挂起函数返回值)和“中间状态”(通过Flow)。
- UI层可以单独收集
authState,当收到Failed时,显示“请重试”的提示,而不需要重新调用整个认证流程。 - 我建议用
SharedFlow而不是StateFlow,因为Failed状态可能连续出现多次(用户连续失败),StateFlow会去重,导致UI收不到第二次失败事件。
四、协程取消处理——别让认证泄漏
协程取消处理,说白了就是:当用户退出页面时,人脸识别的弹窗必须跟着消失,不能留在屏幕上。
看一个完整的ViewModel示例:
class BiometricViewModel : ViewModel() {
private val authManager = BiometricAuthManager(application)
private val _uiState = MutableStateFlow<UiState>(UiState.Idle)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun startAuthentication(promptInfo: BiometricPrompt.PromptInfo) {
viewModelScope.launch {
_uiState.value = UiState.Authenticating
// 收集认证状态
authManager.authState.collect { state ->
when (state) {
is BiometricAuthState.Failed -> {
_uiState.value = UiState.Failed("人脸不匹配,请重试")
}
is BiometricAuthState.Error -> {
_uiState.value = UiState.Error(state.message)
}
is BiometricAuthState.Success -> {
// 最终结果会通过返回值处理
}
}
}
}
viewModelScope.launch {
val result = authManager.authenticate(promptInfo)
when (result) {
is BiometricResult.Success -> {
_uiState.value = UiState.Authenticated
}
is BiometricResult.Error -> {
// 如果已经通过Flow处理了,这里可以忽略
}
}
}
}
override fun onCleared() {
super.onCleared()
// viewModelScope取消时,所有协程都会被取消
// 包括正在进行的认证
}
}
这里有个细节:我启动了两个协程。一个专门收集Flow状态,一个等待最终结果。当ViewModel被清除时,viewModelScope会自动取消所有协程,invokeOnCancellation里的cancelAuthentication()就会被调用,弹窗消失。
我曾经遇到过一个线上bug:用户在认证过程中按了返回键,页面销毁了,但人脸弹窗还在。排查后发现,是因为我在Activity里用lifecycleScope.launch启动协程,但没有处理Lifecycle.Event.ON_DESTROY时的取消。后来统一改用viewModelScope,问题就解决了。
五、避坑指南
最后分享几个我踩过的坑:
- 不要在
onAuthenticationFailed里调用resume:否则协程结束,用户无法重试。用Flow发射状态更合适。 - 记得在
invokeOnCancellation里取消认证:否则协程取消后,人脸弹窗还在运行,造成资源泄漏。 - 注意线程安全:
BiometricPrompt的回调可能不在主线程,但resume和tryEmit是线程安全的,不过UI更新记得切回主线程。 - 不要重复创建
BiometricPrompt实例:每个实例只能认证一次。我习惯在Manager里按需创建,用完就释放。
核心总结:suspendCancellableCoroutine把回调转成挂起函数,Flow处理中间状态,viewModelScope自动处理取消。这三板斧用好了,人脸识别的代码会变得非常清爽。
小技巧:如果你需要支持多次认证(比如用户连续失败后重试),可以考虑把authenticate方法设计成每次调用都创建新的BiometricPrompt实例,避免状态混乱。
警告:suspendCancellableCoroutine的invokeOnCancellation不一定会被调用!如果协程已经完成(resume被调用),再取消协程,invokeOnCancellation不会执行。所以不要在invokeOnCancellation里做“必须执行”的清理工作,比如释放资源。
这张图展示了整个流程:UI层启动协程,Manager内部用suspendCancellableCoroutine封装回调,同时用SharedFlow发射中间状态。当协程取消时,自动调用cancelAuthentication()清理资源。
好了,关于人脸识别与Kotlin协程的结合,今天就聊到这里。代码不多,但每个细节都值得推敲。如果你在实际项目中遇到什么问题,欢迎交流。
公众号:蓝海资料掘金营,微信deep3321