我有个秘密要坦白。
去年,我打开了我们代码库中的一个 ViewModel 文件,它已经膨胀到了 847 行。八百四十七行!里面塞满了业务逻辑、状态管理、API 调用,以及多到足以让 IDE 滚动条看起来像一条绝望细线的 viewModelScope.launch 代码块。
我们都经历过。你从一个干净、简单的 ViewModel 开始。然后产品经理要求“再加一个小功能”。接着又一个。再一个。不知不觉间,你面对的类已经无所不能,从获取用户数据到计算生命的意义。
我知道一定有更好的方法。经过数月的实验、无数次重构和许多个深夜,我找到了一种彻底改变我对 ViewModel 看法的东西。
我称之为 TOAD,即 Typed Object Action Dispatch(类型化对象动作分发)。
没人愿意谈论的问题
让我们诚实地面对大多数 Android/KMP 项目中发生的事情。
你从这里开始:
class ProfileViewModel : ViewModel() {
fun loadProfile() { /* 干净,简单 */ }
}
六个月后,你到了这里:
class ProfileViewModel : ViewModel() {
fun loadProfile() { /*...*/ }
fun updateName() { /*...*/ }
fun updateEmail() { /*...*/ }
fun uploadPhoto() { /*...*/ }
fun deleteAccount() { /*...*/ }
fun changePassword() { /*...*/ }
fun connectSocialMedia() { /*...*/ }
fun verifyPhone() { /*...*/ }
fun updateNotificationSettings() { /*...*/ }
fun exportData() { /*...*/ }
fun toggleDarkMode() { /*...*/ }
//... 还有 30 个方法
}
每次需要添加功能时,你都要打开 ViewModel 并添加另一个方法。类在膨胀。测试变得更困难。代码审查变得更漫长。而那个写了其中一半方法的开发者?他六个月前就离职了。
这不仅仅是代码混乱。这违反了软件设计中最基本的原则之一:开闭原则。
“软件实体应该对扩展开放,对修改关闭。”
每个功能请求都意味着 修改 ViewModel。我们完全搞反了。
如果 ViewModel 永远不需要改变会怎样?
这个问题一直困扰着我。如果一个 ViewModel 一旦写好就永远不需要修改,那会是什么样子?
答案来自一个意想不到的地方:命令模式。
如果,我们不是把方法放在 ViewModel 里,而是拥有代表动作的对象,会怎样?这些对象是自包含、可测试、可替换的,并且恰好封装了一个业务逻辑。
// 而不是这样:
viewModel.loadProfile()
// 试试这样:
viewModel.runAction(LoadProfile)
这个小小的思维转变改变了一切。
介绍 TOAD 🐸
TOAD 代表:
- T yped(类型化) — 编译时类型安全确保动作只能与兼容的状态和事件一起工作。
- O bject(对象化) — 动作是一等公民对象,而不是埋在类里的方法。
- A ction(动作化) — 每个动作恰好封装一个职责。
- D ispatch(分发化) — 所有状态变更的单一入口点。

核心思想是:你的 ViewModel 只有一个公共函数。仅此而已。
class ProfileViewModel(...) : ToadViewModel<ProfileState, ProfileEvent>(...) {
fun runAction(action: ProfileAction) = dispatch(action)
}
新功能?别碰 ViewModel。创建一个新的动作:
data object ShareProfile : ProfileAction() {
override suspend fun execute(
dependencies: ProfileDependencies,
scope: ActionScope<ProfileState, ProfileEvent>
) {
val shareUrl = dependencies.userRepo.getShareUrl()
scope.sendEvent(ProfileEvent.ShareProfile(shareUrl))
}
}
就这样。ViewModel 保持不变。现有功能的测试仍然有效。破坏某些东西的风险?几乎为零。
TOAD 的剖析
让我带你看看这实际上是如何工作的。
-
ViewState — 你的单一事实来源
状态是不可变的。永远如此。变更通过 copy() 发生。
data class ProfileState(
val isLoading: Boolean = false,
val user: User?= null,
val error: String?= null
) : ViewState
-
ViewEvent — 一次性副作用
事件用于那些不应该存在于状态中的事情:导航、Toast、分析。
sealed interface ProfileEvent : ViewEvent {
data class ShowToast(val message: String) : ProfileEvent
data object NavigateToSettings : ProfileEvent
}
-
ActionDependencies — 一次性注入,随处使用
与其将仓库传递给每个动作,不如将它们打包:
class ProfileDependencies(
override val coroutineScope: CoroutineScope,
val userRepository: UserRepository,
val analyticsTracker: AnalyticsTracker
) : ActionDependencies()
-
ViewAction — TOAD 的核心
这就是魔法发生的地方。每个动作都是一个类型化对象,它确切地知道它操作什么状态、可以发出什么事件以及需要什么依赖:
data object LoadProfile : ProfileAction() {
override suspend fun execute(
dependencies: ProfileDependencies,
scope: ActionScope<ProfileState, ProfileEvent>
) {
scope.setState { copy(isLoading = true) }
val user = dependencies.userRepository.getCurrentUser()
scope.setState { copy(isLoading = false, user = user) }
dependencies.analyticsTracker.track("profile_loaded")
}
}
-
ActionScope — 受控的状态访问
动作不能直接访问 StateFlow。它们获得一个受控的 ActionScope,提供安全、定义明确的操作:
class ActionScope<S : ViewState, E : ViewEvent> {
val currentState: S
fun setState(reducer: S.() -> S)
fun sendEvent(event: E)
suspend fun withLoading(...)
}
-
ToadViewModel — 协调者
ViewModel 的唯一工作就是分发动作。它很无聊。而无聊正是我们想要的:
abstract class ToadViewModel<S : ViewState, E : ViewEvent>(
initialState: S
) : ViewModel() {
abstract val dependencies: ActionDependencies
val state: StateFlow<S>
val events: Flow<E>
protected fun dispatch(action: ViewAction<...>)
}
为什么这改变了一切
1. 真正的开闭原则遵从
使用 TOAD,添加功能意味着添加文件,而不是修改它们。

2. 动作测试简单到离谱
测试传统的 ViewModel 意味着用其所有依赖项实例化整个东西。测试一个 TOAD 动作呢?
@Test
fun `LoadProfile updates state with user data`() = runTest {
val mockRepo = mockk<UserRepository>()
val mockScope = mockk<ActionScope<ProfileState, ProfileEvent>>(relaxed = true)
coEvery { mockRepo.getCurrentUser() } returns User("John")
LoadProfile.execute(ProfileDependencies(TestScope(), mockRepo), mockScope)
verify { mockScope.setState(match { it(ProfileState()).user?.name == "John" }) }
}
无需实例化 ViewModel。没有复杂的设置。只有动作、它的依赖项和断言。
3. 你能感受到的类型安全
泛型签名 ViewAction<D, S, E> 确保你不会意外地将 ProfileAction 用于 SettingsViewModel。编译器会捕获它。而不是你的用户。
4. 完美的代码审查
当审查一个添加功能的 PR 时,你看到的是:
无需滚动浏览 500 行的 ViewModel 差异来发现更改了什么。
实际使用
这是在 Jetpack Compose 中使用 TOAD 的样子:
@Composable
fun ProfileScreen(viewModel: ProfileViewModel = koinViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is ProfileEvent.ShowToast -> showToast(event.message)
is ProfileEvent.NavigateToSettings -> navController.navigate("settings")
}
}
}
ProfileContent(
state = state,
onRefresh = { viewModel.runAction(RefreshProfile) },
onEditClick = { viewModel.runAction(OpenEditDialog) },
onPhotoClick = { viewModel.runAction(UploadPhoto(it)) }
)
}
每个用户交互都映射到一个动作。每个动作都是明确的、可追踪的和可测试的。

意想不到的好处
在生产环境中使用 TOAD 几个月后,一些好处让我感到惊讶:
- 上手更快:新开发者不需要理解整个 ViewModel。他们可以查看一个动作,完全理解它,然后做出贡献。
- 调试更清晰:当出现问题时,你确切知道是哪个动作导致的。无需在一个庞大的类中搜寻。
- 重构更安全:将动作移动到不同的模块?只需移动文件。无需对 ViewModel 动手术。
- 代码所有权更自然:不同的团队成员可以拥有不同的动作而不会相互干扰。
何时不使用 TOAD
我不会假装这对每种情况都完美。
如果你的屏幕只有一两个简单的操作,TOAD 可能有点杀鸡用牛刀。一个只有两个方法的传统 ViewModel 就很好。真的。
如果你的团队已经深度投入另一种架构(如 Orbit、MVI Kotlin 等 MVI 库),迁移可能不值得付出努力。
但如果你是从头开始,或者你的 ViewModel 正在失控,试试 TOAD 吧。
开始使用
TOAD 只是一个模式——没有需要安装的库。核心实现大约 150 行 Kotlin 代码。
这是骨架:
// StateContracts.kt
interface ViewState
interface ViewEvent
abstract class ActionDependencies {
abstract val coroutineScope: CoroutineScope
}
// ActionContracts.kt
interface ViewAction<D : ActionDependencies, S : ViewState, E : ViewEvent> {
suspend fun execute(dependencies: D, scope: ActionScope<S, E>)
}
class ActionScope<S : ViewState, E : ViewEvent>(
private val stateFlow: MutableStateFlow<S>,
private val eventChannel: Channel<E>
) {
val currentState: S get() = stateFlow.value
fun setState(reducer: S.() -> S) = stateFlow.update(reducer)
fun sendEvent(event: E) = eventChannel.trySend(event)
}
// ToadViewModel.kt
abstract class ToadViewModel<S : ViewState, E : ViewEvent>(
initialState: S
) : ViewModel() {
protected abstract val dependencies: ActionDependencies
private val _state = MutableStateFlow(initialState)
val state: StateFlow<S> = _state.asStateFlow()
private val _events = Channel<E>(Channel.BUFFERED)
val events: Flow<E> = _events.receiveAsFlow()
protected fun <D : ActionDependencies> dispatch(action: ViewAction<D, S, E>) {
viewModelScope.launch {
action.execute(dependencies as D, ActionScope(_state, _events))
}
}
}
这就是整个框架。复制它,粘贴它,然后开始构建。
最后的话
我最初并没有打算创建一个架构模式。我只是想让我的 ViewModel 停止膨胀成难以管理的怪物。
TOAD 源于挫折、迭代,以及一个固执的信念:一定有更好的方法。它并非革命性的——它只是将命令模式深思熟虑地应用于现代 Android/KMP 开发。
但有时最好的解决方案并不是革命性的。它们只是……恰到好处。
你的 ViewModel 应该很无聊。你的动作应该很有趣。
试试 TOAD 吧。未来的你(和你的代码审查者)会感谢你的。如果你想和更多开发者交流类似的架构心得,欢迎来 云栈社区 分享你的见解。
查看 GitHub 仓库获取示例:
https://github.com/the-mobile-architect/TOAD-Example
原文链接: https://medium.com/@aumaidkh/toad-a-kotlin-first-architecture-pattern-that-finally-made-my-viewmodels-boring-b615a9ab6c30