找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

972

积分

0

好友

122

主题
发表于 昨天 03:29 | 查看: 0| 回复: 0

我有个秘密要坦白。

去年,我打开了我们代码库中的一个 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(分发化) — 所有状态变更的单一入口点。

TOAD架构中ViewModel与各功能模块的关系示意图

核心思想是:你的 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 的剖析

让我带你看看这实际上是如何工作的。

  1. ViewState — 你的单一事实来源
    状态是不可变的。永远如此。变更通过 copy() 发生。

    data class ProfileState(
      val isLoading: Boolean = false,
      val user: User?= null,
      val error: String?= null
    ) : ViewState
  2. ViewEvent — 一次性副作用
    事件用于那些不应该存在于状态中的事情:导航、Toast、分析。

    sealed interface ProfileEvent : ViewEvent {
      data class ShowToast(val message: String) : ProfileEvent
      data object NavigateToSettings : ProfileEvent
    }
  3. ActionDependencies — 一次性注入,随处使用
    与其将仓库传递给每个动作,不如将它们打包:

    class ProfileDependencies(
      override val coroutineScope: CoroutineScope,
      val userRepository: UserRepository,
      val analyticsTracker: AnalyticsTracker
    ) : ActionDependencies()
  4. 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")
        }
    }
  5. ActionScope — 受控的状态访问
    动作不能直接访问 StateFlow。它们获得一个受控的 ActionScope,提供安全、定义明确的操作:

    class ActionScope<S : ViewState, E : ViewEvent> {
      val currentState: S
      fun setState(reducer: S.() -> S)
      fun sendEvent(event: E)
      suspend fun withLoading(...)
    }
  6. 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,添加功能意味着添加文件,而不是修改它们。

传统开发与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架构下的个人资料界面示意图

意想不到的好处

在生产环境中使用 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




上一篇:开源免费:基于 Webman 框架的 PHP USDT-TRC20 支付系统搭建指南
下一篇:eBPF技术如何赋能XDR?探索云原生安全CADR解决方案架构
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-6 04:57 , Processed in 0.359072 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表