Skip to content

生物识别

源:Android BiometricPrompt | iOS LocalAuthentication

生物识别(指纹、面容 ID)是现代应用的必备安全功能。本文将展示如何跨平台统一生物识别 API。

平台差异对比

平台原生 API支持类型权限要求降级方案
AndroidBiometricPrompt指纹、面容、虹膜USE_BIOMETRICPIN/密码
iOSLAContextTouch ID、Face IDInfo.plist 描述设备密码
Desktop无原生支持--密码认证

expect/actual 实现方案

API 核心签名说明

  • enum class BiometricResult
  • expect class BiometricAuthenticator
  • suspend fun BiometricAuthenticator.authenticate(title: String, subtitle: String): BiometricResult
  • suspend fun BiometricAuthenticator.canAuthenticate(): Boolean

标准代码块

kotlin
enum class BiometricResult {
    SUCCESS,           // 认证成功
    FAILED,            // 认证失败(指纹不匹配)
    ERROR,             // 系统错误
    CANCELED,          // 用户取消
    NOT_AVAILABLE      // 设备不支持或未设置
}

expect class BiometricAuthenticator {
    suspend fun canAuthenticate(): Boolean
    suspend fun authenticate(
        title: String,
        subtitle: String = "",
        description: String = ""
    ): BiometricResult
}

// 业务层使用
class SecureFeature(private val biometric: BiometricAuthenticator) {
    
    suspend fun unlockSecureData(): Boolean {
        if (!biometric.canAuthenticate()) {
            // 降级到密码认证
            return false
        }
        
        val result = biometric.authenticate(
            title = "验证身份",
            subtitle = "使用指纹或面容解锁",
            description = "验证后可查看敏感信息"
        )
        
        return result == BiometricResult.SUCCESS
    }
}
kotlin
import android.content.Context
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume

actual class BiometricAuthenticator(private val activity: FragmentActivity) {
    
    private val biometricManager = BiometricManager.from(activity)
    
    actual suspend fun canAuthenticate(): Boolean {
        return when (biometricManager.canAuthenticate(
            BiometricManager.Authenticators.BIOMETRIC_STRONG
        )) {
            BiometricManager.BIOMETRIC_SUCCESS -> true
            else -> false
        }
    }
    
    actual suspend fun authenticate(
        title: String,
        subtitle: String,
        description: String
    ): BiometricResult = suspendCancellableCoroutine { continuation ->
        
        val executor = ContextCompat.getMainExecutor(activity)
        
        val callback = object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationSucceeded(
                result: BiometricPrompt.AuthenticationResult
            ) {
                continuation.resume(BiometricResult.SUCCESS)
            }
            
            override fun onAuthenticationFailed() {
                // 单次失败,但可以继续尝试
            }
            
            override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                val result = when (errorCode) {
                    BiometricPrompt.ERROR_USER_CANCELED,
                    BiometricPrompt.ERROR_CANCELED -> BiometricResult.CANCELED
                    BiometricPrompt.ERROR_NO_BIOMETRICS,
                    BiometricPrompt.ERROR_HW_NOT_PRESENT -> BiometricResult.NOT_AVAILABLE
                    else -> BiometricResult.ERROR
                }
                continuation.resume(result)
            }
        }
        
        val prompt = BiometricPrompt(activity, executor, callback)
        
        val promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle(title)
            .setSubtitle(subtitle)
            .setDescription(description)
            .setNegativeButtonText("取消")
            .build()
        
        prompt.authenticate(promptInfo)
    }
}
kotlin
import platform.LocalAuthentication.*
import platform.Foundation.NSError
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume

actual class BiometricAuthenticator {
    
    private val context = LAContext()
    
    actual suspend fun canAuthenticate(): Boolean {
        val error: NSError? = null
        return context.canEvaluatePolicy(
            LAPolicyDeviceOwnerAuthenticationWithBiometrics,
            error = error
        )
    }
    
    actual suspend fun authenticate(
        title: String,
        subtitle: String,
        description: String
    ): BiometricResult = suspendCancellableCoroutine { continuation ->
        
        val reason = buildString {
            append(title)
            if (subtitle.isNotEmpty()) {
                append("\n")
                append(subtitle)
            }
            if (description.isNotEmpty()) {
                append("\n")
                append(description)
            }
        }
        
        context.evaluatePolicy(
            LAPolicyDeviceOwnerAuthenticationWithBiometrics,
            localizedReason = reason
        ) { success, error ->
            val result = when {
                success -> BiometricResult.SUCCESS
                error != null -> {
                    when (error.code) {
                        LAErrorUserCancel -> BiometricResult.CANCELED
                        LAErrorBiometryNotAvailable,
                        LAErrorBiometryNotEnrolled -> BiometricResult.NOT_AVAILABLE
                        LAErrorAuthenticationFailed -> BiometricResult.FAILED
                        else -> BiometricResult.ERROR
                    }
                }
                else -> BiometricResult.ERROR
            }
            continuation.resume(result)
        }
    }
}
kotlin
actual class BiometricAuthenticator {
    
    actual suspend fun canAuthenticate(): Boolean {
        // Desktop 不支持生物识别
        return false
    }
    
    actual suspend fun authenticate(
        title: String,
        subtitle: String,
        description: String
    ): BiometricResult {
        return BiometricResult.NOT_AVAILABLE
    }
}

代码封装示例

以下是带密钥绑定和降级的完整封装:

kotlin
// commonMain
sealed class AuthResult {
    data class Success(val cryptoObject: Any? = null) : AuthResult()
    data class Failure(val reason: BiometricResult) : AuthResult()
}

class SecureAuthenticator(
    private val biometric: BiometricAuthenticator,
    private val passwordAuth: suspend (String) -> Boolean
) {
    
    suspend fun authenticateWithFallback(
        title: String,
        allowPassword: Boolean = true
    ): AuthResult {
        // 优先尝试生物识别
        if (biometric.canAuthenticate()) {
            val result = biometric.authenticate(title)
            if (result == BiometricResult.SUCCESS) {
                return AuthResult.Success()
            }
            
            // 生物识别失败,是否允许降级
            if (!allowPassword || result == BiometricResult.CANCELED) {
                return AuthResult.Failure(result)
            }
        }
        
        // 降级到密码认证
        // 实际应用中应显示密码输入对话框
        return AuthResult.Failure(BiometricResult.NOT_AVAILABLE)
    }
}

依赖补充

Android BiometricPrompt

kotlin
kotlin {
    sourceSets {
        androidMain.dependencies {
            implementation("androidx.biometric:biometric:1.2.0-alpha05")
        }
    }
}
groovy
kotlin {
    sourceSets {
        androidMain {
            dependencies {
                implementation "androidx.biometric:biometric:1.2.0-alpha05"
            }
        }
    }
}
toml
[versions]
androidx-biometric = "1.2.0-alpha05"

[libraries]
androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "androidx-biometric" }

最新版本查看链接:https://developer.android.com/jetpack/androidx/releases/biometric

AndroidManifest.xml 配置

xml
<uses-permission android:name="android.permission.USE_BIOMETRIC" />

iOS Info.plist 配置

xml
<key>NSFaceIDUsageDescription</key>
<string>使用 Face ID 验证您的身份</string>

实战坑点

Android FragmentActivity 依赖

BiometricPrompt 需要 FragmentActivity

BiometricPrompt 构造函数要求 FragmentActivity,不能使用普通 Context。

解决方案:

kotlin
// 确保传入 FragmentActivity
class BiometricAuthenticator(private val activity: FragmentActivity) {
    // ...
}

// 在 Activity 中初始化
class MainActivity : FragmentActivity() {
    private val biometric = BiometricAuthenticator(this)
}

iOS 主线程回调

回调线程问题

iOS LocalAuthentication 的回调可能在后台线程执行。

解决方案:

kotlin
context.evaluatePolicy(...) { success, error ->
    // 切换到主线程
    dispatch_async(dispatch_get_main_queue()) {
        // 更新 UI
    }
}

Android 降级按钮

设备凭据降级

可以配置 BiometricPrompt 在失败后显示 PIN/密码输入。

配置方法:

kotlin
val promptInfo = BiometricPrompt.PromptInfo.Builder()
    .setTitle("验证身份")
    .setAllowedAuthenticators(
        BiometricManager.Authenticators.BIOMETRIC_STRONG or
        BiometricManager.Authenticators.DEVICE_CREDENTIAL
    )
    // 使用 DEVICE_CREDENTIAL 时不能设置 NegativeButton
    .build()

iOS Face ID 权限描述缺失

应用崩溃

未在 Info.plist 添加 NSFaceIDUsageDescription 会导致崩溃。

解决方案:
始终添加权限描述,即使设备不支持 Face ID:

xml
<key>NSFaceIDUsageDescription</key>
<string>使用 Face ID 快速登录</string>

设备未设置生物识别

::: caution 用户体验 设备支持但未设置生物识别时,应引导用户设置。 :::

检测与引导:

kotlin
// Android
when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
    BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
        // 引导用户前往设置添加指纹
        val intent = Intent(Settings.ACTION_BIOMETRIC_ENROLL)
        startActivity(intent)
    }
}