Do ViewModel Divino para Lógica de Tela Composta
publicado originalmente em yvesandmobile.substack.com
Ao construir uma UI Compose, passar um ViewModel profundamente no UI faz com que os componentes sejam menos reutilizáveis porque eles ficam vinculados a um tipo de ViewModel e, portanto, a uma tela específica. Isso também torna mais difícil testá-los em isolamento e pode quebrar as prévias porque as prévias não são destinadas a reproduzir o ambiente real do aplicativo.
Então, em vez disso, mantemos o ViewModel no nível da rota.
Esta é geralmente a forma recomendada com elevação de estado: mover o estado para o proprietário mais baixo em comum, expor o estado imutável para baixo e enviar eventos de volta quando a UI deseja que algo mude.
Esse modelo nos dá muitos benefícios reais.
Mas à medida que a tela cresce, o mesmo padrão começa a mostrar seus pontos de pressão.
Por Que Isso Começou
O primeiro ponto de pressão é a própria API composable. Se cada pedaço de estado for elevado e cada ação do usuário se tornar uma callback, a função da tela pode crescer rapidamente.
@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,
) {
//...
}Uma solução comum é agrupar todas as ações do usuário em uma classe ou interface selada, então expor um método do ViewModel para lidar com elas.
sealed interface HomeAction {
data object Refresh : HomeAction
data class SourceSelected(val source: SourceItem?, ) : HomeAction
data class SearchQueryChanged(val query: String, ) : HomeAction
//...
}A API da tela fica muito menor :
@Composable
fun HomeScreen(
state: HomeUiState,
onAction: (HomeAction) -> Unit,
modifier: Modifier = Modifier,
)E a rota também fica mais limpa:
@Composable
fun HomeRoute(
viewModel: HomeViewModel = viewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
HomeScreen(
uiState = uiState,
onAction = viewModel::onAction,
)
}Isso resolve um problema.
A função composable não expõe mais uma longa lista de callbacks. A tela tem um parâmetro de estado e um ponto de entrada de ação.
Mas a pressão não desaparece. Geralmente, ela se move para o 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)
}
//...
}
}
}Para algumas telas, essa é uma boa troca. A API da UI fica simples e todas as possíveis ações do usuário são explícitas em um lugar só.
Mas o problema aparece quando a tela continua crescendo.
Depois de um tempo, a declaração when começa a se tornar a nova versão da lista longa de parâmetros. Os callbacks não estão mais visíveis na assinatura da função composable, mas cada interação ainda termina no mesmo ViewModel.
Minha “Trauma do God ViewModel”
Eu também trabalhei em código com ViewModels muito grandes. Às vezes, o problema real é que a arquitetura do resto da aplicação não está fazendo o suficiente.
Regras de negócios que deveriam viver em use cases e decisões de dados que deveriam viver em repositórios terminam no ViewModel. Mapeamento, validação, filtragem, formatação e orquestração são misturados porque o ViewModel é o lugar mais fácil para colocar coisas.
Mas mesmo quando repositórios e use cases estão bem separados, eu ainda tinha a sensação de que poderíamos lidar com telas grandes do Compose melhor. O Compose nos incentiva a dividir a UI em composáveis menores. Não construímos um enorme composable que renderiza toda a tela. Dividimos a UI em partes menores, damos a cada parte uma responsabilidade clara e as compostamos juntas.
E se pudéssemos aplicar a mesma ideia ao comportamento da tela?
E se pudéssemos compostar não apenas composáveis, mas também a lógica por trás desses composáveis?
Mantenhamos um único proprietário de tela enquanto damos a cada parte da tela seu próprio comportamento focado.
Era nesta direção que eu queria explorar.
Compondo a Lógica por Trás da Interface do Usuário
Vamos construir nosso pequeno aplicativo de notícias, que suporta seleção de fonte, paginação, pesquisa e atualização ao puxar para baixo.
Nesta abordagem, a tela ainda tem um único ViewModel, ele ainda expõe o UiState, mas não precisa conter toda a lógica diretamente.
Controlador de Componente
A tela é construída com unidades comportamentais menores. Chamamos essas unidades de controladores.
Cada controlador é uma interface segregada que possui o comportamento de um componente compósito ou seção da tela.
interface SourcesController {
fun selectSource(source: SourceItem?)
}
interface ArticlesController {
fun loadMore()
fun retry()
}
interface SearchController {
fun onQueryChanged(query: String)
fun clearSearch()
}
interface RefreshController {
fun refresh()
}Armazenamento de Estado da Tela
Uma das maiores desafios com esta arquitetura é compartilhar dados entre diferentes componentes sem criar solicitações duplicadas ou condições de corrida.
Caso cada controlador possua seu próprio estado independente ou carregue sua própria versão dos dados, a tela pode facilmente se tornar inconsistente. É por isso que todos os controladores comunicam-se através de um único armazenamento de estado da tela.
class HomeStateStore {
private val _state = MutableStateFlow(HomeUiState.initial())
val state: StateFlow<HomeUiState> = _state.asStateFlow()
fun update(reducer: HomeUiState.() -> HomeUiState) {
_state.update { current -> current.reducer() }
}
}O StateStore é a fonte de verdade mutável para a tela. Ele pode expor atualizações genéricas, mas também pode expor transições de estado nomeadas:
fun atualizarSelecaoFontes(fonte: SourceItem?) {
update {
val estadoFontesAtualizado = when (val atual = fontesUiState) {
is SourcesUiState.Success -> atual.copy(selecionado = fonte)
else -> atual
}
copy(
fonteSelecionada = fonte,
fontesUiState = estadoFontesAtualizado,
)
}
}Isso torna as transições de estado permitidas mais fáceis de descobrir.
E então pode ser injetado em um controlador
class HomeSourcesController(
private val scope: CoroutineScope,
private val stateStore: HomeStateStore,
private val articleRepository: ArticleRepository,
) : SourcesController {
override fun selecionarFonte(fonte: SourceItem?) {
scope.launch {
//...
articleRepository.getArticlesBySource(source = fonte)
.onSuccess(stateStore::updateArticles)
.onFailure { error ->
stateStore.setArticlesError(error.message)
}
}
}
}Lógica Compartilhada
Quando dois ou mais controladores compartilham a mesma lógica reutilizável, essa lógica pode ser extraída para uma classe Logic.
Uma classe Logic pode conter regras reutilizáveis, como validação ou transformação de estado. O controlador continua responsável por reagir às ações do usuário, enquanto a classe lógica contém as decisões reutilizáveis.
class SearchLogic {
fun normalizarConsulta(consulta: String): String {
return consulta.trim()
}
fun podePesquisar(consulta: String): Boolean {
return normalizarConsulta(consulta).isNotBlank()
}
}Colocando tudo junto
O ViewModel pode possuir um objeto agrupado chamado HomeControllers. Este objeto reúne todos os controladores da tela em um lugar e implementa seus contratos através de delegação.
class HomeControllers(
sourcesController: SourcesController,
articlesController: ArticlesController,
refreshController: RefreshController,
) : SourcesController by sourcesController,
ArticlesController by articlesController,
RefreshController by refreshController,Outra regra importante é que dois componentes que precisam dos mesmos dados do backend não devem fazer duas solicitações separadas.
Os dados iniciais compartilhados devem ser carregados uma vez por um carregador de nível de tela, que pode ser chamado pelo ViewModel quando o estado da tela começa a ser observado. Esse carregador pode chamar os repositórios, receber os dados e atualizar o estado compartilhado através do StateStore.
class HomeViewModel(
private val stateStore: HomeStateStore,
val controllers: HomeControllers,
private val initialHomeLogic: InitialHomeLogic,
) : ViewModel() {
val uiState: StateFlow<HomeUiState> =
stateStore.state
.onStart {
initialHomeLogic.load()
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = HomeUiState.initial(),
)
}Nosso ViewModel permanece pequeno sem forçá-lo a encaminhar manualmente cada chamada de função.
Componentes da Interface do Usuário
A tela pode receber um parâmetro controllers, enquanto a implementação permanece dividida por responsabilidade de componente.
@Composable
fun HomeScreenRoute(
viewModel: HomeViewModel = koinViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
HomeScreen(
state = state,
controllers = viewModel.controllers,
)
}Cada seção recebe apenas o estado e contrato de controlador que precisa.
@Composable
fun SourcesSection(
state: SourcesUiState,
controller: SourcesController,
) {
//...
}Escopo de Dependência
Controladores e o armazenamento de estado são escopados com o ViewModel.
Isso é importante porque controladores podem manter trabalhos, ler o estado atual da tela e atualizar o armazenamento compartilhado. Eles devem viver pelo tempo que o ViewModel vive, e se recebem um escopo de coroutine ele também deve ser cancelado quando o ViewModel for destruído.
Concessões
Isso não é uma substituição para todas as telas MVVM.
Se a tela for pequena, um único ViewModel com alguns métodos é mais simples. Adicionar controladores criaria apenas arquivos sem adquirir muita clareza.
Você pode verificar meu projeto de exploração de arquitetura para ver como funciona e não posso esperar para levar você por esse caminho e coisas que podem ser melhoradas.

