现代 Android MVI 架构示例,基于 Jetpack Compose、Koin 和 Navigation,遵循 Android 官方架构指南。
| 组件 | 版本 | 用途 |
|---|---|---|
| Kotlin | 2.3.20 | 编程语言 |
| Compose BOM | 2026.03.01 | UI 框架 |
| Material3 | BOM | 设计系统 |
| Navigation Compose | 2.9.7 | 页面导航 |
| Koin | 4.2.0 | 依赖注入 |
| StateFlow | - | 状态管理 |
| Gradle | 8.14.3 | 构建系统 |
| AGP | 8.13.2 | Android Gradle 插件 |
| compileSdk / minSdk | 36 / 24 | Android SDK |
com.hao.mvi/
│
├── MainActivity.kt # App 入口,Compose + Navigation
├── App.kt # Koin 初始化
│
├── core/ # 🔧 核心层(跨 feature 共享)
│ ├── base/ # MVI 基础设施
│ │ ├── BaseViewModel.kt # 泛型 ViewModel<State, Event>
│ │ ├── MviContract.kt # IUiState / IUiEvent
│ │ └── UiState.kt # Idle/Loading/Success/Error 封装
│ ├── navigation/
│ │ ├── Screen.kt # 路由定义
│ │ └── AppNavGraph.kt # NavHost 配置
│ └── ui/theme/
│ ├── Color.kt
│ ├── Theme.kt
│ └── Type.kt
│
├── di/ # 💉 依赖注入
│ └── AppModule.kt # Koin 模块
│
└── feature/ # 📦 功能模块
├── counter/
│ ├── data/
│ │ └── CounterRepository.kt
│ ├── domain/
│ │ └── CounterUseCases.kt
│ └── presentation/
│ ├── CounterContract.kt
│ ├── CounterScreen.kt
│ └── CounterViewModel.kt
└── detail/
└── presentation/
└── DetailScreen.kt
用户操作 → Event → ViewModel.handleEvent() → setState() → StateFlow → UI 重组
一次性事件(Toast 消息、页面导航)通过 UI State 中的可空字段建模(例如 userMessage: String?、navigateToDetail: Int?)。UI 层通过 LaunchedEffect 消费事件后通知 ViewModel 清除该字段。这遵循了 官方建议:"ViewModel 事件应始终产生 UI 状态更新。"
| 层级 | 包路径 | 职责 |
|---|---|---|
| 核心层 | core.base |
MVI 抽象(IUiState、IUiEvent、UiState<T>) |
| 导航层 | core.navigation |
路由 + NavGraph |
| 依赖注入 | di |
Koin 模块定义 |
| 数据层 | feature.*.data |
Repository 接口 + 实现 |
| 领域层 | feature.*.domain |
UseCase 业务逻辑 |
| 表现层 | feature.*.presentation |
Screen + ViewModel + Contract |
abstract class BaseViewModel<State : IUiState, Event : IUiEvent>(
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main
) : ViewModel() {
abstract fun createInitialState(): State
abstract fun handleEvent(event: Event)
val uiState: StateFlow<State>
val currentState: State
fun sendEvent(event: Event)
protected fun setState(reduce: State.() -> State)
}每个 feature 定义一个 Contract 文件,包含 UI State 和 Event:
// State — UI 状态,配置变更后保留
// 可空字段用于一次性事件(消费后清除)
data class CounterUiState(
val count: Int = 0,
val isLoading: Boolean = false,
val userMessage: String? = null,
val navigateToDetail: Int? = null
) : IUiState
// Event — 用户意图 + 消费事件
sealed class CounterEvent : IUiEvent {
data object Increment : CounterEvent()
data object Decrement : CounterEvent()
data object Reset : CounterEvent()
data object NavigateToDetail : CounterEvent()
data object UserMessageShown : CounterEvent()
data object NavigationHandled : CounterEvent()
}每个页面包含两个 Composable:
- 有状态包装器(如
CounterScreen)— 通过 Koin 连接 ViewModel,使用collectAsStateWithLifecycle()收集状态,通过LaunchedEffect消费一次性事件 - 无状态内容(如
CounterContent)— 接收状态和事件回调作为参数,包含@Preview
用于异步操作的泛型密封类:
sealed class UiState<out T> {
data object Idle : UiState<Nothing>()
data object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String, val throwable: Throwable? = null) : UiState<Nothing>()
}- 创建功能包:
feature/newfeature/
├── data/
│ └── NewFeatureRepository.kt # 接口 + 实现
├── domain/
│ └── NewFeatureUseCases.kt # 用例,使用 operator fun invoke()
└── presentation/
├── NewFeatureContract.kt # UiState + Event 定义
├── NewFeatureScreen.kt # 有状态 + 无状态 Composable
└── NewFeatureViewModel.kt # 继承 BaseViewModel
- 在 Koin 中注册(
di/AppModule.kt)—single用于 Repository,factory用于 UseCase,viewModel用于 ViewModel - 在
core/navigation/Screen.kt添加路由 - 在
core/navigation/AppNavGraph.kt添加 composable
- 使用 Fake 实现(而非 Mock)— 参见测试源码中的
FakeCounterRepository - ViewModel 测试使用
UnconfinedTestDispatcher+Dispatchers.setMain()控制协程 - 通过发送事件后断言
viewModel.uiState.value来测试状态
# 运行所有单元测试
./gradlew testDebugUnitTest
# 运行单个测试类
./gradlew testDebugUnitTest --tests "com.hao.mvi.feature.counter.presentation.CounterViewModelTest"MIT