1. 生物识别能力检测到底在解决什么问题
做过iOS应用的人大概率都遇到过这样的场景:产品经理跑过来提需求,说登录页面要加指纹解锁,支付环节要支持刷脸验证,但同时又要求——如果用户的手机压根没有这些硬件,或者用户自己把功能关了,界面不能报错,得自动降级到密码输入。这个需求听起来简单,但真正动手写的时候,你会发现里面藏着不少细节。
判断iOS机器是否支持TouchId、FaceId这个标题,表面上看只是调用一两个API的事情,但实际落地时会牵扯到硬件能力检测、用户设置状态判断、生物识别类型区分、错误码处理、降级策略设计等一系列问题。我见过不少项目在这块翻车——有的把"硬件不支持"和"用户没录入指纹"混为一谈,导致提示文案驴唇不对马嘴;有的在Face ID设备上硬编码指纹图标,被用户截图吐槽;还有的没有正确处理LAErrorBiometryNotAvailable,导致应用在特定机型上直接卡死。
这篇文章主要面向已经有一定iOS开发基础、正在做登录/支付/隐私相关功能的开发者。我会从底层框架讲起,把LocalAuthentication的核心机制拆开揉碎,然后给出可直接复制使用的检测代码,最后重点分享我在实际项目中踩过的坑和总结出来的降级策略。即使你之前没接触过LocalAuthentication,跟着读下来也能直接上手。
需要提前说明的是,本文讨论的是设备本地生物识别能力的检测,不涉及任何网络传输或数据回传逻辑,所有判断都在设备端完成,这也是苹果设计这套框架的初衷——生物特征数据永远不离开设备。
2. LocalAuthentication框架的能力边界与核心对象
2.1 为什么是LAContext而不是别的
iOS的生物识别能力检测,入口只有一个:LocalAuthentication框架。这个框架从iOS 8开始引入,最初只支持Touch ID,后来逐步扩展到Face ID,再后来Apple Watch的腕部检测也走了类似机制。你不需要引入任何第三方库,系统原生支持,这也是苹果推荐的做法。
核心对象是LAContext。你可以把它理解成"一次生物识别会话的上下文"。每次你要检测能力或者发起验证,都需要创建一个新的LAContext实例。这里有个容易忽略的点:LAContext是有状态的,它缓存了上一次验证的结果,如果你复用同一个实例,可能会拿到过期的状态。所以我的习惯是每次检测都新建一个,用完就丢。
import LocalAuthentication let context = LAContext()就这一行,环境就准备好了。不需要在Info.plist里加任何权限声明,因为生物识别权限是系统级的,不像相机麦克风那样需要应用单独申请。但有一个例外:如果你的应用要使用Face ID,需要在Info.plist里添加NSFaceIDUsageDescription,否则在Face ID设备上调用时会直接失败。这个坑我后面会详细说。
2.2 canEvaluatePolicy的返回值到底意味着什么
检测能力的核心方法是canEvaluatePolicy(_:error:)。它的签名长这样:
func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool返回一个布尔值,同时通过error指针回传具体的失败原因。很多人只看了布尔值,忽略了error,结果就是"不支持"三个字走天下,用户完全不知道问题出在哪。
LAPolicy有几个枚举值,和生物识别相关的主要是:
deviceOwnerAuthenticationWithBiometrics:只使用生物识别(Touch ID或Face ID)deviceOwnerAuthentication:生物识别优先,失败后降级到设备密码
这两个的区别非常关键。如果你只关心"这台机器有没有生物识别硬件",用第一个;如果你要做完整的身份验证流程,通常用第二个,因为用户可能没录指纹但设了密码。
canEvaluatePolicy返回false时,error的code会告诉你具体原因。常见的错误码包括:
| 错误码 | 含义 | 典型场景 |
|---|---|---|
LAError.biometryNotAvailable | 生物识别不可用 | 硬件损坏或被系统禁用 |
LAError.biometryNotEnrolled | 未录入生物特征 | 用户没设置指纹或Face ID |
LAError.biometryLockout | 生物识别被锁定 | 连续失败次数过多 |
LAError.passcodeNotSet | 设备未设置密码 | 用户没设锁屏密码 |
这个表格建议你收藏,后面写降级逻辑时直接对照着用。
2.3 biometryType:区分Touch ID和Face ID的唯一正确方式
检测完"能不能用",下一步是"用的是哪种"。LAContext的biometryType属性就是干这个的。但这里有个巨大的坑:biometryType只有在调用过canEvaluatePolicy之后才有值。如果你直接创建一个LAContext然后读biometryType,拿到的是.none。
正确的顺序是:
let context = LAContext() var error: NSError? let canEvaluate = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) if canEvaluate { switch context.biometryType { case .faceID: print("支持Face ID") case .touchID: print("支持Touch ID") case .opticID: print("支持Optic ID") // visionOS case .none: print("不支持生物识别") @unknown default: print("未知类型") } }注意.opticID是visionOS引入的,iOS端不会出现,但加上@unknown default是好习惯,防止未来苹果新增类型时编译警告。
还有一个细节:在iOS 11之前,biometryType这个属性不存在,那时候只能通过canEvaluatePolicy的布尔值判断。现在最低部署版本一般都到iOS 13了,这个问题基本不用考虑,但如果你维护的是老项目,心里要有数。
3. 从零写一个健壮的检测工具类
3.1 基础版本:三行代码能跑但不够用
先看最简版本,很多人网上搜到的就是这种:
func canUseBiometrics() -> Bool { let context = LAContext() return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) }这三行确实能跑,但它只回答了"能不能用",没回答"为什么不能用"和"用的是哪种"。在实际项目里,产品经理要的往往是更细的信息:是硬件不支持,还是用户没录入?是Touch ID还是Face ID?这些信息决定了UI怎么展示、文案怎么写。
所以我们需要一个更完整的工具类。我的做法是定义一个枚举来表示所有可能的状态:
enum BiometricStatus { case available(BiometricType) case notAvailable // 硬件不支持 case notEnrolled // 未录入 case lockedOut // 被锁定 case passcodeNotSet // 未设密码 case unknown(NSError?) } enum BiometricType { case touchID case faceID case opticID case none }这样调用方拿到状态后,可以精确地决定下一步做什么。
3.2 完整检测逻辑与错误码映射
下面是完整的检测方法,我把错误码映射和类型判断都整合进去了:
func checkBiometricStatus() -> BiometricStatus { let context = LAContext() var error: NSError? let canEvaluate = context.canEvaluatePolicy( .deviceOwnerAuthenticationWithBiometrics, error: &error ) if canEvaluate { let type: BiometricType switch context.biometryType { case .faceID: type = .faceID case .touchID: type = .touchID case .opticID: type = .opticID case .none: type = .none @unknown default: type = .none } return .available(type) } guard let laError = error as? LAError else { return .unknown(error) } switch laError.code { case .biometryNotAvailable: return .notAvailable case .biometryNotEnrolled: return .notEnrolled case .biometryLockout: return .lockedOut case .passcodeNotSet: return .passcodeNotSet default: return .unknown(error) } }这段代码有几个设计考量值得说明。第一,canEvaluatePolicy的error参数是NSErrorPointer,在Swift里用&error传入,拿到的error需要强转成LAError才能读.code。第二,biometryType的读取必须在canEvaluatePolicy返回true之后,否则不可靠。第三,@unknown default是必须的,苹果随时可能加新类型。
3.3 为什么我建议同时检测deviceOwnerAuthentication
上面用的是deviceOwnerAuthenticationWithBiometrics,只检测生物识别。但在实际业务里,我通常还会再检测一次deviceOwnerAuthentication:
let canUsePasscodeFallback = context.canEvaluatePolicy( .deviceOwnerAuthentication, error: nil )这个policy的含义是"生物识别或设备密码,至少有一个可用"。如果它为true,说明用户至少设了密码,那么即使生物识别不可用,你也能降级到密码验证。如果它为false,说明用户连密码都没设,那你的应用只能走自己的账号密码登录,不能依赖系统验证。
这个判断在支付、隐私内容解锁等场景特别重要。我见过一个应用,只检测了生物识别,结果用户没录指纹时直接白屏,因为代码里没有降级路径。加上这个检测,就能优雅地处理。
3.4 一个容易忽略的线程问题
canEvaluatePolicy是同步方法,但它内部可能会和系统服务通信,在极端情况下(比如设备刚启动、系统繁忙)会有轻微阻塞。虽然实测下来通常几毫秒就返回,但如果你在viewDidLoad里连续调用多次,或者在主线程做大量检测,理论上会影响启动速度。
我的做法是把检测放在后台队列,检测完再回主线程更新UI:
DispatchQueue.global(qos: .userInitiated).async { let status = checkBiometricStatus() DispatchQueue.main.async { updateUI(with: status) } }不过说实话,单次检测的开销小到可以忽略,这个优化更多是防御性的。如果你的检测逻辑里还夹杂了其他耗时操作,那确实该放后台。
4. Face ID特有的坑与Info.plist配置
4.1 没有NSFaceIDUsageDescription会怎样
这是Face ID相关最经典的坑,没有之一。在Face ID设备上,如果你的Info.plist里没有NSFaceIDUsageDescription这个key,调用canEvaluatePolicy时不会崩溃,但会直接返回false,error是LAError.biometryNotAvailable。你会误以为设备不支持Face ID,实际上是配置缺失。
更坑的是,这个错误在模拟器上不一定复现,因为模拟器的行为有时和真机不一致。我有个项目就是在模拟器上测得好好的,一到真机就失效,排查了半天才发现是plist漏了。
正确的配置是在Info.plist里加:
<key>NSFaceIDUsageDescription</key> <string>需要使用Face ID来快速验证您的身份</string>这个字符串会显示在系统弹窗里,告诉用户为什么应用要使用Face ID。文案要写清楚用途,苹果审核时也会看这个。别写"为了安全"这种空话,写"用于登录验证"或"用于支付确认"这种具体的。
4.2 模拟器上Face ID的测试方法
模拟器默认不支持Face ID,但可以手动开启。在模拟器的菜单栏里找到Features->Face ID->Enrolled,勾选后模拟器就"录入"了Face ID。然后可以用Matching Face和Non-matching Face来模拟验证成功和失败。
但要注意,模拟器的Face ID检测和真机有差异。比如biometryType在模拟器上返回.faceID,但某些错误码的行为可能不同。所以涉及Face ID的检测逻辑,最终一定要在真机上验证。我一般会准备一台带Face ID的真机和一台带Touch ID的真机(如果还能找到的话),两边都跑一遍。
4.3 Touch ID和Face ID在UI上的差异处理
检测出类型后,UI上要区别对待。Touch ID的图标是指纹,Face ID的图标是人脸,文案也不一样——"请按压指纹"vs"请注视屏幕"。如果你偷懒用同一套文案,用户会觉得应用很粗糙。
我的做法是定义一个协议,让UI层根据BiometricType返回对应的图标和文案:
extension BiometricType { var iconName: String { switch self { case .touchID: return "touchid" case .faceID: return "faceid" case .opticID: return "opticid" case .none: return "lock" } } var prompt: String { switch self { case .touchID: return "请按压指纹验证" case .faceID: return "请注视屏幕进行验证" case .opticID: return "请进行验证" case .none: return "请验证身份" } } }touchid和faceid是SF Symbols里内置的图标名,直接用UIImage(systemName:)就能拿到,不需要自己切图。这个细节能省不少设计资源。
5. 降级策略:检测完之后该做什么
5.1 四种状态对应的四种处理路径
检测出状态只是第一步,关键是检测完之后怎么处理。我把常见状态和处理方式整理成表:
| 状态 | 含义 | 推荐处理 |
|---|---|---|
| available | 可用 | 展示生物识别入口,发起验证 |
| notEnrolled | 未录入 | 提示用户去设置里录入,或降级密码 |
| notAvailable | 硬件不支持 | 隐藏生物识别入口,走密码 |
| lockedOut | 被锁定 | 提示用密码解锁,或引导去设置 |
| passcodeNotSet | 未设密码 | 只能走应用自己的登录 |
这里重点说lockedOut。生物识别连续失败多次(Touch ID是5次,Face ID也是5次左右)后会被系统锁定,此时canEvaluatePolicy返回false,error是biometryLockout。用户必须输入设备密码才能解锁生物识别。如果你的应用在这个状态下还傻傻地弹生物识别验证,会直接失败。
正确的做法是检测到lockedOut时,引导用户使用deviceOwnerAuthentication(带密码降级)来验证,验证成功后生物识别会自动解锁。或者提示用户去系统设置里操作。
5.2 不要用生物识别做唯一验证手段
这是我从多个项目里总结出来的血泪教训。生物识别再方便,也不能作为唯一的身份验证方式。原因有三:第一,硬件可能损坏;第二,用户可能没录入;第三,系统可能锁定。任何一个原因都会导致用户被挡在门外。
所以我的原则是:生物识别永远是"快捷方式",而不是"唯一通道"。应用必须保留账号密码登录作为兜底。在支付等敏感场景,生物识别通过后,后端还应该做二次校验,不能仅凭客户端的一个布尔值就放行。
5.3 检测时机:启动时检测还是使用时检测
另一个常见问题是:什么时候做检测?我的建议是"使用时检测",而不是"启动时检测并缓存结果"。
原因是用户可能在应用运行期间去系统设置里录入或删除指纹,如果你启动时缓存了结果,后面就会用到过期数据。比如用户启动应用时没录指纹,检测结果是notEnrolled,然后他去设置里录了指纹,回到应用,你的缓存还显示不可用,用户就会困惑。
每次要发起验证前重新检测一次,开销极小,但能保证状态最新。如果确实需要缓存(比如为了UI预渲染),也要在应用从后台回到前台时刷新。
6. 实测中遇到的几个非典型问题
6.1 企业签名应用的特殊表现
在某些企业分发或特定签名方式下,生物识别API的行为可能和App Store版本不同。我遇到过一次,同一个检测代码,App Store版本正常,企业签名版本在部分机型上canEvaluatePolicy始终返回false。后来排查发现是签名配置和系统权限的交互问题,重新配置签名后解决。
这个问题的启示是:如果你的应用有多种分发渠道,生物识别功能要在每个渠道上都验证一遍,不能只测App Store版本。
6.2 越狱设备上的不可靠性
在越狱设备上,LocalAuthentication的返回值可能被篡改。有些越狱插件可以伪造canEvaluatePolicy的返回值和biometryType。所以如果你的应用涉及资金安全,不能完全信任客户端的检测结果,关键操作必须由后端做风控判断。
当然,普通应用不用过度担心这个,但心里要有数:客户端检测是"用户体验优化",不是"安全边界"。
6.3 应用从后台恢复时的状态刷新
iOS应用进入后台再回来,LAContext的状态可能变化。我建议在UIApplication.didBecomeActiveNotification里重新检测一次,更新UI。特别是用户去设置里改了生物识别配置再回来,这个刷新能保证界面和实际状态一致。
NotificationCenter.default.addObserver( forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main ) { _ in refreshBiometricStatus() }这个监听很轻量,但能避免很多"界面显示可用但实际不可用"的尴尬。
7. 把检测逻辑封装成可复用的组件
7.1 单例还是每次新建
LAContext本身不建议复用,但检测逻辑可以封装成一个无状态的工具。我倾向于用enum加静态方法,而不是单例:
enum BiometricDetector { static func check() -> BiometricStatus { // 上面的检测逻辑 } }这样调用方BiometricDetector.check()就行,不需要管理实例生命周期。每次调用内部新建LAContext,保证状态新鲜。
7.2 给UI层的友好接口
工具类返回的是枚举,UI层需要的是"能不能显示按钮""显示什么图标""点击后做什么"。所以我再加一层适配:
struct BiometricUIInfo { let shouldShowButton: Bool let iconName: String let title: String let fallbackToPasscode: Bool } func uiInfo(for status: BiometricStatus) -> BiometricUIInfo { switch status { case .available(let type): return BiometricUIInfo( shouldShowButton: true, iconName: type.iconName, title: type.prompt, fallbackToPasscode: true ) case .notEnrolled: return BiometricUIInfo( shouldShowButton: false, iconName: "lock", title: "请先录入生物识别", fallbackToPasscode: true ) // ... 其他状态 } }这样UI层拿到BiometricUIInfo后直接绑定,逻辑清晰,也方便单元测试。
7.3 单元测试怎么写
生物识别的单元测试比较麻烦,因为依赖系统硬件。我的做法是把检测逻辑抽象成协议,测试时注入mock:
protocol BiometricChecking { func check() -> BiometricStatus } class MockBiometricChecker: BiometricChecking { var stubbedStatus: BiometricStatus = .available(.faceID) func check() -> BiometricStatus { stubbedStatus } }这样就能测试各种状态下的UI行为了,不需要真机。真机测试则覆盖集成场景,两者结合。
8. 一些零散但重要的经验
关于biometryType的读取时机,我再强调一次:必须在canEvaluatePolicy之后。我见过有人在LAContext初始化后立刻读,拿到.none,然后以为设备不支持,白白绕了弯路。
关于错误处理,LAError的code是判断依据,但不要忽略error.userInfo里可能有的补充信息。某些情况下系统会给出更详细的描述,对排查有帮助。
关于隐私,生物识别数据永远不离开设备,LocalAuthentication只返回验证结果,不返回任何生物特征数据。这一点可以在隐私政策里明确写出来,增强用户信任。
关于兼容性,deviceOwnerAuthenticationWithBiometrics从iOS 8就有,biometryType从iOS 11才有,opticID是visionOS的。如果你的应用要支持很老的系统,需要做版本判断。不过现在主流应用最低都iOS 13了,这个问题基本可以忽略。
最后说一个我个人的习惯:每次做生物识别相关功能,我都会准备一个"状态矩阵"文档,把所有可能的状态、对应的UI、对应的降级路径列出来,然后逐个在真机上验证。这个文档在需求评审和测试阶段特别有用,能避免很多沟通成本。生物识别看起来简单,但状态组合多,不系统梳理很容易漏掉边界情况。