Composable Architecture: Cómo Estructurar Proyectos Grandes en Jetpack Compose
Guía exhaustiva sobre arquitectura de composables para aplicaciones grandes: modularización, separación de concerns, scaling sin caos. Estructura real para apps de 100+ screens con ejemplos prácticos y patrones probados.
Composable Architecture: Cómo Estructurar Proyectos Grandes en Jetpack Compose
Escalando Composables Sin Caos: De Una App Pequeña a 100+ Screens
🎯 Introducción: El Problema Real
Te identificas con esto:
Semana 1:
- Escribes 2-3 screens en Compose
- Todo es simple, está en 1-2 archivos
- La vida es bella
Semana 4:
- Ahora tienes 8 screens
- Los composables tienen 500+ líneas
- Cambiar un pequeño padding afecta 3 screens
- Nadie quiere tocar el código
Semana 12:
- 30+ screens
- 50+ composables
- Nadie sabe dónde agregar nueva funcionalidad
- Los PMs preguntan "¿Por qué tarda tanto?"
- Los developers responden "porque el código es un desastre"
Semana 24:
- 100+ screens
- Refactorizar se convirtió en "reescribir todo"
- El proyecto se siente como un monolito de la era XML
- "Jetpack Compose no escala", dice alguien que no entiende arquitectura
Aquí está la verdad: Jetpack Compose es increíblemente escalable. Pero solo si estructuras tu código correctamente desde el inicio.
El problema no es Compose. El problema es que la mayoría de developers vienen de era XML/ViewBinding, donde no necesitaban pensar en modularización porque estaban limitados por el framework.
Con Compose, tienes libertad total. Y esa libertad es un arma de doble filo. Si no sabes qué hacer con ella, te destruye.
¿Por Qué Esta Guía Es Diferente?
La mayoría de guías de Compose te enseñan:
✅ "Aquí está un Composable simple"
✅ "Aquí está cómo usar State"
❌ "Aquí está cómo estructurar una app de 100+ screens sin que sea un desastre"
Esta guía existe para cerrar ese vacío.
Vamos a construir una arquitectura de composables que:
✅ Escala sin dolor - Agregar features es tan fácil en la semana 100 como en la semana 1 ✅ Modularización clara - Cada composable sabe qué hace, nada más ✅ Separación de concerns - UI, State, Business Logic están separados ✅ Testeable - Cada composable puede ser testeado sin mocks complejos ✅ Reutilizable - Componentes que escribes hoy se usan en 10 places mañana ✅ Mantenible - Los nuevos developers entienden la estructura en 1 hora
Lo Que Construiremos
Vamos a usar una App de Gestión de Tareas como caso de estudio. No es una app trivial:
Screens:
- Autenticación (Login, Signup)
- Dashboard principal
- Lista de tareas (con filtros, búsqueda, paginación)
- Detalle de tarea
- Crear/editar tarea
- Ajustes de usuario
- Sincronización offline
- Notificaciones
Complejidad:
- 50+ composables
- 15+ ViewModels
- State management con Flow/StateFlow
- Navigation complexity
- Deep linking
- Shared state entre screens
🏗️ Parte 1: Los Principios Fundamentales
Principio 1: Composables Deben Ser Pequeños y Enfocados
Regla de oro: Si un composable hace más de una cosa, divídelo.
// ❌ MALO: Un composable hace TODO
@Composable
fun TaskListScreen(
viewModel: TaskViewModel,
onNavigateToDetail: (Int) -> Unit
) {
// Obtener datos
val tasks by viewModel.tasks.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
// Renderizar header
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Mis Tareas", style = MaterialTheme.typography.headlineSmall)
IconButton(onClick = { /* ... */ }) {
Icon(Icons.Default.Settings, contentDescription = null)
}
}
// Search bar
TextField(
value = searchQuery,
onValueChange = { viewModel.onSearchQueryChanged(it) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
placeholder = { Text("Buscar tareas...") }
)
// Loading indicator
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(32.dp)
)
}
// List de tareas
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
items(tasks.size) { index ->
val task = tasks[index]
TaskItem(
task = task,
onClick = { onNavigateToDetail(task.id) }
)
Divider()
}
}
}
}
El problema: Este composable hace 5 cosas:
- Gestiona estado (obtiene de ViewModel)
- Renderiza header
- Renderiza search bar
- Renderiza loading indicator
- Renderiza lista
Cuando cambia una cosa, todo el composable se rebuilda.
La solución: Dividir en pequeños composables enfocados
// ✅ BUENO: Composables pequeños y enfocados
@Composable
fun TaskListScreen(
viewModel: TaskViewModel,
onNavigateToDetail: (Int) -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
// Delegamos a composables especializados
Column(modifier = Modifier.fillMaxSize()) {
TaskListHeader(
onSettingsClick = { /* navigate */ }
)
TaskSearchBar(
searchQuery = uiState.searchQuery,
onSearchQueryChanged = { viewModel.onSearchQueryChanged(it) }
)
when {
uiState.isLoading -> TaskListLoadingIndicator()
uiState.tasks.isEmpty() -> TaskListEmptyState()
else -> TaskListContent(
tasks = uiState.tasks,
onTaskClick = onNavigateToDetail
)
}
}
}
@Composable
private fun TaskListHeader(onSettingsClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Mis Tareas", style = MaterialTheme.typography.headlineSmall)
IconButton(onClick = onSettingsClick) {
Icon(Icons.Default.Settings, contentDescription = null)
}
}
}
@Composable
private fun TaskSearchBar(
searchQuery: String,
onSearchQueryChanged: (String) -> Unit
) {
TextField(
value = searchQuery,
onValueChange = onSearchQueryChanged,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
placeholder = { Text("Buscar tareas...") }
)
}
@Composable
private fun TaskListLoadingIndicator() {
CircularProgressIndicator(
modifier = Modifier
.fillMaxSize()
.wrapContentSize(Alignment.Center)
)
}
@Composable
private fun TaskListEmptyState() {
Column(
modifier = Modifier
.fillMaxSize()
.wrapContentSize(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.TaskAlt,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(16.dp))
Text(
"No hay tareas",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.outline
)
}
}
@Composable
private fun TaskListContent(
tasks: List<Task>,
onTaskClick: (Int) -> Unit
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
items(
count = tasks.size,
key = { tasks[it].id }
) { index ->
TaskItem(
task = tasks[index],
onClick = { onTaskClick(tasks[index].id) }
)
if (index < tasks.size - 1) {
Divider()
}
}
}
}
Beneficios:
- TaskListHeader solo se rebuilda si el callback cambia (casi nunca)
- TaskSearchBar solo se rebuilda si searchQuery cambia
- TaskListContent solo se rebuilda si tasks cambia
- Fácil de testear cada parte independientemente
Principio 2: Separar Estado (State) de Presentación (UI)
Regla de oro: El composable no debe saber cómo obtener estado, solo cómo presentarlo.
// ❌ MALO: Composable obtiene estado directamente de ViewModel
@Composable
fun TaskItem(viewModel: TaskViewModel, taskId: Int) {
val task by viewModel.getTask(taskId).collectAsState()
val isCompleted by viewModel.isTaskCompleted(taskId).collectAsState()
Row {
// UI
}
}
// ✅ BUENO: Composable recibe estado como parámetro
data class TaskItemState(
val task: Task,
val isCompleted: Boolean,
val isLoading: Boolean = false
)
@Composable
fun TaskItem(
state: TaskItemState,
onCompletedChange: (Boolean) -> Unit,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = !state.isLoading, onClick = onClick)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else {
Checkbox(
checked = state.isCompleted,
onCheckedChange = onCompletedChange
)
Column(modifier = Modifier.weight(1f)) {
Text(
state.task.title,
style = MaterialTheme.typography.bodyLarge,
textDecoration = if (state.isCompleted) TextDecoration.LineThrough else TextDecoration.None
)
Text(
state.task.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
}
}
}
¿Por qué esto importa?
Testabilidad:
❌ MALO: Necesitas mockear ViewModel, Flow, StateFlow, repositorio...
✅ BUENO: Solo pasas un data class, listo.
Reusabilidad:
❌ MALO: TaskItem solo funciona con ese ViewModel específico
✅ BUENO: TaskItem funciona en cualquier contexto donde tengas TaskItemState
Performance:
❌ MALO: Si ViewModel.getTask() o isTaskCompleted() cambian, se rebuilda
✅ BUENO: Solo se rebuilda si TaskItemState cambió
Principio 3: Composables Tienen Tres Capas
Cada pantalla debe tener exactamente tres capas de composables:
// CAPA 1: Screen (Conecta ViewModel con UI)
@Composable
fun TaskListScreen(
viewModel: TaskListViewModel,
onNavigateToDetail: (Int) -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
TaskListContent(
state = uiState,
onSearchQueryChanged = { viewModel.onSearchQueryChanged(it) },
onTaskClick = { viewModel.onTaskClick(it); onNavigateToDetail(it) }
)
}
// CAPA 2: Content (Lógica de layout, manejo de estados)
@Composable
private fun TaskListContent(
state: TaskListUiState,
onSearchQueryChanged: (String) -> Unit,
onTaskClick: (Int) -> Unit
) {
Column(modifier = Modifier.fillMaxSize()) {
TaskListHeader()
TaskSearchBar(
searchQuery = state.searchQuery,
onSearchQueryChanged = onSearchQueryChanged
)
when {
state.isLoading -> TaskListLoadingIndicator()
state.tasks.isEmpty() -> TaskListEmptyState()
else -> TaskListContent(
tasks = state.tasks,
onTaskClick = onTaskClick
)
}
}
}
// CAPA 3: Components (UI pura, reusable)
@Composable
fun TaskItem(
state: TaskItemState,
onCompletedChange: (Boolean) -> Unit,
onClick: () -> Unit
) {
Row {
// UI pura, sin lógica
}
}
Las tres capas:
┌─────────────────────────────────────┐
│ Screen Layer │
│ - Conecta ViewModel a UI │
│ - Obtiene estado de ViewModel │
│ - Maneja navigation │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Content Layer │
│ - Lógica de layout │
│ - Manejo de conditional rendering │
│ - Composición de componentes │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Component Layer │
│ - UI pura y reusable │
│ - Sin estado, solo parámetros │
│ - Fáciles de testear │
└─────────────────────────────────────┘
📁 Parte 2: Modularización de Composables
Estructura de Carpetas
La estructura que funciona para 100+ screens:
app/
├── src/
│ └── main/
│ └── kotlin/
│ └── com/example/taskapp/
│ ├── MainActivity.kt
│ ├── navigation/
│ │ ├── NavGraph.kt
│ │ ├── Route.kt
│ │ └── Destinations.kt
│ ├── feature/
│ │ ├── taskList/
│ │ │ ├── TaskListScreen.kt
│ │ │ ├── TaskListViewModel.kt
│ │ │ ├── TaskListUiState.kt
│ │ │ └── components/
│ │ │ ├── TaskItem.kt
│ │ │ ├── TaskListHeader.kt
│ │ │ ├── TaskSearchBar.kt
│ │ │ ├── TaskListEmptyState.kt
│ │ │ └── TaskListLoadingIndicator.kt
│ │ ├── taskDetail/
│ │ │ ├── TaskDetailScreen.kt
│ │ │ ├── TaskDetailViewModel.kt
│ │ │ ├── TaskDetailUiState.kt
│ │ │ └── components/
│ │ │ ├── TaskDetailHeader.kt
│ │ │ ├── TaskContent.kt
│ │ │ ├── TaskActions.kt
│ │ │ └── TaskComments.kt
│ │ ├── auth/
│ │ │ ├── loginScreen/
│ │ │ ├── signupScreen/
│ │ │ └── shared/
│ │ ├── settings/
│ │ └── /* más features */
│ ├── ui/
│ │ ├── components/
│ │ │ ├── AppBar.kt
│ │ │ ├── AppButton.kt
│ │ │ ├── AppCard.kt
│ │ │ ├── AppDivider.kt
│ │ │ └── /* componentes compartidos */
│ │ ├── theme/
│ │ │ ├── Color.kt
│ │ │ ├── Type.kt
│ │ │ ├── Shape.kt
│ │ │ └── Theme.kt
│ │ └── modifiers/
│ │ ├── PaddingModifier.kt
│ │ ├── ElevationModifier.kt
│ │ └── /* modifiers custom */
│ ├── domain/
│ │ ├── model/
│ │ ├── repository/
│ │ └── usecase/
│ ├── data/
│ │ ├── repository/
│ │ ├── datasource/
│ │ └── model/
│ ├── di/
│ │ └── AppModule.kt
│ └── utils/
¿Por qué esta estructura?
feature/taskList/:
- Todo lo relacionado con task list en un lugar
- Fácil de encontrar
- Fácil de eliminar completa si necesitas
feature/*/components/:
- Componentes específicos de esa feature
- Si necesitas reutilizar, muévelo a ui/components/
- Evita duplicación
ui/:
- Componentes compartidos por todas las features
- Design system centralizado
- Cambios de theme en un lugar
Feature Module Organization
Cada feature (taskList, taskDetail, auth, etc.) debe tener esta estructura:
feature/taskList/
├── TaskListScreen.kt ← Screen layer
├── TaskListViewModel.kt ← State management
├── TaskListUiState.kt ← UI state contract
├── TaskListIntent.kt ← User intents (opcional)
├── TaskListEvent.kt ← UI events (opcional)
└── components/ ← Content + Component layers
├── TaskItem.kt
├── TaskListHeader.kt
├── TaskSearchBar.kt
├── TaskListEmptyState.kt
└── TaskListLoadingIndicator.kt
TaskListUiState.kt:
// ✅ BUENO: Un estado único para toda la feature
data class TaskListUiState(
val tasks: List<Task> = emptyList(),
val searchQuery: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val selectedTask: Int? = null,
val sortBy: SortOption = SortOption.RECENT,
val filterBy: FilterOption = FilterOption.ALL
)
enum class SortOption {
RECENT, PRIORITY, ALPHABETICAL
}
enum class FilterOption {
ALL, COMPLETED, PENDING
}
TaskListViewModel.kt:
@HiltViewModel
class TaskListViewModel @Inject constructor(
private val getTasksUseCase: GetTasksUseCase,
private val completeTaskUseCase: CompleteTaskUseCase,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _uiState = MutableStateFlow(TaskListUiState())
val uiState: StateFlow<TaskListUiState> = _uiState.asStateFlow()
private val _navigationEvent = Channel<TaskListNavigationEvent>()
val navigationEvent: Flow<TaskListNavigationEvent> = _navigationEvent.receiveAsFlow()
init {
loadTasks()
}
fun onSearchQueryChanged(query: String) {
_uiState.update { it.copy(searchQuery = query) }
filterTasks()
}
fun onTaskClick(taskId: Int) {
viewModelScope.launch {
_navigationEvent.send(TaskListNavigationEvent.NavigateToDetail(taskId))
}
}
fun onCompleteTask(taskId: Int) {
viewModelScope.launch {
try {
completeTaskUseCase(taskId)
loadTasks()
} catch (e: Exception) {
_uiState.update { it.copy(error = e.message) }
}
}
}
private fun loadTasks() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
val tasks = getTasksUseCase()
_uiState.update {
it.copy(
tasks = tasks,
isLoading = false
)
}
} catch (e: Exception) {
_uiState.update {
it.copy(
error = e.message,
isLoading = false
)
}
}
}
}
private fun filterTasks() {
viewModelScope.launch {
val filtered = getTasksUseCase()
.filter { task ->
task.title.contains(_uiState.value.searchQuery, ignoreCase = true)
}
_uiState.update { it.copy(tasks = filtered) }
}
}
}
sealed class TaskListNavigationEvent {
data class NavigateToDetail(val taskId: Int) : TaskListNavigationEvent()
}
TaskListScreen.kt:
@Composable
fun TaskListScreen(
viewModel: TaskListViewModel = hiltViewModel(),
onNavigateToDetail: (Int) -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.navigationEvent.collect { event ->
when (event) {
is TaskListNavigationEvent.NavigateToDetail -> {
onNavigateToDetail(event.taskId)
}
}
}
}
TaskListContent(
state = uiState,
onSearchQueryChanged = { viewModel.onSearchQueryChanged(it) },
onTaskClick = { viewModel.onTaskClick(it) },
onCompleteTask = { viewModel.onCompleteTask(it) }
)
}
@Composable
private fun TaskListContent(
state: TaskListUiState,
onSearchQueryChanged: (String) -> Unit,
onTaskClick: (Int) -> Unit,
onCompleteTask: (Int) -> Unit
) {
Column(modifier = Modifier.fillMaxSize()) {
TaskListHeader()
TaskSearchBar(
searchQuery = state.searchQuery,
onSearchQueryChanged = onSearchQueryChanged
)
when {
state.isLoading -> TaskListLoadingIndicator()
state.error != null -> TaskListErrorState(error = state.error)
state.tasks.isEmpty() -> TaskListEmptyState()
else -> TaskListItems(
tasks = state.tasks,
onTaskClick = onTaskClick,
onCompleteTask = onCompleteTask
)
}
}
}
@Composable
private fun TaskListItems(
tasks: List<Task>,
onTaskClick: (Int) -> Unit,
onCompleteTask: (Int) -> Unit
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
items(
count = tasks.size,
key = { tasks[it].id }
) { index ->
TaskItem(
state = TaskItemState(
task = tasks[index],
isCompleted = tasks[index].isCompleted
),
onCompletedChange = { onCompleteTask(tasks[index].id) },
onClick = { onTaskClick(tasks[index].id) }
)
if (index < tasks.size - 1) {
Divider()
}
}
}
}
🔌 Parte 3: Separación de Concerns en UI
Niveles de Reusabilidad
Cada composable debe tener un nivel de reusabilidad claro:
// NIVEL 1: Design System Components
// Pueden usarse en CUALQUIER aplicación, completamente agnóstico
@Composable
fun AppButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
loading: Boolean = false
) {
Button(
onClick = onClick,
modifier = modifier
.height(48.dp)
.fillMaxWidth(),
enabled = enabled && !loading,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
if (loading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text(text)
}
}
}
// NIVEL 2: Feature-Specific Components
// Solo se usan dentro de una feature específica
@Composable
fun TaskItem(
state: TaskItemState,
onCompletedChange: (Boolean) -> Unit,
onClick: () -> Unit
) {
// Usa AppButton, AppCard, etc.
}
// NIVEL 3: Screen Composables
// Se usan para ensamblar un screen completo
@Composable
fun TaskListScreen(
viewModel: TaskListViewModel = hiltViewModel(),
onNavigateToDetail: (Int) -> Unit
) {
// Ensambla TaskItem, AppButton, etc.
}
Estructura de carpetas por nivel:
ui/
├── components/
│ ├── AppButton.kt ← NIVEL 1: Design System
│ ├── AppCard.kt
│ ├── AppTextField.kt
│ ├── AppDialog.kt
│ └── /* más componentes base */
│
feature/
├── taskList/
│ ├── components/
│ │ ├── TaskItem.kt ← NIVEL 2: Feature-Specific
│ │ ├── TaskListHeader.kt
│ │ └── TaskSearchBar.kt
│ └── TaskListScreen.kt ← NIVEL 3: Screen
Patrón de Composables Base
El error más común: Copiar diseño en cada componente
// ❌ MALO: Duplicar padding, colores, etc.
@Composable
fun TaskItem(...) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Task", style = MaterialTheme.typography.bodyLarge)
Text("Description", color = MaterialTheme.colorScheme.outline)
}
}
@Composable
fun UserItem(...) {
Column(modifier = Modifier.padding(16.dp)) {
Text("User", style = MaterialTheme.typography.bodyLarge)
Text("Email", color = MaterialTheme.colorScheme.outline)
}
}
// ✅ BUENO: Crear base component, reutilizar
@Composable
fun ListItem(
title: String,
subtitle: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(16.dp)
) {
Text(title, style = MaterialTheme.typography.bodyLarge)
Text(subtitle, color = MaterialTheme.colorScheme.outline)
}
}
@Composable
fun TaskItem(
state: TaskItemState,
onCompletedChange: (Boolean) -> Unit,
onClick: () -> Unit
) {
ListItem(
title = state.task.title,
subtitle = state.task.description,
onClick = onClick
)
}
@Composable
fun UserItem(
state: UserItemState,
onClick: () -> Unit
) {
ListItem(
title = state.user.name,
subtitle = state.user.email,
onClick = onClick
)
}
El Patrón State Container
Para composables complejos que gestionar su propio estado local:
// ✅ BUENO: State container pattern
@Composable
fun TaskDetailScreen(
viewModel: TaskDetailViewModel = hiltViewModel(),
onNavigateBack: () -> Unit
) {
val screenState by viewModel.screenState.collectAsState()
// State container maneja todo
val state = remember {
TaskDetailScreenState(
onNavigateBack = onNavigateBack
)
}
TaskDetailContent(
screenState = screenState,
state = state
)
}
class TaskDetailScreenState(
val onNavigateBack: () -> Unit
) {
var isDescriptionExpanded by mutableStateOf(false)
var selectedTab by mutableStateOf(0)
fun toggleDescription() {
isDescriptionExpanded = !isDescriptionExpanded
}
fun selectTab(index: Int) {
selectedTab = index
}
}
@Composable
private fun TaskDetailContent(
screenState: TaskDetailUiState,
state: TaskDetailScreenState
) {
Column {
TaskDetailHeader(
title = screenState.task.title,
onBack = state.onNavigateBack
)
TaskDetailBody(
screenState = screenState,
isExpanded = state.isDescriptionExpanded,
onToggle = { state.toggleDescription() }
)
TaskDetailTabs(
selectedTab = state.selectedTab,
onTabSelected = { state.selectTab(it) }
)
}
}
🎨 Parte 4: Shared UI Components & Design System
Crear un Design System Centralizado
// ui/theme/Color.kt
val primaryColor = Color(0xFF6200EE)
val secondaryColor = Color(0xFF03DAC6)
val tertiaryColor = Color(0xFF018786)
// ui/theme/Type.kt
val AppTypography = Typography(
displayLarge = TextStyle(
fontSize = 57.sp,
fontWeight = FontWeight.Bold,
lineHeight = 64.sp
),
headlineSmall = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
lineHeight = 32.sp
),
bodyLarge = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
lineHeight = 24.sp
),
bodySmall = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Normal,
lineHeight = 16.sp
)
)
// ui/theme/Theme.kt
@Composable
fun TaskAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) {
darkColorScheme(
primary = primaryColor,
secondary = secondaryColor,
tertiary = tertiaryColor
)
} else {
lightColorScheme(
primary = primaryColor,
secondary = secondaryColor,
tertiary = tertiaryColor
)
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
content = content
)
}
Componentes Base del Design System
// ui/components/AppCard.kt
@Composable
fun AppCard(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Card(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(
defaultElevation = 4.dp
)
) {
content()
}
}
// ui/components/AppTopBar.kt
@Composable
fun AppTopBar(
title: String,
onBackClick: (() -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {}
) {
TopAppBar(
title = { Text(title) },
navigationIcon = if (onBackClick != null) {
{
IconButton(onClick = onBackClick) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
}
} else null,
actions = actions,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary
)
)
}
// ui/components/AppDialog.kt
@Composable
fun AppDialog(
title: String,
message: String,
confirmText: String = "Confirmar",
dismissText: String = "Cancelar",
onConfirm: () -> Unit,
onDismiss: () -> Unit,
isShowing: Boolean = false
) {
if (isShowing) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(message) },
confirmButton = {
Button(onClick = onConfirm) {
Text(confirmText)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(dismissText)
}
}
)
}
}
// ui/modifiers/PaddingModifiers.kt
object AppPadding {
val small = 8.dp
val medium = 16.dp
val large = 24.dp
val extraLarge = 32.dp
}
fun Modifier.screenPadding(): Modifier = this.padding(AppPadding.medium)
fun Modifier.itemPadding(): Modifier = this.padding(AppPadding.small)
fun Modifier.sectionPadding(): Modifier = this.padding(AppPadding.large)
🗂️ Parte 5: State Management Pattern Avanzado
StateFlow + ViewModel Pattern
// UiState unificado para toda la feature
data class TaskListUiState(
val tasks: List<Task> = emptyList(),
val searchQuery: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val selectedTask: Int? = null,
val sortBy: SortOption = SortOption.RECENT,
val filterBy: FilterOption = FilterOption.ALL,
val isRefreshing: Boolean = false,
val hasMoreItems: Boolean = true
)
// Events para navegación y side-effects
sealed class TaskListEvent {
data class NavigateToDetail(val taskId: Int) : TaskListEvent()
data class ShowError(val message: String) : TaskListEvent()
object NavigateBack : TaskListEvent()
}
// ViewModel
@HiltViewModel
class TaskListViewModel @Inject constructor(
private val getTasksUseCase: GetTasksUseCase,
private val completeTaskUseCase: CompleteTaskUseCase,
private val deleteTaskUseCase: DeleteTaskUseCase,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _uiState = MutableStateFlow(TaskListUiState())
val uiState: StateFlow<TaskListUiState> = _uiState.asStateFlow()
private val _event = Channel<TaskListEvent>()
val event: Flow<TaskListEvent> = _event.receiveAsFlow()
init {
loadTasks()
}
// Intent methods (what user can do)
fun onSearchQueryChanged(query: String) {
_uiState.update { it.copy(searchQuery = query) }
filterTasks()
}
fun onSortOptionChanged(sortOption: SortOption) {
_uiState.update { it.copy(sortBy = sortOption) }
sortTasks()
}
fun onFilterOptionChanged(filterOption: FilterOption) {
_uiState.update { it.copy(filterBy = filterOption) }
filterTasks()
}
fun onTaskClick(taskId: Int) {
viewModelScope.launch {
_event.send(TaskListEvent.NavigateToDetail(taskId))
}
}
fun onCompleteTask(taskId: Int) {
viewModelScope.launch {
try {
completeTaskUseCase(taskId)
loadTasks()
} catch (e: Exception) {
_event.send(TaskListEvent.ShowError(e.message ?: "Unknown error"))
}
}
}
fun onDeleteTask(taskId: Int) {
viewModelScope.launch {
try {
deleteTaskUseCase(taskId)
loadTasks()
} catch (e: Exception) {
_event.send(TaskListEvent.ShowError(e.message ?: "Unknown error"))
}
}
}
fun onRefresh() {
_uiState.update { it.copy(isRefreshing = true) }
loadTasks()
}
// Private helpers
private fun loadTasks() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
val tasks = getTasksUseCase()
_uiState.update {
it.copy(
tasks = tasks,
isLoading = false,
isRefreshing = false,
error = null
)
}
} catch (e: Exception) {
_uiState.update {
it.copy(
error = e.message,
isLoading = false,
isRefreshing = false
)
}
}
}
}
private fun filterTasks() {
val currentState = _uiState.value
val filtered = currentState.tasks
.filter { task ->
task.title.contains(currentState.searchQuery, ignoreCase = true)
}
.filter { task ->
when (currentState.filterBy) {
FilterOption.ALL -> true
FilterOption.COMPLETED -> task.isCompleted
FilterOption.PENDING -> !task.isCompleted
}
}
_uiState.update { it.copy(tasks = filtered) }
}
private fun sortTasks() {
val currentState = _uiState.value
val sorted = currentState.tasks.sortedWith(
when (currentState.sortBy) {
SortOption.RECENT -> compareByDescending { it.createdAt }
SortOption.PRIORITY -> compareByDescending { it.priority }
SortOption.ALPHABETICAL -> compareBy { it.title }
}
)
_uiState.update { it.copy(tasks = sorted) }
}
}
Consuming Events en el Screen
@Composable
fun TaskListScreen(
viewModel: TaskListViewModel = hiltViewModel(),
onNavigateToDetail: (Int) -> Unit,
onNavigateBack: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
// Consumir events
LaunchedEffect(Unit) {
viewModel.event.collect { event ->
when (event) {
is TaskListEvent.NavigateToDetail -> onNavigateToDetail(event.taskId)
is TaskListEvent.ShowError -> {
// Mostrar snackbar
println("Error: ${event.message}")
}
TaskListEvent.NavigateBack -> onNavigateBack()
}
}
}
TaskListContent(
state = uiState,
onSearchQueryChanged = { viewModel.onSearchQueryChanged(it) },
onSortOptionChanged = { viewModel.onSortOptionChanged(it) },
onFilterOptionChanged = { viewModel.onFilterOptionChanged(it) },
onTaskClick = { viewModel.onTaskClick(it) },
onCompleteTask = { viewModel.onCompleteTask(it) },
onDeleteTask = { viewModel.onDeleteTask(it) },
onRefresh = { viewModel.onRefresh() }
)
}
🔄 Parte 6: Scaling de 10 a 100+ Screens
Feature-Based Modularization
Cuando alcanzas 30+ screens, necesitas dividir por features:
app/
├── feature/
│ ├── auth/
│ │ ├── login/
│ │ ├── signup/
│ │ ├── reset_password/
│ │ └── shared/
│ ├── task/
│ │ ├── list/
│ │ ├── detail/
│ │ ├── create/
│ │ ├── edit/
│ │ └── shared/
│ │ ├── models/
│ │ ├── repositories/
│ │ └── usecases/
│ ├── user/
│ │ ├── profile/
│ │ ├── settings/
│ │ └── shared/
│ └── /* más features */
Navigation Graph Management
// navigation/NavGraph.kt
@Composable
fun AppNavGraph(
navController: NavHostController,
startDestination: String = Screen.TaskList.route
) {
NavHost(
navController = navController,
startDestination = startDestination
) {
// Auth feature
authNavGraph(navController)
// Task feature
taskNavGraph(navController)
// User feature
userNavGraph(navController)
}
}
// feature/task/navigation/TaskNavGraph.kt
fun NavGraphBuilder.taskNavGraph(navController: NavHostController) {
navigation(
startDestination = TaskScreen.List.route,
route = TaskFeature.route
) {
composable(TaskScreen.List.route) {
TaskListScreen(
onNavigateToDetail = { taskId ->
navController.navigate(TaskScreen.Detail.createRoute(taskId))
}
)
}
composable(
route = TaskScreen.Detail.route,
arguments = listOf(navArgument("taskId") { type = NavType.IntType })
) {
TaskDetailScreen(
onNavigateBack = { navController.popBackStack() }
)
}
composable(TaskScreen.Create.route) {
TaskCreateScreen(
onNavigateBack = { navController.popBackStack() }
)
}
}
}
// Navigation destinations
sealed class Screen(val route: String) {
object TaskList : Screen("task/list")
object TaskDetail : Screen("task/detail/{taskId}") {
fun createRoute(taskId: Int) = "task/detail/$taskId"
}
object TaskCreate : Screen("task/create")
}
Shared State Between Features
// Cuando necesitas compartir estado entre features
@HiltViewModel
class AppStateViewModel : ViewModel() {
private val _currentUser = MutableStateFlow<User?>(null)
val currentUser: StateFlow<User?> = _currentUser.asStateFlow()
private val _appTheme = MutableStateFlow<AppTheme>(AppTheme.Light)
val appTheme: StateFlow<AppTheme> = _appTheme.asStateFlow()
fun setCurrentUser(user: User) {
_currentUser.value = user
}
fun setAppTheme(theme: AppTheme) {
_appTheme.value = theme
}
}
// En screens que necesitan acceder a este estado
@Composable
fun TaskListScreen(
viewModel: TaskListViewModel = hiltViewModel(),
appStateViewModel: AppStateViewModel = hiltViewModel(),
onNavigateToDetail: (Int) -> Unit
) {
val currentUser by appStateViewModel.currentUser.collectAsState()
val appTheme by appStateViewModel.appTheme.collectAsState()
TaskListContent(
currentUser = currentUser,
appTheme = appTheme,
// ... rest of props
)
}
🧪 Parte 7: Testing Composables
Testing Components Puros
// Composables sin estado son fáciles de testear
@Composable
fun TaskItem(
state: TaskItemState,
onCompletedChange: (Boolean) -> Unit,
onClick: () -> Unit
) {
// UI pura
}
// Test
class TaskItemTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun taskItem_displays_title() {
composeTestRule.setContent {
TaskItem(
state = TaskItemState(
task = Task(id = 1, title = "Test Task"),
isCompleted = false
),
onCompletedChange = {},
onClick = {}
)
}
composeTestRule.onNodeWithText("Test Task")
.assertIsDisplayed()
}
@Test
fun taskItem_calls_onClick() {
var clicked = false
composeTestRule.setContent {
TaskItem(
state = TaskItemState(
task = Task(id = 1, title = "Test Task"),
isCompleted = false
),
onCompletedChange = {},
onClick = { clicked = true }
)
}
composeTestRule.onNodeWithText("Test Task")
.performClick()
assertTrue(clicked)
}
}
Testing Screens con ViewModel
class TaskListScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
private val fakeViewModel = FakeTaskListViewModel()
@Test
fun taskListScreen_displays_tasks() {
composeTestRule.setContent {
TaskListScreen(
viewModel = fakeViewModel,
onNavigateToDetail = {}
)
}
composeTestRule.onNodeWithText("Task 1")
.assertIsDisplayed()
composeTestRule.onNodeWithText("Task 2")
.assertIsDisplayed()
}
}
// Fake ViewModel para testing
class FakeTaskListViewModel : TaskListViewModel(
getTasksUseCase = FakeGetTasksUseCase(),
completeTaskUseCase = FakeCompleteTaskUseCase(),
deleteTaskUseCase = FakeDeleteTaskUseCase(),
savedStateHandle = SavedStateHandle()
) {
init {
// Override states for testing
}
}
⚡ Parte 8: Performance & Optimization
Evitar Recomposiciones Innecesarias
// ❌ MALO: Recomposición completa cuando cambien muchas cosas
@Composable
fun TaskList(
viewModel: TaskListViewModel
) {
val uiState by viewModel.uiState.collectAsState()
// TODO ESTO REBUILDA si cualquier parte de uiState cambió
TaskListContent(
tasks = uiState.tasks,
isLoading = uiState.isLoading,
error = uiState.error,
// ... 10 más propiedades
)
}
// ✅ BUENO: Rebuildar solo lo necesario
@Composable
fun TaskList(
viewModel: TaskListViewModel
) {
val tasks by viewModel.tasks.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val error by viewModel.error.collectAsState()
// Cada uno rebuilda independientemente
TaskListContent(
tasks = tasks,
isLoading = isLoading,
error = error
)
}
// O mejor aún: usar remember
@Composable
fun TaskList(
viewModel: TaskListViewModel
) {
val uiState by viewModel.uiState.collectAsState()
// Memoizar derivadas para evitar recomposiciones
val filteredTasks = remember(uiState.tasks, uiState.filter) {
uiState.tasks.filter { /* ... */ }
}
TaskListContent(
tasks = filteredTasks,
isLoading = uiState.isLoading
)
}
Memory Leaks Prevention
// ❌ MALO: LaunchedEffect sin cleanup
@Composable
fun TaskDetailScreen(viewModel: TaskDetailViewModel) {
LaunchedEffect(Unit) {
viewModel.observeTask().collect { task ->
// Nunca cancela, memory leak
}
}
}
// ✅ BUENO: LaunchedEffect con proper scope
@Composable
fun TaskDetailScreen(viewModel: TaskDetailViewModel) {
val task by viewModel.task.collectAsState()
// Automatically cancelled cuando composable leave composition
LaunchedEffect(task) {
if (task != null) {
// Safe, cancels when task changes or composable leaves
}
}
}
// ✅ BUENO: DisposableEffect para resources
@Composable
fun CameraPreview(cameraManager: CameraManager) {
DisposableEffect(Unit) {
val preview = cameraManager.startPreview()
onDispose {
cameraManager.stopPreview(preview)
}
}
}
📋 Checklist: Estructura de Proyecto Escalable
- Modularización: Cada feature en su propia carpeta
- Separación de Concerns: Composables pequeños (< 200 líneas)
- Design System: Componentes base centralizados
- State Management: Un UiState por feature, Events para navegación
- Testing: Todos los composables puros son testeables
- Navigation: NavGraph centralizado, Routes claros
- Performance: Recomposiciones optimizadas, sin memory leaks
- Documentation: Cada composable tiene @Composable + comentarios
🎓 Conclusión: La Arquitectura Correcta Es Invisible
Cuando tu arquitectura es correcta, no la ves. Los developers simplemente agregan features sin pensar en el sistema. El código es predecible, mantenible, testeable.
Los signs de buena arquitectura:
✅ Un nuevo developer entiende la estructura en 1 hora
✅ Agregar una feature toma el mismo tiempo en semana 1 que en semana 100
✅ Los cambios están localizados (cambias una cosa, no rompes 5)
✅ Testear es fácil (sin mocks complejos)
✅ Code reviews son sobre lógica, no sobre estructura
✅ Refactorizar es seguro (tests catchean problemas)
Los signs de arquitectura mala:
❌ Nadie entiende la estructura, preguntan "¿dónde va esto?"
❌ Agregar features se vuelve cada vez más lento
❌ Los cambios tienen efectos secundarios en random places
❌ Testear requiere mocks de mocks
❌ Code reviews gastan tiempo en "por qué pusiste esto aquí"
❌ Refactorizar es aterrador
La buena noticia: Jetpack Compose, si lo estructuras correctamente, escala tan bien como cualquier UI framework moderno.
La arquitectura correcta no es un detalle. Es el fundamento de un proyecto mantenible.
Tags
Artículos relacionados
API Versioning Strategies: Cómo Evolucionar APIs sin Romper Clientes
Una guía exhaustiva sobre estrategias de versionado de APIs: URL versioning vs Header versioning, cómo deprecar endpoints sin shock, migration patterns reales, handling de cambios backwards-incompatibles, y decisiones arquitectónicas que importan. Con 50+ ejemplos de código en Go.
Arquitectura de software: Más allá del código
Una guía completa sobre arquitectura de software explicada en lenguaje humano: patrones, organización, estructura y cómo construir sistemas que escalen con tu negocio.
La Capa de Repositorio en Go: Conexiones, Queries y Arquitectura Agnóstica
Una guía exhaustiva sobre cómo construir la capa de repositorio en Go: arquitectura hexagonal con ports, conexiones a MongoDB, PostgreSQL, SQLite, manejo de queries, validación, escalabilidad y cómo cambiar de base de datos sin tocar la lógica de negocio.