用 Jetpack Compose + MVI 开发了一个 Authenticator 双因素认证应用


一、背景:为什么又要造一个 Authenticator?

事情的起因很日常。

那天早上到公司,内部系统突然弹出了二次验证。我掏出手机,下意识去搜 Google Authenticator------结果下载完、扫完码,怎么都对不上。要么是版本问题,要么是没找对入口,折腾了十几分钟,咖啡都凉了。

当时就在想:一个 2FA 验证码工具,按说不应该这么麻烦。更麻烦的是,后续如果需要把账号迁移到别的 App,或者想查看一下底层 secret,很多工具要么不支持,要么藏得很深。而且,作为一个天天带手机的人,我其实很希望它能用指纹/面容锁住------毕竟这里存的是所有账户的「第二把钥匙」。

于是干脆自己动手。

我花了一点时间研究 TOTP/HOTP 的验证原理,发现核心逻辑其实非常优雅:基于时间和共享密钥生成一次性六位数字。趁着最近一直在学习 Jetpack Compose 的最新写法、状态管理和架构分层,我决定把这些知识点串起来,做一款完整、可用、好看的双因素认证应用。产品设计则借助开源的 Open Design 完成,省掉了很多从零画 UI 的时间。

这个项目就叫 Authenticator


二、先看成品

下面是应用的主要界面。整个设计走简洁现代的路线,首页展示账户与实时验证码,支持扫码和手动两种添加方式,设置页可以切换深色模式并开启生物识别锁。

三、2FA 验证在做什么?

在聊代码之前,简单回顾一下双因素认证(2FA)里最常见的 TOTP(Time-based One-Time Password)机制:

  • 服务端和客户端共享一个 Secret(通常以 Base32 编码)。
  • 双方约定一个 时间窗口,常见是 30 秒。
  • 客户端用 HMAC-SHA1(secret, currentTime / period) 计算出一个哈希,再截断成 6 位数字。
  • 因为服务端也用同样的算法和密钥计算,所以只要时间同步,两边就能对上。

整个流程不依赖网络,只依赖时间和密钥。理解了这一点,整个 App 的业务层就变得非常清晰:存账户、读 Secret、按时间生成验证码、展示出来。


四、技术选型

这个项目主要用来实践最新的 Android 技术栈:

层级 技术
UI Jetpack Compose + Material3
架构 MVI(单向数据流)
依赖注入 Hilt
本地存储 Room + DataStore
相机扫码 CameraX + ML Kit / ZXing
生物识别 BiometricPrompt
设计 Open Design(开源设计系统)

接下来重点分享 MVI 架构在这个项目里的落地。


五、为什么需要 MVI

这个 App 的核心功能并不复杂:展示账户列表、生成 TOTP 验证码、扫码或手动添加账户、设置主题与生物识别锁。但最初的代码存在几个典型问题:

  1. 页面持有业务逻辑QRScanner.kt 既管 CameraX 预览,又管扫码解析、状态判断、账户添加。
  2. 状态散落:搜索框展开、删除确认弹窗、复制成功提示等状态直接写在 Composable 里,难以测试。
  3. 副作用容易重复触发 :导航、Toast 等一次性行为如果用 StateFlow 持有,会在重组时重复执行。
  4. 职责边界模糊:UI、ViewModel、UseCase 之间互相渗透。

MVI 的核心价值就是单向数据流 + 关注点分离

  • State:UI 的完整快照,不可变。
  • Event:用户意图或系统事件,单向流入 ViewModel。
  • Effect :一次性副作用,通过 Channel 消费后即消失。

六、项目结构

bash 复制代码
app/src/main/kotlin/com/hgr/authenticator/
├── data/
│   ├── local/              # Room 数据库、DataStore
│   └── repository/         # 仓库实现
├── di/                     # Hilt 模块
├── domain/
│   ├── model/              # Account、ThemeMode 等
│   ├── repository/         # 仓库接口
│   └── usecase/            # 用例(生成验证码、增删账户等)
├── presentation/
│   ├── base/               # MVI 基础组件
│   │   ├── UiState.kt
│   │   ├── UiEvent.kt
│   │   ├── UiEffect.kt
│   │   └── BaseViewModel.kt
│   ├── home/               # 首页
│   │   ├── HomeContract.kt
│   │   ├── HomeViewModel.kt
│   │   └── HomeScreen.kt
│   ├── addaccount/         # 添加账户
│   │   ├── AddAccountContract.kt
│   │   ├── AddAccountViewModel.kt
│   │   └── AddAccountScreen.kt
│   ├── settings/           # 设置
│   │   ├── SettingsContract.kt
│   │   ├── SettingsViewModel.kt
│   │   └── SettingsScreen.kt
│   ├── components/         # 纯 UI 组件(AccountCard、QRScannerView 等)
│   ├── navigation/         # 导航图
│   └── theme/              # 主题
└── utils/                  # 工具类

七、MVI 基础层

三个空标记接口,让类型系统约束每一层的输入输出:

kotlin 复制代码
// presentation/base/UiState.kt
interface UiState

// presentation/base/UiEvent.kt
interface UiEvent

// presentation/base/UiEffect.kt
interface UiEffect

BaseViewModel

kotlin 复制代码
abstract class BaseViewModel<
    State : UiState,
    Event : UiEvent,
    Effect : UiEffect
>(initialState: State) : ViewModel() {

    private val _state = MutableStateFlow(initialState)
    val state: StateFlow<State> = _state.asStateFlow()

    private val _effect = Channel<Effect>(Channel.BUFFERED)
    val effect: Flow<Effect> = _effect.receiveAsFlow()

    protected val currentState: State get() = _state.value

    protected fun setState(reduce: State.() -> State) {
        _state.update { it.reduce() }
    }

    protected fun setEffect(effect: Effect) {
        viewModelScope.launch { _effect.send(effect) }
    }

    abstract fun onEvent(event: Event)
}

关键设计点:

  • stateStateFlow 暴露,保证 Compose 能 collectAsStateWithLifecycle() 订阅生命周期感知的状态。
  • effectChannel + receiveAsFlow(),避免旋转屏幕或重组时重复消费。
  • setState { copy(...) } 强制基于旧状态生成新状态,不可变。
  • onEvent(event: Event) 是 UI 层唯一能调用 ViewModel 的入口。

八、首页:Home

8.1 Contract

因为首页状态比较复杂(账户列表、验证码、搜索、删除确认、详情弹窗等),这里用 data class 而不是 sealed interface:

kotlin 复制代码
object HomeContract {

    data class HomeState(
        val accounts: List<Account> = emptyList(),
        val verificationCodeResults: Map<Long, VerificationCodeResult> = emptyMap(),
        val searchQuery: String = "",
        val isSearchActive: Boolean = false,
        val isMenuOpen: Boolean = false,
        val isLoading: Boolean = true,
        val copiedAccountId: Long? = null,
        val hasTimeDrift: Boolean = false,
        val deleteConfirmAccountId: Long? = null,
        val detailAccountId: Long? = null,
        val showAboutDialog: Boolean = false
    ) : UiState

    sealed class HomeEvent : UiEvent {
        data class OnSearchQueryChange(val query: String) : HomeEvent()
        data object OnToggleSearch : HomeEvent()
        data object OnOpenMenu : HomeEvent()
        data object OnCloseMenu : HomeEvent()
        data class OnCopyCode(val accountId: Long) : HomeEvent()
        data class OnDeleteAccount(val id: Long) : HomeEvent()
        data class OnDeleteConfirmed(val id: Long) : HomeEvent()
        data object OnDeleteDismissed : HomeEvent()
        data class OnShowAccountDetail(val id: Long) : HomeEvent()
        data object OnDismissDetail : HomeEvent()
        data object OnShowAbout : HomeEvent()
        data object OnDismissAbout : HomeEvent()
        data object OnTimeDriftWarningClick : HomeEvent()
        data object OnRefreshCodes : HomeEvent()
    }

    sealed class HomeEffect : UiEffect {
        data class ShowSnackbar(val message: String) : HomeEffect()
        data object NavigateToSettings : UiEffect()
        data object NavigateToDateSettings : HomeEffect()
    }
}

选择 data class 还是 sealed interface 取决于你的 UI 是否天然是「多互斥状态」。首页同时显示列表、搜索框、弹窗,data class 更合适;加载/成功/错误三态屏则用 sealed interface。

8.2 ViewModel

kotlin 复制代码
@HiltViewModel
class HomeViewModel @Inject constructor(
    getAccountsUseCase: GetAccountsUseCase,
    private val deleteAccountUseCase: DeleteAccountUseCase,
    private val generateVerificationCodeUseCase: GenerateVerificationCodeUseCase
) : BaseViewModel<HomeState, HomeEvent, HomeEffect>(HomeState()) {

    private val accountsFlow = getAccountsUseCase()
    private var codeRefreshJob: Job? = null
    private var copyTimeoutJob: Job? = null

    init {
        observeAccounts()
        startVerificationCodeTimer()
    }

    override fun onEvent(event: HomeEvent) {
        when (event) {
            is HomeEvent.OnSearchQueryChange -> onSearchQueryChange(event.query)
            is HomeEvent.OnToggleSearch -> onToggleSearch()
            is HomeEvent.OnOpenMenu -> setState { copy(isMenuOpen = true) }
            is HomeEvent.OnCloseMenu -> setState { copy(isMenuOpen = false) }
            is HomeEvent.OnCopyCode -> onCopyCode(event.accountId)
            is HomeEvent.OnDeleteAccount -> setState { copy(deleteConfirmAccountId = event.id) }
            is HomeEvent.OnDeleteConfirmed -> onDeleteConfirmed(event.id)
            is HomeEvent.OnDeleteDismissed -> setState { copy(deleteConfirmAccountId = null) }
            is HomeEvent.OnShowAccountDetail -> setState { copy(detailAccountId = event.id) }
            is HomeEvent.OnDismissDetail -> setState { copy(detailAccountId = null) }
            is HomeEvent.OnShowAbout -> setState { copy(showAboutDialog = true) }
            is HomeEvent.OnDismissAbout -> setState { copy(showAboutDialog = false) }
            is HomeEvent.OnTimeDriftWarningClick -> setEffect(HomeEffect.NavigateToDateSettings)
            is HomeEvent.OnRefreshCodes -> refreshVerificationCodes()
        }
    }

    private fun onSearchQueryChange(query: String) {
        setState { copy(searchQuery = query) }
    }

    private fun onCopyCode(accountId: Long) {
        setState { copy(copiedAccountId = accountId) }
        copyTimeoutJob?.cancel()
        copyTimeoutJob = viewModelScope.launch {
            delay(2_000)
            setState { copy(copiedAccountId = null) }
        }
    }

    private fun onDeleteConfirmed(id: Long) {
        setState { copy(deleteConfirmAccountId = null) }
        viewModelScope.launch { deleteAccountUseCase(id) }
    }

    private fun observeAccounts() {
        viewModelScope.launch {
            accountsFlow.collect { accounts ->
                setState { copy(accounts = accounts, isLoading = false) }
            }
        }
    }

    private fun startVerificationCodeTimer() {
        codeRefreshJob?.cancel()
        codeRefreshJob = viewModelScope.launch {
            while (true) {
                refreshVerificationCodes()
                setState { copy(hasTimeDrift = TimeSource.hasSignificantDrift()) }
                delay(1_000)
            }
        }
    }

    private fun refreshVerificationCodes() {
        val results = currentState.accounts.associate { account ->
            account.id to generateVerificationCodeUseCase(account.secret, account.period)
        }
        setState { copy(verificationCodeResults = results) }
    }

    override fun onCleared() {
        codeRefreshJob?.cancel()
        copyTimeoutJob?.cancel()
        super.onCleared()
    }
}

注意:

  • 所有 Job 都在 onCleared() 中取消,避免内存泄漏。
  • copy() 保持状态不可变。
  • 不持有 Context,导航等副作用通过 Effect 发到 UI 层处理。

8.3 Screen

kotlin 复制代码
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
    onNavigateToAddAccount: () -> Unit,
    onNavigateToSettings: () -> Unit,
    viewModel: HomeViewModel = hiltViewModel()
) {
    val uiState by viewModel.state.collectAsStateWithLifecycle()
    val snackbarHostState = remember { SnackbarHostState() }
    val context = LocalContext.current

    LaunchedEffect(Unit) {
        viewModel.effect.collect { effect ->
            when (effect) {
                is HomeEffect.ShowSnackbar -> {
                    snackbarHostState.showSnackbar(effect.message)
                }
                is HomeEffect.NavigateToSettings -> onNavigateToSettings()
                is HomeEffect.NavigateToDateSettings -> {
                    context.startActivity(Intent(Settings.ACTION_DATE_SETTINGS))
                }
            }
        }
    }

    // UI 只根据 uiState 渲染,事件都通过 viewModel.onEvent(...) 发送
    ScreenScaffold(
        topBar = { ... },
        floatingActionButton = { ... },
        snackbarHost = { SnackbarHost(snackbarHostState) }
    ) { padding ->
        HomeContent(
            uiState = uiState,
            onEvent = viewModel::onEvent,
            modifier = Modifier.padding(padding)
        )
    }
}

九、添加账户:AddAccount 与 QRScanner 解耦

这是重构中最典型的一个模块。最初 QRScanner 里塞了解析二维码、添加账户等逻辑。改造后:

  • QRScannerView:纯 UI,只负责 CameraX 预览、扫描动画、权限申请界面。
  • AddAccountViewModel:持有扫码状态机、表单校验、账户添加。

9.1 Contract

kotlin 复制代码
object AddAccountContract {

    data class AddAccountState(
        val selectedTab: Tab = Tab.Scan,
        val accountName: String = "",
        val secretKey: String = "",
        val userName: String = "",
        val isLoading: Boolean = false,
        val errorMessage: String? = null,
        val isSuccess: Boolean = false,
        val scanState: ScanState = ScanState.Idle,
        val hasCameraPermission: Boolean = false
    ) : UiState

    enum class Tab { Scan, Manual }

    sealed class ScanState {
        data object Idle : ScanState()
        data object Scanning : ScanState()
        data class Detected(val qrData: String) : ScanState()
        data class Processing(val qrData: String) : ScanState()
        data class Success(val qrData: String) : ScanState()
        data class Error(val message: String) : ScanState()
    }

    sealed class AddAccountEvent : UiEvent {
        data class OnTabSelected(val tab: Tab) : AddAccountEvent()
        data class OnAccountNameChange(val name: String) : AddAccountEvent()
        data class OnSecretKeyChange(val key: String) : AddAccountEvent()
        data class OnUserNameChange(val name: String) : AddAccountEvent()
        data object OnAddAccount : AddAccountEvent()
        data class OnCameraPermissionResult(val granted: Boolean) : AddAccountEvent()
        data object OnCameraPermissionRequested : AddAccountEvent()
        data class OnQrCodeDetected(val qrData: String) : AddAccountEvent()
        data object OnDismissError : AddAccountEvent()
    }

    sealed class AddAccountEffect : UiEffect {
        data class ShowSnackbar(val message: String) : AddAccountEffect()
        data object NavigateBack : AddAccountEffect()
    }
}

9.2 ViewModel

kotlin 复制代码
@HiltViewModel
class AddAccountViewModel @Inject constructor(
    private val addAccountUseCase: AddAccountUseCase
) : BaseViewModel<AddAccountState, AddAccountEvent, AddAccountEffect>(AddAccountState()) {

    private var scanValidationJob: Job? = null

    override fun onEvent(event: AddAccountEvent) {
        when (event) {
            is AddAccountEvent.OnTabSelected -> onTabSelected(event.tab)
            is AddAccountEvent.OnAccountNameChange ->
                setState { copy(accountName = event.name, errorMessage = null) }
            is AddAccountEvent.OnSecretKeyChange ->
                setState { copy(secretKey = event.key, errorMessage = null) }
            is AddAccountEvent.OnUserNameChange ->
                setState { copy(userName = event.name) }
            is AddAccountEvent.OnAddAccount -> onAddAccount()
            is AddAccountEvent.OnCameraPermissionResult -> onCameraPermissionResult(event.granted)
            is AddAccountEvent.OnCameraPermissionRequested -> {}
            is AddAccountEvent.OnQrCodeDetected -> onQrCodeDetected(event.qrData)
            is AddAccountEvent.OnDismissError ->
                setState { copy(errorMessage = null, scanState = ScanState.Scanning) }
        }
    }

    private fun onCameraPermissionResult(granted: Boolean) {
        setState {
            copy(
                hasCameraPermission = granted,
                scanState = if (granted) ScanState.Scanning else ScanState.Idle
            )
        }
    }

    private fun onQrCodeDetected(qrData: String) {
        if (currentState.scanState !is ScanState.Scanning) return

        setState { copy(scanState = ScanState.Detected(qrData)) }
        scanValidationJob?.cancel()
        scanValidationJob = viewModelScope.launch {
            delay(600)
            setState { copy(scanState = ScanState.Processing(qrData)) }
            delay(800)

            val otpData = OtpUriParser.parse(qrData)
            if (otpData != null) {
                setState { copy(scanState = ScanState.Success(qrData)) }
                delay(500)
                addAccount(otpData.issuer, otpData.secret, otpData.accountName)
            } else {
                setState { copy(scanState = ScanState.Error("Invalid QR code")) }
                delay(1_500)
                setState { copy(scanState = ScanState.Scanning) }
            }
        }
    }

    private fun onAddAccount() {
        val state = currentState
        if (state.accountName.isBlank() || state.secretKey.isBlank()) {
            setState { copy(errorMessage = "Please fill in required fields") }
            return
        }
        viewModelScope.launch {
            addAccount(state.accountName, state.secretKey, state.userName)
        }
    }

    private suspend fun addAccount(issuer: String, secret: String, name: String) {
        setState { copy(isLoading = true, errorMessage = null) }
        try {
            val account = Account(
                issuer = issuer,
                name = name.ifBlank { "$issuer User" },
                secret = secret,
                color = getColorForIssuer(issuer),
                icon = issuer.firstOrNull()?.uppercase() ?: "?"
            )
            addAccountUseCase(account)
            setState { copy(isLoading = false, isSuccess = true) }
            setEffect(AddAccountEffect.ShowSnackbar("Account added successfully"))
            delay(1_000)
            setEffect(AddAccountEffect.NavigateBack)
        } catch (e: Exception) {
            setState { copy(isLoading = false, errorMessage = "Failed to add account") }
        }
    }

    override fun onCleared() {
        scanValidationJob?.cancel()
        super.onCleared()
    }
}

9.3 QRScannerView:纯 UI

kotlin 复制代码
@Composable
fun QRScannerView(
    scanState: AddAccountContract.ScanState,
    hasPermission: Boolean,
    onPermissionResult: (Boolean) -> Unit,
    onRequestPermission: () -> Unit,
    onQrCodeDetected: (String) -> Unit,
    onDismissError: () -> Unit,
    modifier: Modifier = Modifier
) {
    val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission()
    ) { granted -> onPermissionResult(granted) }

    LaunchedEffect(Unit) {
        onRequestPermission()
    }

    if (hasPermission) {
        CameraPreview(
            scanState = scanState,
            onQrCodeDetected = onQrCodeDetected,
            onDismissError = onDismissError,
            modifier = modifier
        )
    } else {
        PermissionDeniedContent(
            onRequestPermission = { launcher.launch(Manifest.permission.CAMERA) },
            modifier = modifier
        )
    }
}

CameraPreview 内部通过 AndroidView 绑定 PreviewView,并在 DisposableEffect 中关闭线程池,避免内存泄漏:

kotlin 复制代码
val executor = remember { Executors.newSingleThreadExecutor() }

DisposableEffect(Unit) {
    onDispose { executor.shutdown() }
}

这样页面层只写:

kotlin 复制代码
QRScannerView(
    scanState = uiState.scanState,
    hasPermission = uiState.hasCameraPermission,
    onPermissionResult = { viewModel.onEvent(AddAccountEvent.OnCameraPermissionResult(it)) },
    onRequestPermission = { viewModel.onEvent(AddAccountEvent.OnCameraPermissionRequested) },
    onQrCodeDetected = { viewModel.onEvent(AddAccountEvent.OnQrCodeDetected(it)) },
    onDismissError = { viewModel.onEvent(AddAccountEvent.OnDismissError) }
)

扫码状态机、权限结果、账户添加逻辑全部在 ViewModel 中,UI 只是「显示和转发」。


十、设置页:Settings

设置页状态简单,主要演示 Effect 和纯 UI 组件的拆分:

kotlin 复制代码
object SettingsContract {

    data class SettingsState(
        val settings: AppSettings = AppSettings()
    ) : UiState

    sealed class SettingsEvent : UiEvent {
        data class OnThemeSelected(val mode: ThemeMode) : SettingsEvent()
        data class OnBiometricToggle(val enabled: Boolean) : SettingsEvent()
    }

    sealed class SettingsEffect : UiEffect {
        data class ShowSnackbar(val message: String) : SettingsEffect()
    }
}

ViewModel:

kotlin 复制代码
@HiltViewModel
class SettingsViewModel @Inject constructor(
    private val getSettingsUseCase: GetSettingsUseCase,
    private val updateSettingsUseCase: UpdateSettingsUseCase,
    private val biometricManager: BiometricManager
) : BaseViewModel<SettingsState, SettingsEvent, SettingsEffect>(SettingsState()) {

    init {
        viewModelScope.launch {
            getSettingsUseCase().collect { settings ->
                setState { copy(settings = settings) }
            }
        }
    }

    override fun onEvent(event: SettingsEvent) {
        when (event) {
            is SettingsEvent.OnThemeSelected -> updateTheme(event.mode)
            is SettingsEvent.OnBiometricToggle -> updateBiometric(event.enabled)
        }
    }

    private fun updateTheme(mode: ThemeMode) {
        viewModelScope.launch {
            updateSettingsUseCase { it.copy(themeMode = mode) }
        }
    }

    private fun updateBiometric(enabled: Boolean) {
        if (enabled && !biometricManager.canAuthenticate() == BIOMETRIC_SUCCESS) {
            setEffect(SettingsEffect.ShowSnackbar("Biometric not available"))
            return
        }
        viewModelScope.launch {
            updateSettingsUseCase { it.copy(biometricEnabled = enabled) }
        }
    }
}

十一、导航与依赖注入

AppNavHost 只负责路由跳转,不处理业务:

kotlin 复制代码
@Composable
fun AppNavHost(navController: NavHostController) {
    NavHost(navController = navController, startDestination = Screen.Home.route) {
        composable(Screen.Home.route) {
            HomeScreen(
                onNavigateToAddAccount = { navController.navigate(Screen.AddAccount.route) },
                onNavigateToSettings = { navController.navigate(Screen.Settings.route) }
            )
        }
        composable(Screen.AddAccount.route) {
            AddAccountScreen(onNavigateBack = { navController.popBackStack() })
        }
        composable(Screen.Settings.route) {
            SettingsScreen(onNavigateBack = { navController.popBackStack() })
        }
    }
}

Hilt 提供 UseCase 和 Repository:

kotlin 复制代码
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds
    abstract fun bindAccountRepository(
        impl: AccountRepositoryImpl
    ): AccountRepository
}

十二、MVI 数据流

flowchart LR A[用户点击/输入] -->|Event| B[ViewModel.onEvent] B -->|调用| C[UseCase / Repository] C -->|结果| D[setState 更新 StateFlow] D -->|collectAsStateWithLifecycle| E[Compose 重组] B -->|setEffect| F[Channel Effect] F -->|LaunchedEffect| G[Snackbar / 导航 / 弹窗]

十三、关键经验总结

  1. State 用 data class 还是 sealed interface?

    • 同时存在多个子状态时,用 data class(如首页搜索+列表+弹窗)。
    • 互斥状态(加载/成功/错误)用 sealed interface。
  2. Effect 为什么用 Channel 不用 SharedFlow/StateFlow?

    • Effect 是一次性消费。Channel + receiveAsFlow() 保证每个副作用只被处理一次,旋转屏幕不会重发。
  3. UI 层不要写业务逻辑

    • QRScannerView 只负责相机和动画,解析和添加账户交给 ViewModel。
    • 所有点击事件都通过 viewModel.onEvent(...) 转发。
  4. 防止内存泄漏

    • ViewModel 中的 JobonCleared() 中取消。
    • CameraX 的线程池在 DisposableEffect.onDispose 中 shutdown。
  5. 生命周期感知

    • 使用 collectAsStateWithLifecycle() 订阅状态,避免后台时不必要的重组。

十四、写在最后

从最初的「扫码登不上公司系统」到今天这款功能完整的 Authenticator,整个过程让我重新体会了一个道理:很多小工具之所以值得重做一遍,不是因为技术有多难,而是因为现有方案总有些地方没有照顾好真实场景。

MVI 不是模板代码的堆砌,而是一种强制你把 UI 状态、用户意图、一次性副作用分开思考的约束。对于 Authenticator 这种状态繁多的应用,改造后最大的收益是:

  • 每个 Screen 只描述「看到什么、点击后发送什么事件」。
  • 每个 ViewModel 只描述「收到事件后如何改变状态」。
  • 每个 Effect 只描述「需要触发一次的行为」。

代码因此变得可预测、可测试、可维护。

如果你也在用 Jetpack Compose 开发 Android 应用,希望这篇基于真实项目的分享能给你一些参考。


项目源码GitHub - HugeRivers/Authenticator

相关推荐
故渊at2 小时前
第十三板块:Android 综合架构与未来演进 | 第三十二篇:Android 内存管理与 LMK 机制的深度剖析
android·架构·内存管理·内存回收·lmk机制·收割算法
某林2122 小时前
ROS2 并行编译死锁与 Linux 后台声卡/提权踩坑实录:大型轮足机器人架构复盘
linux·架构·机器人·iassc
AI科技星2 小时前
第四卷:橡皮泥江湖(拓扑学)――诸同奥义,九同立境贯拓扑
网络·人工智能·线性代数·架构·概率论·学习方法·拓扑学
DianSan_ERP3 小时前
架构师视角:电商大促高并发下的订单API限流与防漏单架构演进
java·运维·网络·安全·微服务·架构·自动化
一尘之中3 小时前
基于架构的软件开发方法
学习·架构·ai写作
AI科技星4 小时前
第三卷:质数王朝志 第四章:RSA护国玄阵,质数锁天地,一数镇万法
android·人工智能·架构·概率论·学习方法