一、背景:为什么又要造一个 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 验证码、扫码或手动添加账户、设置主题与生物识别锁。但最初的代码存在几个典型问题:
- 页面持有业务逻辑:
QRScanner.kt既管 CameraX 预览,又管扫码解析、状态判断、账户添加。 - 状态散落:搜索框展开、删除确认弹窗、复制成功提示等状态直接写在 Composable 里,难以测试。
- 副作用容易重复触发:导航、Toast 等一次性行为如果用
StateFlow持有,会在重组时重复执行。 - 职责边界模糊:UI、ViewModel、UseCase 之间互相渗透。
MVI 的核心价值就是单向数据流 + 关注点分离:
- State:UI 的完整快照,不可变。
- Event:用户意图或系统事件,单向流入 ViewModel。
- Effect:一次性副作用,通过
Channel消费后即消失。
六、项目结构
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 基础层
三个空标记接口,让类型系统约束每一层的输入输出:
// presentation/base/UiState.kt
interface UiState
// presentation/base/UiEvent.kt
interface UiEvent
// presentation/base/UiEffect.kt
interface UiEffect
BaseViewModel
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)
}
关键设计点:
state用StateFlow暴露,保证 Compose 能collectAsStateWithLifecycle()订阅生命周期感知的状态。effect用Channel+receiveAsFlow(),避免旋转屏幕或重组时重复消费。setState { copy(...) }强制基于旧状态生成新状态,不可变。onEvent(event: Event)是 UI 层唯一能调用 ViewModel 的入口。
八、首页:Home
8.1 Contract
因为首页状态比较复杂(账户列表、验证码、搜索、删除确认、详情弹窗等),这里用 data class 而不是 sealed interface:
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
@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
@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
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
@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
@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 中关闭线程池,避免内存泄漏:
val executor = remember { Executors.newSingleThreadExecutor() }
DisposableEffect(Unit) {
onDispose { executor.shutdown() }
}
这样页面层只写:
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 组件的拆分:
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:
@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 只负责路由跳转,不处理业务:
@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:
@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 / 导航 / 弹窗]
十三、关键经验总结
-
State 用 data class 还是 sealed interface?
- 同时存在多个子状态时,用 data class(如首页搜索+列表+弹窗)。
- 互斥状态(加载/成功/错误)用 sealed interface。
-
Effect 为什么用 Channel 不用 SharedFlow/StateFlow?
- Effect 是一次性消费。
Channel+receiveAsFlow()保证每个副作用只被处理一次,旋转屏幕不会重发。
- Effect 是一次性消费。
-
UI 层不要写业务逻辑
QRScannerView只负责相机和动画,解析和添加账户交给 ViewModel。- 所有点击事件都通过
viewModel.onEvent(...)转发。
-
防止内存泄漏
- ViewModel 中的
Job在onCleared()中取消。 - CameraX 的线程池在
DisposableEffect.onDispose中 shutdown。
- ViewModel 中的
-
生命周期感知
- 使用
collectAsStateWithLifecycle()订阅状态,避免后台时不必要的重组。
- 使用
十四、写在最后
从最初的「扫码登不上公司系统」到今天这款功能完整的 Authenticator,整个过程让我重新体会了一个道理:很多小工具之所以值得重做一遍,不是因为技术有多难,而是因为现有方案总有些地方没有照顾好真实场景。
MVI 不是模板代码的堆砌,而是一种强制你把 UI 状态、用户意图、一次性副作用分开思考的约束。对于 Authenticator 这种状态繁多的应用,改造后最大的收益是:
- 每个 Screen 只描述「看到什么、点击后发送什么事件」。
- 每个 ViewModel 只描述「收到事件后如何改变状态」。
- 每个 Effect 只描述「需要触发一次的行为」。
代码因此变得可预测、可测试、可维护。
如果你也在用 Jetpack Compose 开发 Android 应用,希望这篇基于真实项目的分享能给你一些参考。