我之前做导航的时候,写过一个引导页面,光 ViewModel 就有 4000 多行代码。
我没法儿抱怨,因为里面 90% 的代码是我自己写的……
我写 Compose 大概有 3–4 年了,这期间,我一直在想:页面上的操作越来越多,这些行为该放在哪里、又该如何配合?
最近半年才开始沉淀出一套适合页面开发的架构,也就是如何用 Composable + ViewModel 描述产品逻辑。
一开始我在构建 Compose UI 时,喜欢把 ViewModel 一路传到 UI 层级深处,这样做有个好处,快!
但随之而来的问题是,组件会依赖某个具体的 ViewModel 类型,进而与特定页面绑定,难以复用。
时间一长,项目一大,问题就来了:第一,不方便对组件进行独立测试;第二,很容易导致预览失败,因为预览无法重建应用真实的运行环境,因而无法创建 ViewModel。
因此,后来我学乖了,把 ViewModel 留在路由层,也就是贴近 Navigation 的地方。
这确实符合状态提升(state hoisting)的推荐做法:把状态提升到所有使用方的最低公共所有者,向下传递不可变状态;当 UI 希望改变状态时,再向上传递事件。
这种模式确实有不少好处。但随着页面变得复杂,它也会逐渐暴露出一些问题。
这篇文章,我们就来探讨一下如何向 Composable 传递状态,或者把问题放宽一点:如何定义一个好的 Composable 函数。
为什么探索这种做法
首先遇到的是可组合函数的 API 问题。如果每一份状态都向上提升,每个用户操作都对应一个回调,页面函数的参数列表很快就会变长。
@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 暴露一个方法来处理这些操作。
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 就简洁多了:
@Composable
fun HomeScreen(
state: HomeUiState,
onAction: (HomeAction) -> Unit,
modifier: Modifier = Modifier,
) {
// ...
}
路由层也更清晰:
@Composable
fun HomeRoute(
viewModel: HomeViewModel = viewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
HomeScreen(
state = uiState,
onAction = viewModel::onAction,
)
}
这样,Composable 函数不再需要暴露一长串回调,页面只接收一个状态参数和一个操作入口。
但!聪明的开发者一定会看出问题:实际上,这样做只是把复杂度转移到了 ViewModel 中。
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)。名称本身并不是重点。
每个控制器都通过一个职责独立的接口,负责某个可组合函数或页面区域的行为。
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)交换状态。
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 统一持有并更新页面状态,是页面状态的唯一可信来源。它既可以提供通用的更新方法,也可以提供表达具体状态转换的方法:
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 注入控制器:
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 类可以包含校验、状态转换等可复用规则。控制器负责响应用户操作,逻辑类负责其中可复用的判断。
class SearchLogic {
fun normalizeQuery(query: String): String {
return query.trim()
}
fun canSearch(query: String): Boolean {
return normalizeQuery(query).isNotBlank()
}
}
这里稍微花点篇幅说一下 Logic:它仍然是一个普通的 Kotlin 类,需要由控制器调用。
比如,搜索词 query 来自输入框的 onValueChange,先交给 SearchController:
@Composable
fun SearchSection( // UI
query: String,
controller: SearchController,
) {
TextField(
value = query,
onValueChange = controller::onQueryChanged,
)
}
控制器收到输入后,再把它传给 SearchLogic。下面只展示参数传递和规则调用,状态更新、请求及清空搜索后的列表处理暂时省略:
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 的接口委托实现它们的接口。
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,下面省略其实现,只展示调用位置。
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 参数,内部实现仍然按组件职责拆分。
@Composable
fun HomeScreenRoute(
viewModel: HomeViewModel = koinViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
HomeScreen(
state = state,
controllers = viewModel.controllers,
)
}
每个区域只接收自己需要的状态和控制器接口。
@Composable
fun SourcesSection(
state: SourcesUiState,
controller: SourcesController,
) {
// ...
}
依赖的作用域
控制器和状态存储的作用域都应与 ViewModel 一致。
控制器可能会持有协程任务(Job)、读取当前页面状态,以及更新共享存储,因此它们的生命周期应与页面的 ViewModel 一致。如果向控制器传入协程作用域,也应在 ViewModel 被清除时取消该作用域。
取舍
这种方案并不适合替代所有 MVVM 页面的实现。
如果页面很小,一个 ViewModel 加上几个方法会更简单。引入 Controller 只会增加文件数量,对代码清晰度帮助不大。