
我之前做导航的时候,写过一个引导页面,光 ViewModel 就有 4000 多行代码。
我没法儿抱怨,因为里面 90% 的代码是我自己写的......
我写 Compose 大概有 3--4 年了,这期间,我一直在想:页面上的操作越来越多,这些行为该放在哪里、又该如何配合?
最近半年才开始沉淀出一套适合页面开发的架构,也就是如何用 Composable + ViewModel 描述产品逻辑。
一开始我在构建 Compose UI 时,喜欢把 ViewModel 一路传到 UI 层级深处,这样做有个好处,快!
但随之而来的问题是,组件会依赖某个具体的 ViewModel 类型,进而与特定页面绑定,难以复用。
时间一长,项目一大,问题就来了:第一,不方便对组件进行独立测试;第二,很容易导致预览失败,因为预览无法重建应用真实的运行环境,因而无法创建 ViewModel。
因此,后来我学乖了,把 ViewModel 留在路由层,也就是贴近 Navigation 的地方。
这确实符合状态提升(state hoisting)的推荐做法:把状态提升到所有使用方的最低公共所有者,向下传递不可变状态;当 UI 希望改变状态时,再向上传递事件。
这种模式确实有不少好处。但随着页面变得复杂,它也会逐渐暴露出一些问题。
这篇文章,我们就来探讨一下如何向 Composable 传递状态,或者把问题放宽一点:如何定义一个好的 Composable 函数。
为什么探索这种做法
首先遇到的是可组合函数的 API 问题。如果每一份状态都向上提升,每个用户操作都对应一个回调,页面函数的参数列表很快就会变长。
kotlin
@Composable
fun HomeScreen(
state: HomeUiState,
onRefresh: () -> Unit,
onRetry: () -> Unit,
onSourceSelected: (SourceItem?) -> Unit,
onSearchQueryChanged: (String) -> Unit,
onClearSearch: () -> Unit,
onLoadMore: () -> Unit,
onArticleClicked: (ArticleItem) -> Unit,
onErrorDismissed: () -> Unit,
modifier: Modifier = Modifier,
) {
// ...
}
这个问题有个常见的处理方式:用密封类或密封接口统一表示所有用户操作,再由 ViewModel 暴露一个方法来处理这些操作。
kotlin
sealed interface HomeAction {
data object Refresh : HomeAction
data object ClearSearch : HomeAction
data class SourceSelected(val source: SourceItem?) : HomeAction
data class SearchQueryChanged(val query: String) : HomeAction
// ...
}
这样做,页面 API 就简洁多了:
kotlin
@Composable
fun HomeScreen(
state: HomeUiState,
onAction: (HomeAction) -> Unit,
modifier: Modifier = Modifier,
) {
// ...
}
路由层也更清晰:
kotlin
@Composable
fun HomeRoute(
viewModel: HomeViewModel = viewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
HomeScreen(
state = uiState,
onAction = viewModel::onAction,
)
}
这样,Composable 函数不再需要暴露一长串回调,页面只接收一个状态参数和一个操作入口。
但!聪明的开发者一定会看出问题:实际上,这样做只是把复杂度转移到了 ViewModel 中。
kotlin
class HomeViewModel(
// ...
) : ViewModel() {
fun onAction(action: HomeAction) {
when (action) {
HomeAction.Refresh -> refresh()
HomeAction.ClearSearch -> clearSearch()
is HomeAction.SourceSelected -> {
selectSource(action.source)
}
is HomeAction.SearchQueryChanged -> {
updateSearchQuery(action.query)
}
// ...
}
}
}
对某些页面来说,这个取舍很合理:UI 的 API 更简单,所有可能的用户操作也都集中定义在一处。
但页面继续扩展后,问题又会出现。时间一长,when 语句就成了另一种形式的长参数列表。回调虽然从可组合函数的签名里消失了,每个细小的交互却仍然要交给同一个 ViewModel 处理。
被巨型 ViewModel 折腾
开头那个 4000 多行的 ViewModel,要重构,首先得分清:哪些逻辑本来就不该由它负责,哪些是页面自身需要协调的行为。
有时,ViewModel 变得庞大,只是因为应用架构中的其他部分没有承担起应有的职责。
本该放在用例(UseCase)中的业务规则、本该由仓储(Repository,简称 Repo)处理的数据决策,最终都挤进了 ViewModel。
映射、校验、过滤、格式化和流程编排混在一起,只因为往 ViewModel 里放代码最省事。
即使 Repo 和 UseCase 的职责已经划分清楚,我仍然觉得,大型 Compose 页面的逻辑可以组织得更好。
Compose 鼓励我们把 UI 拆成更小的可组合函数。我们会将 UI 拆分成职责明确的小块,再把它们组合起来,而不是用一个巨大的可组合函数渲染整个页面。
同样的思路,能不能用在页面行为上?除了组合可组合函数,我们能不能也组合这些函数背后的逻辑?
我们能否保留一个统一的页面所有者,同时让页面的各个部分分别负责自己的行为,避免让一个巨型 ViewModel 处理所有组件的细小交互?
这就是我想探索的方向。
当然,这只是众多优秀方案中的一种,适合大型 App 中的页面。
Compose UI 背后的逻辑
我们以一个小型新闻应用为例,它支持新闻来源选择、分页、搜索和下拉刷新。
在这种方案中,页面仍然只有一个 ViewModel,也仍然由它暴露 UiState,但它不再需要直接实现所有行为。

Controller
我们把页面行为拆成更小的单元,称为控制器(controller)。名称本身并不是重点。
每个控制器都通过一个职责独立的接口,负责某个可组合函数或页面区域的行为。
kotlin
interface SourcesController {
fun selectSource(source: SourceItem?)
}
interface ArticlesController {
fun loadMore()
fun retry()
}
interface SearchController {
fun onQueryChanged(query: String)
fun clearSearch()
}
interface RefreshController {
fun refresh()
}
也就是说,页面支持哪些操作,在 Controller 中就能看到。这与上面的 Action 类似,只不过这里提供的是可调用的接口,而不是 Action 回调。
存储页面状态
这类架构的一大难点是:如何在不同组件之间共享数据,同时避免重复请求和竞争。
如果每个控制器都维护独立的状态,或各自加载一份数据,页面就很容易出现状态不一致。因此,所有控制器都通过同一个共享的页面状态存储(state store)交换状态。
kotlin
class HomeStateStore {
private val _state = MutableStateFlow(HomeUiState.initial())
val state: StateFlow<HomeUiState> = _state.asStateFlow()
fun update(reducer: HomeUiState.() -> HomeUiState) {
_state.update { current -> current.reducer() }
}
}
StateStore 统一持有并更新页面状态,是页面状态的唯一可信来源。它既可以提供通用的更新方法,也可以提供表达具体状态转换的方法:
kotlin
fun updateSourcesSelection(source: SourceItem?) {
update {
val updatedSourcesUiState = when (val current = sourcesUiState) {
is SourcesUiState.Success -> current.copy(selected = source)
else -> current
}
copy(
selectedSource = source,
sourcesUiState = updatedSourcesUiState,
)
}
}
这样更容易看出页面允许哪些状态转换。

接着,就可以把 Repo 注入控制器:
kotlin
class HomeSourcesController(
private val scope: CoroutineScope,
private val stateStore: HomeStateStore,
private val articleRepository: ArticleRepository,
) : SourcesController {
override fun selectSource(source: SourceItem?) {
scope.launch {
// ...
articleRepository.getArticlesBySource(source = source)
.onSuccess(stateStore::updateArticles)
.onFailure { error ->
stateStore.setArticlesError(error.message)
}
}
}
}
共享逻辑
当两个或多个 Controller 需要相同的可复用逻辑时,可以把这部分逻辑提取到一个 Logic 类中。
Logic 类可以包含校验、状态转换等可复用规则。控制器负责响应用户操作,逻辑类负责其中可复用的判断。
kotlin
class SearchLogic {
fun normalizeQuery(query: String): String {
return query.trim()
}
fun canSearch(query: String): Boolean {
return normalizeQuery(query).isNotBlank()
}
}
这里稍微花点篇幅说一下 Logic:它仍然是一个普通的 Kotlin 类,需要由控制器调用。
比如,搜索词 query 来自输入框的 onValueChange,先交给 SearchController:
kotlin
@Composable
fun SearchSection( // UI
query: String,
controller: SearchController,
) {
TextField(
value = query,
onValueChange = controller::onQueryChanged,
)
}
控制器收到输入后,再把它传给 SearchLogic。下面只展示参数传递和规则调用,状态更新、请求及清空搜索后的列表处理暂时省略:
kotlin
class HomeSearchController(
private val searchLogic: SearchLogic,
) : SearchController {
override fun onQueryChanged(query: String) {
// 将原始 query 写入页面状态,供输入框显示,具体更新代码省略。
val normalizedQuery = searchLogic.normalizeQuery(query)
if (searchLogic.canSearch(normalizedQuery)) {
// 使用 normalizedQuery 搜索,具体请求代码省略。
}
}
override fun clearSearch() {
onQueryChanged("")
// 取消搜索并恢复默认列表,具体处理代码省略。
}
}
这样,输入经过 UI 回调传给控制器,再由控制器调用规则。整理后的搜索词用于请求,输入框仍显示用户输入的原始内容,避免用户刚输入的空格被立即删掉。
如果刷新控制器也需要根据当前搜索词重新请求,就可以从共享状态中读取搜索词,再调用同一份 SearchLogic。这就是提取共享规则的用途。

把各部分组装起来
ViewModel 可以持有一个名为 HomeControllers 的聚合对象。这个对象集中持有页面的所有控制器,并通过 Kotlin 的接口委托实现它们的接口。
kotlin
class HomeControllers(
sourcesController: SourcesController,
articlesController: ArticlesController,
searchController: SearchController,
refreshController: RefreshController,
) : SourcesController by sourcesController,
ArticlesController by articlesController,
SearchController by searchController,
RefreshController by refreshController
还有一条规则:同一页面中的多个组件如果共享同一份初始数据,应避免重复请求;不同筛选条件、分页或刷新需求仍可能需要独立请求。
共享的初始数据应由页面级加载器统一加载一次。ViewModel 可以在页面状态开始被订阅时调用这个加载器。加载器调用仓储、获取数据,再通过 StateStore 更新共享状态。
这里把加载器命名为 InitialHomeLoader,与前面的 SearchLogic 区分开。SearchLogic 提供控制器可调用的搜索规则;InitialHomeLoader 负责页面初始化的数据加载,因此由页面级的 ViewModel 触发。加载器通过构造参数接收所需仓储和 HomeStateStore,下面省略其实现,只展示调用位置。
kotlin
class HomeViewModel(
private val stateStore: HomeStateStore,
val controllers: HomeControllers,
private val initialHomeLoader: InitialHomeLoader,
) : ViewModel() {
val uiState: StateFlow<HomeUiState> =
stateStore.state
.onStart {
initialHomeLoader.load()
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = HomeUiState.initial(),
)
}
这样,ViewModel 可以保持精简,也不必手动转发每个方法调用。
UI 组件
页面只需接收一个 controllers 参数,内部实现仍然按组件职责拆分。
kotlin
@Composable
fun HomeScreenRoute(
viewModel: HomeViewModel = koinViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
HomeScreen(
state = state,
controllers = viewModel.controllers,
)
}
每个区域只接收自己需要的状态和控制器接口。
kotlin
@Composable
fun SourcesSection(
state: SourcesUiState,
controller: SourcesController,
) {
// ...
}
依赖的作用域
控制器和状态存储的作用域都应与 ViewModel 一致。
控制器可能会持有协程任务(Job)、读取当前页面状态,以及更新共享存储,因此它们的生命周期应与页面的 ViewModel 一致。如果向控制器传入协程作用域,也应在 ViewModel 被清除时取消该作用域。
取舍
这种方案并不适合替代所有 MVVM 页面的实现。
如果页面很小,一个 ViewModel 加上几个方法会更简单。引入 Controller 只会增加文件数量,对代码清晰度帮助不大。