WhatsApp

  

Android -MVVM con Jetpack Compose-

Aprende MVVM en android studio

¿Qué es MVVM y por qué usarlo en Android Studio?

MVVM (Model-View-ViewModel) es un patrón de arquitectura diseñado para separar la lógica de negocio de la interfaz gráfica en aplicaciones. En Android, este patrón ayuda a organizar el código en tres capas:

  • Model, que gestiona los datos y la lógica de negocio.

  • View, que representa la interfaz de usuario (Activity, Fragment).

  • ViewModel, que actúa como intermediario entre la vista y el modelo, manteniendo el estado y la lógica de presentación.

Esta separación permite que cada componente tenga responsabilidades claras, lo que facilita el mantenimiento y la escalabilidad del proyecto.

Usar MVVM en Android ofrece varias ventajas:

  • Mejor organización del código: evita que la lógica se mezcle con la interfaz.

  • Compatibilidad con el ciclo de vida: los ViewModels están diseñados para sobrevivir a cambios de configuración como rotaciones de pantalla.

  • Facilita pruebas unitarias: al desacoplar la lógica de la UI, es más sencillo probar el comportamiento de la aplicación.

  • Escalabilidad y mantenibilidad: ideal para proyectos grandes donde se necesita modularidad.

  • Integración con librerías modernas: como LiveData, Flow y Data Binding, que permiten una comunicación reactiva entre la vista y el ViewModel

Componentes del MVVM

Model, View, ViewModel

Model. En el patrón MVVM, el Model representa la capa encargada de la lógica de negocio y la gestión de datos. Es responsable de obtener, procesar y proporcionar la información que necesita la aplicación, ya sea desde una base de datos local, una API remota o cualquier otra fuente. El Model no tiene conocimiento de la interfaz gráfica (View) ni de cómo se muestran los datos; su función es mantener la aplicación desacoplada y facilitar la reutilización y testabilidad del código.


View. En el patrón MVVM, la View es la capa encargada de mostrar la interfaz gráfica y recibir la interacción del usuario. Su función principal es presentar los datos que provienen del ViewModel y reflejar los cambios en la UI sin contener lógica de negocio. En Android, la View suele estar representada por Activity, Fragment o componentes de UI, y se apoya en mecanismos como Data Binding o View Binding para enlazar los datos de forma reactiva. La View no debe manipular directamente el modelo, sino comunicarse con el ViewModel, manteniendo así una separación clara de responsabilidades.

ViewModel. El ViewModel es el componente que actúa como intermediario entre la vista (Activity/Fragment) y el modelo. Su principal función es gestionar y preparar los datos para la interfaz de usuario, asegurando que la lógica de negocio no se mezcle con la lógica de presentación. Además, el ViewModel está diseñado para sobrevivir a cambios de configuración, como rotaciones de pantalla, evitando que se pierdan los datos. Normalmente se combina con LiveData o StateFlow para observar cambios y actualizar la UI de forma reactiva.

Digrama del MVVM

Práctica

Para probar el MVVM, vamos a crear un nuevo proyecto en android studio: 

Hasta este punto, vamos a crear diversos archivos, coloca la vista como android y estructura de la siguiente forma. 

recuerda colocar a "fi.ng" en la carpeta drawable.

Para el MainActivity.kt


package com.example.mvvm
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Surface
import androidx.compose.material3.MaterialTheme
import com.example.mvvm.ui.login.ui.LoginScreen
import com.example.mvvm.ui.login.ui.LoginViewModel
import com.example.mvvm.ui.theme.MVVMTheme
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MVVMTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    LoginScreen(LoginViewModel())
                }
            }
        }
    }
}

Para el LoginScreen.kt


package com.example.mvvm.ui.login.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import com.example.mvvm.R
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
@Composable
fun LoginScreen(viewModel: LoginViewModel){
    Box(Modifier.fillMaxSize().padding(16.dp)){
        Login(Modifier.align(Alignment.Center), viewModel)
    }
}
@Composable
fun Login(modifier: Modifier, viewModel: LoginViewModel){
    val email: String by viewModel.email.observeAsState(initial = "")
    val password: String by viewModel.password.observeAsState(initial = "")
    val loginEnable: Boolean by viewModel.loginEnable.observeAsState(initial = false)
    val isLoading: Boolean by viewModel.isLoading.observeAsState(initial = false)
    val corutina = rememberCoroutineScope()
    if (isLoading){
        Box(Modifier.fillMaxSize()){
            CircularProgressIndicator(Modifier.align(Alignment.Center))
        }
    }
    else{
        Column(modifier = Modifier){
            HeaderImage(Modifier.align(Alignment.CenterHorizontally))
            Spacer(modifier = Modifier.padding(16.dp))
            EmailField(email) { viewModel.onLoginChanged(it, password) }
            Spacer(modifier = Modifier.padding(4.dp))
            passwordField(password) {viewModel.onLoginChanged(email,it)}
            Spacer(modifier = Modifier.padding(8.dp))
            ForgotPassword(Modifier.align(Alignment.End))
            Spacer(modifier = Modifier.padding(16.dp))
            LoginButton(loginEnable) {
                corutina.launch {
                    viewModel.onLoginSelected()
                }
            }
        }
    }
}
@Composable
fun LoginButton(loginEnable: Boolean, onLoginSelected: () -> Unit) {
    Button(
        onClick = {onLoginSelected()},
        modifier = Modifier
            .fillMaxWidth()
            .height(48.dp),
        colors = ButtonDefaults.buttonColors(
            containerColor = Color(0xFF4EA8E9),
            disabledContainerColor = Color(0xFFF78058),
            contentColor = Color.White,
            disabledContentColor = Color.White
        ), enabled = loginEnable
    ) {
        Text(text = "Iniciar Sesión")
    }
}
@Composable
fun ForgotPassword(modifier: Modifier) {
    Text(
        text = "Forgot password?",
        modifier = modifier.clickable{},
        fontSize = 12.sp,
        fontWeight = FontWeight.Bold,
        color = Color(0xFF4EA8E9)
    )
}
@Composable
fun passwordField(password: String, onTextFieldChanged: (String) -> Unit) {
    TextField(
        value = password,
        onValueChange = {onTextFieldChanged(it)},
        placeholder = { Text(text = "Password")},
        modifier = Modifier.fillMaxWidth(),
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
        singleLine = true,
        maxLines = 1,
        colors = TextFieldDefaults.colors(
            focusedContainerColor = Color(0xFFDEDDDD),
            unfocusedContainerColor = Color(0xFFDEDDDD),
            focusedTextColor = Color(0xFF636262),
            unfocusedTextColor = Color(0xFF636262),
            cursorColor = Color.Black
        )
    )
}
@Composable
fun HeaderImage(modifier: Modifier) {
    Image(painterResource(id = R.drawable.fi),contentDescription = "Header",modifier = Modifier)
}
@Composable
fun EmailField(email: String, onTextFieldChanged: (String) -> Unit){
    TextField(
        value = email,
        onValueChange = {onTextFieldChanged(it)},
        modifier = Modifier.fillMaxWidth(),
        placeholder = { Text(text = "Email")},
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
        singleLine = true,
        maxLines = 1,
        colors = TextFieldDefaults.colors(
            focusedContainerColor = Color(0xFFDEDDDD),
            unfocusedContainerColor = Color(0xFFDEDDDD),
            focusedTextColor = Color(0xFF636262),
            unfocusedTextColor = Color(0xFF636262),
            cursorColor = Color.Black
        )
    )
}

Para LoginViewModel.kt


package com.example.mvvm.ui.login.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import com.example.mvvm.R
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
@Composable
fun LoginScreen(viewModel: LoginViewModel){
    Box(Modifier.fillMaxSize().padding(16.dp)){
        Login(Modifier.align(Alignment.Center), viewModel)
    }
}
@Composable
fun Login(modifier: Modifier, viewModel: LoginViewModel){
    val email: String by viewModel.email.observeAsState(initial = "")
    val password: String by viewModel.password.observeAsState(initial = "")
    val loginEnable: Boolean by viewModel.loginEnable.observeAsState(initial = false)
    val isLoading: Boolean by viewModel.isLoading.observeAsState(initial = false)
    val corutina = rememberCoroutineScope()
    if (isLoading){
        Box(Modifier.fillMaxSize()){
            CircularProgressIndicator(Modifier.align(Alignment.Center))
        }
    }
    else{
        Column(modifier = Modifier){
            HeaderImage(Modifier.align(Alignment.CenterHorizontally))
            Spacer(modifier = Modifier.padding(16.dp))
            EmailField(email) { viewModel.onLoginChanged(it, password) }
            Spacer(modifier = Modifier.padding(4.dp))
            passwordField(password) {viewModel.onLoginChanged(email,it)}
            Spacer(modifier = Modifier.padding(8.dp))
            ForgotPassword(Modifier.align(Alignment.End))
            Spacer(modifier = Modifier.padding(16.dp))
            LoginButton(loginEnable) {
                corutina.launch {
                    viewModel.onLoginSelected()
                }
            }
        }
    }
}
@Composable
fun LoginButton(loginEnable: Boolean, onLoginSelected: () -> Unit) {
    Button(
        onClick = {onLoginSelected()},
        modifier = Modifier
            .fillMaxWidth()
            .height(48.dp),
        colors = ButtonDefaults.buttonColors(
            containerColor = Color(0xFF4EA8E9),
            disabledContainerColor = Color(0xFFF78058),
            contentColor = Color.White,
            disabledContentColor = Color.White
        ), enabled = loginEnable
    ) {
        Text(text = "Iniciar Sesión")
    }
}
@Composable
fun ForgotPassword(modifier: Modifier) {
    Text(
        text = "Forgot password?",
        modifier = modifier.clickable{},
        fontSize = 12.sp,
        fontWeight = FontWeight.Bold,
        color = Color(0xFF4EA8E9)
    )
}
@Composable
fun passwordField(password: String, onTextFieldChanged: (String) -> Unit) {
    TextField(
        value = password,
        onValueChange = {onTextFieldChanged(it)},
        placeholder = { Text(text = "Password")},
        modifier = Modifier.fillMaxWidth(),
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
        singleLine = true,
        maxLines = 1,
        colors = TextFieldDefaults.colors(
            focusedContainerColor = Color(0xFFDEDDDD),
            unfocusedContainerColor = Color(0xFFDEDDDD),
            focusedTextColor = Color(0xFF636262),
            unfocusedTextColor = Color(0xFF636262),
            cursorColor = Color.Black
        )
    )
}
@Composable
fun HeaderImage(modifier: Modifier) {
    Image(painterResource(id = R.drawable.fi),contentDescription = "Header",modifier = Modifier)
}
@Composable
fun EmailField(email: String, onTextFieldChanged: (String) -> Unit){
    TextField(
        value = email,
        onValueChange = {onTextFieldChanged(it)},
        modifier = Modifier.fillMaxWidth(),
        placeholder = { Text(text = "Email")},
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
        singleLine = true,
        maxLines = 1,
        colors = TextFieldDefaults.colors(
            focusedContainerColor = Color(0xFFDEDDDD),
            unfocusedContainerColor = Color(0xFFDEDDDD),
            focusedTextColor = Color(0xFF636262),
            unfocusedTextColor = Color(0xFF636262),
            cursorColor = Color.Black
        )
    )
}


En este proyecto se está implementando el patrón MVVM (Model-View-ViewModel) de manera clara y moderna usando Jetpack Compose y LiveData. La View está representada por los Composables (LoginScreen, EmailField, passwordField, LoginButton, etc.), que son responsables únicamente de mostrar la interfaz y reaccionar a cambios de estado. Estos Composables no contienen lógica de negocio; en su lugar, observan el estado expuesto por el ViewModel mediante observeAsState(), lo que permite que la UI se actualice automáticamente cuando cambian los datos. El ViewModel (LoginViewModel) actúa como intermediario entre la vista y el modelo, gestionando el estado de los campos (email, password), validando las entradas con funciones como isValidEmail() y isValidPassword(), y controlando la habilitación del botón de login mediante loginEnable. Además, maneja el flujo de login simulado con corrutinas (delay(4000)), actualizando el estado isLoading para mostrar un CircularProgressIndicator en la UI mientras se procesa la acción. El Model en este caso es mínimo, ya que no hay una capa de datos real, pero el ViewModel encapsula la lógica que normalmente interactuaría con repositorios o servicios externos. Gracias a esta separación, la UI no necesita saber cómo se valida el email ni cómo se realiza el login; solo reacciona a los cambios del ViewModel. Esto asegura una arquitectura escalable, fácil de mantener y testear, donde cada capa tiene responsabilidades bien definidas: la vista muestra datos, el ViewModel gestiona lógica y estado, y el modelo provee datos. Además, el uso de rememberCoroutineScope() en la vista para disparar acciones asincrónicas sin bloquear la UI refuerza la naturaleza reactiva del patrón. En resumen, este ejemplo demuestra cómo MVVM y Compose trabajan juntos para crear una interfaz declarativa, reactiva y desacoplada, cumpliendo con las mejores prácticas modernas de Android.


 

Jesús Alejandro Tenorio 19 noviembre, 2025
Compartir
Iniciar sesión dejar un comentario

  
Guía completa de localStorage y sessionStorage en JavaScript
Descubre en detalle cómo funcionan localStorage y sessionStorage, sus diferencias, casos de uso, ejemplos prácticos y mejores prácticas de seguridad y rendimiento.