SwiftUI 中 Face ID / Passcode 随机重复及回调缺失
结论
日志中的 error code: -2 对应 LAError.userCancel,说明用户取消了这次 evaluatePolicy。系统已将该认证请求判定为失败,当前代码也收到了失败回调。
取消后再次出现 Face ID,通常意味着 App 又调用了一次 evaluatePolicy。可能的触发点包括:
- SwiftUI 的
.onAppear、.task或.onChange再次执行。 handleBiometricAlertFailure()间接发起了认证。isBiometricInProgress = false改变视图状态,导致认证逻辑重新执行。- 多个页面、订阅者或 Use Case 共用同一个
LAContext。 SharedBiometricContext.reset()创建新 context 后,其他代码立即用它发起认证。
authenticateForBiometricAlert 没有再次打印日志,说明第二次 Face ID 可能从其他方法进入,也可能属于另一个已经开始的认证请求。系统显示认证界面时,不会自动调用 App 的业务方法。
从这些现象看,问题更可能出在重复认证、状态竞争或导航条件上,不能简单归因于 OS 的随机行为。
为什么取消后还能进入 Dashboard
取消后是否进入 Dashboard,要看 Face ID 在业务流程中的作用。
如果用户已经通过 email 和 password 登录,Face ID 页面只用于询问是否启用快捷登录,那么取消后进入 Dashboard 没有问题,但不能把生物识别标记为已启用。
如果 Face ID 或设备密码是登录必须通过的第二道验证,.userCancel 就必须阻止导航。只有 evaluatePolicy 返回 success == true 时,才能进入 Dashboard。
需要重点检查当前代码中的:
self.handleBiometricAlertFailure()
completion(.failure)
以及:
viewModel.isBiometricInProgress = false
确认这些状态变化会不会触发 Dashboard 导航或新一轮认证。日志已经表明这次请求失败,所以取消后进入 Dashboard 与这次 evaluatePolicy 的认证结果无关。
evaluatePolicy 的回调规则
每次调用:
context.evaluatePolicy(...)
都会产生一次异步结果。认证成功、失败或取消后,completion handler 会返回一次。
认证请求从下面的方法进入:
authenticateForBiometricAlert(completion:)
Face ID 成功后,系统不会再次调用这个方法。排查时应该关注传给 evaluatePolicy 的 completion handler。
如果第二次 Face ID 成功,却没有进入当前 completion handler,基本可以确定它属于另一条认证调用链。建议在每次调用 evaluatePolicy 前记录唯一的请求 ID,不要只记录上层方法名。
当前实现的风险点
1. 共享 LAContext
多个并发认证流程不应共用同一个 LAContext。静态 context 容易让不同页面或 Use Case 互相影响,尤其是在认证尚未完全结束时执行:
context.invalidate()
context = buildContext()
invalidate() 会使该 context 上尚未完成的认证失效,通常返回 .appCancel。创建新 context 不会直接弹出 Face ID,但其他代码可能马上取得这个 context,再次调用 evaluatePolicy。
建议为每次认证创建单独的 LAContext,并在认证管理器中保存当前 context,用来阻止重复请求。
2. 取消回调导致状态重入
当前流程大致是:
- 收到
.userCancel。 - 重建共享 context。
- 发布
biometricProgressStatus。 - 将
isBiometricInProgress设为false。 - 上层收到
.failure。 handleBiometricAlertFailure()修改其他 UI 状态。
这些状态变化可能让 SwiftUI 重新计算视图,继而再次运行 .task、.onAppear 或 .onChange 中的认证代码。此时新的 context 已经可用,第二次认证便可能启动。
3. 多层主线程调度
authenticate(using:) 已经将结果切到主线程,上层的 authenticateForBiometricAlert 又执行了一次:
DispatchQueue.main.async
这通常不会直接造成 Face ID 重复,但会改变状态更新的先后顺序,增加发生重入的机会。认证结果只需要在一个层级切换到主线程。
4. 使用未公开的错误码判断
下面的判断不够可靠:
errorType.code.rawValue == CommonConstants.userInteractionNotHappened
除非该常量来自应用自行定义并完全控制的协议,否则不要依赖 LAError 的裸整数值。应直接处理公开的 LAError.Code:
switch error.code {
case .userCancel, .systemCancel, .appCancel:
// 认证被取消
case .userFallback:
// 用户选择了应用提供的 fallback
case .biometryLockout:
// 生物识别被锁定
default:
// 其他失败
}
推荐实现
下面的实现会为每次认证创建单独的 context,并通过请求 ID 阻止并发调用。结果类型可以替换为项目现有的 BiometricAuthResult。
import LocalAuthentication
enum AuthenticationOutcome {
case success
case cancelled(LAError.Code)
case unavailable(Error?)
case failure(Error?)
case alreadyInProgress
}
final class BiometricAuthenticator {
private var activeContext: LAContext?
private var activeRequestID: UUID?
func authenticate(
using policy: LAPolicy,
reason: String,
completion: @escaping (AuthenticationOutcome) -> Void
) {
dispatchPrecondition(condition: .onQueue(.main))
guard activeRequestID == nil else {
completion(.alreadyInProgress)
return
}
let requestID = UUID()
let context = LAContext()
activeRequestID = requestID
activeContext = context
var evaluationError: NSError?
guard context.canEvaluatePolicy(policy, error: &evaluationError) else {
finish(
requestID: requestID,
result: .unavailable(evaluationError),
completion: completion
)
return
}
Logger.sharedInstance.log(
"Starting LocalAuthentication request: \(requestID)",
level: .debug
)
context.evaluatePolicy(
policy,
localizedReason: reason
) { [weak self] success, error in
DispatchQueue.main.async {
guard let self else {
return
}
Logger.sharedInstance.log(
"Finished LocalAuthentication request: \(requestID), success: \(success), error: \(String(describing: error))",
level: .debug
)
let result: AuthenticationOutcome
if success {
result = .success
} else if let laError = error as? LAError {
switch laError.code {
case .userCancel, .systemCancel, .appCancel:
result = .cancelled(laError.code)
default:
result = .failure(laError)
}
} else {
result = .failure(error)
}
self.finish(
requestID: requestID,
result: result,
completion: completion
)
}
}
}
func cancel() {
dispatchPrecondition(condition: .onQueue(.main))
activeContext?.invalidate()
}
private func finish(
requestID: UUID,
result: AuthenticationOutcome,
completion: @escaping (AuthenticationOutcome) -> Void
) {
guard activeRequestID == requestID else {
return
}
activeRequestID = nil
activeContext = nil
completion(result)
}
}
调用方根据最终结果更新一次状态:
authenticator.authenticate(
using: .deviceOwnerAuthentication,
reason: "Authenticate to continue"
) { [weak self] result in
guard let self else {
return
}
self.isBiometricInProgress = false
switch result {
case .success:
self.storageManager.setIsBiometricOrPasscodeEnabled(value: true)
self.navigateToDashboard()
case .cancelled:
// 如果生物识别只是可选设置,可以进入 Dashboard,
// 但不要把 biometric enabled 设置为 true。
self.storageManager.setIsBiometricOrPasscodeEnabled(value: false)
self.navigateToDashboard()
case .unavailable, .failure:
self.showAuthenticationError()
case .alreadyInProgress:
break
}
}
如果 Face ID 是强制认证条件,取消分支不能调用 navigateToDashboard()。
选择正确的 LAPolicy
认证允许在 Face ID 失败后改用设备密码时,应使用:
.deviceOwnerAuthentication
只允许生物识别时,应使用:
.deviceOwnerAuthenticationWithBiometrics
Face ID、设备密码的切换方式以及系统认证界面中的提示文本由 iOS 管理,App 无法控制所有界面细节。App 需要控制的是调用 evaluatePolicy 的时机、是否允许并发认证,以及认证结果如何影响导航。
查找第二次认证的发起位置
为每次调用生成请求 ID,并在所有 evaluatePolicy 调用点记录日志:
let requestID = UUID()
Logger.sharedInstance.log(
"evaluatePolicy started, requestID: \(requestID)",
level: .debug
)
同时全局搜索:
evaluatePolicy(
authenticateForBiometricAlert(
biometricAuthUseCase.execute(
重点检查以下位置:
.onAppear.task.onChangehandleBiometricAlertFailure()handleBiometricAlertSuccess()biometricProgressStatus的所有订阅者- Dashboard 导航条件
- 是否存在多个
BiometricAuthenticator或 ViewModel 实例
handleBiometricInprogressCallback() 也应只注册一次。每调用一次,都会向 cancellables 添加一个订阅。之后同一条通知可能触发多次状态更新。
现有日志可以确认,第一次请求因 .userCancel 结束。接下来需要找到第二个 evaluatePolicy 调用点,并检查取消结果为什么仍然满足 Dashboard 的导航条件。
备注:内容仅供参考。
