22、MVVM架构实践:Repository模式、ViewModel管理认证状态、LiveData/Flow数据流

好,我们直接进入正题。虹膜认证这个功能,如果只是写个Demo,那怎么搞都行。但一旦要放到正式产品里,代码结构就得讲究了。我个人习惯用MVVM来组织这类生物识别逻辑,原因很简单——好维护、好测试、不容易写出“屎山”。

今天我们就来拆解一下,在虹膜认证场景下,Repository模式怎么用、ViewModel怎么管状态、LiveData和Flow怎么选。

一、为什么非得用Repository?

你想想看,虹膜认证的调用方是谁?可能是系统服务、可能是厂商SDK、甚至可能是你封装的一个底层库。如果Activity直接去调这些底层API,那画面太美我不敢看——耦合严重、没法单元测试、换一个厂商就得改一堆代码。

Repository的作用,说白了就是“中间人”。它把数据来源(比如系统BiometricManager、本地缓存、甚至远程配置)统一封装起来,上层ViewModel只管调用repository的方法,不用关心数据是从哪来的。

核心原则: ViewModel不直接持有任何与Android Framework相关的引用。所有系统调用都隔离在Repository层。

二、Repository层的设计

我在项目中遇到过一种情况:同一个App需要兼容不同厂商的虹膜SDK。有的厂商提供的是回调式API,有的是协程式。这时候Repository就派上大用场了——我在Repository内部做适配,对外暴露统一的接口。

来看一个简化版的结构:

// 定义接口,不依赖具体实现
interface IrisAuthRepository {
    fun startAuth(activity: FragmentActivity): Flow<IrisAuthResult>
}

// 具体实现,封装系统BiometricManager
class SystemIrisAuthRepository @Inject constructor(
    private val biometricManager: BiometricManager
) : IrisAuthRepository {
    override fun startAuth(activity: FragmentActivity): Flow<IrisAuthResult> {
        return callbackFlow {
            val promptInfo = BiometricPrompt.PromptInfo.Builder()
                .setTitle("虹膜验证")
                .setSubtitle("请正视屏幕")
                .setAllowedAuthenticators(BIOMETRIC_IRIS)
                .build()

            val callback = object : BiometricPrompt.AuthenticationCallback() {
                override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                    trySend(IrisAuthResult.Success(result))
                }
                override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                    trySend(IrisAuthResult.Error(errorCode, errString.toString()))
                }
                override fun onAuthenticationFailed() {
                    trySend(IrisAuthResult.Failed)
                }
            }

            BiometricPrompt(activity, callback).authenticate(promptInfo)

            awaitClose { /* 清理资源 */ }
        }
    }
}
我的习惯: Repository层返回Flow,而不是LiveData。原因后面会讲。

三、ViewModel管理认证状态

ViewModel的核心职责就两个:持有UI状态、协调业务逻辑。对于虹膜认证来说,状态无非就是:空闲中、认证中、成功、失败、错误。

我建议用一个密封类来定义这些状态:

sealed class IrisAuthUiState {
    object Idle : IrisAuthUiState()
    object Authenticating : IrisAuthUiState()
    data class Success(val biometricResult: BiometricPrompt.AuthenticationResult) : IrisAuthUiState()
    data class Error(val code: Int, val message: String) : IrisAuthUiState()
    object Failed : IrisAuthUiState()
}

然后在ViewModel里,我们这样管理:

@HiltViewModel
class IrisAuthViewModel @Inject constructor(
    private val irisAuthRepository: IrisAuthRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow<IrisAuthUiState>(IrisAuthUiState.Idle)
    val uiState: StateFlow<IrisAuthUiState> = _uiState.asStateFlow()

    private var authJob: Job? = null

    fun startAuth(activity: FragmentActivity) {
        // 防止重复点击
        if (_uiState.value is IrisAuthUiState.Authenticating) return

        authJob?.cancel()
        authJob = viewModelScope.launch {
            _uiState.value = IrisAuthUiState.Authenticating

            irisAuthRepository.startAuth(activity)
                .catch { e ->
                    _uiState.value = IrisAuthUiState.Error(-1, e.message ?: "未知错误")
                }
                .collect { result ->
                    _uiState.value = when (result) {
                        is IrisAuthResult.Success -> IrisAuthUiState.Success(result.data)
                        is IrisAuthResult.Error -> IrisAuthUiState.Error(result.code, result.message)
                        is IrisAuthResult.Failed -> IrisAuthUiState.Failed
                    }
                }
        }
    }

    fun resetState() {
        authJob?.cancel()
        _uiState.value = IrisAuthUiState.Idle
    }

    override fun onCleared() {
        super.onCleared()
        authJob?.cancel()
    }
}
注意: 千万不要在ViewModel里持有Activity的引用!上面代码中把activity传给了Repository,但Repository内部使用完后不会长期持有。如果你不小心把Activity存到了ViewModel的成员变量里,旋转屏幕就会内存泄漏。我曾经踩过这个坑,排查了一下午。

四、LiveData vs Flow,到底选哪个?

嗯,这个问题其实挺多人在问。我直接给结论:新项目无脑用Flow,老项目如果已经全面LiveData了,也别急着重构。

为什么我推荐Flow?三个理由:

  • 协程原生支持: Flow天然和协程配合,viewModelScope.launch里直接collect,不用额外转换。
  • 操作符丰富: map、filter、catch、combine……这些LiveData做起来很别扭,Flow信手拈来。
  • 冷流特性: 每次有新的collector,都会重新执行上游代码。对于虹膜认证这种“每次认证都是新的一次”的场景,非常合适。

但LiveData也有它的优势:

  • 自动感知生命周期,不用手动取消。
  • 在XML绑定中直接使用,非常方便。

我个人做法是:Repository层统一返回Flow,ViewModel层对外暴露StateFlow,UI层用repeatOnLifecycle来收集。这样既享受了Flow的灵活性,又不会出现生命周期问题。

五、整体架构图

下面这张图展示了我们刚才讲的所有组件之间的关系。我特意把数据流向画清楚了,你一看就明白。

Activity / Fragment 观察 StateFlow IrisAuthViewModel 管理 UiState IrisAuthRepository 封装系统API BiometricManager 调用 调用 委托 Flow 数据流 UiState 密封类 Idle/Authenticating/Success/Error/Failed

六、避坑指南

最后分享几个我实际踩过的坑:

  • 不要用LiveData做一次性事件: 比如“认证成功跳转页面”,如果用LiveData,旋转屏幕后会再次触发。我改用Channel+SharedFlow来处理这类一次性事件。
  • ViewModel里别做耗时操作: 虹膜认证本身是系统异步回调的,但如果你在ViewModel里做数据库写入或网络请求,记得切到Dispatchers.IO。我见过有人直接在main线程做加密运算,结果ANR了。
  • 测试Repository时记得Mock系统服务: BiometricPrompt没法在单元测试里跑。我的做法是把Repository接口化,测试时注入一个假的实现,返回预设的Flow数据。
总结一句话: Repository隔离变化,ViewModel管理状态,Flow串联数据。这套组合拳打好了,虹膜认证这块代码三五年不用大改。

公众号:蓝海资料掘金营,微信deep3321