PAYATHON 2026

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. 取消回调导致状态重入

当前流程大致是:

  1. 收到 .userCancel。
  2. 重建共享 context。
  3. 发布 biometricProgressStatus。
  4. 将 isBiometricInProgress 设为 false。
  5. 上层收到 .failure。
  6. 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
  • .onChange
  • handleBiometricAlertFailure()
  • handleBiometricAlertSuccess()
  • biometricProgressStatus 的所有订阅者
  • Dashboard 导航条件
  • 是否存在多个 BiometricAuthenticator 或 ViewModel 实例

handleBiometricInprogressCallback() 也应只注册一次。每调用一次,都会向 cancellables 添加一个订阅。之后同一条通知可能触发多次状态更新。

现有日志可以确认,第一次请求因 .userCancel 结束。接下来需要找到第二个 evaluatePolicy 调用点,并检查取消结果为什么仍然满足 Dashboard 的导航条件。

备注:内容仅供参考。