Los tipos se infieren de manera automática, es decir, no siempre es necesario declararlos.
Operaciones bitwise (bit a bit)
1valbitwiseOr = FLAG1 or FLAG2
2valbitwiseAnd = FLAG1 and FLAG2
En Kotlin se usan operadores simbólicos.
Acceso y recorrido de cadenas
1vals = "Ejemplo"2valc = s[3] // Accede a 'm'
3vals2 = "Example"4for (c2 in s2) print(c2) // Recorre cada carácter
Null safety
En Java, el código generalmente es defensivo, por lo que se debe comprobar en todo momento que no se produzca un null y prevenir NullPointerException. Kotlin, en cambio, es null safety, lo que significa que se puede definir si un objeto puede o no ser null utilizando para ello el operador ?. Fíjate en los siguientes ejemplos.
1funmain(args: Array<String>) {
2// No compilaría, Artista no puede ser nulo.
3varnotNullArtista: Artista = null 4 5// Artista puede ser nulo.
6valartista: Artista? = null 7 8// No compilará, artista podría ser nulo.
9 artista.toString()
1011// Mostrará por pantalla artista si es distinto de nulo.
12 artista?.toString()
1314// No necesitaríamos utilizar el operador ? si previamente
15// comprobamos si es nulo.
16if (artista !=null) {
17 artista.toString()
18 }
19// Esta operación la utilizaremos si estamos completamente seguros
20// que no será nulo. En caso contrario se producirá una excepción.
21 artista!!.toString()
2223// También podríamos utilizar el operador Elvis (?:) para dar
24// una alternativa en caso que el objeto sea nulo.
25valnombre = artista?.nombre ?:"vacío"26}
La doble exclamación (!!) se utiliza para indicar al compilador que ese objeto no será nulo, evitando así posibles comprobraciones.
0.5. Control de flujo en Kotlin
Kotlin incluye diversas estructuras de control de flujo, como todos los lenguajes, que permiten gestionar la ejecución del código según condiciones, repeticiones o casos específicos.
if - else
Forma básica:
1vala = 52valb = 103if (a > b) {
4 println("a es mayor que b")
5} else {
6 println("a no es mayor que b")
7}
Como expresión, devolviendo un valor:
1valmax = if (a > b) a else b
2println("El máximo es $max")
when
La sentencia when podría decirse que es el equivalente de switch en Java, aunque con algunas diferencias:
when no necesita la sentencia break, switch sí.
when se puede utilizar para comprobar datos de un rango (1..6), switch no.
when es más flexible que switch.
when permite la verificación de tipos, switch no.
when permite diferentes tipos de verificación de tipos de datos, switch no.
switch tiene limitaciones y solo admite tipos primitivos, enum y string, when no.
1classPersona(nombre: String, apellido: String) {
2varnombre: String = nombre
3set(value) { field = if (value.isEmpty()) "Sin nombre"elsevalue }
4get() = "Nombre: $field" 5 6varapellido: String = apellido
7set(value) { field = if (value.isEmpty()) "Sin apellido"elsevalue }
8get() = "Apellido: " + field.uppercase()
910varedad: Int = 011set(value) { field = if (value < 0) 0elsevalue }
1213varanyo: Int = 01415constructor(nombre: String, apellido: String, edad: Int) : this(nombre, apellido) { this.edad = edad }
16constructor(nombre: String, apellido: String, edad: Int, anyo: Int) : this(nombre, apellido, edad) { this.anyo = anyo }
17}
El uso de constructor permite definir constructores primarios y secundarios. El uso de this en el contructor tras los dos puntos (:) invoca al constructor en línea de la clase.
field accede al respaldo interno de la propiedad cuando se sobrecargan los getters y setters.
init
También se dispone en Kotlin del bloque init, es un inicializador que se ejecutará de manera automática al crearse una instancia de la clase, inmediatamente después del constructor primario. Es especialmente útil para realizar operaciones de inicialización complejas, validaciones o cálculos adicionales con los parámetros del constructor. Se puede declarar más de un bloque init, y se ejecutarán en el orden en que aparecen en el código. Aunque las propiedades pueden inicializarse directamente, init permite incluir lógica adicional.
La desestructuración permite extraer valores fácilmente.
0.8. Sealed classes
1sealedclassVehiculo(varnRuedas: Int)
2dataclassMotocicleta(varruedas: Int = 2) : Vehiculo(ruedas)
3dataclassTurismo(varruedas: Int = 4, varpuertas: Int = 2) : Vehiculo(ruedas)
4 5funtipoVehiculo(vehiculo: Vehiculo): String {
6returnwhen (vehiculo) {
7is Motocicleta ->"Es del tipo Motocicleta" 8is Turismo ->"Es del tipo Turismo" 9 }
10}
Las sealed classes en Kotlin permiten restringir el número de subclases que una clase puede tener (heredar), asegurando así, que todas las subclases se declaren en el mismo archivo. Esto proporciona mayor seguridad en tiempo de compilación, ya que el compilador sabe todos los posibles tipos de subclases y puede verificar el uso exhaustivo de estas en expresiones como when. Son ideales para representar jerarquías cerradas, donde cada tipo o variante está bien definido y no se puede extender fuera del contexto previsto. Un ejemplo común es cuando se desea modelar un estado finito o conjunto limitado de resultados como una respuesta de red o un tipo de mensaje.
Un object permite declarar un objeto como una única instancia (singleton) sin necesidad de definir una clase y crear instancias separadas. Es ideal para definir constantes, utilidades, o estructuras que no requieran múltiples copias. Los objetos también pueden tener propiedades, funciones, inicializadores (init) e incluso implementar interfaces. Además, se pueden utilizar para definir companion objects, que actuarán como miembros estáticos compartidos entre todas las instancias de una clase. Esto facilita organizar código relacionado y compartirlo de forma global, sin perder las ventajas del enfoque orientado a objetos de Kotlin.
Los companion object son un objeto declarado dentro de una clase que permite definir miembros estáticos, es decir, propiedades y métodos compartidos por todas las instancias de la clase. Funciona como acompañante (de ahí el nombre) a la clase que lo contiene, y permite acceder a sus miembros directamente a través del nombre de la clase, sin necesidad de crear instancias de esta. Puede resultar útil para crear, constantes o utilidades comunes, manteniendo una sintaxis clara.
0.14. Funciones avanzadas para colecciones en Kotlin
Kotlin ofrece potentes herramientas funcionales para manipular colecciones de forma eficiente, legible y concisa. A continuación, se muestran las funciones más relevantes con ejemplos detallados.
filter
Filtra elementos que cumplan una condición específica.
Conocer Jetpack Compose y las diferencias con el sistema tradicional basado en vistas (XML).
Identificar las ventajas de utilizar Jetpack Compose para el desarrollo de interfaces.
Explorar y comprender la relación entre Kotlin y Jetpack Compose.
Establecer la estructura básica de un proyecto Compose.
Distinguir el flujo de trabajo del compilador y el runtime en la composición de interfaces.
Construir interfaces básicas utilizando funciones Composables y layouts principales.
Entender el proceso de recomposición y cómo se gestiona el estado en Compose.
1.1. Fundamentos de Compose
1.1.1. ¿Qué es Jetpack Compose?
Jetpack Compose es el framework moderno de Android que permite construir interfaces de usuario de forma declarativa. Diseñado para escribir UI de forma más intuitiva, menos propenso a errores y totalmente integrable con el lenguaje de programación Kotlin.
Se basa en tres ideas clave:
UI declarativa: la idea es describir qué se quiere mostrar, en lugar de dibujarlo con XML.
Reactividad: se actualiza automáticamente cuando los datos cambian.
Menos código: no se utiliza XML ni findViewById.
1.1.2. Diferencias entre el sistema basado en vistas y Jetpack Compose
Sistema basado en vistas (XML)
Jetpack Compose
XML separado de lógica
Código unificado en Kotlin
findViewById necesario o ViewBinding
No requiere vinculación manual
Inflado de vistas
Composición directa en runtime
Mucho código
Sintaxis más concisa
Acoplamiento más rígido
Modularidad y reutilización nativa
1.1.3. Ventajas de Jetpack Compose
Declarativo: se define la UI como función del estado, esto significa que la interfaz de usuario se crea a partir del estado de los datos actuales, si estos cambian la UI se actualiza.
Menos código: elimina gran parte del boilerplate.
Testing más sencillo: las funciones Composables pueden probarse directamente.
Integración total con Kotlin.
Mejora el rendimiento en muchos casos gracias a la recomposición eficiente.
Animaciones fáciles de implementar.
Migración progresiva: puede utilizarse junto con el sistema de vistas tradicional.
Ejemplo 1.1. UI reactiva con estado
Este ejemplo muestra un saludo reactivo. Al pulsar el botón, el estado (nombre) cambia y Compose actualiza automáticamente la interfaz, mostrando “Hola, Patricia” sin que haya que modificar directamente el Text.
Jetpack Compose se divide en tres módulos que trabajan conjuntamente y con tareas claramente definidas:
Compose Compiler: plugin del compilador de Kotlin que transforma las funciones etiquetadas como @Composable en código optimizado y eficiente para ser ejecutado sobre la plataforma Android. Además de inyectar la lógica interna necesaria para hacer que la UI sea reactiva y eficiente.
Compose Runtime: el motor de ejecución que mantiene el estado, gestiona las recomposiciones y decide qué UI debe actualizarse. Es capaz de saber qué partes de la UI deben volver a generarse.
Compose UI: contiene los elementos visuales como Text, Button, Row, Column, etc. Aquí está el trabajo directo del desarrollador para construir interfaces. A nivel interno trabaja con la librería gráfica de Android.
Estos tres módulos trabajan de forma desacoplada, lo que permite actualizar o extender cada parte por separado.
Modularidad y escalabilidad
Gracias a esta arquitectura desacoplada, Jetpack Compose es:
Extensible: puedes crear tus propios elementos UI (@Composable personalizados) o incluso reemplazar partes del runtime si lo necesitas.
Testable: puedes probar el runtime o la UI por separado.
Ligero y escalable: puedes incluir solo los módulos necesarios.
Ejemplo 1.2. Modularidad
El siguiente ejemplo permite añadir una TopAppBar estableciendo el título de la aplicación a través del recurso String.
Debes tener en cuenta que esta no será la mejor manera de mostrar una TopAppBar, pero de momento puede servir. Más adelante se verá el componente Scaffold.
1.3. Trabajo del compilador (IR y transformaciones)
1.3.1. Funcionamiento del compilador en Compose
Cuando se escribe una función etiquetada con @Composable, el compilador no la ejecuta tal cual. En lugar de eso, el plugin de Compose para Kotlin modifica esa función y añade el código adicional necesario para gestionar:
El control de recomposición
El seguimiento del estado
La eficiencia en la actualización de la UI
1.3.2. Composable Compiler Plugin
Se integra en el proceso de compilación de Kotlin.
Convierte funciones @Composableen llamadas más complejas que pueden ser monitorizadas por el runtime.
Inyecta parámetros invisibles como el Composer, que rastrea si una función necesita recomponerse o no.
Este código resultado no se verá, pero es el encargado del funcionamiento óptimo y eficiente para Compose.
1.3.3. Transformación en el Intermediate Representation (IR)
El compilador de Compose trabaja en la fase de IR (Intermediate Representation) de Kotlin, dónde se realizarán transformaciones como:
Inyección de lógica para recomposición condicional.
División de Composables en múltiples fases si contienen múltiples niveles de recomposición.
Manejo de claves y grupos para optimizar la reconstrucción de UI.
1.4. Conexión con el tiempo de ejecución
1.4.1. Cómo se interpretan y ejecutan los Composables
Cuando un método etiquetado como @Composable se llama desde setContent o desde otro Composable, no se dibujará directamente en pantalla, sino que entra en juego el runtime de Compose:
Interpretará la estructura del árbol Composable.
Evaluará si debe recomponer por cambios de estado.
Utilizará el sistema de slots para decidir qué partes de la UI deberán redibujarse.
1.4.2. Sistema de slots y control de recomposición
El sistema de slots (Slot Table) es la estructura interna encargada de:
Representar cada nodo del árbol de la UI (Text, Button, Column…).
Registrar qué parte del árbol corresponde a qué parte del estado.
Guardar el orden y la identidad de cada elemento para poder hacer una recomposición eficiente.
Podría verse como un índice dinámico del árbol de UI.
La recomposición se produce cuando un valor observado cambia (por ejemplo, una variable del tipo remember { mutableStateOf(...) }), Compose marca los Composables afectados como sucios (dirty). En la siguiente fase del frame, solo esos Composables se vuelven a ejecutar.
El encargado de esto es el runtime, sin que el desarrollador tenga que intervenir manualmente.
¿Y cómo sabe Compose qué recomponer para ser eficiente?
Cada método Composable recibe información sobre su “posición” en el árbol.
El runtime asigna un grupo de recomposición a cada llamada Composable.
Si el estado relevante cambia, solo ese grupo se vuelve a ejecutar.
Esto hace que Compose sea más eficiente que sistemas anteriores.
Puedes probarlo en Android Studio y añadir justo antes de la etiqueta @Composable la etiqueta @Preview(showBackground = true) para ver una previsualización sin necesidad de ejecutar la aplicación.
1.6. Composición y recomposición
1.6.1. ¿Qué es la recomposición?
La recomposición de Jetpack Compose es el proceso por el cual el sistema vuelve a ejecutar funciones @Composable con el fin de actualizar la interfaz de usuario (UI) como respuesta a cambios en el estado. Esto permite que la UI muestre siempre el estado actual de la aplicación.
Por ejemplo, si una variable de estado cambia, Compose identificará las partes de la UI que dependen de ese estado y volverá a ejecutar solo los métodos @Composable para refrescar la pantalla.
1.6.2. Triggers de recomposición
La recomposición en Jetpack Compose se activa cuando:
Se producen cambios en variables de estado: Al modificar una variable creada con mutableStateOf, Compose detecta el cambio y recompone los métodos que la utilizan.
Hay nuevos valores en parámetros de funciones @Composable: Al llamar a un método @Composable con diferentes argumentos, se considera que su entrada ha cambiado y se recompone.
Cambios en claves de listas: Al modificar la clave de un elemento en una lista, Compose puede recomponer ese elemento específico.
Destacar que Compose optimiza este proceso, recomponiendo únicamente las partes necesarias de la UI. Por ejemplo, si una lista de elementos cambia en orden pero no en contenido, Compose puede evitar recomponer los elementos que no han cambiado.
1.7. Estado y diferenciación inteligente
1.7.1. Estado observable
En Jetpack Compose, el estado representa cualquier dato que puede cambiar y debe reflejarse en la UI. Cuando un estado cambia, Compose vuelve a ejecutar los métodos @Composable que dependen de ese estado para actualizar la UI según corresponda.
Para gestionar el estado de una manera eficiente, Compose facilita varias APIs:
remember: Almacena un valor en memoria durante la composición, útil para conservar el estado entre recomposiciones.
1valbandera = remember { false }
mutableStateOf: Crea un objeto mutable que Compose observa, cuando cambia su valor se produce la recomposición.
1vartextoby remember { mutableStateOf("") }
Declaración
Reactivo
Recomposición
Adecuado para estado UI
by remember { mutableStateOf(false) }
✅ Sí
✅ Automática
✅ Sí
remember { false }
❌ No
❌ No automática
❌ No
derivedStateOf: Permite derivar un nuevo estado a partir de otros estados. Solo se actualizará cuando el resultado derivado cambie, lo que evita recomposiciones innecesarias.
El uso de estas APIs pueden ayudar a optimizar la recomposición y evitar así, recomposiciones innecesarias.
Consejo
Evita operaciones complejas en métodos @Composable, los métodos @Composable deben ser rápidos y sin efectos secundarios. Las operaciones intensivas deben realizarse fuera de estas funciones y sus resultados deben pasarse como parámetros.
Compose optimiza las recomposiciones mediante un sistema de diferenciación inteligente. Lo que significa que solo las partes de la UI que dependen de un estado que ha cambiado se vuelven a componer.
Para aprovechar esta diferenciación:
Minimiza el alcance del estado: Define los estados en los niveles más bajos posible del árbol de Composables, limitando así las recomposiciones.
Evita operaciones costosas en composables: Realiza cálculos intensivos fuera de las funciones @Composable y pasa los resultados como parámetros.
Usa remember y derivedStateOf adecuadamente: Estas funciones ayudan a conservar valores y evitar recomposiciones innecesarias.
En este código de ejemplo, la variable esTextoLargo se actualiza únicamente cuando la longitud de texto cambie y supere la longitud establecida, evitando las recomposiciones innecesarias.
1.8. Naturaleza y propiedades de las funciones componibles
1.8.1. Reglas de los Composables
Las funciones @Composable son la parte principal de Jetpack Compose. Estas funciones deberán cumplir ciertas reglas que garanticen una UI eficiente y predecible:
Anotación obligatoria: Toda función que construya UI deberá estar anotada como @Composable.
No deben devolver valores: Como norma general, este tipo de funciones no devolverán ningún valor, deben describir como se mostrará la UI.
Llamadas a otras composables: Pueden llamar a otras funciones @Composable para construir interfaces más complejas.
Sin efectos secundarios: Deben ser “puras”, es decir, no deben modificar el estado global ni realizar operaciones que afecten fuera del alcance del método.
Estas reglas básicas garantizan que Compose pueda realizar una gestión eficientemente de la recomposición y mantener una UI coherente.
1.8.2. Efectos secundarios y pureza
En el paradigma declarativo de Compose, que las funciones @Composable sean puras es muy importante. Esto significa que, dadas las mismas entradas, siempre deben producir la misma salida sin causar efectos secundarios.
Si fuese necesario realizar operaciones que requieran efectos secundarios, Compose proporciona APIs específicas:
LaunchedEffect: Ejecuta una operación de suspensión cuando una clave específica cambia.
1LaunchedEffect(key1 = clave) {
2// Operación de suspensión
3}
rememberUpdatedState: Permite acceder al valor más reciente de una variable dentro de un efecto.
DisposableEffect: Realiza una operación cuando el Composable entra en la composición y limpia cuando sale.
Estas herramientas permiten manejar efectos secundarios de una manera controlada, manteniendo la integridad del sistema de composición.
1.8.3. Buenas prácticas
Para escribir funciones @Composable de manera eficiente:
Mantén la pureza: No modifiques estados globales o realices operaciones que modifiquen elementos fuera del alcance de la función.
Descomposición en funciones pequeñas: Facilita la lectura y reutilización del código.
Evita recomposiciones innecesarias: Utiliza remember y derivedStateOf para memorizar valores y evitar recomposiciones innecesarias.
Utiliza las APIs adecuadas para manejar efectos secundarios: Como LaunchedEffect o DisposableEffect.
Seguir estas prácticas garantiza una UI eficiente y un código fácil de mantener.
1.9. Estrategias de migración desde Views
1.9.1. Interoperabilidad entre Views y Compose
Jetpack Compose se diseño para coexistir con el sistema tradicional de vistas, basado en Views (XML), esto permite una migración progresiva y controlada.
1.9.2. AndroidView: Incluir Views en Compose
Es posible reutilizar componentes existentes basados en Views dentro de una interfaz construida con Compose, para ello se utiliza el ComposableAndroidView.
1@Composable 2funVistaPersonalizada() {
3 AndroidView(
4 factory = { context -> 5 TextView(context).apply {
6 text = "Texto desde una View tradicional" 7 }
8 }
9 )
10}
Puede resultar útil cuando se necesita incorporar widgets personalizados, o bibliotecas que no tienen un equivalente en Compose.
1.9.3. ComposeView: Incluir Compose en layouts de Views
Por otra lado, existe la posibilidad de insertar contenido de Compose en una jerarquía de Views existente, para lo que se utilizará ComposeView.
1valcomposeView = findViewById<ComposeView>(R.id.compose_view)
2composeView.setContent {
3 Text("Contenido de Compose dentro de una View")
4}
De esta manera, es posible introducir nuevas funcionalidades haciendo uso de Compose sin reescribir código de las pantallas existentes.
1.9.4. Migración progresiva
Si te ves en la situación de ralizar una migración a Jetpack Compose, se recomienda realizarla de manera progresiva, permitiendo que Compose y Views coexistan en el mismo proyecto hasta que la aplicación esté completamente migrada.
Construir nuevas pantallas con Compose: Desarrolla las nuevas funcionalidades directamente con Compose, aprovechando sus beneficios desde el inicio.
Identificar componentes reutilizables: Crea bibliotecas de componentes UI comunes en Compose, fomentando así la reutilización y manteniendo una fuente única de verdad.
Reemplazar pantallas existentes gradualmente: Migra las pantallas existentes una a una, comenzando por las más sencillas, o aquellas que requieran cambios, asegurando una transición controlada.
Este enfoque mantiene la estabilidad de la aplicación mientras se produce el cambio a la nueva tecnología.
Consideraciones adicionales
Compatibilidad con temas y estilos: Asegúrarte que los temas definidos en Views son compatibles, o adaptados a Compose para mantener una apariencia coherente.
Gestión del ciclo de vida: Ten en cuenta el ciclo de vida de los componentes al integrar Compose y Views, especialmente en actividades y fragments.
Pruebas y depuración: Actualiza las pruebas existentes y considera nuevas estrategias de testing para componentes en Compose.
1.10. Ciclo de vida de una aplicación móvil
1.10.1. Ciclo de vida de una Activity
Las aplicaciones móviles en Android están sujetas a un ciclo de vida gestionado por el sistema operativo:
onCreate() -> Se inicializan componentes y UI.
onStart() -> La Activity es visible, pero no interactúa con el usuario todavía.
onResume() -> La Activity ya está en primer plano y permite interacciones con el usuario.
onPause() -> Pierde el foco, momento en el que se puede guardar datos o pausar tareas.
onStop() -> La Activity ya no es visible y se liberan recursos pesados.
onDestroy() -> Se destruye la Activity y se limpian los recursos finales.
Conocer el ciclo de vida es vital para manejar recursos, permisos y situaciones como rotaciones, cambios de configuración o interrupciones.
1.10.2. Ciclo de vida de un Composable
El runtime de Compose dispone su propio ciclo de vida, este se compone de tres fases fundamentales:
Enter the Composition: punto de inicio, es cuando la función @Composable se ejecuta por primera vez.
Recomposition: vuelve a ejecutarse si el estado cambia, actualizando solo aquello que es necesario.
Leave the Composition: se elimina del árbol UI y se liberan los recursos asociados.
1.10.3. Relación entre ciclos
Aunque separados, estos ciclos interactúan entre sí en aplicaciones Compose:
Las recomposiciones ocurren dentro del contexto de la Activity que controla la Composition.
Si se destruye la Activity, se abandona la Composition y todos los efectos son cancelados(DisposableEffect, LaunchedEffect, etc.).
Para reaccionar a eventos del ciclo de vida de la Activity dentro de Compose, se puede observar Lifecycle usando APIs como LifecycleEventEffect o lifecycle.currentStateAsState() del módulo lifecycle-runtime-compose.
Ejemplo práctico
1@Composable 2funCiclosDeVida() {
3// Se utiliza lifecycleOwner para observar el ciclo de vida de la actividad o fragmento.
4vallifecycleOwner = LocalLifecycleOwner.current
5// Se obtiene el estado actual del ciclo de vida como un estado Compose.
6valestado = lifecycleOwner.lifecycle.currentStateAsState()
7 8// Se muestra el estado actual del ciclo de vida.
9Log.d("CiclosDeVida", "Estado del ciclo de vida: ${estado.value}")
10 Text(
11 text = "Estado del ciclo de vida: ${estado.value}",
12 modifier = Modifier.padding(16.dp)
13 )
14}
Una posible salida por el Logcat sería:
2025...-18617 CiclosDeVida es.javiercarrasco.examplet01b D Estado del ciclo de vida: RESUMED
2025...-18617 CiclosDeVida es.javiercarrasco.examplet01b D Estado del ciclo de vida: STARTED
2025...-18617 CiclosDeVida es.javiercarrasco.examplet01b D Estado del ciclo de vida: CREATED
2025...-18617 CiclosDeVida es.javiercarrasco.examplet01b D Estado del ciclo de vida: RESUMED
La documentación de Google para el desarrollo de aplicaciones Android es muy completa y de fácil consulta. Se comenzará por centrar la atención en la instalación de Android Studio1, desde este enlace, desde donde podrás descargar la última versión.
Las aplicaciones nativas para Android se desarrollan utilizando como lenguaje de programación Java, o Kotlin. Por tanto, será necesario tener instalado en el SO el software necesario para la ejecución de Java. Se hace referencia a la máquina virtual de Java (JVM) y su entorno de ejecución de Java (JRE). Puedes descargarlo desde la página de recursos técnicos de Oracle.
Una vez instalado Java en el ordenador, el siguiente paso será descargar Android Studio para el sistema operativo con el que se vaya a trabajar.
Una vez hayas descargado el instalador, ya puedes dar comienzo a la instalación del IDE en el sistema operativo. Como podrás ver a continuación, se trata de una instalación clásica guiada a través del asistente de instalación. Se han omitido algunos pasos del asistente, como el tipo de instalación, que suele seleccionarse standard, o la elección del tema, oscuro o claro.
Nota
Las imágenes mostradas en este apartado pueden no coincidir con las que se muestran en el proceso de instalación, ya que Google actualiza con frecuencia el asistente de instalación de Android Studio.
Si tienes alguna duda durante el proceso de instalación, puedes consultar la documentación oficial.
Una vez finalizada la instalación, ya puedes ejecutar el IDE. En primer lugar podrás ver en la parte izquierda las opciones Projects, para crear y gestionar los proyectos que se vayan generando. La opción Customize, esta permite acceder de manera rápida a opciones visuales y de accesibilidad, pero también encontrarás el acceso a todas las opciones de configuración del IDE.
La opción Plugins, desde la cual pueden añadirse y eliminarse plugins al entorno de desarrollo.
Y por último Learn Android Studio, donde encontrarás enlaces a documentación, ejemplos y consejos sobre el IDE.
Android SDK
Ahora, se mostrará como añadir SDKs (Software Development Kit) de Android según se necesite. Tras la instalación, vendrá instalada la última versión disponible, pero si investigas un poco, verás que la última versión, no es siempre la más utilizada en el caso de Android. Desde Projects, selecciona la opción SDK Manager. Si la pantalla no aparece como la imagen siguiente, si no tienes proyectos creados, la encontrarás justo en el centro, en la opción More Actions.
De momento no es necesario añadir ningún API al IDE, se utilizará la última que contiene todo lo necesario para poder trabajar.
Si se necesitase añadir una API anterior, bastará con seleccionar la API deseada y pulsar el botón Apply, tras lo que se mostrará un mensaje informando de la descarga e instalación que va a producirse, deberás pulsar el botón OK para comenzar.
Seguidamente se deberá aceptar el contrato de licencia y pulsar el botón Next para comenzar con el proceso. A partir de aquí, Android Studio comenzará su trabajo, descargará e instalará los componentes seleccionados.
Una vez terminada la instalación, pulsa el botón Finish, seguidamente verás que aparecen marcadas las versiones instaladas. Ya podrías continuar preparando el entorno de trabajo.
Antes de comenzar a desarrollar, se terminará de configurar el entorno de desarrollo. Las pruebas, como bien sabrás, son una parte muy importante durante el desarrollo, es necesario saber si lo que se está haciendo funciona o no.
Se centrará la atención en el emulador AVD que incorpora Android Studio y en los dispositivos físicos. Existen otras alternativas en caso de no poder utilizar ninguno de estos dos medios, como las siguientes:
Como último recurso para realizar pruebas, se puede generar un archivo de instalación de Android, fichero del tipo .apk, y enviarlo al dispositivo, esto se mostrará más adelante.
Emulador AVD
Como ya se ha comentado, AVD (Android Virtual Device) es emulador que viene por defecto con Android Studio, este permitirá simular características físicas de dispositivos reales, ya bien sean teléfonos, tablets, incluso dispositivos Wear OS, Android TV, etc.
La recomendación de Google es crear un AVD por cada una de las imágenes disponibles del sistema, pero en este caso, con una nos bastará, quiero hacer hincapié en el espacio en disco, ya que si además de tener instaladas varias APIs, debe crearse una imagen AVD por cada una …
Para acceder al administrador AVD, se seguirán los mismos pasos que los utilizados para acceder al SDK Manager, pero esta vez, selecciona la opción Virtual Device Manager.
Una vez dentro, se mostraría el listado de los AVDs creados, en este caso al tratarse del primero, ese listado estará vacío y la única opción disponibles será Create Virtual Device… En versiones anteriores, en la parte inferior podías encontrar un enlace a Android Dashboards, donde podías encontrar estadísticas sobre los dispositivos utilizados, tamaños de pantalla, dispositivos que utilizan OpenGL, etc.
Una vez pulsada la opción Create Virtual Device, o el botón Create Device, se pasará a la selección del dispositivo, Category, en este caso se seleccionará Phone, y se seleccionará un modelo de la lista del centro, encontrarás modelos ofrecidos por Google (los Pixel), o modelos genéricos que encontrarás en la parte baja de la lista.
Se recomienda utilizar un model Pixel con Servicios de Google Play, estos, al igual que en los dispositivos reales, permiten hacer uso de funciones avanzadas que como desarrollador, facilitan el uso de ciertos servicios, como el localización, Places, Firebase, etc. Su uso también hará que se necesiten permisos de administrador para realizar ciertas modificaciones sobre el dispositivo emulado.
Nota
Se recomienda utilizar una imagen con Google Play y ABI x86_64, ya que ofrecen un mejor rendimiento.
Si no se dispones de un equipo con procesador Intel o AMD, se puede utilizar una imagen ARM, pero el rendimiento será mucho peor.
El siguiente paso será seleccionar la imagen del sistema, al tratarse de la primera, verás que todas requieren descarga. Según el momento de instalación, la API a seleccionar puede variar. Se recomienda utilizar, para el curso, no la última, pero que esté al menos dos niveles por debajo, de esta forma se trabajará en un término medio. Deberás descargarla para continuar.
El siguiente paso será indicar el nombre del dispositivo, recomiendo indicar información de la API que tiene instalado para facilitar la elección, no será el caso ahora, pero se suelen tener más de dos o tres AVDs creados para las pruebas.
Una opción que puede venir bien es Enable Device Frame, por defecto viene activada, pero se recomienda desactivarla para aligerar levemente el uso del emulador, sobretodo si tu equipo no tiene suficiente memoria.
Una vez finalizado el proceso, el dispositivo aparecerá en el listado del Device Manager, ya solo faltará lanzarlo, utilizando el botón play que aparece en la columna Actions, para ver que funciona correctamente. Tras unos segundos de tensa espera, deberá cargarse el dispositivo en el emulador.
Una vez cargado, es posible que comience a actualizar aplicaciones como si de un dispositivo físico se tratase, aprovecha el momento para configurar el idioma, la cuenta de Google (opcional), etc.
Información
Si el emulador no se inicia, puede ser por varios motivos, los más comunes son:
No tener activada la virtualización en la BIOS/UEFI.
No tener instalado el Intel HAXM (Intel Hardware Accelerated Execution Manager) o el Hypervisor Framework en Mac.
No tener suficiente memoria RAM disponible.
Problemas con los controladores de la tarjeta gráfica.
Problemas con la versión de Android Studio o del SDK.
Problemas con el antivirus o firewall.
Problemas con la configuración del emulador.
Problemas con el hardware del equipo.
Para solucionar estos problemas, se recomienda revisar la configuración del equipo, actualizar los controladores, desactivar el antivirus o firewall, y revisar la configuración del emulador. También se puede consultar la documentación oficial de Android Studio o buscar ayuda en foros especializados.
Consejo
Es recomendable asignar al menos 2 GB de RAM al emulador para un rendimiento óptimo. Si el equipo tiene poca memoria, se puede asignar menos, pero el rendimiento será peor.
Nota
Se recomienda tener al menos dos o más AVDs creados, al menos uno con una versión baja de Android (API 29) y otro con una versión alta (API 30 o superior), para poder probar las aplicaciones en diferentes versiones del sistema operativo, y evitar problemas que pueden surgir por la emulación.
Otra opción para realizar las pruebas es utilizar un dispositivo físico, un teléfono o una tablet, que tenga instalado Android. Esta opción es la más recomendable, ya que se podrá probar la aplicación en un entorno real, con sus limitaciones y características propias.
Los dispositivos reales ya son otra historia, por un lado, ayuda ver tu aplicación funcionando en un dispositivo real, parece más profesional, además, te permite liberar memoria en el equipo al no tener que cargar un AVD.
Si utilizas macOS, no tienes que hacer nada, al menos nunca se me ha planteado lo contrario, pinchas el dispositivo, AS lo reconoce y a funcionar. Eso sí, necesitarás activar el modo desarrollador en tu dispositivo.
Si no es tu caso, y trabajas con Windows, debes instalar el driver USB, en primer lugar prueba con el Google USB Driver que puedes instalar desde el gestor Android SDK, el mismo que se utilizó para añadir las APIs, seguramente tendrás que reiniciar el sistema.
El driver de Google ha mejorado bastante y, salvo que tu dispositivo sea muy muy actual, no deberías tener problemas.
Aviso
Si no funciona, prueba a buscar el driver específico para tu dispositivo, normalmente en la web del fabricante, o en foros especializados, para obtener el ADB driver para tu sistema operativo. Por ejemplo, para un móvil Samsung.
Activar el modo desarrollador
Si decides hacer uso de un dispositivo móvil, deberás activar el modo desarrollador para realizar pruebas y acceder a otras funciones, como la depuración, capturar pantalla e incluso grabarla.
Para acceder a las opciones para desarrolladores en un dispositivo Android, en primer lugar dirígete a los ajustes del dispositivo, busca la información del dispositivo y localiza el número de compilación. Una vez localizado el número de compilación, deberás pulsar sobre él siete veces, verás que aparece un mensaje indicando que las opciones para desarrollador están activadas.
Ahora, en la sección Sistema, en las opciones avanzadas deberás buscar Opciones para desarrolladores y activar Depuración por USB.
El dispositivo ya estaría listo para realizar las pruebas. Este proceso puede variar según el dispositivo, por ejemplo, en un dispositivo Redmi 9, deberás hacer las siete pulsaciones en la opción Versión de MIUI para activar las opciones para desarrolladores.
Conectar el dispositivo
Una vez que el dispositivo está listo, con el modo desarrollador activado y la depuración por USB habilitada, conecta el dispositivo al ordenador mediante un cable USB. Asegúrate de utilizar un cable que permita la transferencia de datos, no todos los cables USB lo permiten.
Al conectar el dispositivo, es posible que aparezca una ventana emergente en el dispositivo solicitando permiso para permitir la depuración USB desde el ordenador. Asegúrate de aceptar esta solicitud para que Android Studio pueda comunicarse con el dispositivo.
Entender el funcionamiento de un observable y las diferencias entre utilizar remember { mutableStateOf(false) } y remember { false }.
Desarrollo
La clase MainActivity es la típica configuración de una actividad con Jetpack Compose, donde se establece el tema y el Scaffold, desde donde se llama a la función composable RememberVsMutableStateDemo:
Observa el código del método RememberVsMutableStateDemo, en el se muestran dos bloques similares pero con comportamientos diferentes:
1@Composable 2funRememberVsMutableStateDemo(modifier: Modifier = Modifier) {
3 Surface(modifier = modifier.fillMaxSize()) {
4 Column(
5 modifier = Modifier
6 .padding(16.dp)
7 .fillMaxSize(),
8 verticalArrangement = Arrangement.spacedBy(24.dp)
9 ) {
1011// 1. Estado observable: cambia y la UI se actualiza.
12 Card {
13 Column(
14 modifier = Modifier.padding(16.dp),
15 verticalArrangement = Arrangement.spacedBy(12.dp)
16 ) {
17 Text(
18 text = "Estado observable con mutableStateOf",
19 style = MaterialTheme.typography.titleMedium
20 )
21varcheckedby remember { mutableStateOf(false) }
22 Row(
23 verticalAlignment = Alignment.CenterVertically,
24 horizontalArrangement = Arrangement.spacedBy(12.dp)
25 ) {
26 Switch(
27 checked = checked,
28 onCheckedChange = { checked = it }
29 )
30 Text(
31if (checked) "Estado: ACTIVO (se recompone)"else"Estado: INACTIVO (se recompone)"32 )
33 }
34 Text(
35 text = "Aquí usamos un State<Boolean>. Cambiar su valor notifica a Compose y la UI se recompone."36 )
37 }
38 }
3940// 2. Valor recordado NO observable: no hay recomposición al reasignar la variable local.
41 Card {
42 Column(
43 modifier = Modifier.padding(16.dp),
44 verticalArrangement = Arrangement.spacedBy(12.dp)
45 ) {
46 Text(
47 text = "Valor recordado sin estado observable",
48 style = MaterialTheme.typography.titleMedium
49 )
5051// OJO: esto recuerda "false" una vez, pero NO es State<T>.
52varnaive = remember { false }
5354 Row(
55 verticalAlignment = Alignment.CenterVertically,
56 horizontalArrangement = Arrangement.spacedBy(12.dp)
57 ) {
58// La UI LEE 'naive' pero cambiarlo no provoca recomposición.
59 Switch(
60 checked = naive,
61 onCheckedChange = { newValue ->62// Esto cambia la variable local, pero NO notifica a Compose.
63 naive = newValue
64 println("Desde el Switch: $naive")
65 }
66 )
67 Text(
68if (naive) "Valor leído: TRUE (no observable)"else"Valor leído: FALSE (no observable)"69 )
70 }
7172 Button(
73 onClick = {
74// También “cambia” la variable local, pero la UI no se enterará.
75 naive = !naive
76 println("Desde el Button: $naive")
77 }
78 ) {
79 Text("Intentar alternar (no actualizará la UI)")
80 }
8182 Text(
83 text = "Este bloque usa un Boolean 'recordado' pero no observable. " +
84"Reasignarlo no dispara recomposición, por lo que la UI no refleja los cambios."85 )
86 }
87 }
8889// Nota pedagógica opcional
90 AssistChip(
91 onClick = {},
92 label = { Text("Consejo: si necesitas UI reactiva, usa State<T> (p. ej., mutableStateOf).") }
93 )
94 }
95 }
96}
Estado observable con mutableStateOf:
La variable checked se define como var checked by remember { mutableStateOf(false) }.
Esto crea un estado observable. Cuando el usuario interactúa con el Switch y cambia su valor, la UI se recompone automáticamente para reflejar el nuevo estado.
El texto del Switch muestra “Estado: ACTIVO” o “Estado: INACTIVO” según el valor de checked, y este texto se actualiza dinámicamente.
Cada vez que checked cambia, Compose detecta el cambio y vuelve a ejecutar la función composable, actualizando la interfaz de usuario.
Valor recordado sin estado observable:
La variable naive se define como var naive = remember { false }.
Esto recuerda el valor “false” una vez, pero no es un estado observable. Cambiar naive no provoca recomposición.
Cuando el usuario interactúa con el Switch o el Button, la variable naive cambia internamente, pero la UI no se actualiza para reflejar estos cambios.
El texto del Switch muestra “Valor leído: FALSE” o “Valor leído: TRUE” según el valor de naive, pero este texto no se actualizará nunca.
Conclusión
Este ejemplo ilustra claramente la diferencia entre usar un estado observable (mutableStateOf) y un valor recordado no observable (remember { false }). Para que la UI sea reactiva y se actualice automáticamente cuando los datos cambian, es esencial utilizar estados observables en Jetpack Compose.
Comprender y aplicar modificadores de Jetpack Compose para ajustar el estilo, disposición y comportamiento de los elementos de la UI.
Diseñar y personalizar layouts adaptados a diferentes contextos mediante Modifier.layout, medidas intrínsecas y restricciones.
Analizar el proceso de renderizado en Compose.
Utilizar componentes gráficos como Canvas y graphicsLayer para enriquecer la experiencia visual con formas, transformaciones y animaciones.
Estructurar pantallas completas usando Scaffold, barras de herramientas (TopAppBar) y acciones flotantes (FAB).
Crear listas eficientes y reutilizables con LazyColumn y LazyRow (alternativa moderna a RecyclerView).
Implementar elementos interactivos esenciales como Snackbar, Toast, cuadros de diálogo (AlertDialog) y menús desplegables (Spinner).
Gestionar la navegación entre pantallas utilizando tanto múltiples Activities, como la solución moderna basada en Navigation Compose.
Evaluar cuándo conviene usar una arquitectura basada en múltiples actividades frente a una navegación controlada dentro de una única actividad.
2.1. Modificadores
Los modificadores en Jetpack Compose son objetos que permiten modificar, o extender el comportamiento y la apariencia de un elemento Composable, como puede ser su tamaño, padding, clics, animaciones o aspecto gráfico. Son un componente esencial para la creación de interfaces en Compose.
2.1.1. Sintaxis de encadenamiento
La sintaxis de los modificadores está basada en el encadenamiento de funciones, similar a la programación funcional. Se aplica utilizando el operador punto(.) sobre el parámetro modifier que acepta cada Composable.
Este código de ejemplo, muestra un Text que tiene un padding, un fondo gris y responde al clic.
Todos los modificadores devuelven un nuevo Modifier, lo que permite que su encadenamiento sea fluido.
Como se verá a continuación, el orden importa.
2.1.2. Orden de aplicación y optimización
Destacar que en Compose, el orden de los modificadores afectará directamente al resultado visual y funcional.
1// El padding se aplica antes que el fondo.
2Modifier
3 .padding(16.dp)
4 .background(Color.Red)
Utilizando este orden se quedará el fondo ajustado al contenido sin incluir el padding. Sin embargo:
1// El fondo se aplica antes del padding.
2Modifier
3 .background(Color.Red)
4 .padding(16.dp)
En este caso el fondo abarca también el espacio del padding, ya que se aplicará primero.
Información
Los modificadores se aplicarán de arriba hacia abajo según el orden escrito, es decir, de izquierda a derecha en el renderizado visual.
Optimización
Jetpack Compose está diseñado para optimizar los modificadores comunes, como padding, size, offset o background, pero:
Aplicar muchos modificadores anidados de manera innecesaria puede aumentar el número de nodos en la jerarquía (LayoutNode).
Es recomendable agrupar modificadores relacionados y evitar repeticiones.
2.1.3. Modificadores internos vs personalizados
Modificadores internos
Los modificadores internos son los que proporciona Compose, entre los más comunes:
padding()
background()
fillMaxWidth(), height(), size()
clickable()
offset()
graphicsLayer()
Totalmente optimizados y recomendados cuando se ajustan a las necesidades.
Modificadores personalizados
Es posible crear tus propios modificadores cuando sea necesario encapsular lógica de presentación, o comportamiento, para simplificar, mejorar la legibilidad y reutilizar código. Por ejemplo:
Este modificador personalizado se utilizaría de la siguiente forma:
1Box(modifier = Modifier.tarjetaRedonda())
Consejo
Puedes también crear modificadores más complejos utilizando funciones como Modifier.drawBehind, Modifier.composed, o incluso Modifier.pointerInput para gestos personalizados.
Ejemplo 2.1. Modificadores
El siguiente ejemplo muestra el encadenamiento de modificadores, el orden en padding y background y un modificador personalizado (tarjetaRedonda).
Para verlo en funcionamiento puedes pegar este código en cualquier @Composable de Android Studio o en el componente de vista previa (@Preview).
En este ejemplo se introduce el concepto de Context, su uso en Compose varía. Se utiliza LocalContext.current, que permite acceder al contexto de Android dentro de un método Composable.
2.2. Layouts
En este apartado se explicará cómo funcionan los layouts más comunes en Jetpack Compose, la creación de layouts personalizados utilizando Modifier.layout, y cuándo es adecuado utilizarlo frente a los layouts predeterminados como Column, Row o Box.
2.2.1. Introducción a los layouts
Los layouts en Jetpack Compose son componentes que organizan y posicionan otros Composables en la pantalla. Los layouts predeterminados más comunes son:
Column: organiza los elementos en una columna vertical.
Row: organiza los elementos en una fila horizontal.
Box: superpone elementos uno encima de otro.
Estos layouts proporcionan una forma sencilla y eficiente de estructurar la interfaz de usuario. La estructura básica de un layout es la siguiente:
Entre las propiedades más comunes de los layouts se pueden encontrar:
Propiedad
Descripción
modifier
Permite aplicar modificadores para ajustar tamaño, padding, etc.
verticalArrangement
Define cómo se distribuyen los elementos verticalmente (solo en Column).
horizontalArrangement
Define cómo se distribuyen los elementos horizontalmente (solo en Row).
contentAlignment
Define la alineación del contenido dentro del layout (solo en Box).
weight
Permite que un elemento ocupe un espacio proporcional dentro del layout.
padding
Añade espacio alrededor del layout o de los elementos dentro de él.
Estos layouts son altamente personalizables mediante modificadores y permiten crear interfaces complejas de manera sencilla.
2.2.2. Layout personalizado con Modifier.layout
Modifier.layout permite crear Composables con disposición personalizada, es decir, que no dependen de los layouts predeterminados como Column, Row o Box, sino que definen sus propias medidas y ubicación del contenido.
Cómo crear layouts con reglas propias
Conceptos clave
Modifier.layout { measurable, constraints -> ... } permite un control total sobre cómo se mide y posiciona un composable hijo.
Trabaja directamente con el ciclo de composición y disposición: Measurable.measure() → Placeable.place().
Se pueden implementar reglas personalizadas: alineaciones, offsets, centrar, limitar tamaño, aplicar rotaciones manuales, etc.
En este punto tratará de entenderse como funciona internamente el sistema de renderizado en Jetpack Compose a través del layout tree, o LayoutNode, y las tres fases clave del proceso de renderizado: composición, disposición y dibujo.
2.3.1. ¿Qué es el Layout Tree?
En Jetpack Compose, cada elemento visible es representado como un nodo dentro del layout tree, o LayoutNode. Este árbol definerá:
La jerarquía visual de la UI.
Cómo se calculan y distribuyen tamaños.
El orden en que se dibujan y posicionan los elementos.
Layout Inspector (Android Studio): permite ver el LayoutNode en tiempo real.
Modifier.layout: permite intervenir directamente en measure y place.
Modifier.drawBehind, Modifier.graphicsLayer: permiten intervenir en la fase de dibujo.
2.4. Intrínsecos y restricciones
Es necesario conocer que son las medidas intrínsecas en Jetpack Compose para saber cuándo es útil utilizarlas y cómo se pueden gestionar restricciones de tamaño, utilizando Composables como BoxWithConstraints.
2.4.1. Las medidas intrínsecas
Las medidas intrínsecas son una forma de calcular el tamaño mínimo o máximo que necesita un composable sin tener en cuenta las restricciones del padre. Se pueden usar para ajustar el layout en función del contenido, en lugar de limitarse por fillMaxWidth, wrapContentHeight, etc.
En Jetpack Compose, se pueden utilizar:
Modifier.width(IntrinsicSize.Min): ajusta el ancho del componente al mínimo necesario para que su contenido no se recorte.
Modifier.width(IntrinsicSize.Max): ajusta el ancho del componente al máximo permitido según el contenido más ancho de sus hijos.
Modifier.height(IntrinsicSize.Min): ajusta la altura del componente al mínimo necesario para mostrar correctamente su contenido.
Modifier.height(IntrinsicSize.Max): ajusta la altura del componente al máximo permitido según el hijo más alto o el contenido más grande.
El siguiente ejemplo permite visualizar cómo las medidas intrínsecas afectan el tamaño de los contenedores en función del contenido.
Coste alto de rendimiento: requieren múltiples pases de medición, por lo que se deben evitar en listas largas o layouts complejos.
Es preferible el uso de BoxWithConstraints o Modifier.layout cuando el tamaño se predecible o haya que adaptarlo manualmente.
2.4.3. Uso de BoxWithConstraints
BoxWithConstraints permite acceder y modificar las restricciones de tamaño desde dentro del composable, lo que facilita la creación de interfaces adaptativas.
Se puede usar maxWidth, minWidth, maxHeight y minHeight para realizar evaluaciones lógicas en tiempo de composición.
Diferencias
Enfoque
Uso
IntrinsicSize.Min / Max
Ajusta el tamaño según el contenido.
BoxWithConstraints
Adapta la UI al tamaño del contenedor (responsive).
Para resumir, las medidas intrínsecas permiten que un composable se adapte a su contenido, debiéndose utilizar con cuidado por razones de rendimiento. BoxWithConstraints es preferible para layouts adaptativos o responsive.
Ahora se verá cómo utilizar Canvas en Jetpack Compose para dibujar elementos gráficos personalizados como líneas, formas y colores, y comprender cómo se integra esta fase con el ciclo de composición.
2.5.1. ¿Qué es Canvas?
Canvas es un composable especial que permite dibujar directamente sobre la pantalla utilizando para ello primitivas gráficas:
Líneas
Rectángulos
Círculos
Texto
Imágenes y gradientes
Esta API es similar a la de Canvas tradicional de Android (vistas), pero adaptada a Compose y Kotlin.
2.5.2. Uso básico de Canvas
El siguiente código dibuja un rectángulo azul de 100x100 en la posición (20,20).
Como puedes observar, el uso de Canvases puramente decorativo, para dibujos personalizados, gráficos o animaciones. Debes tener en cuenta que el orden de las operaciones es importante, cada llamada a un draw... se superpone a las anteriores. Puedes combinar Canvas con otros layouts y modificadores (padding, clip, etc.).
El modificador graphicsLayer en Jetpack Compose permite transformaciones a nivel de capa modificando propiedades como rotación, escala, alfa, traslación, etc.
2.6.1. ¿Qué es graphicsLayer?
graphicsLayer crea una capa de composición separada para el elemento, permitiendo así transformaciones y efectos visuales que no afectan a otros nodos.
Es ideal para animaciones, efectos complejos o para optimizar redibujados cuando se aplican múltiples transformaciones.
El componente Scaffold se utiliza para estructurar pantallas completas en Compose, organizando elementos como TopAppBar, BottomAppBar, FloatingActionButton y el contenido principal de la UI mediante secciones.
2.7.1. ¿Qué es Scaffold?
Scaffold es un contenedor base que permite estructurar la pantalla mediante secciones predefinidas.
Permite organizar los elementos comunes de una app:
Este punto tratará los componentes básicos de Jetpack Compose, haciendo una correlación con alugnos de sus equivalente en vistas (XML) de TextView, EditText, CheckBox, RadioButton, ImageView, controlando su estado, estilo e interacción.
2.8.1. Texto o etiqueta: Text (equivalente a TextView)
Se configura el estilo y color utilizando MaterialTheme y Color.
2.8.2. Cuadro de texto: TextField (equivalente a EditText)
1@Composable 2funEjemploTextField() {
3varnombreby remember { mutableStateOf("") }
4 5 Column(modifier = Modifier.wrapContentHeight().padding(16.dp)) {
6 TextField(
7 modifier = Modifier.fillMaxWidth(),
8value = nombre,
9 onValueChange = { nombre = it },
10 label = { Text("Nombre") },
11 placeholder = { Text("Escribe tu nombre") } // Equivalente a hint en XML
12 )
13 Spacer(Modifier.height(16.dp))
14 Text("Hola, $nombre")
15 }
16}
El estado está gestionado con remember y mutableStateOf, se usa de label para asignarle una etiqueta al campo y placeholder para indicar las instrucciones, sería el equivalente a hint para las vistas XML. El input es reactivo al estado, apareciendo el texto en un Text separado por un Spacer.
Puedes cambiar el estilo del cuadro de texto utilizando OutlinedTextField en lugar de TextField.
El cuadro de texto puede configurarse para distintos tipos de entrada, como correo electrónico, número, contraseña, etc., utilizando el parámetro keyboardOptions.
Cuando se utiliza KeyboardType.Password, es recomendable añadir visualTransformation = PasswordVisualTransformation() para ocultar el texto introducido.
En definitiva, TextField es un componente versátil para entrada de texto en Compose, con múltiples opciones de personalización y gestión de estado. Su uso hace que aparezca el teclado virtual automáticamente al enfocarlo, pero en ocasiones puede ser necesario controlarlo manualmente mediante SoftwareKeyboardController, por ejemplo, para ocultarlo tras pulsar un botón. Para ello, se puede utilizar LocalSoftwareKeyboardController.current para obtener una instancia del controlador del teclado.
Se pueden cargar imágenes desde recursos (R), además, se puede recortar (clip), redondear esquinas y aplicar borde.
2.8.7. Cargar imágenes desde URL con librerías externas
Para estos casos es necesario el uso de Internet, por lo que la aplicación deberá tener declarado dicho permiso en el Manifest. Además se añade un segundo permiso para permitir a estas librerías hacer comprobaciones del estado de la red.
GlideImage pertenece a la librería Glide, actualmente la integración oficial con Jetpack Compose está en fase beta experimental, por lo que su uso puede ser algo impredecible.
En primer lugar se deberá añadir la siguiente dependencia al Gradle y sincronizar.
1// Glide for image loading
2implementation("com.github.bumptech.glide:compose:1.0.0-alpha.1")
Para aplicar Glide, añade el siguiente método Composable:
Como puedes observar, se carga la imagen en model, se añade una descripción para la imagen y se modifica el contenedor de la imagen con el padding, el tamaño y se recorta (clip) redondeando las esquinas. Por último se escala la imagen.
Para utilizar este método bastará con pasar la URL por parámetro al llamarlo.
Otra opción para cargar imágenes desde URL en Jetpack Compose es usando la librería Coil y el ComposableAsyncImage. En primer lugar se deberá añadir las siguientes dependencias al Gradle y sincronizar.
1// Coil for image loading
2implementation("io.coil-kt.coil3:coil-compose:3.2.0")3implementation("io.coil-kt.coil3:coil-network-okhttp:3.2.0")
El método Composable para cargar una imagen desde una URL puede ser como se muestra a continuación:
Esta es una versión más compleja, ya que se utiliza ImageRequest para habilitar crossfade, utilizando LocalContext.current para construirlo. Además, se especifica placeholder y error. A diferencia de Glide, se puede añadir un borde a la imagen.
Para utilizar este método bastará con pasar la URL por parámetro al llamarlo.
Coil actualmente tiene una integración estable, uso sencillo, tamaño reducido y una API moderna para Compose.
Glide tiene mejor rendimiento para GIFs o caching, y también tiene integración con Compose, pero hay que tener en cuenta que está en beta y puede requerir cambios futuros. Es más pesado que Coil, pero tiene mejor rendimiento en listas.
2.8.8. Desplazamiento del contenido: ScrollView
El desplazamiento de contenido en Compose puede aplicarse directamente sobre Column y Row con los modificadores verticalScroll y horizontalScroll, que equivalen al clásico ScrollView en XML.
Un scroll de este tipo, no es lazy, como se verá más adelante, carga todo el contenido.
El uso recomendado de este elemento es para vistas fijas, tipo formularios o de contenido corto pero que no cabe en la parte visible.
2.9. Listas con LazyColumn y LazyRow
Para crear listas eficientes y escalables en Jetpack Compose se utiliza LazyColumn y LazyRow, estos vienen a ser los equivalentes modernos a RecyclerView.
2.9.1. Diferencias clave respecto a RecyclerView
Si conocéis RecyclerView para vistas XML, la siguiente tabla os aclarará algunos conceptos.
Apartado
RecyclerView
LazyColumn / LazyRow
Arquitectura
Basado en ViewHolder + Adapter
Declarativo, sin adapter
Layout
XML + inflado manual
Composable
Ciclo de vida
Fragment / Activity
Composable puro
Reutilización
Sí, con pool de vistas
Sí, de forma implícita y lazy
Configuración
Compleja (LayoutManager, Adapter)
Muy simple (items o itemsIndexed)
Escalabilidad
Muy buena
Excelente para listas dinámicas
2.9.2. Estructura básica con items
Por ejemplo, se crea un companion que se pasará en la llamada al método desde onCreate() que contrendrá la lista de datos.
Se delega el evento de clic utilizando Modifier.clickable, y se gestiona el estado de selección en la vista, sin necesidad de adapter, simplemente controlando la selección en la propiedad background.
2.9.5. Optimización de listas grandes
Si la lista a utilizar tiene elementos con IDs únicos se utilizará items(..., key = { it.id }):
En este caso se ha utilizado items para crear una lista de tarjetas con los nombres de los usuarios.
Cada tarjeta es clicable y muestra un Toast al seleccionarla, recuerda que hay que recuperar el contexto actual para poder mostrarlo.
El uso de key = { it } asegura que cada elemento tenga una clave única, si por ejemplo, Ana estuviese duplicada, se produciría el siguiente error:
java.lang.IllegalArgumentException: Key "Ana" was already used. If you are using LazyColumn/Row please make sure you provide a unique key for each item.
En este punto se verá cómo mostrar notificaciones visuales al usuario utilizando Snackbar (propio de Compose y Material Design) y Toast (clásico de Android), viendo su uso e implementación de forma correcta en Compose.
SnackbarHostState mantiene el estado del Snackbar, con el método showSnackbar() se lanza una corutina para mostrarlo en pantalla. A continuación, se comprueba (if) si el usuario pulsa la acción con SnackbarResult y la constante ActionPerformed, para comprobar que el usuario no la puede utilizarse Dismissed.
1if (resultado ==SnackbarResult.Dismissed) {
2Log.d("SNACKBAR", "El usuario descartó el Snackbar")
3}
Para este case es neceario usar LocalContext.current para acceder a un Context que pueda ser utilizado. El Toast se muestra como en el sistema clásico de vistas de Android y no depende de Scaffold.
No muestres múltiples Toast seguidos (no cancelables por el usuario).
Se recomienda observar el resultado del Snackbar si se utilizan acciones.
2.11. Menús en Jetpack Compose
Ahora se verá como implementar diferentes tipos de menús en Jetpack Compose, desde los más simples hasta los personalizados o en cascada, tratando de comprender cuándo usar cada uno.
Este menú está formado por DropdownMenu, el menú en sí, y DropdownMenuItem, las opciones del menú. El popup aparecerá anclado al elemento que dispara el menú, en este caso el IconButton.
Este patrón es ideal para pantallas con un menú de opciones en la barra superior. Si observas el código, es el mismo que el utilizado para el menú básico.
Ahora bien, lo ideal sería reutilizar código, algo bastante habitual en los menús, creando un método que contenga la estructura del menú, dejando el Scaffold más limpio. En primer lugar se crearía el Composable que crea el menú.
En este código se introduce la delegación del callback para hacerlo más reutilizable. El Scaffold quedaría más limpio, extrayendo el callback, además, se lleva fuera, dejándolo más sencillo.
Las ventajas de utilizar esta librería, principalmente, es la simplificación a la hora de crear menús jerárquicos. Además, añade animaciones en la expansión y contracción de los menús y es compatible con Material Design y Compose.
Este punto pretendre introducir la creación y gestión de cuadros de diálogo en Jetpack Compose con AlertDialog (MaterialAlertDialog versión para vistas), y entender cómo personalizarlos para diferentes contextos: confirmación, alerta, formulario, etc.
Los diálogos son un componente modal que interrumpe el flujo de la interfaz para presentar información importante o solicitar una acción del usuario.
2.12.1. Cuadro de diálogo informativo
El siguiente código muestra un cuadro de diálogo muy simple, de uso informativo.
1valopenInfoDialog = remember { mutableStateOf(false) }
2 3/* ... */ 4 5Button(onClick = { openInfoDialog.value = true }) {
6 Text("Mostrar info")
7}
8 9if (openInfoDialog.value) {
10 AlertDialog(
11 onDismissRequest = { openInfoDialog.value = false }, // Se cierra el diálogo al tocar fuera de él.
12 title = { Text("Información") },
13 text = { Text("Esta es una notificación informativa.") },
14 confirmButton = {
15 TextButton(onClick = { openInfoDialog.value = false }) {
16 Text(LocalContext.current.getString(android.R.string.ok))
17 }
18 }
19 )
20}
2.12.2. Cuadro de diálogo básico
El siguiente código muestra el método Composable para mostrar un cuadro de diálogo, además, se añaden los parámetros necesarios para delegar las acciones, un posible icono y el texto. Esto hará del componente más reutilizable.
1@Composable 2funAlertDialogExample(
3 onDismissRequest: () -> Unit,
4 onConfirmation: () -> Unit,
5 dialogTitle: String,
6 dialogText: String,
7 icon: ImageVector = Icons.Default.Warning,
8) {
9 AlertDialog(
10 icon = { Icon(icon, contentDescription = "Example Icon") },
11 title = { Text(text = dialogTitle) },
12 text = { Text(text = dialogText) },
13 properties = DialogProperties(
14 dismissOnBackPress = false, // Se evita el cierre al presionar atrás.
15 dismissOnClickOutside = false// Se evita el cierre al tocar fuera del diálogo.
16 ),
17 onDismissRequest = { onDismissRequest() }, // Se llama a la función de cierre del diálogo si no está bloqueado el cierre.
18 confirmButton = {
19 TextButton(onClick = { onConfirmation() }) {
20 Text(LocalContext.current.getString(android.R.string.ok))
21 }
22 },
23 dismissButton = {
24 TextButton(onClick = { onDismissRequest() }) {
25 Text(LocalContext.current.getString(android.R.string.cancel))
26 }
27 }
28 )
29}
El uso de este cuadro de diálogo será como se muestra a continuación.
Como puedes observar en este código, se ha optado por una estrategia diferente, el propio método se encarga de gestionar el estado de visibilidad del cuadro de diálogo y de mostrar el botón que desencadena la acción. Se propaga el dato recogido mediante el callbackonSave.
Pero… según la documentación oficial “si quieres crear un diálogo más complejo, quizás con formularios y varios botones, debes usar Dialog con contenido personalizado”aquí
Si se adapta al uso de Dialog, el método CustomDialog podría quedar así:
Los Modifiers son elementos fundamentales en Jetpack Compose que permiten modificar la apariencia y el comportamiento de los componentes de la interfaz de usuario. A continuación, se describen algunas de las propiedades básicas de los Modifiers:
Propiedad
Descripción
Ejemplo de uso
padding()
Añade espacio interno alrededor del contenido (dentro del contenedor).
Modifier.padding(16.dp)
fillMaxWidth()
Hace que el elemento ocupe todo el ancho disponible.
Modifier.fillMaxWidth()
fillMaxHeight()
Hace que el elemento ocupe toda la altura disponible.
Modifier.fillMaxHeight()
fillMaxSize()
Hace que el elemento ocupe todo el espacio disponible, tanto en ancho como en alto.
Modifier.fillMaxSize()
size()
Define un tamaño fijo para el elemento.
Modifier.size(100.dp)
width() / height()
Establecen un ancho o alto específico.
Modifier.width(200.dp) / Modifier.height(100.dp)
wrapContentWidth() / wrapContentHeight()
Ajustan el tamaño del elemento al contenido, solo en el eje indicado.
Modifier.wrapContentWidth()
background()
Pinta un color de fondo o un Brush.
Modifier.background(Color.LightGray)
clip()
Recorta el contenido a una forma (ej. CircleShape, RoundedCornerShape).
Ajusta la transparencia del elemento (1f = opaco, 0f = invisible).
Modifier.alpha(0.5f)
rotate()
Rota el contenido un número de grados determinado.
Modifier.rotate(45f)
scale()
Escala el tamaño del elemento en los ejes X y Y.
Modifier.scale(1.5f)
shadow()
Aplica una sombra al elemento.
Modifier.shadow(4.dp, RoundedCornerShape(8.dp))
Estas propiedades de Modifier son esenciales para personalizar y controlar la apariencia y el comportamiento de los componentes en Jetpack Compose. Al combinarlas, puedes crear interfaces de usuario ricas y adaptativas.
Nota
Recuerda que las propiedades de Modifier pueden encadenarse libremente, aplicándose en el orden en que se escriben.
Ejemplo práctico 1: Centrar un Text usando Modifier.layout
Objetivo
Aprende a crear un layout personalizado que centre un composable hijo (en este caso, un Text) manualmente, sin usar Box o Arrangement.Center.
Estructura del Modifier.layout
1Modifier.layout { measurable, constraints -> 2// Se mide el hijo con las restricciones.
3valplaceable = measurable.measure(constraints)
4 5// Se calcula el tamaño total del padre.
6valwidth = constraints.maxWidth
7valheight = constraints.maxHeight
8 9// se calcula la posición centrada.
10valx = (width - placeable.width) / 211valy = (height - placeable.height) / 21213// Se devuelve el layout y se coloca el hijo.
14 layout(width, height) {
15 placeable.place(x, y)
16 }
17}
Ejemplo completo
Se crea el método composable:
1@Composable 2funLayoutPersonalizadoDemo() {
3 Box(
4 modifier = Modifier
5 .wrapContentHeight() // Se ajusta al contenido
6 .background(Color(0xFFEFEFEF)) // Fondo gris claro
7 .centroManual() // Modificador personalizado
8 ) {
9 Text(
10 text = "Texto centrado con layout personalizado",
11 fontSize = 18.sp,
12 color = Color.Black
13 )
14 }
15}
A continuación, se crea el modificador personalizado centroManual():
Ejemplo práctico 2: Limitar al 50% del espacio disponible usando Modifier.layout
Objetivo
Se creará un modificador personalizado llamado limitarAnchoAl50Porciento() que limite el ancho del hijo al 50% del ancho máximo disponible. Además, colocará el hijo centrado horizontalmente dentro del espacio total y mantendrá la altura original del hijo.
Modificador personalizado
1funModifier.limitarAnchoAl50Porciento(): Modifier = this.then(
2Modifier.layout { measurable, constraints -> 3// Se calcula el 50% del ancho disponible.
4valanchoDisponible = constraints.maxWidth
5valanchoLimitado = anchoDisponible / 2 6 7// Se crean nuevas restricciones con ancho máximo reducido.
8valnewConstraints = constraints.copy(maxWidth = anchoLimitado)
910// Se mide el hijo con esas restricciones.
11valplaceable = measurable.measure(newConstraints)
1213// La altura del padre será la del hijo, ancho será el original.
14 layout(anchoDisponible, placeable.height) {
15// Se centra horizontalmente
16valx = (anchoDisponible - placeable.width) / 217 placeable.place(x, 0)
18 }
19 }
20)
Este ejemplo muestra un texto diferente según si el ancho del contenedor es mayor o menor a 300.dp. Además, el fondo cambia de color para mayor visibilidad.
Para dotar de movimiento a la barra de progreso, se añadirá el siguiente código en el método onCreate(). Además, se podrá ver una breve introducción al uso de Scaffold, Toolbar y FloatingActionButton.
Ejemplo práctico 6: Animación con graphicsLayer y Scaffold
Objetivo
Este ejemplo se creará un Scaffold con TopAppBar y FloatingActionButton. Se creará un composable que anime su rotación y escala usando graphicsLayer y un botón flotante que inicie o detenga la animación.
En resumen, se coloca un Scaffold con barra superior y un FAB, se crea un Box central ocupando toda la pantalla que rota 360 grados y se escala a 1.5x cuando se pulsa el FAB. El estado animar controla si se inicia o detiene la animación. Por último, se utiliza animateFloatAsState para interpolar suavemente.
En este ejemplo se utiliza Material 3 para añadir un Scaffold con TopAppBar, BottomAppBar, y FloatingActionButton. Además, se utiliza un Snackbar mediante SnackbarHostState y cambio de contenido al pulsar el FAB, mostrando Snackbar cuando el contador alcanza el máximo (5).
En resumen, se utiliza remember { SnackbarHostState() } para evitar que se cree una nueva en cada recomposición. El Scaffold recibe el snackbarHost, enlazado con el SnackbarHostState creado.
El FAB incrementa contador que, al llegar a 5, mostrará un Snackbar con opción “Reiniciar” y resetea el contador si se pulsa la acción.
innerPadding hace que el contenido central respete las barras del Scaffold.
Ejemplo práctico 8: Menú básico en TopAppBar con devolución de selección vía callback
Objetivo
Este ejemplo trata de plantear una posible solución a problemas que pueden plantearse durante el desarrollo de aplicaciones móviles. La idea es crear un componente para montar una TopAppBar con un menú, evaluando la selección del usuario mediante un único callback, comprobando la respuesta producida y actuando en consecuencia. Debes tener en cuenta que en Compose los métodos no devuelven valores, de ahí el uso de callbacks.
Recursos en string.xml
Tratará de evitarse lo máximo posible el hardcoded text, aunque en ocasiones, verás que se omite por razones didácticas.
1<resources> 2<stringname="app_name">ExampleT2_8</string> 3 4<stringname="txt_welcome">Selecciona una opción del menú</string> 5 6<stringname="txt_option_title">Más opciones</string> 7 8<stringname="txt_option_share">Compartir</string> 9<stringname="txt_option_save">Guardar</string>10<stringname="txt_option_logout">Cerrar sesión</string>1112<stringname="txt_share">Has seleccionado la opción <b>Compartir</b>.</string>13<stringname="txt_save">Has seleccionado la opción <b>Guardar</b>.</string>14<stringname="txt_logout">Has seleccionado la opción <b>Cerrar sesión</b>.</string>15</resources>
Sealed Class
Para simplificar el código, se creará la siguiente sealed class en un fichero a parte, lo que permite reducir las evaluaciones para este caso.
El uso de este esquema permite el tipado seguro, evitando así errores de escritura en las cadenas, es escalable, está integrado con when lo que permite una evaluación exhaustiva y permite la reutilización, ya que la acción se realizará en el when, y no en el método encargado de montar el menú.
Puede ser más óptimo, por ejemplo, añadiendo propiedades como label o icon dentro de la sealed class.
Ejemplo práctico 9: Menú con DropdownMenu en BottomAppBar
Objetivo
Este ejemplo es una variante del anterior. Se sustituirá la TopAppBar con un menú por una BottomAppBar, evaluando la selección del usuario mediante un único callback, comprobando la respuesta recibida y actuando en consecuencia. Debes tener en cuenta que en Compose los métodos no devuelven valores, de ahí el uso de callbacks. Se mantendrá la misma estructura de sealed class, añadiendo label e icon.
Recursos en string.xml
Como en la versión anterior, se tratará de evitar lo máximo posible el hardcoded text, aunque en ocasiones, verás que se omite por razones didácticas.
1<resources> 2<stringname="app_name">ExampleT2_8</string> 3 4<stringname="txt_welcome">Selecciona una opción del menú</string> 5 6<stringname="txt_option_title">Más opciones</string> 7 8<stringname="txt_option_share">Compartir</string> 9<stringname="txt_option_save">Guardar</string>10<stringname="txt_option_logout">Cerrar sesión</string>1112<stringname="txt_share">Has seleccionado la opción <b>Compartir</b>.</string>13<stringname="txt_save">Has seleccionado la opción <b>Guardar</b>.</string>14<stringname="txt_logout">Has seleccionado la opción <b>Cerrar sesión</b>.</string>15</resources>
Sealed Class
Para simplificar el código, se creará la siguiente sealed class en un fichero a parte, lo que permite reducir las evaluaciones para este caso. Como se ha comentado, se añadirán dos nuevas propiedades a la clase label e icon.
Supón que quieres reutilizar este componente en más de una vista, para eso se creará este componente en un fichero separado, por ejemplo, Utils.kt. Esta versión está mejorada con respecto al ejemplo anterior, se crea un bucle para mostrar las opciones que se vayan añadiendo en la sealed class.
Como puedes ver, BottomAppBar permite el uso de actions igual que la TopAppBar. También es posible implementar este menú utilizando la sección floatingActionButton y utilizando FloatingActionButton, y se puede combinar ambas barras (topBar y bottomBar) en el mismo Scaffold.
Se creará el siguiente compose para mostrar el cuadro de diálogo personalizado, en esta ocasión se utiliza AlertDialog ya que la personalización es mínima y simplifica el código, pero para los cuadros de diálogo personalizados se recomienda el uso de Dialog.
Observa el uso de visualTransformation en el OutlinedTextField, este permite la ocultación del password.
Compose MainScreen
Siguiendo con la reutilización y actualización del estado, se creará el siguiente composable para mostrar el botón de login o el mensaje de login correcto.
1@Composable 2funMainScreen() {
3varisLoggedInby remember { mutableStateOf(false) }
4valctxt = LocalContext.current
5 6if (!isLoggedIn) {
7// Se muestra el diálogo de inicio de sesión
8 LoginDialog(
9 onLogin = { user, pass ->10// Aquí se maneja la lógica de inicio de sesión
11// Por ejemplo, verificar las credenciales
12if (user =="admin"&& pass =="1234") {
13 isLoggedIn = true// Simulación de inicio de sesión exitoso
14 println("Inicio de sesión correcto. Usuario: $user, Contraseña: $pass")
15 } else {
16// Se muestra un mensaje de error o manejar el fallo de inicio de sesión
17Toast.makeText(
18 ctxt,
19 ctxt.getString(R.string.txt_login_error),
20Toast.LENGTH_SHORT
21 ).show()
22 }
23 }
24 )
25 } else {
26// Contenido principal de la aplicación
27 Text(text = ctxt.getString(R.string.txt_login_ok))
28 }
29}
Resultado de la MainActivity
Ahora, la actividad principal tendrá el siguiente aspecto.
Comprender las diferentes estrategias de navegación en aplicaciones Android.
Dominar el uso de la clase Intent para la comunicación entre componentes.
Aplicar técnicas modernas para el envío de datos entre actividades y la recepción de resultados.
Gestionar adecuadamente los permisos peligrosos en Android.
Gestionar la navegación con ViewModel en el contexto de MVVM y Clean Architecture.
Integrar el patrón ViewModel en la gestión de la navegación y el estado de los permisos.
Comparar cuándo elegir múltiples actividades o una sola actividad con varias pantallas.
3.1. Creación y navegación entre actividades
Para crear una Activity nueva en el modelo de vistas se utiliza una clase conocida como Intent, esto pueden ser de dos formas:
Explícitos, indicarán que deben lanzar exactamente, su uso típico es ejecutar diferentes componentes internos de una aplicación. Por ejemplo, una actividad (ventana nueva).
Implícitos, se utilizan para lanzar tareas abstractas, del tipo “quiero hacer una llamada” o “quiero hacer una foto”. Estas peticiones se resuelven en tiempo de ejecución, por lo que el sistema buscará los componentes registrados para la tarea pedida, si encontrase varias, el sistema preguntará al usuario que componente prefiere.
3.1.1. Crear nuevas Activity
Para crear una Activity es necesrio extender ComponentActivity y usar startActivity(Intent(this, OtraActivity::class.java)). Este sistema se utiliza para lanzar nuevas actividades en el sistema de vistas, y sería un Intent explícito_.
3.1.2. Enviar datos con Intent
Para añadir datos a la llamada se usará intent.putExtra("clave", valor), y se recibirá en OtraActivity utilizando intent.getXXXExtra("clave"). Como en la creación, se utiliza para lanzar nuevas actividades en el sistema de vistas.
3.1..3. Recibir resultados
Para recuperar datos de OtraActivity se recomienda utilizar la API moderna, creando el siguiente callback, sustituyendo la versión anterior que utilizaba onActivityResult(). A este método también se le conoce como Intent por contrato.
Cuando se quiere lanzar una tarea que no es propia de la app se tendrá que tratar con otro tema, los permisos. El tratamiento de los permisos en Android cambió a partir de la API 23, hasta entonces, se concedían durante la instalación. Ahora, debe concederse de manera explícita aquellos considerados peligrosos, ya no se pide permiso durante el proceso de instalación sino en tiempo de ejecución.
A raíz de este cambio se produce una clasificación de los permisos, básicamente se distinguirán tres tipos de permisos
según sea su nivel de peligrosidad.
Permisos normales: estos se utilizan cuando la aplicación necesita acceder a recursos o servicios fuera del ámbito de la app, donde no existe riesgo para la privacidad del usuario o para el funcionamiento de otras aplicaciones, por ejemplo, cambiar el uso horario.
Si se declara en el manifest de la aplicación uno de estos permisos, el sistema otorgará automáticamente permiso para su uso durante la instalación. Además de no preguntarse al usuario por ellos, estos no podrán revocarlos.
Permisos de firma: estos son concedidos durante la instalación de la app, pero sólo cuando la aplicación que intenta utilizar el permiso está firmada por el mismo certificado que la aplicación que define el permiso. Estos permisos no se suelen utilizar con aplicaciones de terceros, es decir, se utilizan entre aplicaciones del mismo desarrollador.
Permisos peligrosos: estos permisos involucran áreas potencialmente peligrosas, como son la privacidad del usuario, la información almacenada por los usuarios o la interacción con otras aplicaciones. Si se declara la necesidad de uso de uno de estos permisos, se necesitará el consentimiento explícito del usuario, y se hará en tiempo de ejecución. Hasta que no se conceda el permiso, la app no podrá hacer uso de esa funcionalidad. Por
ejemplo, acceder a los contactos.
Puedes encontrar todos los permisos que se pueden utilizar en la documentación de Google para Android. El valor que necesites añadir al manifest lo encontrarás en Constant Value, y Protection level indica el tipo de permiso que es.
INTERNET
public static final String INTERNET
Allows applications to open network sockets.
Protection level: normal
Constant Value: "android.permission.INTERNET"
Los permisos normales no requieren de una solicitud al usuario para poder funcionar, es por eso que se comenzarán por los permisos peligrosos, concretamente, uno de los más habituales, el uso de la cámara de fotos del dispositivo. Según la documentación de Google, cuando uno se plantea la gestión de permisos debe plantearse el siguiente flujo de trabajo para una correcta gestión.
Como estamos introduciendo el uso de Jetpack Compose, una de las cosas que cambia con respecto al sistema de vistas es la gestión de permisos, para ello se hará una primera aproximación a ViewModel.
3.2.1. ¿Qué es un ViewModel?
Un ViewModel es un componente de Architecture Components de Android Jetpack que permite almacenar y gestionar datos relacionados con la UI, de forma que sobreviven a cambios de configuración (como rotaciones de pantalla). Sus principales características son:
Separar la lógica de negocio de la UI, manteniendo los Composables atómicos y centrados en la presentación.
Mantener la consistencia del estado tras representaciones de activities o fragments.
Diseñado para integrarse fácilmente con librerías como Hilt, Navigation Compose y funciones de flujo de datos como StateFlow o LiveData.
En Jetpack Compose, los ViewModel se crearán e inyectarán en Composables utilizando las funciones viewModel() o hiltViewModel().
3.2.2. Integrar permisos con ViewModel
Se creará una nueva clase (PermissionHandlerViewModel) que extenderá (heredará) de ViewModel y centralizará la lógica para solicitar y comprobar el estado del permiso.
1classPermissionHandlerViewModel : ViewModel() {
2dataclassPermissionUiState(
3valgranted: Boolean = false,
4valshowRationale: Boolean = false,
5valpermanentlyDenied: Boolean = false 6 )
7 8// MutableStateFlow to hold the UI state, we use backing property.
9privateval_uiState = MutableStateFlow(PermissionUiState())
10valuiState: StateFlow<PermissionUiState> = _uiState.asStateFlow()
1112// Function to update the UI state based on permission results.
13funonPermissionResult(granted: Boolean, shouldShowRationale: Boolean) {
14 _uiState.update {
15it.copy(
16 granted = granted,
17 showRationale = !granted && shouldShowRationale,
18 permanentlyDenied = !granted && !shouldShowRationale
19 )
20 }
21 }
22}
El método onPermissionResult actualizará el estado según la respuesta del usuario:
granted: permiso concedido.
showRationale: denegado con posible explicación.
permanentlyDenied: denegación del permiso sin posibilidad de volver a preguntar.
El uso de MutableStateFlow en el ViewModel es para representar un estado que pueda sobrevivir a cambios de configuración, integrarse con flujos de datos y mantenerse testable y encapsulado. Se expone como StateFlow para su consumo externo, esta técnica se conoce como backing. En Compose, se usa collectAsState() para convertirlo en un estado observable y disparar la recomposición.
Ahora se añadirá al Manifest el permiso para poder utilizar la cámara de fotos del dispositivo.
Para este tipo de acciones, es necesario establecer en el Manifest lo que se conoce como queries, estas permiten indicar al sistema operativo que la aplicación va ha necesitar una aplicación de terceros. La siguiente querie se utiliza para indicar que la aplicación va a necesitar el uso de la cámara de fotos.
Ahora se creará el método OpenCamera() que será el encargado de gestionar los permisos y mostrar la UI según la respuesta del usuario.
1@Composable 2funOpenCamera(viewModel: PermissionHandlerViewModel = viewModel()) {
3valpermissionState = viewModel.uiState.collectAsState() // Obtiene el estado del permiso desde el ViewModel.
4valctxt = LocalContext.current
5// Este callback se usa para solicitar el permiso de cámara.
6valrequestPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> 7 viewModel.onPermissionResult(
8 granted, ActivityCompat.shouldShowRequestPermissionRationale(
9 ctxt as Activity, Manifest.permission.CAMERA
10 )
11 )
12 }
1314// Observamos el estado del permiso y actuamos en consecuencia.
15 LaunchedEffect(permissionState) {
16when {
17 permissionState.value.granted -> {
18// Aquí abrimos la cámara; por simplicidad indicamos con un log
19Log.d("CameraPermission", "Acceso a cámara concedido")
20// Podrías lanzar una navegación o mostrar vista de cámara
21 }
2223 permissionState.value.showRationale -> {
24// Mostrar diálogo explicativo
25 }
2627 permissionState.value.permanentlyDenied -> {
28// Mostrar diálogo con opción a abrir ajustes
29 }
3031else-> {
32// Primer lanzamiento: solicitamos el permiso
33 requestPermission.launch(Manifest.permission.CAMERA)
34 }
35 }
36 }
3738// Aquí se muestra la UI dependiendo del estado del permiso.
39when {
40 permissionState.value.granted -> {
41 Text("Pulsa el botón para abrir un intent")
42 Button(
43 onClick = {
44Log.d("DEBUG", "Botón pulsado")
4546valcameraIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
47if (cameraIntent.resolveActivity(ctxt.packageManager) !=null)
48 ctxt.startActivity(cameraIntent)
49elseLog.e("DEBUG", "No hay aplicación que pueda manejar la cámara")
50 },
51 modifier = Modifier.padding(8.dp).fillMaxWidth()
52 ) {
53 Text(text = "Abrir la cámara")
54 }
55 }
5657 permissionState.value.showRationale -> {
58 Text("Se necesita acceso a la cámara de fotos")
59Toast.makeText(
60 ctxt,
61"Es necesario tener acceso a la cámara de fotos",
62Toast.LENGTH_LONG
63 ).show()
64 }
6566 permissionState.value.permanentlyDenied -> {
67 Text("Permiso denegado permanentemente")
68 Button(
69 onClick = { // Se abren los ajustes de la aplicación para que el usuario pueda conceder el permiso manualmente.
70 ctxt.startActivity(
71 Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
72data = Uri.fromParts("package", ctxt.packageName, null)
73 }
74 )
75 }, modifier = Modifier.padding(8.dp).fillMaxWidth()
76 ) {
77 Text("Abrir ajustes")
78 }
79 }
8081else-> {
82 Text("Solicitando permiso para acceder a la cámara")
83// Aquí podrías mostrar un diálogo o una UI que explique por qué se necesita el permiso
84 }
85 }
86}
Deberás añadir la siguiente dependencia para simplificar la creación y uso de ViewModel al build.gradle.kts (Module :app). Recuerda sincronizar el proyecto para que surta efecto.
Como habrás observado, ViewModel centraliza todo el estado de los permisos (concedido, con justificación, denegado permanentemente).
El Composable observa ese estado con collectAsState() y lanza acciones según el estado:
Solicitar permiso.
Mostrar explicaciones.
Abrir ajustes del sistema si hay denegación permanente.
Ejecutar acceso a la cámara cuando esté concedido.
viewModel() es la forma recomendada en Compose para instanciar ViewModels vinculados al ciclo de vida (debes incluir la biblioteca).
No necesitas hacer nada en el Activity ni pasar parámetros adicionales.
Asegura la consistencia del estado y facilitando además la integración con el patrón MVVM y clean architecture.
3.3. Intents implícitos
Pero, para abrir los Intents implícitos, se sigue utilizando la clase Intent, a continuación, se muestran algunos ejemplos sencillos, empezando por aquellos que no requieren permiso del usuario.
3.3.1. Abrir una URL en el navegador del dispositivo
En este es necesario establecer en el Manifest la queries para indicar que la aplicación va a necesitar un navegador web.
1<?xml version="1.0" encoding="utf-8"?> 2<manifestxmlns:android="http://schemas.android.com/apk/res/android"> 3 4<!-- Comprueba que existe un navegador en el sistema --> 5<queries> 6<intent> 7<actionandroid:name="android.intent.action.VIEW"/> 8<categoryandroid:name="android.intent.category.BROWSABLE"/> 9<dataandroid:scheme="https"/>10</intent>11</queries>1213<application...></application>14</manifest>
El código para lanzar el Intent podría ser como el que se muestra a continuación.
1Button(
2 onClick = {
3Log.d("DEBUG", "Botón pulsado")
4 5// Intent para abrir un navegador web
6 Intent(Intent.ACTION_VIEW, "https://www.javiercarrasco.es".toUri()).apply {
7if (this.resolveActivity(ctxt.packageManager) !=null)
8 ctxt.startActivity(this)
9elseLog.d("DEBUG", "Hay un problema para encontrar un navegador.")
10 }
11 },
12 modifier = Modifier
13 .padding(8f.dp)
14 .fillMaxWidth()
15) {
16 Text("Abrir navegador")
17}
Puedes refactorizar el código y llevarte el código para crear el Intent en un método a parte, al que se le pase el contexto y la URL que quieras abrir.
1funopenWebPage(ctxt: Context, url: String) {
2// Intent para abrir un navegador web
3 Intent(Intent.ACTION_VIEW, url.toUri()).apply {
4 addCategory(Intent.CATEGORY_BROWSABLE) // Añade categoría para navegadores.
5 flags = Intent.FLAG_ACTIVITY_NEW_TASK // Asegura que se abra en una nueva tarea.
6if (this.resolveActivity(ctxt.packageManager) !=null)
7 ctxt.startActivity(this)
8elseLog.d("DEBUG", "Hay un problema para encontrar un navegador.")
9 }
10}
3.3.2. Marcar un número de teléfono
Este no necesita crear una query, se entiende que el dispositivo está preparado, y no necesita solicitar al usuario permiso explícito.
1funopenDialer(ctxt: Context, phoneNumber: String) {
2// Intent para abrir la aplicación de teléfono
3 Intent(Intent.ACTION_DIAL, "tel:$phoneNumber".toUri()).apply {
4 addCategory(Intent.CATEGORY_DEFAULT) // Añade categoría por defecto
5 flags = Intent.FLAG_ACTIVITY_NEW_TASK // Asegura que se abra en una nueva tarea
6 ctxt.startActivity(this)
7 }
8}
3.3.3. Abrir una aplicación de mapas
Tampoco requiere query ni permiso específico ya que no se está utilizando geolocalización, para lo que sí sería necesario.
1funopenMap(ctxt: Context, geo: String) { // geo: "geo:0,0?q=Alicante"
2// Intent para abrir la aplicación de teléfono
3 Intent(Intent.ACTION_VIEW, geo.toUri()).apply {
4 addCategory(Intent.CATEGORY_DEFAULT) // Añade categoría por defecto
5 flags = Intent.FLAG_ACTIVITY_NEW_TASK // Asegura que se abra en una nueva tarea
6 ctxt.startActivity(this)
7 }
8}
3.3.4. Escribir un correo electrónico
En primer lugar se creará la siguiente query en el Manifest.
1<!-- Comprueba que existe una aplicación de correo electrónico -->2<queries>3<intent>4<actionandroid:name="android.intent.action.SENDTO"/>5<categoryandroid:name="android.intent.category.DEFAULT"/>6<dataandroid:scheme="mailto"/>7</intent>8</queries>
Un posible método para componer un correo podría ser como el siguiente.
1funcomposeMail(ctxt: Context, email: String, subject: String, body: String) {
2// Intent para enviar un correo electrónico
3 Intent(Intent.ACTION_SENDTO).apply {
4data = "mailto:".toUri() // Asegura que solo se manejen aplicaciones de correo
5 putExtra(Intent.EXTRA_EMAIL, arrayOf(email)) // Destinatario del correo
6// putExtra(Intent.EXTRA_CC, arrayOf(emailsCC)) // Destinatarios en copia (opcional)
7 putExtra(Intent.EXTRA_SUBJECT, subject)
8 putExtra(Intent.EXTRA_TEXT, body)
910 addCategory(Intent.CATEGORY_DEFAULT) // Añade categoría por defecto
11 flags = Intent.FLAG_ACTIVITY_NEW_TASK // Asegura que se abra en una nueva tarea
1213if (this.resolveActivity(ctxt.packageManager) !=null)
14 ctxt.startActivity(Intent.createChooser(this, "Enviar correo..."))
15elseLog.d("DEBUG", "Hay un problema para enviar el correo electrónico.")
16 }
17}
3.3.5. Crear una alarma
El siguiente ejemplo necesita establecer el permiso correspondiente para poder crear una alarma en el despertador, en el manifest deberás añadir la siguiente línea. Este permiso está catalogado como normal, por tanto no se necesita pedir permiso al usuario.
También habrá que añadir la query para buscar el tipo de aplicación necesaria.
1<!-- Comprueba que existe una aplicación para establecer alarmas -->2<queries>3<intent>4<actionandroid:name="android.intent.action.SET_ALARM"/>5<categoryandroid:name="android.intent.category.DEFAULT"/>6</intent>7</queries>
Un posible método para establecer una alarma en la aplicación de reloj podría ser el siguiente.
1funsetAlarm(ctxt: Context, mensaje: String, hora: Int, minuto: Int) {
2Log.d("SetAlarm", "Estableciendo alarma: $mensaje a las $hora:$minuto")
3 4 Intent(AlarmClock.ACTION_SET_ALARM).apply {
5 putExtra(AlarmClock.EXTRA_MESSAGE, mensaje)
6 putExtra(AlarmClock.EXTRA_HOUR, hora)
7 putExtra(AlarmClock.EXTRA_MINUTES, minuto)
8 9if (this.resolveActivity(ctxt.packageManager) !=null) {
10 ctxt.startActivity(this)
11 } else {
12Log.d("DEBUG", "Hay un problema para establecer la alarma.")
13Toast.makeText(
14 ctxt,
15"No se pudo establecer la alarma, comprueba que tienes una aplicación de reloj instalada.",
16Toast.LENGTH_LONG
17 ).show()
18 }
19 }
20}
A continuación, se muestra el uso de otro Intent que sí requieren permiso del usuario, haciendo uso de la clase vista en el punto anterior. Recuerda añadir la dependencia "androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1" al Gradle.
3.3.6. Realizar una llamada telefónica
El siguiente Intent, a diferencia del anterior, sí requiere permiso expreso por parte del usuario, ya que se va a producir una acción considerada peligrosa.
En primer lugar habrá que indicar en el Manifest el uso del permiso en cuestión, y la necesidad del componente hardware necesario para realizar la acción.
La adaptación del método para realizar la llamada, controlando el estado de los permisos podría quedar como se muestra a continuación.
1@Composable 2funCallPhone(phoneNumber: String, viewModel: PermissionHandlerViewModel = viewModel()) {
3valpermissionState =
4 viewModel.uiState.collectAsState() // Obtiene el estado del permiso desde el ViewModel.
5valctxt = LocalContext.current
6// Este callback se usa para solicitar el permiso de cámara.
7valrequestPermission =
8 rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> 9 viewModel.onPermissionResult(
10 granted, ActivityCompat.shouldShowRequestPermissionRationale(
11 ctxt as Activity, Manifest.permission.CALL_PHONE
12 )
13 )
14 }
1516// Se observa el estado del permiso y actuamos en consecuencia.
17 LaunchedEffect(permissionState) {
18when {
19 permissionState.value.granted -> {
20// Aquí abrimos la cámara; por simplicidad indicamos con un log
21Log.d("CallPermission", "Acceso a llamar concedido")
22 }
2324else-> {
25// Primer lanzamiento: solicitamos el permiso
26 requestPermission.launch(Manifest.permission.CALL_PHONE)
27 }
28 }
29 }
3031// Aquí se muestra la UI dependiendo del estado del permiso.
32when {
33 permissionState.value.granted -> {
34 Text("Pulsa el botón para abrir un intent")
35 Button(
36 onClick = {
37Log.d("DEBUG", "Botón pulsado")
3839// Intent para realizar una llamada telefónica
40 Intent(Intent.ACTION_CALL, "tel:$phoneNumber".toUri()).apply {
4142 addCategory(Intent.CATEGORY_DEFAULT) // Añade categoría por defecto
43 flags = Intent.FLAG_ACTIVITY_NEW_TASK // Asegura que se abra en una nueva tarea
4445// Nota: ACTION_CALL requiere el permiso CALL_PHONE en el manifiesto
46if (this.resolveActivity(ctxt.packageManager) !=null)
47 ctxt.startActivity(this)
48elseLog.d("DEBUG", "Hay un problema para realizar la llamada.")
49 }
50 },
51 modifier = Modifier.padding(8.dp).fillMaxWidth()
52 ) {
53 Text(text = "Realizar llamada telefónica")
54 }
55 }
5657 permissionState.value.showRationale -> {
58 Text("Se necesita acceso para realizar llamadas telefónicas")
59// Solicitar nuevamente el permiso.
60 Button(
61 onClick = { // Se solicita el permiso de llamada telefónica.
62 requestPermission.launch(Manifest.permission.CALL_PHONE)
63 },
64 modifier = Modifier.padding(8.dp).fillMaxWidth(),
65 colors = ButtonDefaults.buttonColors(containerColor = Color.Red)
66 ) {
67 Text("Solicitar permiso")
68 }
6970Toast.makeText(
71 ctxt,
72"Es necesario tener acceso para realizar llamadas telefónicas",
73Toast.LENGTH_LONG
74 ).show()
75 }
7677 permissionState.value.permanentlyDenied -> {
78 Text("Permiso denegado permanentemente")
79 Button(
80 onClick = { // Se abren los ajustes de la aplicación para que el usuario pueda conceder el permiso manualmente.
81 ctxt.startActivity(
82 Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
83data = Uri.fromParts("package", ctxt.packageName, null)
84 }
85 )
86 }, modifier = Modifier.padding(8.dp).fillMaxWidth()
87 ) {
88 Text("Abrir ajustes")
89 }
90 }
9192else-> {
93 Text("Solicitando permiso para realizar llamadas telefónicas")
94// Aquí podrías mostrar un diálogo o una UI que explique por qué se necesita el permiso
95 }
96 }
97}
3.4. Creación y navegación entre actividades
Jetpack Compose apuesta por el uso de una sola Activity con múltiples pantallas (Composable), pero, en algunas situaciones es útil o necesario utilizar varias actividades: interoperabilidad con vistas heredadas, flujos aislados o necesidades de integración específicas.
3.4.1. Crear nuevas Activity
En Android, una actividad representa una pantalla completa. Crear una nueva Activity en un proyecto con Jetpack Compose es algo más sencillo que hacerlo en el sistema basado en vistas, ya que no tienes que crear el XML que la represente. El primer paso será crear una nueva clase. Puedes utilizar la opción File > New > Compose > Empty Activity. Tras eliminar algo de boilerplate code, podría quedar así.
Conocer las alternativas de navegación en Android: actividades múltiples vs pantallas en Compose.
Aprender a crear y vincular actividades, enviar datos y recibir resultados.
Introducción a Navigation Compose: NavHost, NavController, rutas y argumentos.
Gestionar la navegación con ViewModel en el contexto de MVVM y Clean Architecture.
Comparar cuándo elegir múltiples actividades o una sola actividad con varias pantallas.
4.1. Introducción a Navigation Compose
Navigation Compose es una extensión de la Jetpack Navigation Architecture Component, está adaptada para trabajar con Jetpack Compose, y permite gestionar la navegación entre pantallas o destinos (screens) de forma declarativa, simplificando así el manejo de la pila de navegación y mejorando la estructura del proyecto.
Gracias a la integración con Jetpack Compose, Navigation Compose facilita la navegación entre diferentes Composables sin necesidad de usar múltiples actividades o fragmentos, algo muy habitual en aplicaciones tradicionales que utilizan vistas basadas en XML.
4.1.1. Beneficios frente a múltiples actividades
El uso de Navigation Compose frente al uso de múltiples actividades ofrece varias ventajas:
Menor complejidad del proyecto
En vez de tener múltiples Activity, todo el flujo de navegación se maneja desde una única Activity que contiene los Composable destino.
Esto reduce la complejidad del proyecto y mejora la mantenibilidad del código.
Facilita la gestión del estado compartido entre pantallas dentro de una misma Activity, lo cual es más complicado cuando se usan múltiples actividades.
Ambas pantallas compartirán el mismo SharedViewModel:
Cuando tienes múltiples Activity, compartir datos entre ellas requiere serializar objetos, usar Bundle, Intent, o incluso patrones como ViewModel compartidos con un alcance específico.
Comparativa rápida
Característica
Múltiples Activity
Navigation Compose
Cantidad de archivos
Mayor
Menor
Gestión del estado
Compleja
Más simple
Velocidad de navegación
Más lenta
Más rápida
Consumo de recursos
Alto
Bajo
Integración con Jetpack Compose
No nativa
Integación total
4.2. Definición de destinos (Composable)
En Jetpack Compose, los destinos (Composables) son las pantallas o vistas a las que se puede navegar dentro de una aplicación. La navegación se gestiona mediante la librería Navigation Component, pero adaptada para Compose, para ello se añadirá la dependencia al Gradle.
Un destino se define dentro del NavHost, asociado a una ruta de navegación. Cada destino puede mostrar un Composable diferente, en el punto anterior ya se ha podido ver.
NavHost
Es el contenedor que gestiona los destinos de navegación.
Define qué Composable deberá mostrarse según la ruta.
Se crea mediante el componente NavHost proporcionado por la librería androidx.navigation:navigation-compose.
Este ejemplo define dos destinos: “home” y “detail”, cada uno mostrando un Composable diferente.
4.2.1. Rutas con parámetros
Las rutas con parámetros permiten navegar a destinos dinámicos, pasando valores como parte de la ruta. Esto es útil para mostrar información específica, como un producto, usuario o noticia.
Rutas
Cada pantalla (destino) debe tener una ruta asociada, que será un identificador único (string).
Se pueden utilizar rutas simples como “home” o con parámetros como “details/{id}”.
Sintaxis de ruta básica:
1composable("aboutit"){ AboutIt() }
Sintaxis de ruta con parámetro:
1composable("detail/{id}")
Este ejemplo muestra el parámetro {id}, que es dinámico y podrá recuperarse dentro del Composable.
Navegar pasando el parámetro ID:
1// Desde una pantalla anterior
2navController.navigate("detail/123")
Identificadores únicos que representan cada pantalla.
4.2.2. Paso de datos entre pantallas
El paso de datos entre pantallas es algo muy común en las aplicaciones. En Jetpack Compose, esto se puede hacer de varias formas, pero la más común es mediante parámetros en la ruta o usando argumentos explícitos.
1@Composable2funDetailScreen(navBackStackEntry: NavBackStackEntry) {
3validProducto = navBackStackEntry.arguments?.getString("id")
45 Text(
6 text = "Mostrando detalles del producto con ID: $idProducto",
7 modifier = Modifier.padding(16.dp)
8 )
9}
Pantalla “Acerca de”:
1@Composable2funAboutIt() {
3 Text(
4 text = "App creada por Javier Carrasco para la documentación de T4.2",
5 modifier = Modifier.padding(16.dp)
6 )
7}
El NavHost en el método ComposableNavigation() quedaría así:
1@Composable 2funNavigation() {
3// Aquí se definirían las rutas de navegación.
4// Por ejemplo, usando NavHost y composable.
5valnavController: NavHostController = rememberNavController()
6 7 NavHost(navController = navController, startDestination = "home") {
8 composable("home") { HomeScreen(navController) }
9 composable("aboutit"){ AboutIt() }
10 composable("detail/{id}") { backStackEntry ->11// Aquí se recibe el parámetro id de la ruta.
12validProducto = backStackEntry.arguments?.getString("id")
13// Se puede usar el idProducto para mostrar detalles específicos.
14 idProducto?.let {
15 DetailScreen(backStackEntry)
16 }
17 }
18 }
19}
Información
Se utiliza idProducto?.let para comprobar que se pasa el parámetro y no sea nulo.
Por último, la clase MainActivity podría quedar así:
Si buscas más separación entre destinos cuando la complejidad de estos aumenta, puedes llevarte los métodos HomeScreen() y DetailsScreen() a ficheros separados.
Resumen rápido
Concepto
Descripción
Destino (composable)
Es una pantalla que se muestra al navegar, definida con composable("ruta").
Ruta con parámetro
Se define como "ruta/{param}" y se recupera con backStackEntry.arguments.
Paso de datos
Se realiza a través de parámetros en la ruta o mediante argumentos extras.
Integración
Se puede usar en MVVM para cargar datos desde ViewModel, ROOM o Retrofit2 (se verá en próximos temas).
4.3. Navegación controlada con NavController
Como ya se ha comentado, la navegación en Jetpack Compose entre pantallas se gestiona mediante la librería Navigation Compose, la cual permite crear una jerarquía de pantallas y navegar entre ellas de forma sencilla.
Para controlar la navegación, se utiliza el objeto NavController, que da acceso a métodos como navigate(), popBackStack() o navigateUp(). Estos métodos permiten gestionar la pila de navegación y el comportamiento del botón de retroceso del dispositivo.
4.3.1. navigate(), popBackStack(), navigateUp()
navigate()
El método navigate() se utiliza para ir de una pantalla a otra. Primero se deberá definir las rutas de navegación, y luego usar navigate() pasando la ruta destino. Ya has tenido contacto con este método en puntos anteriores.
Recuerda añadir el composable para Pantalla2 en el NavHost.
navigateUp()
Este método funciona de forma similar a popBackStack(), pero se usa generalmente cuando hay una jerarquía anidada de pantallas, como en pantallas de detalles o en navegación por pestañas.
navigateUp() puede no funcionar si no estás en una ruta con padre definido. Para la mayoría de casos, popBackStack() es suficiente.
4.3.2. Manejo del stack y el botón “atrás”
El stack de navegación es como una pila donde se van guardando las pantallas por las que se pasan. Cada vez que se utiliza navigate(), la nueva pantalla se apila encima. Al pulsar el botón “atrás”, se desapila la última pantalla.
Ejemplo del stack:
Inicialmente: pantalla1.
Navegas a pantalla2: el stack es [pantalla1, pantalla2].
Pulsas atrás: se elimina pantalla2 y vuelves a pantalla1.
Personalizar el comportamiento del botón “atrás”:
Por defecto, Android gestiona el botón de retroceso con el stack de navegación. Pero si quieres hacer algo especial al pulsarlo (como mostrar un diálogo antes de salir), se puede usar BackHandler para modificar el comportamiento básico.
Ejemplo:
1@Composable 2funPantalla2(navController: NavController) {
3valctxt = LocalContext.current
4 5// Manejo del botón de retroceso
6 BackHandler {
7// Aquí se define lo que ocurre al pulsar atrás
8 navController.popBackStack()
910Toast.makeText(ctxt, "Volviendo a pantalla 1", Toast.LENGTH_SHORT).show()
11Log.d("Pantalla2", "Back button pressed")
12 }
1314/* ... */15}
Información
BackHandler permite interceptar el evento del botón “atrás” y definir un comportamiento personalizado.
Limpiar el stack de navegación:
En algunos casos, puede ser necesario limpiar el stack de navegación para evitar que el usuario pueda volver a ciertas pantallas. Esto se puede hacer usando la sobrecarga de navigate() con popUpTo.
Esta sobrecarga permite eliminar todas las pantallas anteriores a pantalla1 de la pila de navegación, por lo que pulsar atrás no volverá a ellas, sino que saldrá de la aplicación o volverá a la pantalla principal.
Resumen de los métodos
Método
Función
navigate(route)
Va a otra pantalla.
popBackStack()
Vuelve a la pantalla anterior.
navigateUp()
Vuelve a la pantalla padre (si existe jerarquía).
BackHandler {}
Controla el botón de retroceso del dispositivo.
Con NavController y sus métodos, tendrás el control total sobre la navegación de la app. Esto es fundamental cuando trabajas con arquitecturas como MVVM o Clean Architecture, ya que la navegación puede estar controlada desde el ViewModel.
En aplicaciones Android modernas que usan arquitecturas como MVVM (Modelo-Vista-ViewModel), es fundamental gestionar correctamente el estado de la navegación y compartirlo entre pantallas cuando sea necesario.
En Jetpack Compose se puede usar ViewModel para mantener el estado de la aplicación, incluso para cambiar de pantalla. Esto permite evitar que se pierdan datos al navegar o al rotar la pantalla.
4.4.1. Compartir estado entre pantallas
Imagina que tienes una pantalla de formulario (FormScreen) y otra de resumen (SummaryScreen). Quieres que los datos introducidos en el formulario estén disponibles en la pantalla de resumen.
Usar un ViewModel compartido
Un ViewModel puede ser compartido entre pantallas si se crea en un ámbito (scope) común, como el de la navegación completa.
Además de la librería de Navigation Compose será necesaria la librería para la gestión del ciclo de vida y el uso de ViewModel.
En algunas ocasiones no se necesita que el ViewModel esté compartido entre todas las pantallas, sino que su alcance (scope) esté limitado a un destino específico de la navegación.
Jetpack Compose permite asociar un ViewModel a un destino específico usando el viewModel() dentro del composable.
¿Cuándo hacerlo? Cuando se necesita que el estado solo exista mientras se está en esa pantalla, y que se reinicie si se vuelve a ella más tarde.
Información
Nunca debe pasarse al ViewModel parámetros sin crear ViewModelProvider.Factory o añadir inyección de dependencias con Hilt.
Versión ViewModelProvider.Factory
Se crea la clase DetalleViewModel que extenderá de ViewModel() y se añade el factory.
1classDetalleViewModel(id: String) : ViewModel() {
2valitemId = id
3valcontenido = mutableStateOf("Contenido del ítem $itemId")
4}
5 6classDetalleViewModelFactory(privatevalid: String) : ViewModelProvider.Factory {
7overridefun <T : ViewModel> create(modelClass: Class<T>): T {
8@Suppress("UNCHECKED_CAST")
9return DetalleViewModel(id) as T
10 }
11}
Se crea la nueva ruta con composable, haciendo uso del factory creado en el NavHost.
1composable("detalle/{id}") { backStackEntry ->2valid = backStackEntry.arguments?.getString("id") ?:"default"// Valor por defecto si no se pasa un ID.
3valfactory = DetalleViewModelFactory(id) // Crear una instancia del ViewModel con el ID recibido.
4valdetalleViewModel: DetalleViewModel = viewModel(factory = factory) // Usar el factory para crear el ViewModel.
56 DetalleScreen(detalleViewModel)
7}
Se hace la llamada desde un botón (por ejemplo).
1Button(
2 onClick = {
3// Navegar a la pantalla de detalle con un ID ficticio
4 navController.navigate("detalle/321")
5 }
6) { Text("Detalle item") }
Se creará una aplicación con tres pantallas (Home, Detalle, Configuración) que permita navegar entre ellas utilizando botones. Además, desde Configuración, se podrá volver a Home eliminando del stack a Detalle.
Configuración del proyecto
En primer lugar, deberás añadir al build.gradle.kts (Module :app) la librería Navigation Compose. Recuerda sincronizar.
La diferencia entre los Composables anteriores está en el navigate(), popUpTo() se encarga de limpiar la pila, puedes probarlo con el botón atrás cuando estés nuevamente en Home, la aplicación se cerrará en lugar de volver a la pantalla anterior.
MainActivity
La clase MainActivity podría quedar como se muestra a continuación.
Ejemplo práctico 2: Scope adecuado del ViewModel por destino con HILT
Objetivo
Se reproducirá la aplicación de ejemplo planteada en la documentación (punto 4.4.2.) utilizando inyección de dependencias con HILT.
Configuración del proyecto
La configuración de un proyecto Android Studio que haga uso de HILT es algo más compleja que añadir una simple librería, pero no es un inconveniente teniendo en cuenta la ayuda que proporciona. En primer lugar deberás añadir los plugins de KSP y HILT al build.gradle.kts (Project: ...) y sincronizar Gradle.
1plugins {2...3 id("com.google.devtools.ksp") version "2.0.21-1.0.28" apply false// KSP for annotation processing, used by libraries like Hilt.
4 id("com.google.dagger.hilt.android") version "2.57" apply false// Hilt for dependency injection.
5}
¿Qué es KSP? Es una herramienta que permite procesar anotaciones (@) de forma más eficiente que la anterior (KAPT) ya que está optimizada para Kotlin.
¿Cómo saber que versión utilizar? Para saber que versión debes utilizar, tendrás que consultar la URL, en la que deberás buscar tu versión de Kotlin según el archivo del proyecto libs.versions.toml en la propiedad kotlin. Por ejemplo, en este caso la versión es kotlin = "2.0.21", que coincide con el primer valor del plugin.
!> Cuidado con actualizar la versión de Kotlin, también deberás actualizar la versión del pluginKSP.
Ahora, en el build.gradle.kts (Module :app), en la sección de plugins añade los siguientes plugins. No sincronices todavía, no pasa nada, pero te dirá que falta la dependencia de HILT.
La anotación @HiltAndroidApp crea un contenedor de dependencias global asociado al ciclo de vida de la aplicación, permitiendo que cualquier Activity, Fragment, etc, pueda recibir dependencias de Hilt. Dicho de otro modo, convierte la aplicación en el punto central en el que HILT configura e inyecta las dependencias necesarias para el proyecto.
Ahora, para que sea la primera clase en crearse al lanzar la aplicación, deberás registrala en el AndroidManifest.xml.
La clase MainActivity podría quedar como se muestra a continuación. Observa la anotación @AndroidEntryPoint, esta le indica a Hilt que una clase de Android (como Activity, Fragment, View, etc.) será un punto de entrada para la inyección de dependencias. Básicamente habilita la inyección automática de dependencias en una clase de Android, gestionando su ciclo de vida y las instancias necesarias.
Entender la separación de responsabilidades en aplicaciones Android.
Implementar el patrón MVVM en proyectos Android con Jetpack Compose.
Integrar Clean Architecture con componentes como Room, Retrofit2 y ViewModel.
Aprender a usar corutinas de Kotlin para realizar operaciones asíncronas.
Aplicar inyección de dependencias con Koin/Hilt.
Realizar pruebas unitarias básicas en capas lógicas.
5.1. ¿Qué es MVVM?
MVVM (Model-View-ViewModel) es un patrón de arquitectura que separa la lógica de la interfaz de usuario (UI).
Componentes principales:
Modelo (Model): representará la capa de datos o lógica de negocio. Únicamente contendrá la información, no habrán métodos o acciones que manipulen los datos y, no tendrá ninguna dependencia de la vista.
Vista (View): será la parte encargada de representar la información al usuario. En el patrón MVVM, las vistas son activas, reaccionando a eventos o cambios de los datos (Jetpack Compose en este caso).
Modelo de vista (ViewModel): es el intermediario entre el modelo y la vista, mantiene el estado de la UI y contiene la lógica de negocio y abstracción de la interfaz. El enlace con la vista se realizará mediante el enlace de datos.
Ejemplo básico con Jetpack Compose:
Como se hace uso de viewModel() será necesaria añadir la siguiente dependencia:
1// ViewModel dependencies for Compose
2implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
Contador básico:
1classCounterViewModel : ViewModel() {
2// Técnica de backing con StateFlow para manejar el estado del contador.
3privateval_count = MutableStateFlow(0) // MutableStateFlow para el estado del contador.
4valcount: StateFlow<Int> = _count // Exponer el estado como StateFlow para que pueda ser observado por la UI.
5 6funincrement() {
7 _count.value++ 8 }
9}
1011@Composable12funCounterScreen(viewModel: CounterViewModel = viewModel()) {
13valcountby viewModel.count.collectAsState()
14 Column {
15 Text("Contador: $count")
16 Button(onClick = viewModel::increment) {
17 Text("Incrementar")
18 }
19 }
20}
5.2. Separación de responsabilidades
View (Compose): Dibuja la UI, no contiene lógica.
ViewModel: Gestiona el estado y eventos de la UI.
Repository: Interactúa con fuentes de datos (API, BD).
Model/Domain: Reglas de negocio independientes del contexto.
5.3. ViewModel, Flow y LiveData
ViewModel
Se encarga de almacenar el estado de la UI y sobrevive a cambios de configuración (rotación de pantalla por ejemplo).
LiveData vs. Flow
LiveData: Observa los cambios en la UI (solo emite en contexto de Android).
Si además de la aplicación del patrón MVVM, se aplican conceptos de Clean Architecture se conseguirá mayor independencia entre módulos y proyectos más compactos. El uso de Clean Architecture se basa en la estructuración del código por capas, donde cada una de estas capas se comunicará con sus capas más cercanas. Además, cada una de estas capas tendrá un único objetivo, separando responsabilidades. Esta combinación permitirá soportar el crecimiento de la aplicación de manera más fiable.
Las capas comunes de Clean Architecture son:
Presentación, esta es la capa que interactúa directamente con el usuario.
Casos de uso, capa que suele contener las acciones que el usuario puede activar.
Dominio, contiene la lógica de negocio, suele contener los modelos, por ejemplo las clases SuperHero o Editorial.
Datos, esta capa contiene las definiciones de la fuente de datos y cómo se utilizará. Puede no limitarse a una única fuente de datos. Se suele utilizar el patrón repositorio para decidir que fuente de datos (DataSource) utilizar.
Framework, esta capa define las distintas fuentes de datos, por ejemplo, Room en modo local o una API de forma remota.
El diagrama clásico que representa la Clean Architecture creado por Robert C. Martin es posible que ya lo hayas visto.
5.4.1. Capas: Presentation, Domain, Data
Evidentemente, puede hacerse una libre interpretación de la arquitectura, eliminando capas, unificando, etc. Esto es así porque no es realmente una arquitectura como tal, sino una guía con recomendaciones a seguir. En Android es muy común unificar las capas, lo que además permitirá simplificar el modelo.
Capa presentación, donde se aplicará MVVM.
Capa dominio, contendrá el modelo de negocio y los casos de uso.
Capa datos, se utilizará el modelo repositorio y el acceso a datos.
La comunicación entre todos los componentes de las capas será la siguiente.
5.5. Uso de corutinas
Ya se ha hecho un uso básico de ellas en puntos anteriores, a grandes rasgos, manejan tareas asíncronas sin bloquear el hilo principal, engargado de gestionar la UI.
Más adelante se hará un uso más detallado de ellas.
5.6. Pruebas unitarias básicas
En este punto se tratará de mostrar una prueba “básica” para evaluar el método fetchData() de la clase FlowViewModel(). En concreto se crearán pruebas utilizando JUnit, kotlinx-coroutines-test y Turbine (para probar Flow).
JUnit ya se encuentra añadida por defecto en el Gradle de los proyectos de Android Studio, por lo que habrá que añadir las dos que faltan al build.gradle.kts (Module: app).
1// Coroutines y para pruebas
2androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")34// Para utilizar InstantTaskExecutorRule
5androidTestImplementation("androidx.arch.core:core-testing:2.2.0")67// Para Turbine (test de Flows, test, awaitItem, cancelAndConsumeRemainingEvents)
8androidTestImplementation("app.cash.turbine:turbine:1.2.1")
Información
Turbine es clave para probar Flow de forma sencilla. Permite recolectar valores emitidos y hacer aserciones.
Se creará una clase en el directorio destinado a los androidTest.
1importandroidx.arch.core.executor.testing.InstantTaskExecutorRule 2importapp.cash.turbine.test 3importkotlinx.coroutines.Dispatchers 4importkotlinx.coroutines.ExperimentalCoroutinesApi 5importkotlinx.coroutines.test.* 6importorg.junit.* 7importorg.junit.Assert.assertEquals 8 9// FlowViewModelTest.kt
1011@OptIn(ExperimentalCoroutinesApi::class)
12classFlowViewModelTest {
13// Regla para ejecutar tareas en el hilo inmediato (sincroniza LiveData)
14@get:Rule
15valinstantTaskExecutorRule = InstantTaskExecutorRule()
1617// Disponemos de un TestDispatcher para controlar el tiempo
18privatelateinitvartestDispatcher: TestDispatcher
19privatelateinitvarviewModel: FlowViewModel
2021@Before22funsetUp() {
23 testDispatcher = UnconfinedTestDispatcher() // Permite controlar corutinas
24Dispatchers.setMain(testDispatcher)
25 viewModel = FlowViewModel()
26 }
2728@After29funtearDown() {
30Dispatchers.resetMain()
31 }
3233@Test// Cuando se llama a fetchData, emite nuevo valor tras 1 segundo.
34funtestCallfetchData() = runTest {
35// GIVEN: Estado inicial es 0
36 assertEquals(0, viewModel.uiState.value)
3738// WHEN: Se llama a fetchData
39 viewModel.fetchData()
4041// THEN: Aún no se ha emitido nada (por el delay de 1 segundo)
42 assertEquals(0, viewModel.uiState.value)
4344// Avanzamos el tiempo virtual
45 advanceTimeBy(1100) // Simula algo más de 1 segundo
4647// Verificamos que el valor cambió a 10
48 assertEquals(10, viewModel.uiState.value)
49 }
5051@Test// uiState emite valores correctamente con Turbine.
52funtestUiStateTurbine() = runTest {
53// GIVEN: Recolectamos el Flow con Turbine
54 viewModel.uiState.test {
55// THEN: Primer valor emitido debe ser 0
56 assertEquals(0, awaitItem())
5758// WHEN: Llamamos a fetchData
59 viewModel.fetchData()
6061// Y avanzamos el tiempo
62 advanceTimeBy(1000)
6364// THEN: Debe emitir 10
65 assertEquals(10, awaitItem())
6667// Finalizamos la recolección
68 cancelAndConsumeRemainingEvents()
69 }
70 }
7172@Test// fetchData puede llamarse múltiples veces y suma correctamente.
73funtestCallfetchDataMultipleTurbine() = runTest {
74 viewModel.uiState.test {
75 assertEquals(0, awaitItem())
7677// Primera llamada
78 viewModel.fetchData()
79 advanceTimeBy(1000)
80 assertEquals(10, awaitItem())
8182// Segunda llamada
83 viewModel.fetchData()
84 advanceTimeBy(1000)
85 assertEquals(20, awaitItem())
86 }
87 }
88}
Resumen del test
runTest: Reemplaza a runBlocking. Permite controlar el tiempo con advanceTimeBy().
testDispatcher: Simula el lanzamiento de corutinas sin depender del tiempo real.
viewModel.uiState.test { ... }: Con Turbine, se puede recolectar los valores emitidos por el Flow.
awaitItem(): Espera a que se emita un valor (ideal para pruebas asíncronas).
advanceTimeBy(1000) : Simula que han pasado 1000 ms, haciendo que delay(1000) termine.
Entender el propósito de ROOM como solución de persistencia local en Android.
Crear entidades , DAOs y una base de datos usando ROOM.
Integrar ROOM con el patrón MVVM y el componente ViewModel .
Realizar consultas básicas y avanzadas en ROOM.
Gestionar migraciones simples al evolucionar la base de datos.
6.1. Introducción a ROOM
ROOM es una biblioteca de persistencia oficial de Android (parte de Jetpack) que facilita el acceso a la base de datos SQLite. Proporciona una capa de abstracción sobre SQLite, permitiendo escribir consultas de forma más segura y con menos código que usando SQLiteDatabase directamente.
Ventajas de Room:
Tipado seguro: Detecta errores en tiempo de compilación.
Integración con LiveData y Coroutines: Perfecto para aplicar el patrón MVVM.
Sin boilerplate: No se necesita escribir manualmente ContentValues, Cursor, etc y otro código innecesario o redundante.
Migraciones controladas: Facilita el cambio de versiones de la base de datos.
Información
ROOM no se ejecuta en el hilo principal de la aplicación (UI). Siempre deberán utilizarse Coroutines, LiveData y/o Flow para evitar ANR (Application Not Responding).
6.2. Configuración de ROOM en el proyecto
En primer lugar, se añadirá el complemento KSP en el archivo build.gradle.kts (Project:), alineando la versión de KSP con la versión de Kotlin del proyecto. Puedes encontrar una lista de las actualizaciones en la página de GitHub de KSP.
1plugins {2...3 id("com.google.devtools.ksp") version "2.2.0-2.0.2" apply false4}
A continuación, habilita KSP en el archivo build.gradle.kts (Module :app) a nivel del módulo:
1plugins {2...3 id("com.google.devtools.ksp")4}
Para terminar, seguiendo con el archivo build.gradle.kts (Module :app), se añadirán las siguientes dependencias para poder hacer uso de ROOM.
1// ROOM dependencies
2implementation("androidx.room:room-runtime:2.7.2") 3implementation("androidx.room:room-ktx:2.7.2")// Soporte para Coroutines y Kotlin Extensions.
4ksp("androidx.room:room-compiler:2.7.2")// KSP para procesamiento de anotaciones.
5 6// ViewModel y LiveData
7implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.2") 8implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2") 9implementation("androidx.compose.runtime:runtime-livedata:1.9.0")1011// Navigation Compose
12implementation("androidx.navigation:navigation-compose:2.9.3")
Recuerda sincronicar el Gradle con cada paso.
6.3. Modelo
Para simplificar, se omite el dominio y directamente se creará un packagemodel para alojar las data classes que representarán las tablas.
La siguiente clase representa la relación entre SuperHero y Editorial con cardinalidad N:1. Observa que las clases que representan una relación no llevan la etiqueta @Entity.
@Embedded se utiliza para incluir los campos de la entidad SuperHero en el objeto SuperWithEditorial.
@Relation se utiliza para definir la relación entre SuperHero y Editorial, especificando las columnas que se utilizan para enlazarlas.
6.4. Creación del DAO
El DAO(Data Access Object) define las operaciones que pueden realizarse sobre la base de datos (CRUD). Siguiendo con la ordenación según la Clean Architecture se creará el packagedata, dentro se creará la interface para el DAO.
1importandroidx.lifecycle.LiveData 2importandroidx.room.Dao 3importandroidx.room.Delete 4importandroidx.room.Insert 5importandroidx.room.OnConflictStrategy 6importandroidx.room.Query 7importandroidx.room.Transaction 8importkotlinx.coroutines.flow.Flow 910// SupersDAO.kt
11@Dao12interfaceSupersDAO {
13// Versión de consultas que devuelven un FLOW.
14@Transaction// Permite obtener datos de varias tablas relacionadas con una sola consulta.
15@Query("SELECT * FROM SuperHero ORDER BY superName")
16fungetSuperHerosWithEditorials(): Flow<List<SuperWithEditorial>>
1718@Query("SELECT * FROM Editorial")
19fungetAllEditorials(): Flow<List<Editorial>>
2021// Versión de consultas que devuelven un LIVEDATA (...LD).
22@Transaction23@Query("SELECT * FROM SuperHero ORDER BY superName")
24fungetSuperHerosWithEditorialsLD(): LiveData<List<SuperWithEditorial>>
2526@Query("SELECT * FROM Editorial")
27fungetAllEditorialsLD(): LiveData<List<Editorial>>
2829// Resto de consultas.
3031@Query("SELECT * FROM SuperHero WHERE idSuper = :idSuper")
32suspendfungetSuperById(idSuper: Int): SuperHero?
3334@Query("SELECT * FROM Editorial WHERE idEd = :editorialId")
35suspendfungetEditorialById(editorialId: Int): Editorial?
3637@Insert(onConflict = OnConflictStrategy.REPLACE)
38suspendfuninsertEditorial(editorial: Editorial)
3940@Insert(onConflict = OnConflictStrategy.REPLACE)
41suspendfuninsertSuperHero(superHero: SuperHero)
4243@Delete44suspendfundeleteSuperHero(superHero: SuperHero): Int
45}
@Transaction permite obtener datos de varias tablas relacionadas con una sola consulta.
@Query permite realizar consultas SQL directamente sobre la base de datos.
@Insert permite insertar un nuevo registro en la base de datos. Devuelve como Long el ID del registro insertado, si se ha insertado correctamente.
El uso de onConflictStrategy.REPLACE permite reemplazar un registro existente si hay un conflicto de clave primaria. Si se intenta insertar un SuperHero o Editorial con un id que ya existe, se actualizará el registro existente, puede utilizarse para ahorrarse un método para actualizar (@Update).
@Delete permite eliminar un registro de la base de datos, devolvilendo como Int el número de filas afectadas.
Observa que se han duplicado dos métodos, esto se hace a modo didáctico para ilustrar el uso de Flow y LiveData.
6.5. Definición de la base de datos
Se creará a continuación una clase abstracta para definir la base de datos y conecta las entidades con los DAOs.
1importandroid.content.Context 2importandroidx.room.Database 3importandroidx.room.Room 4importandroidx.room.RoomDatabase 5 6// AppDatabase.kt
7@Database(
8 entities = [SuperHero::class, Editorial::class],
9 version = 1,
10 exportSchema = true// Importante para migraciones
11)
12abstractclassAppDatabase : RoomDatabase() {
13abstractfunsupersDAO(): SupersDAO // Conexión con DAO de SuperHéroes.
1415companionobject {
16@Volatile17privatevarINSTANCE: AppDatabase? = null1819fungetInstance(context: Context): AppDatabase {
20return INSTANCE ?: synchronized(this) {
21valinstance = Room.databaseBuilder(
22 context.applicationContext,
23 AppDatabase::class.java,
24"SuperHeros.db"25 ).fallbackToDestructiveMigration(true) // Solo en desarrollo.
26 .build()
2728 INSTANCE = instance // Asigna la instancia a la variable volátil.
29 instance // Devuelve la instancia de la base de datos.
30 }
31 }
32 }
33}
@Volatile asegura visibilidad del hilo.
synchronized evita creación múltiple de la instancia de la base de datos.
exportSchema = true genera un JSON con el esquema (necesario para migraciones).
fallbackToDestructiveMigration(boolen) se utiliza durante la configuración de la base de datos permitiendo controlar cómo se manejarán las migraciones cuando no se ha definido una estrategia.
false desactiva la migración destructiva, si no hay una migración definida entre dos versiones del esquema, ROOM lanzará una excepción (IllegalStateException) en lugar de borrar y recrear la base de datos. Por defecto.
true facilita los cambios rápidos del esquema sin tener que escribir migraciones cada vez, borra y crea la base de datos. Solo en desarrollo.
6.6. Uso de ROOM con ViewModel
Para respetar el patrón MVVM, nunca se accederá a Room desde la UI. Se utilizará un ViewModel para iniciar la interacción Repository <-> Datasource <-> Framework. Al aplicar la capa intermedia (Repository - Datasource), inicialmente puede verse como una repetición o redundancia de código, pero tiene una lógica, y es separar la lógica de acceso al Framework del Repositoy, separando y facilitando así el acceso a distintas fuentes de datos.
Información
UI (Compose) <-> ViewModel <-> Repository <-> Datasource <-> [API (Retrofit) o DB (ROOM)]
Comenzando por el Datasource se creará en el packagedata la siguiente clase:
Se opta por el nombre LocalDatasource.kt porque es la clase que da acceso al almacenamiento local. Ahora se creará Repository.kt, que será en este caso muy similar, pero ya se verá su utilidad real.
A continuación, se creará el ViewModel compartido entre pantallas dónde se establecerá la conexión a la base de datos y la interacción con el repositorio. La clase SupersViewModel.kt estará a la misma altural que la clase MainActivity.kt en la estructura de árbol del proyecto.
En este ejemplo se utiliza AndroidViewModel para crear el ViewModel, que proporciona acceso al contexto de la aplicación, necesario para inicializar la base de datos.
1importandroid.app.Application 2importandroidx.lifecycle.AndroidViewModel 3importandroidx.lifecycle.LiveData 4importandroidx.lifecycle.viewModelScope 5importkotlinx.coroutines.Deferred 6importkotlinx.coroutines.async 7importkotlinx.coroutines.flow.MutableStateFlow 8importkotlinx.coroutines.flow.StateFlow 9importkotlinx.coroutines.flow.catch10importkotlinx.coroutines.launch1112// SupersViewModel.kt
13classSupersViewModel(application: Application) : AndroidViewModel(application) {
14// Se inicializa el repositorio y el datasource.
15privatevalrepository: Repository
16privatevallocalDatasource: LocalDatasource
1718// Se exponen los StateFlow para que la UI observe los cambios.
19privateval_currentSupers = MutableStateFlow<List<SuperWithEditorial>>(emptyList())
20valcurrentSupers: StateFlow<List<SuperWithEditorial>> = _currentSupers
2122privateval_currentEditorials = MutableStateFlow<List<Editorial>>(emptyList())
23valcurrentEditorials: StateFlow<List<Editorial>> = _currentEditorials
2425// Se exponen los LiveData según sea necesario.
26valcurrentSupersLD: LiveData<List<SuperWithEditorial>>
27valcurrentEditorialLD: LiveData<List<Editorial>>
2829init {
30// Inicialización del repositorio y el datasource.
31valdatabase = AppDatabase.getInstance(application)
32valdao = database.supersDAO()
33 localDatasource = LocalDatasource(dao)
34 repository = Repository(localDatasource)
3536// Carga inicial de superhéroes y editoriales, versión Flow.
37 loadSupers()
38 loadEditorials()
3940// Inicialización del LiveData para los superhéroes.
41 currentSupersLD = repository.currentSupersLD
42 currentEditorialLD = repository.currentEditorialsLD
43 }
4445// Se observan los StateFlow para que la UI pueda reaccionar a los cambios con Flow una vez
46// que se hayan cargado los datos iniciales.
47funloadEditorials() {
48 viewModelScope.launch {
49 repository.currentEditorials
50 .catch { e -> e.printStackTrace() } // Manejo de errores.
51 .collect { editorials ->52 _currentEditorials.value = editorials // Actualiza el StateFlow con las editoriales.
53 }
54 }
55 }
5657funloadSupers() {
58 viewModelScope.launch {
59 repository.currentSupers
60 .catch { e -> e.printStackTrace() } // Manejo de errores.
61 .collect { supers ->62 _currentSupers.value = supers // Actualiza el StateFlow con los superhéroes.
63 }
64 }
65 }
6667funsaveEditorial(editorial: Editorial) {
68 viewModelScope.launch {
69 repository.saveEditorial(editorial)
70 }
71 }
7273funsaveSuper(superHero: SuperHero) {
74 viewModelScope.launch {
75 repository.saveSuper(superHero)
76 }
77 }
7879suspendfundelSuper(superHero: SuperHero) : Int{
80return deleteSuper(superHero).await()
81 }
8283// Esta función devuelve un Deferred para que se pueda esperar su resultado de forma asíncrona.
84privatefundeleteSuper(superHero: SuperHero): Deferred<Int> {
85return viewModelScope.async {
86 repository.deleteSuper(superHero)
87 }
88 }
8990fungetSuperById(superId: Int): Deferred<SuperHero?> {
91return viewModelScope.async { repository.getSuperById(superId) }
92 }
9394fungetEdById(editorialId: Int): Deferred<Editorial?> {
95return viewModelScope.async { repository.getEdById(editorialId) }
96 }
97}
Este ViewModel muestra dos formas de recuperar la información de la BD, una mediante MutableStateFlow para la versión con Flows y otra utilizando LiveData, aquí por motivos didácticos se tienen las dos a la vez, no es lo habitual, siempre se elegirá una única forma de trabajar, se recomienda el uso de Flows para Jetpack Compose.
6.6.1. Consumir los datos desde la UI
Versión para Flow
En la versión para Flows, puede obtenerse el flugo de datos de la siguiente manera:
1@Composable 2funMainScreen(navController: NavController, viewModel: SupersViewModel) {
3valsnackbarHostState = remember { SnackbarHostState() }
4valscope = rememberCoroutineScope()
5 6// Se recolecta el StateFlow del ViewModel para observar el flujo de datos
7// de los superhéroes y las editoriales. Se puede usar collectAsState() o
8// collectAsStateWithLifecycle() para obtener el estado actual.
9valcurrentSupersby viewModel.currentSupers.collectAsStateWithLifecycle()
10valcurrentEditorialsby viewModel.currentEditorials.collectAsStateWithLifecycle()
1112...
13}
Cuando se observa StateFlow o Flow se utilizan los método collectAsState() o collectAsStateWithLifecycle(), aquí tienes una comparativa entre ambos.
Característica
collectAsState()
collectAsStateWithLifecycle()
Ciclo de vida
Siempre ecolecta, incluso estando en segundo plano
Solo recolectará cuando el estado de vida sea STARTED (pantalla visible)
Consumo de recursos
Puede consumir batería innecesariamente
Más eficiente, pausa la recolección en segundo plano
Uso recomendado
En apps simples o prototipos
Recomendado para producción
Dependencia extra
No necesita dependencias adicionales
Necesita la librería androidx.lifecycle:lifecycle-viewmodel-compose
collectAsStateWithLifecycle() es la opción recomendada para aplicaciones reales y proyectos con ViewModel + Compose, permitiendo así una gestión eficiente del ciclo de vida.
Nota
Desde Compose BOM 2023.10.01 y Lifecycle 2.6.2+, Google añadió una serie de mejoras importantes, ahora, collectAsState() dentro de un @Composable respeta el ciclo de vida si se usa junto con ViewModel y StateFlow/MutableStateFlow.
Esto quiere decir que collectAsState() pausa la recolección cuando la pantalla no está en primer plano, igual que hacía collectAsStateWithLifecycle().
Una vez se obtienen los datos, ya se puede trabajar con ellos, por ejemplo, comprobar la existencia previa de editoriales para permitir añadir superhéroes.
1LaunchedEffect(currentEditorials.isEmpty()) {
2 delay(1_000) // Se espera un segundo para dar tiempo a que se carguen los datos.
3if (currentEditorials.isEmpty()) {
4 5 snackbarHostState.showSnackbar(
6 message = "No hay editoriales disponibles, debe existir al menos una para poder añadir superhéroes.",
7 duration = SnackbarDuration.Short
8 )
9 }
10}
En este ejemplo se utiliza LaunchedEffect(key), esta es una función de Compose que permite lanzar una Coroutine cuando un componente se muestra (o vuelve a componerse bajo ciertas condiciones).
Se usa para ejecutar tareas asíncronas desde la UI, como:
Llamar a una función suspendida del ViewModel.
Mostrar un Snackbar.
Ejecutar una tarea o acción tras un evento (ej: después de guardar).
El parámetro key del método se utilizará de la siguiente manera:
Si el objeto pasado como key cambia, la acción se vuelve a ejecutar.
Si la clave es Unit, se ejecutará solo una vez al entrar en composición.
En el caso de currentSupers, que es una lista, se utilizará como tal:
1items(currentSupers) { oneSuper ->2...
3}
Para consumir un método del ViewModel que devuelve un valor de forma asíncrona (Deferred), se puede utilizar LaunchedEffect de la siguiente manera:
En el caso de utilizar LiveData los datos se observan.
1// Se recolecta el LiveData del ViewModel
2valcurrentSupersLDby viewModel.currentSupersLD.observeAsState()
3valcurrentEditorialsLDby viewModel.currentEditorialLD.observeAsState()
En este caso deberán realizar más comprobaciónes, controlando los posibles nulos.
1if (currentEditorialsLD !=null) {
2 LaunchedEffect(currentEditorialsLD!!.isEmpty()) {
3 delay(1_000) // Se espera un segundo para dar tiempo a que se carguen los datos.
4if (currentEditorialsLD!!.isEmpty()) {
5 6 snackbarHostState.showSnackbar(
7 message = "No hay editoriales disponibles, debe existir al menos una para poder añadir superhéroes.",
8 duration = SnackbarDuration.Short
9 )
10 }
11 }
12}
Entender qué es una API REST y cómo comunicarse desde app Android.
Configurar y usar Retrofit2 para realizar peticiones HTTPS desde una app Android.
Convertir respuestas JSON en objetos Kotlin usando Gson.
Gestionar errores en las llamadas a la API.
Integrar Retrofit2 con ViewModel, ROOM (para caché offline) y el patrón MVVM.
Usar corutinas para gestionar llamadas asíncronas sin bloquear la interfaz de usuario.
7.1. Introducción a APIs REST
Una API REST es un servicio que facilita una serie de mecanismos para obtener información de un cliente externo, generalmente una base de datos que nutra la aplicación. Las peticiones que pueden hacerse a una API REST son los siguientes:
GET, devuelven información, puede pasársele parámetros a través de la URL, pero es poco segura.
POST, similar a GET, pero más segura, los parámetros no se pasan en la URL.
PUT, se utilizará para crear registros en la base de datos.
DELETE, permite eliminar registros de la base de datos.
La información devuelva por una API REST estará por lo general en formato JSON. Como norma general, para poder modificar el contenido mediante una API REST, será necesario algún tipo de autenticación, aunque muchas son utilizadas como consulta (GET) y no requieren de este sistema de seguridad.
Retrofit2 es una librería de Square que convierte una API REST en una interfaz de Java o Kotlin, facilitando mucho las llamadas HTTP en Android de una manera relativamente sencilla. Esta biblioteca permite el consumo de APIs REST, además se combinará con corrutinas y con el uso de Flows.
En este caso se trata de una data class sencilla, pero puedes encontrarte con JSONs más complejos. Existen en Android Studio un plugin que puede ayudarte para estas situaciones, JSON To Kotlin Class.
7.4. Configuración de Retrofit2
Para ordenar el código, la configuración de Retrofit2 se hará dentro del package data, concretamente se creará el fichero RetrofitClient.kt en el que se ubicará un object para tener una única instancia de Retrofit2 (patrón Singleton) y la interfaz para utilizar las anotaciones que definirán las peticiones a la API.
Response<T>: incluye código de estado, mensaje y cuerpo. Ideal para manejar errores.
addConverterFactory(GsonConverterFactory.create()): convierte automáticamente el JSON a objetos Kotlin y viceversa. Retrofit no procesa JSON por sí solo, necesita un convertidor. El más usado es GsonConverter, importado al inicio del tema.
7.4.1. Manejo de errores y excepciones
Cuando se hacen llamadas a una API pueden producirse errores que hay que controlar:
No hay conexión a Internet.
Servidor caído (500).
Recurso no encontrado (404).
Respuesta vacía.
Se utilizará try-catch y se analizará el Response obtenido para gestionarlos.
7.5. Creación del flujo e integración con ViewModel
En aplicaciones profesionales, se recomienda guardar los datos de manera local con ROOM y utilizar la API solo si no hay datos en caché o si el usuario refresca.
Flujo recomendado (Clean Architecture + MVVM)
Información
UI (Compose) <-> ViewModel <-> Repository <-> Datasource <-> [API (Retrofit) o DB (ROOM)]
Se creará en primer lugar la obtención de la información de la API, como ya se dispone del interface para la API, se pasará a crear el Datasource en el package data.
1// RemoteDatasource.kt
2classRemoteDatasource {
3// Servicio API utilizando Retrofit.
4privatevalapiService = RetrofitClient.apiService
5 6// Funciones para obtener datos desde la API.
7suspendfungetPosts() = apiService.getPosts()
8 9// Obtener un post por su ID.
10suspendfungetPostById(id: Int) = apiService.getPostById(id)
11}
Observa como quedaría ahora el repositorio, controlando posibles errores y haciendo uso del Datasource remoto.
1// Repository.kt
2classRepository(privatevalremoteDatasource: RemoteDatasource) {
3// Manejo de errores básico con try-catch.
4// En caso de error, se devuelve una lista vacía.
5suspendfungetPosts(): List<Post>? {
6returntry {
7valresponse = remoteDatasource.getPosts()
8if (response.isSuccessful) {
9valposts = response.body() ?: emptyList()
10 posts
11 } else {
12Log.e("Repository", "Error response: ${response.code()} - ${response.message()}")
13 emptyList()
14 }
15 } catch (e: Exception) {
16Log.e("Repository", e.message, e)
17throw e // Lanzar la excepción para que el ViewModel pueda manejarla.
18 }
19 }
2021// Obtener un post por su ID con manejo de errores.
22// En caso de error, se devuelve null.
23suspendfungetPostById(id: Int): Post? {
24returntry {
25valresponse = remoteDatasource.getPostById(id)
26if (response.isSuccessful) {
27valpost = response.body()
28 post
29 } else {
30Log.e("Repository", "Error response: ${response.code()} - ${response.message()}")
31null32 }
33 } catch (e: Exception) {
34Log.e("Repository", "Error fetching post by ID", e)
35throw e
36 }
37 }
38}
El siguiente paso será crear el ViewModel que se encargará de facilitar la información a la UI.
1importandroidx.lifecycle.ViewModel 2importandroidx.lifecycle.viewModelScope 3importkotlinx.coroutines.flow.MutableStateFlow 4importkotlinx.coroutines.flow.StateFlow 5importkotlinx.coroutines.launch 6 7classMainViewModel : ViewModel() {
8// Se inicializa el repositorio y el datasource.
9privatevalrepository: Repository
10privatevalremoteDatasource: RemoteDatasource
1112// Estado para la lista de posts, estado de carga y errores.
13privateval_posts = MutableStateFlow<List<Post>>(emptyList())
14valposts: StateFlow<List<Post>> = _posts
1516// Estado de carga y errores.
17privateval_loading = MutableStateFlow(false)
18valloading: StateFlow<Boolean> = _loading
1920// Estado de error.
21privateval_error = MutableStateFlow<String?>(null)
22valerror: StateFlow<String?> = _error
2324init {
25 remoteDatasource = RemoteDatasource()
26 repository = Repository(remoteDatasource)
27 }
2829funfetchPosts() {
30 viewModelScope.launch {
31 _loading.value = true32 _error.value = null3334try {
35valposts = repository.getPosts()
36 _posts.value = posts ?: emptyList()
37 } catch (e: Exception) {
38 _error.value = "ERROR: ${e.message}"39 } finally {
40 _loading.value = false41 }
42 }
43 }
44}
Información
Se utiliza StateFlow para exponer los datos a la UI.
viewModelScope.launch ejecuta la corutina en el contexto del ViewModel.
Para cargar los datos desde la UI, puede utilizarse un Composable como el siguiente:
Este Composable muestra en pantalla un botón que el usuario debe pulsar para cargar la información, si prefieres que se cargen automáticamente, basta con añadir un LaunchedEffect.
En una aplicación móvil es muy común deslizar hacia abajo (pull down) para que se actualice el contenido (por ejemplo, nuevos correos, noticias o publicaciones). En Android con vistas tradicionales (XML) se usaba SwipeRefreshLayout, pero en Jetpack Compose, desde 2024, se utiliza PullToRefreshBox, que es parte de Material 3 y ofrece una experiencia más fluida y moderna. En versiones anteriores, debía añadirse pullRefresh como un modificador de un contenedor Box.
Para aplicarlo, se modificará el ComposablePostScreen creado en el punto anterior. Se reutilizará el estado loadingy se simplificará el código, ya no hará falta utilizar CircularProgressIndicator.
1@OptIn(ExperimentalMaterial3Api::class)
2@Composable 3funPostScreen(viewModel: MainViewModel = viewModel()) {
4valposts: List<Post> by viewModel.posts.collectAsState()
5valloadingby viewModel.loading.collectAsState() // Estado de carga.
6valerrorby viewModel.error.collectAsState()
7 8// Estado del pull-to-refresh.
9valrefreshState = rememberPullToRefreshState()
1011 LaunchedEffect(posts) {
12if (posts.isEmpty() && !loading && error ==null) {
13 viewModel.fetchPosts()
14 }
15 }
1617 Scaffold(
18 topBar = { TopAppBar({ Text("Documentation T7.1") }) },
19 modifier = Modifier.fillMaxSize()
20 ) { paddingValues ->21 Column(modifier = Modifier.padding(paddingValues)) {
22if (error !=null) {
23 Text(text = "Error: $error", color = Color.Red, modifier = Modifier.padding(16.dp))
2425 Button(
26 modifier = Modifier.fillMaxWidth().padding(8.dp),
27 onClick = { viewModel.fetchPosts() }) {
28 Text("Actualizar")
29 }
30 } else {
31// Implementación de Pull to Refresh.
32 PullToRefreshBox(
33 isRefreshing = loading, // Usa el estado de carga del ViewModel.
34 state = refreshState, // Estado del pull-to-refresh.
35 modifier = Modifier.fillMaxSize(),
36 onRefresh = { viewModel.fetchPosts() } // Acción al refrescar.
37 ) {
38// Contenido que se puede refrescar
39 LazyColumn {
40 items(posts){ post ->41 Card(modifier = Modifier.padding(8.dp).fillMaxWidth()) {
42 Text("Título: ${post.title}", Modifier.padding(8.dp))
43 Text("Cuerpo: ${post.body}", modifier = Modifier.padding(8.dp))
44 }
45 }
46 }
47 }
48 }
49 }
50 }
51}
En este caso se deja el botón para forzar la actualización en caso de producirse algún error.
7.6. Integración con ROOM
Para realizar la integración con ROOM, se seguirán los pasos vistos en el tema anterior para la configuración del proyecto (ver aquí).
7.6.1. Modelo
Se modificará la data class que representa el modelo para que pueda utilizarse con ROOM.
@Entity: indica que esta clase será una tabla en la base de datos.
@PrimaryKey: el campo id es la clave primaria (obligatorio en Room).
7.6.2. Configuración de la BD y DAO
También será necesario definir la base de datos y crear el DAO.
1// AppDatabase.kt
2@Database(
3 entities = [Post::class],
4 version = 1,
5 exportSchema = true// Importante para migraciones
6)
7abstractclassAppDatabase : RoomDatabase() {
8abstractfunpostsDAO(): PostsDAO // Conexión con DAO de Posts.
910companionobject {
11@Volatile12privatevarINSTANCE: AppDatabase? = null1314fungetInstance(context: Context): AppDatabase {
15return INSTANCE ?: synchronized(this) {
16valinstance = Room.databaseBuilder(
17 context.applicationContext,
18 AppDatabase::class.java,
19"Posts.db"20 ).fallbackToDestructiveMigration(true) // Solo en desarrollo.
21 .build()
2223 INSTANCE = instance // Asigna la instancia a la variable volátil.
24 instance // Devuelve la instancia de la base de datos.
25 }
26 }
27 }
28}
1// PostsDAO.kt
2@Dao 3interfacePostsDAO {
4// Obtiene todos los posts como un Flow para observar cambios en tiempo real.
5@Query("SELECT * FROM posts")
6fungetPosts(): Flow<List<Post>>
7 8// Inserta una lista de posts. Si ya existen, los reemplaza.
9@Insert(onConflict = OnConflictStrategy.REPLACE)
10suspendfuninsertAllPosts(posts: List<Post>)
11}
Información
Flow<List<Post>>: devuelve un flujo de datos que se actualizará automáticamente cuando los datos cambien en la base de datos (ideal para Jetpack Compose).
7.6.2.1. Autenticación con token (opcional)
En ocasiones, las APIs REST requieren autenticación mediante tokens. Si la API que utilizas lo requiere para realizar algunas operaciones, como puede ser un PUT o DELETE, deberás añadir un interceptor al cliente HTTP de Retrofit para incluir el token en las cabeceras de las peticiones, utilizando la etiqueta @Headers("Content-Type: application/json").
1// LocalDatasource.kt
2classLocalDatasource(privatevaldao: PostsDAO) {
3 4// Obtiene todos los posts desde la base de datos local.
5fungetPosts(): Flow<List<Post>> = dao.getPosts()
6 7// Inserta una lista de posts en la base de datos local.
8suspendfuninsertAllPosts(posts: List<Post>) {
9 dao.insertAllPosts(posts)
10 }
11}
Como se puede observar, la clase LocalDatasource.kt es bastante sencilla y separa la lógica del Datasource local del remoto.
7.6.4. Repository
Ahora el Repository deberá inyectar ambos Datasources.
Y el método getPosts() se modificará de la siguiente forma para almacenar los posts en la BD y devolver de esta cuando se produzca algún error de la API.
1suspendfungetPosts(): List<Post>? {
2returntry {
3valresponse = remoteDatasource.getPosts()
4if (response.isSuccessful) {
5valposts = response.body() ?: emptyList()
6// Almacenar los posts obtenidos en la base de datos local.
7 localDatasource.insertAllPosts(posts)
8 posts
9 } else {
10Log.e("Repository", "Error response: ${response.code()} - ${response.message()}")
11 localDatasource.getPosts().first() // Se obtienen los posts almacenados localmente.
12 }
13 } catch (e: Exception) {
14Log.e("Repository", e.message, e)
15valdbdata = localDatasource.getPosts().first()
16if (dbdata.isNotEmpty())
17 dbdata
18elsethrow e // Lanzar la excepción para que el ViewModel pueda manejarla.
19 }
20}
Información
localDatasource.insertAllPosts(posts) guarda los datos obtenidos de la API en la BD, de esta manera, aunque el usuario esté sin internet, podrá ver los datos almacenados.
first() se utiliza para obtener el primer valor emitido por el flujo y luego cancelar la suscripción. Se utiliza cuando no se necesita observar cambios continuamente.
Observa que se controla en el bloque del catch si se debe lanzar la excepción o no, si hay datos en la BD no se lanzará.
7.6.5. ViewModel
Por último, habrá que modificar el ViewModel de la siguiente forma.
1classMainViewModel(application: Application) : AndroidViewModel(application) {
2// Se inicializa el repositorio y el datasource.
3privatevalrepository: Repository
4privatevalremoteDatasource: RemoteDatasource
5privatevallocalDatasource: LocalDatasource
6 7...
8 9init {
10// Se inicializa la base de datos local y el DAO.
11valdatabase = AppDatabase.getInstance(application)
12valdao = database.postsDAO()
1314 remoteDatasource = RemoteDatasource()
15 localDatasource = LocalDatasource(dao)
16 repository = Repository(remoteDatasource, localDatasource)
17 }
1819...
20}
Básicamente se añade el LocalDatasource y se establece la conexión a la BD en el contructor init. También se modifica la declaración de la clase.
Reproducir audio y video en una app Android usando MediaPlayer y ExoPlayer.
Capturar fotos y vídeos mediante la cámara del dispositivo.
Acceder a la galería de imágenes del dispositivo utilizando PhotoPicker.
Usar del micrófono del disposivo.
Utilizar sensores básicos del dispositivo como el acelerómetro y el sensor de luz.
Integrar funcionalidades en una arquitectura moderna (MVVM + Clean Architecture) con Jetpack Compose.
8.1. Reproducción de audio y video
En Android hay diferentes maneras de reproducir contenido multimedia. Las más comunes son MediaPlayer (nativa) y ExoPlayer, esta última más moderna y recomendada para Compose.
8.1.1. MediaPlayer (API Nativa)
Esta clase está integrada en Android, por lo que no necesita dependencias externas, es sencilla de utilizar pero algo limitada y poco personalizable.
1valmediaPlayer = MediaPlayer.create(ctxt, R.raw.epic_cinematic)
2mediaPlayer.start() // Reproduce.
3mediaPlayer.pause() // Pausa la reproducción.
4mediaPlayer.stop() // Detiene.
5mediaPlayer.prepare() // Prepara el MediaPlayer para poder reproducirlo de nuevo.
6mediaPlayer.release() // // Libera recursos del MediaPlayer.
MediaPlayer no está recomendado para reproducción en streaming o formatos complejos.
8.1.2. ExoPlayer
ExoPlayer es una biblioteca de código abierto desarrollada por Google, ahora ya forma parte de Jetpack Media3. Es más potente y flexible que MediaPlayer y se encuentra actualizada. Además de ser la recomendación actual.
Dependencias necesarias para incluir ExoPlayer en el proyecto.
Puedes obtener más vídeos de muestra en este repositorio.
8.2. Captura de imágenes
Para capturar imágenes desde la cámara se puede utilizar ActivityResultContracts.TakePicture. Será necesario especificar en el Manifiest el permiso de cámara:
El siguiente Composable muestra como capturar una imagen previa de la captura fotográfica, previa solicitud de permisos, utilizando la clase PermissionHandlerViewModel del tema 3.
1@Composable 2funCheckPermission(padding: PaddingValues, viewModel: PermissionHandlerViewModel = viewModel()) {
3valctxt = LocalContext.current
4 5// Obtiene el estado del permiso desde el ViewModel.
6valpermissionState = viewModel.uiState.collectAsState()
7// Este callback se usa para solicitar el permiso de cámara.
8valrequestPermission =
9 rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->10 viewModel.onPermissionResult(
11 granted, ActivityCompat.shouldShowRequestPermissionRationale(
12 ctxt as Activity, Manifest.permission.CAMERA
13 )
14 )
15 }
1617 LaunchedEffect(permissionState) {
18when {
19 permissionState.value.granted -> {
20Log.d("CameraPermission", "Acceso a cámara concedido")
21 }
2223else-> {
24// Primer lanzamiento: solicitamos el permiso
25 requestPermission.launch(Manifest.permission.CAMERA)
26 }
27 }
28 }
2930when {
31 permissionState.value.granted -> {
32 CameraCapture(padding = padding) {/* onImageCaptured */33Log.i("MainActivity", "onImageCaptured: $it")
34 }
35 }
36 }
37}
TakePicturePreview() devuelve un Bitmap. Para guardar una imagen completa, deberás utilizar TakePicture (requiere Uri).
8.3. Captura de imágenes y vídeo con CameraX
CameraX es una biblioteca de Android Jetpack que simplifica el uso de la cámara en dispositivos Android. Diseñada para trabajar de forma consistente en diferentes modelos y marcas, resuelve muchos problemas de compatibilidad. Recomendada para proyectos con Jetpack Compose y arquitectura MVVM y Clean Architecture.
Nota
Para utilizar CameraX y reducir el uso de permisos, se recomienda usar CameraX con ViewModel y lifecycle, evitando así el permiso de almacenamiento en Android 10 (API 29) y posteriores.
Ventajas que ofrece:
Funciona en dispositivos con API 21+.
Diseño orientado a Compose.
Soporte integrado para vistas de previsualización (PreviewView).
Integración con ViewModel y lifecycle.
Permite captura de fotos, grabación de vídeo y análisis de imágenes (como detección de rostros).
8.3.1. Dependencias y permisos necesarias para CameraX
Además, es necesario añadir los permisos en el archivo AndroidManifest.xml:
1<uses-permissionandroid:name="android.permission.CAMERA"/>2<uses-permissionandroid:name="android.permission.RECORD_AUDIO"/><!-- Para grabar audio -->3<uses-featureandroid:name="android.hardware.camera.any"/><!-- Para usar cualquier cámara (frontal o trasera) -->4<uses-featureandroid:name="android.hardware.microphone"android:required="true"/><!-- Para grabar audio -->5<uses-permissionandroid:name="android.permission.WRITE_EXTERNAL_STORAGE"android:maxSdkVersion="28"/>
Nota
En Android 10 (API 29) y posteriores, no se necesita el permitso WRITE_EXTERNAL_STORAGE si se guarda en directorios de la app (como getExternalFilesDir). Se utilizará MediaStore para guardar en galería.
Importante
Es posible que en algunos emuladores no funcione la cámara. Se recomienda probar en un dispositivo real.
8.3.2. Captura de imágenes básica con CameraX en Compose con vista previa
El objetivo es crear una app que permita capturar fotos guardándolos en el almacenamiento interno del dispositivo, la actividad principal se encargará de inicializar CameraX y gestionar la UI, mostrando la previsualización de la cámara y un botón para capturar la imagen.
En primer lugar, en la MainActivity se crea un objeto para gestionar un ExecutorService, se usará para ejecutar tareas relacionadas con la cámara en un hilo secundario, sin bloquear la UI principal.
1classMainActivity : ComponentActivity() {
2// Executor para tareas en segundo plano.
3privatelateinitvarcameraExecutor: ExecutorService
4 5overridefunonCreate(savedInstanceState: Bundle?) {
6super.onCreate(savedInstanceState)
7 enableEdgeToEdge()
8 9 setContent {
10// Se inicializa el executor una sola vez.
11 cameraExecutor = Executors.newSingleThreadExecutor()
12 CameraApp()
13 }
14 }
1516...
17}
Observa como en el bloque setContent se inicializa cameraExecutor, creando un hilo único dedicado a ejecutar las operaciones de la cámara. Esto garantiza que las tareas se procesen de manera secuencial en ese hilo y no interfieran con el hilo principal. Luego se llama a la función CameraApp(), que es el Composable principal de la aplicación.
Importante
Todos los métodos que se describen a continuación deben estar dentro de la clase MainActivity.
Ahora se creará el método CameraApp(), composable gestiona la solicitud de permisos en tiempo de ejecución y decide si muestra la pantalla de la cámara o una pantalla solicitando permisos. Utiliza la API ActivityResult desde Compose, recordando el estado, configurado para lanzar la petición una sola vez.
Se crea el objeto context a partir de LocalContext.current para exponer el Context de Android dentro del árbol de composición. Este contexto es esencial para muchas operaciones en Android, como acceder a recursos, iniciar actividades o servicios, y obtener información del sistema. En este caso, se pasa a CameraScreen para que pueda utilizarlo al configurar la cámara y guardar imágenes.
Se define una variable mutable hasPermissions para comprobar si la aplicación tiene los permisos necesarios. Inicialmente es false.
Se utiliza rememberLauncherForActivityResult para crear un lanzador que solicitará múltiples permisos. El resultado de la solicitud se maneja en el lambdaonResult, donde se actualiza hasPermissions si todos los permisos han sido concedidos.
LaunchedEffect(Unit) se usa para lanzar la solicitud de permisos cuando el composable se monta por primera vez. Dependiendo de la versión de Android, se solicitan los permisos necesarios.
Finalmente, se muestra CameraScreen si los permisos han sido concedidos, o RequestPermissionScreen si no. Este último muestra un mensaje y un botón para solicitar los permisos de nuevo.
A continuación, se implementa el composableRequestPermissionScreen, que muestra un mensaje indicando que se necesitan permisos y un botón para reintentar la solicitud.
El siguiente paso es implementar el composableCameraScreen, que se encargará de mostrar la previsualización de la cámara y un botón para capturar fotos. Este composable utiliza PreviewView para mostrar la vista previa de la cámara y ImageCapture para capturar imágenes.
1@Composable 2privatefunCameraScreen(context: Context, executor: ExecutorService) {
3valimageCaptureState = remember { mutableStateOf<ImageCapture?>(null) }
4valisCameraReadyby remember { derivedStateOf { imageCaptureState.value!=null } }
5 6 Column(
7 modifier = Modifier
8 .fillMaxSize()
9 .padding(48.dp)
10 ) {
11// Vista de previsualización
12 AndroidView(
13 factory = { ctx ->14valpreviewView = PreviewView(ctx)
15vallifecycleOwner = context as LifecycleOwner
1617// Iniciar la cámara después de que la vista esté lista
18 startCamera(
19 context = context,
20 previewView = previewView,
21 lifecycleOwner = lifecycleOwner,
22 onSuccess = { capture ->23Log.d("CameraX", "Cámara iniciada correctamente")
24 imageCaptureState.value = capture // <-- Actualizamos el estado
25 },
26 onError = { e ->27Toast.makeText(
28 context,
29"Error cámara: ${e.message}",
30Toast.LENGTH_SHORT
31 ).show()
32 }
33 )
3435 previewView
36 },
37 modifier = Modifier.weight(1f)
38 )
3940// Botón para tomar foto (solo si está listo)
41 Button(
42 onClick = {
43valcapture = imageCaptureState.value44if (capture !=null) {
45 takePhoto(capture, context, executor) {
46// Se utiliza el main executor para mostrar el Toast.
47ContextCompat.getMainExecutor(context).execute {
48Toast.makeText(context, "Foto guardada", Toast.LENGTH_SHORT).show()
49 }
50 }
51 } else {
52Toast.makeText(context, "Espere… cámara iniciándose", Toast.LENGTH_SHORT).show()
53 }
54 },
55 enabled = isCameraReady, // Deshabilitado hasta que esté listo
56 modifier = Modifier
57 .align(Alignment.CenterHorizontally)
58 .padding(16.dp)
59 ) {
60 Text(if (isCameraReady) "Hacer Foto"else"Iniciando cámara...")
61 }
62 }
63}
Se define imageCaptureState como un estado mutable que almacenará la instancia de ImageCapture una vez que la cámara esté lista. Inicialmente es null.
La variable derivada isCameraReady indica si la cámara está lista para capturar fotos, es decir, si imageCaptureState.value no es null.
Se utiliza AndroidView para integrar PreviewView, que muestra la previsualización de la cámara. Dentro del factory se llama a startCamera para configurar e iniciar la cámara.
El botón para tomar fotos estará habilitado cuando la cámara está lista (isCameraReady es true). Al hacer clic, se llama a takePhoto para capturar y guardar la imagen.
Se mostrará un Toast para notificar al usuario que la foto se ha guardado o si la cámara aún se está iniciando.
Se utiliza ContextCompat.getMainExecutor(context).execute para asegurarse de que el Toast se muestre en el hilo principal, de no hacer así, podría producirse un error de ejecución.
A continuación, se implementa la función startCamera, que configura e inicia la cámara utilizando CameraX. Este método no es un composable, sino una función normal que se llama desde el factory de AndroidView.
1privatefunstartCamera(
2 context: Context,
3 previewView: PreviewView,
4 lifecycleOwner: LifecycleOwner,
5 onSuccess: (ImageCapture) -> Unit,
6 onError: (Exception) -> Unit
7) {
8valcameraProviderFuture = ProcessCameraProvider.getInstance(context)
910 cameraProviderFuture.addListener({
11try {
12valcameraProvider = cameraProviderFuture.get()
1314valpreview = Preview.Builder().build().apply {
15 setSurfaceProvider(previewView.surfaceProvider)
16 }
1718valimageCapture = ImageCapture.Builder()
19 .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
20 .build()
2122valcameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
2324// Se desvincula el provider previo antes de re-vincular
25 cameraProvider.unbindAll()
2627// Se vincula al ciclo de vida correcto
28 cameraProvider.bindToLifecycle(
29 lifecycleOwner,
30 cameraSelector,
31 preview,
32 imageCapture
33 )
3435// Se notifica que ImageCapture está listo
36 onSuccess(imageCapture)
3738 } catch (e: Exception) {
39 onError(e)
40 }
41 }, ContextCompat.getMainExecutor(context))
42}
Se obtiene una instancia de ProcessCameraProvider, que es será la responsable de gestionar la cámara.
Se añade un listener para cuando el CameraProvider esté listo, y dentro del bloque try-catch se configura la cámara.
Se crea un Preview para mostrar la vista previa de la cámara en PreviewView.
Se configura ImageCapture para capturar las fotos, estableciendo el modo de captura para minimizar la latencia.
Se selecciona la cámara trasera utilizando CameraSelector.DEFAULT_BACK_CAMERA.
Se desvinculan todas las cámaras previamente vinculadas para evitar conflictos con el método unbindAll().
Se vinculan Preview e ImageCapture al ciclo de vida del lifecycleOwner, asegurando que la cámara se gestione correctamente según el estado de la actividad o fragmento.
Si todo es correcto, se llama a onSuccess pasando la instancia de ImageCapture. Si se produjese un error, se llama a onError.
El siguiente método, takePhoto, se encargará de capturar la foto y guardarla en el almacenamiento interno del dispositivo. Al igual que startCamera, no es un composable, ya que se llama desde el botón en CameraScreen y realiza operaciones que no están relacionadas con la UI directamente.
Se crea un ContentValues para definir los metadatos de la imagen, como el nombre del archivo, el tipo MIME y la ruta relativa (solo para Android 10 y posteriores).
Se configuran las opciones de salida utilizando ImageCapture.OutputFileOptions.Builder, especificando el ContentResolver, la URI de destino y los ContentValues.
Se llama a takePicture de la instancia de ImageCapture, pasándole las opciones de salida, el executor para ejecutar la operación en segundo plano, y un callback para manejar el resultado.
En onImageSaved, se llama a onSaved() para notificar que la imagen se ha guardado correctamente.
En onError, se maneja cualquier error que pueda producirse durante la captura o el guardado de la imagen, imprimiendo el error en el log y mostrando un Toast en el hilo principal para informar al usuario.
Se utiliza ContextCompat.getMainExecutor(context).execute para asegurarse de que el Toast se muestre en el hilo principal, de no hacer así, podría producirse un error de ejecución.
El uso de MediaStore permite que las imágenes capturadas se guarden en la galería del dispositivo, haciendo que sean accesibles para otras aplicaciones y para el propio usuario.
Por último, es importante liberar los recursos del ExecutorService cuando la actividad se destruya, para evitar fugas de memoria. Esto se hace sobrescribiendo el método onDestroy en MainActivity.
1overridefunonDestroy() {
2super.onDestroy()
3 cameraExecutor.shutdown() // Se libera el executor.
4}
8.3.3. Captura de vídeo con CameraX en Compose con vista previa
En primer lugar, recuerda añadir la dependencia de VideoCapture en el archivo build.gradle (ver sección 8.3.1) y los permisos necesarios en el AndroidManifest.xml.
Este ejemplo se inicia en un proyecto nuevo, desde la MainActivity se gestionará la cámara y la grabación de vídeo.
También se añadirá el método RecordingHolder, que es un objeto singleton que se utilizará para mantener una referencia al Recording activo. Será necesario para poder detener la grabación cuando se desee.
1// Holder para el Recording activo (fuera del ViewModel para simplicidad del ejemplo)
2privateobjectRecordingHolder {
3varrecording: Recording? = null4}
A continuación, se implementa el composableCameraRecorderScreen, que se encargará de mostrar la previsualización de la cámara y los botones para iniciar y detener la grabación de vídeo. Se detallará cada parte del código paso a paso.
1@Composable 2funCameraRecorderScreen(innerPadding: PaddingValues) {
3valcontxt = LocalContext.current // Contexto necesario para varias llamadas.
4vallifecycleOwner = LocalLifecycleOwner.current // Necesario para vincular el ciclo de vida a CameraX.
5 6// Gestión de permisos, estado simple para el ejemplo.
7varhasCameraby remember { mutableStateOf(false) }
8varhasMicby remember { mutableStateOf(false) }
910valpermissionsLauncher = rememberLauncherForActivityResult(
11ActivityResultContracts.RequestMultiplePermissions()
12 ) { result ->13 hasCamera = result[Manifest.permission.CAMERA] ==true14 hasMic = result[Manifest.permission.RECORD_AUDIO] ==true15 }
1617// Se lanza la petición de permisos al inicio, solo una vez (key1 = Unit).
18 LaunchedEffect(Unit) {
19 permissionsLauncher.launch(
20 arrayOf(
21Manifest.permission.CAMERA,
22Manifest.permission.RECORD_AUDIO
23 )
24 )
25 }
En esta primera parte del código, se definen las variables y estados necesarios:
contxt: Obtiene el contexto actual de Android utilizando LocalContext.current. Este contexto es necesario para muchas operaciones en Android, como acceder a recursos, iniciar actividades o servicios, y obtener información del sistema. En este caso, se pasa a CameraX para configurar la cámara y guardar vídeos.
lifecycleOwner: Obtiene el propietario del ciclo de vida actual utilizando LocalLifecycleOwner.current. Esto es crucial para vincular los casos de uso de CameraX al ciclo de vida de la actividad o fragmento, asegurando que la cámara se gestione correctamente según el estado de la UI.
hasCamera y hasMic: Variables de estado que indican si la aplicación tiene los permisos necesarios para acceder a la cámara y al micrófono, respectivamente. Se inician a false.
permissionsLauncher: Utiliza rememberLauncherForActivityResult para crear un lanzador que solicitará múltiples permisos. El resultado de la solicitud se maneja en el lambdaonResult (parámetro result), donde se actualizan hasCamera y hasMic según los permisos concedidos.
LaunchedEffect(Unit): Se usa para lanzar la solicitud de permisos cuando el composable se monta por primera vez. Se solicitan los permisos necesarios para la cámara y el micrófono.
27// Se crea previewView para la cámara, se usa remember para que no se recree en recomposiciones.
28valpreviewView = remember {
29 PreviewView(contxt).apply { // PreviewView es un View de Android.
30 layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
31 implementationMode = PreviewView.ImplementationMode.COMPATIBLE
32 scaleType = PreviewView.ScaleType.FILL_CENTER
33 }
34 }
3536// cameraProviderFuture es un proceso asíncrono, se guarda en remember para que no se reinicie.
37valcameraProviderFuture = remember { ProcessCameraProvider.getInstance(contxt) }
38// videoCapture se guarda como estado para poder usarlo en los botones.
39varvideoCaptureby remember { mutableStateOf<VideoCapture<Recorder>?>(null) }
40// isRecording para controlar el estado de grabación y deshabilitar botones.
41varisRecordingby remember { mutableStateOf(false) }
42// lastVideoUri para mostrar la última ruta guardada en Galería.
43varlastVideoUriby remember { mutableStateOf<String?>(null) }
4445// Executor principal para callbacks de CameraX
46valmainExecutor = remember { ContextCompat.getMainExecutor(contxt) }
Seguidamente, se han definido más variables y estados necesarios para la configuración de la cámara y la grabación de vídeo:
previewView: Se crea una instancia de PreviewView, que es una vista nativa de Android utilizada para mostrar la previsualización de la cámara. Se utiliza remember para asegurarse de que esta vista no se recree en cada recomposición del composable. Se configuran sus parámetros de diseño y modo de implementación.
cameraProviderFuture: Se obtiene una instancia de ProcessCameraProvider utilizando getInstance(contxt). Este proceso es asíncrono, por lo que se guarda en remember para evitar que se reinicie en cada recomposición.
videoCapture: Se define como un estado mutable que almacenará la instancia de VideoCapture<Recorder> una vez que la cámara esté configurada. Inicialmente es null.
isRecording: Variable de estado que indica si la grabación de vídeo está en curso. Se utiliza para controlar el estado de los botones de grabación y detener. Inicialmente será false.
lastVideoUri: Variable de estado que almacenará la URI del último vídeo guardado en la galería. Se utiliza para mostrar esta información al usuario. Inicialmente también es null.
mainExecutor: Se obtiene el ejecutor principal utilizando ContextCompat.getMainExecutor(contxt). Este executor se utilizará para manejar los callbacks de CameraX, asegurando que se ejecuten en el hilo principal.
48// Configurar CameraX una vez que el CameraProvider esté disponible.
49 LaunchedEffect(cameraProviderFuture) {
50// Listener asíncrono, se lanza cuando cameraProviderFuture está listo.
51 cameraProviderFuture.addListener({
52// CameraProvider listo, se configuran casos de uso.
53valcameraProvider = cameraProviderFuture.get()
5455valpreviewUseCase = Preview.Builder().build()
56 .also { it.setSurfaceProvider(previewView.surfaceProvider) }
5758// Configuración de grabación con calidad y fallback.
59// Se puede ajustar la lista de calidades según necesidades.
60valqualitySelector = QualitySelector.fromOrderedList(
61 listOf(Quality.UHD, Quality.FHD, Quality.HD, Quality.SD),
62FallbackStrategy.lowerQualityOrHigherThan(Quality.SD)
63 )
6465// Recorder con el selector de calidad.
66valrecorder = Recorder.Builder()
67 .setQualitySelector(qualitySelector)
68 .build()
6970// Caso de uso de VideoCapture
71valvideoUseCase = VideoCapture.withOutput(recorder)
7273try {
74 cameraProvider.unbindAll()
75 cameraProvider.bindToLifecycle(
76 lifecycleOwner,
77CameraSelector.DEFAULT_BACK_CAMERA,
78 previewUseCase,
79 videoUseCase
80 )
81 videoCapture = videoUseCase
82 } catch (e: Exception) {
83Log.e("CameraX", "Fallo al vincular casos de uso", e)
84 }
85 }, mainExecutor)
86 }
Ahora se ha configurado CameraX una vez que el CameraProvider está disponible:
LaunchedEffect(cameraProviderFuture): Se utiliza para ejecutar el bloque de código cuando cameraProviderFuture cambia. Esto asegura que la configuración de la cámara solo se realice una vez que el CameraProvider esté listo.
addListener: Se añade un listener asíncrono que se ejecutará cuando cameraProviderFuture está listo. Dentro de este bloque, se obtiene la instancia de CameraProvider.
previewUseCase: Se crea un caso de uso de Preview para mostrar la previsualización de la cámara en previewView.
qualitySelector: Se configura un selector de calidad que define una lista ordenada de calidades de vídeo (UHD, FHD, HD, SD) y una estrategia de fallback para seleccionar una calidad inferior o superior si la solicitada no está disponible. La estrategia de fallback asegura que siempre se seleccione una calidad válida, evitando errores si la calidad deseada no está soportada por el dispositivo.
recorder: Se crea una instancia de Recorder utilizando el qualitySelector.
videoUseCase: Se crea un caso de uso de VideoCapture utilizando el recorder.
bindToLifecycle: Se desvinculan todos los casos de uso previamente vinculados con unbindAll(), y luego se vinculan previewUseCase y videoUseCase al ciclo de vida del lifecycleOwner, utilizando la cámara trasera.
Si la vinculación es correcta, se actualiza videoCapture con la instancia de videoUseCase. Si se produce un error, se captura la excepción y se registra en el log.
Se utiliza mainExecutor para asegurar que las operaciones relacionadas con la UI se ejecuten en el hilo principal.
Para terminar, se implementa la UI de la pantalla de grabación de vídeo:
Se utiliza un Box para contener la vista previa de la cámara y los controles de grabación.
AndroidView se usa para integrar previewView, que muestra la previsualización de la cámara.
Se crea una columna para los controles, que incluye un mensaje si no se tiene permiso para grabar audio.
Se añaden dos botones: uno para iniciar la grabación y otro para detenerla.
El botón “Grabar” está habilitado solo si no se está grabando, se tiene permiso de cámara y videoCapture no es null. Al hacer clic, se prepara la grabación creando un registro en MediaStore, configurando las opciones de salida y habilitando el audio si se tiene permiso. Luego, inicia la grabación y escucha los eventos de grabación para actualizar el estado.
El botón “Detener” está habilitado solo si se está grabando. Al hacer clic, detiene la grabación utilizando el Recording almacenado en RecordingHolder.
Se muestra la URI del último vídeo guardado en la galería si está disponible.
Se utiliza mainExecutor para asegurar que las operaciones relacionadas con la UI se ejecuten en el hilo principal.
8.4. Selección de imágenes desde la galería con el Photo Picker
Photo Picker es una API que aparece en Android 13 (API 33) y permite a las aplicaciones acceder a los archivos multimedia del usuario sin necesidad de solicitar permisos de almacenamiento. Su diseño se centra en la privacidad y la simplicidad, delegando al sistema la presentación del selector y el control de los permisos temporales de acceso a los archivos.
A diferencia del acceso tradicional mediante permisos (READ_EXTERNAL_STORAGE, READ_MEDIA_IMAGES), el Photo Picker:
No requiere permisos adicionales.
Ofrece una interfaz de selección uniforme y gestionada por el sistema.
Permite elegir imágenes, vídeos o ambos, según la configuración del contrato.
En Compose, el Photo Picker se integra mediante el uso del Activity Result API, que proporciona un mecanismo seguro y estructurado para iniciar actividades y recibir resultados.
Para mostrar su funcionamiento se desarrollará una aplicación que muestre el selector de imágenes del sistema para elegir una imagen de la galería y mostrarla en pantalla. Se utilizará Coil para cargar y mostrar la imagen seleccionada, por lo que deberás añadir la dependencia en el archivo build.gradle:
Al utilizar coil-network-okhttp, se añade soporte para cargar imágenes desde URLs y otras fuentes de red, mejorando la capacidad de la aplicación para manejar imágenes de diversas ubicaciones, añade el permiso de acceso a Internet en el AndroidManifest.xml (no es necesario para el Photo Picker, pero sí para cargar imágenes desde la web):
Este método getImageInfo recibe un Context y un Uri, y devuelve una cadena con el nombre del archivo, su tamaño en KB y el tipo MIME. Se utiliza un ContentResolver para consultar los metadatos del archivo. A continuación, se implementa el método GalleryPicker, que será el composable encargado de mostrar el botón para abrir el selector y la imagen seleccionada.
1@Composable 2funGalleryPicker(modifier: Modifier = Modifier, onImageSelected: (Uri?) -> Unit) {
3valcontxt = LocalContext.current
4// Se guarda la Uri de forma "saveable" (Uri es Parcelable) para sobrevivir a rotaciones, etc.
5varselectedImageUriby rememberSaveable { mutableStateOf<Uri?>(null) }
6varimageInfoby rememberSaveable { mutableStateOf("") }
7 8// Lanzador del Photo Picker (selección única)
9valpickImageLauncher = rememberLauncherForActivityResult(
10 contract = ActivityResultContracts.PickVisualMedia(),
11 onResult = { uri: Uri? ->12 selectedImageUri = uri
13 imageInfo = uri?.let { getImageInfo(contxt, it) } ?:""14 onImageSelected(uri)
15 }
16 )
1718 Column(
19 modifier = modifier
20 .fillMaxSize()
21 .padding(16.dp)
22 .verticalScroll(rememberScrollState()),
23 horizontalAlignment = Alignment.CenterHorizontally,
24 verticalArrangement = Arrangement.Center
25 ) {
26 AsyncImage( // Vista previa con Coil si hay Uri
27 model = selectedImageUri,
28 contentDescription = "Imagen seleccionada",
29 modifier = Modifier
30 .size(320.dp)
31 .clip(RoundedCornerShape(16.dp)),
32 contentScale = ContentScale.Crop
33 )
3435 Spacer(Modifier.height(4.dp))
3637// Información de la imagen
38if (selectedImageUri !=null) {
39 Spacer(Modifier.height(16.dp))
40 Text(
41 text = imageInfo,
42 style = MaterialTheme.typography.bodyMedium
43 )
44 }
4546 Spacer(Modifier.height(32.dp))
4748 Button(
49 onClick = {
50// Solo imágenes (puedes cambiar a ImageAndVideo si procede)
51 pickImageLauncher.launch(
52 PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
53 )
54 }
55 ) {
56 Text("Elegir de galería")
57 }
58 }
59}
En este método se definen varios elementos clave:
selectedImageUri: Estado que almacena la URI de la imagen seleccionada. Se utiliza rememberSaveable para que el valor persista a través de recomposiciones y cambios de configuración (como rotaciones).
imageInfo: Estado que almacena la información de la imagen seleccionada, obtenida mediante el método getImageInfo.
pickImageLauncher: Utiliza rememberLauncherForActivityResult para crear un lanzador que inicia el Photo Picker. El contrato utilizado es ActivityResultContracts.PickVisualMedia(), que permite seleccionar medios visuales. El resultado se maneja en el lambdaonResult, donde se actualizan selectedImageUri e imageInfo, y se llama a onImageSelected para notificar al padre.
La UI se compone de una columna que contiene:
AsyncImage: Componente de Coil que muestra la imagen seleccionada si selectedImageUri no es null. Se aplica una forma redondeada y se ajusta el contenido para recortar la imagen (Crop).
Un Text que muestra la información de la imagen si hay una imagen seleccionada.
Un botón que, al hacer clic, lanza el Photo Picker para seleccionar solo imágenes (se puede cambiar a ImageAndVideo si quieres permitir la selección de vídeos también).
Por último, se utiliza GalleryPicker en la MainActivity para mostrar el selector de imágenes.
1classMainActivity : ComponentActivity() {
2overridefunonCreate(savedInstanceState: Bundle?) {
3super.onCreate(savedInstanceState)
4 enableEdgeToEdge()
5 6 setContent {
7 DocumentationT8_6Theme {
8 Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> 9 GalleryPicker(modifier = Modifier.padding(innerPadding)) { uri: Uri? ->10// Aquí se puede manejar el Uri seleccionado (o null si se canceló)
11if (uri ==null) {
12Toast.makeText(
13this,
14"No se ha seleccionado ninguna imagen",
15Toast.LENGTH_LONG
16 ).show()
17return@GalleryPicker18 } elseToast.makeText(this, "Uri: ${uri.path}", Toast.LENGTH_LONG).show()
19 }
20 }
21 }
22 }
23 }
24}
Para la grabación de audio, Android dispone de la clase MediaRecorder, que permite capturar sonido desde el micrófono y codificarlo en distintos formatos, como AAC, AMR o MP3 (dependiendo del dispositivo).
8.5.1. Permisos necesarios
Para grabar audio, es necesario solicitar el permiso RECORD_AUDIO en el archivo AndroidManifest.xml:
Si el audio se guarda en MediaStore (colección de música), no será necesario el permiso WRITE_EXTERNAL_STORAGE en Android 10 o superior.
En Android 13 (API 33) se introducen permisos granulares (READ_MEDIA_AUDIO, READ_MEDIA_IMAGES, READ_MEDIA_VIDEO), pero no son necesarios para insertar archivos propios en MediaStore.
8.5.2 MediaRecorder
La clase MediaRecorder permite configurar y controlar la grabación de audio. A continuación, se describen los pasos básicos para utilizar esta clase:
Configurar la fuente de audio: setAudioSource(MediaRecorder.AudioSource.MIC)
Definir el formato de salida y el codificador:
Formato recomendado: MPEG_4
Códec recomendado: AAC
Establecer el destino de salida (setOutputFile), en este caso, un descriptor de archivo obtenido de MediaStore.
Llamar a prepare() y luego start().
Al finalizar, detener con stop() y liberar recursos con release().
8.5.3 Ejemplo completo de Grabadora con cronómetro
A continuación, se muestra una implementación completa y funcional de una grabadora de audio en Jetpack Compose. En el ejemplo podrás ver cómo solicitar permisos, iniciar y detener la grabación, y mostrar un cronómetro en tiempo real.
En primer lugar se creará el método encargado de formatear el tiempo en segundos a un formato mm:ss.
Como puedes ver, el método formatTime toma un valor en segundos y lo convierte en una cadena con el formato mm:ss, donde mm representa los minutos y ss los segundos, ambos con dos dígitos. A continuación, se implementa el método createMediaStoreOutput, que se encargará de crear un archivo de salida en MediaStore y devolver su URI y descriptor de archivo.
1privatefuncreateMediaStoreOutput(context: Context): Pair<Uri, ParcelFileDescriptor> {
2valtimestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
3valname = "REC_$timestamp.m4a" 4 5valvalues = ContentValues().apply {
6 put(MediaStore.Audio.Media.DISPLAY_NAME, name)
7 put(MediaStore.Audio.Media.MIME_TYPE, "audio/mp4")
8 put(MediaStore.Audio.Media.RELATIVE_PATH, "${Environment.DIRECTORY_MUSIC}/PMDM")
9 }
1011valresolver: ContentResolver = context.contentResolver
12valuri = resolver.insert(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, values)
13?:throw IllegalStateException("No se pudo crear el archivo en MediaStore")
1415valpfd = resolver.openFileDescriptor(uri, "w")
16?:throw IllegalStateException("No se pudo abrir el descriptor de archivo")
1718return uri to pfd
19}
Si se entra en detalle, el método createMediaStoreOutput realiza las siguientes acciones:
Genera un nombre de archivo único basado en la fecha y hora actual, con el formato REC_yyyyMMdd_HHmmss.m4a.
Crea un ContentValues para definir los metadatos del archivo, incluyendo el nombre, el tipo MIME y la ruta relativa dentro del directorio de música.
Utiliza el ContentResolver para insertar un nuevo registro en MediaStore, obteniendo la URI del archivo creado.
Abre un ParcelFileDescriptor en modo escritura ("w") para el archivo recién creado.
Devuelve un par que contiene la URI y el descriptor de archivo, que se utilizarán para configurar el MediaRecorder.
Ya por último, se implementa el composableAudioRecorderScreen, que es la interfaz de usuario para la grabadora de audio.
Si se analiza el código, el composableAudioRecorderScreen realiza las siguientes funciones:
Define varios estados:
isRecording: Indica si la grabación está en curso.
elapsedTime: Almacena el tiempo transcurrido en segundos.
recorder: Mantiene una referencia al objeto MediaRecorder.
outputUri: Almacena la URI del archivo de audio guardado.
Utiliza rememberLauncherForActivityResult para crear un lanzador que solicita el permiso RECORD_AUDIO. Si el permiso es denegado, muestra un mensaje de error.
LaunchedEffect(Unit): Solicita el permiso de grabación de audio cuando el composable se monta por primera vez.
LaunchedEffect(isRecording): Implementa un cronómetro que incrementa elapsedTime cada segundo mientras isRecording es true.
La UI se compone de una columna que contiene:
Un título y un texto que muestra el tiempo transcurrido formateado.
Un botón que inicia o detiene la grabación. Al hacer clic:
Si ya se está grabando, detiene y libera el MediaRecorder, actualiza el estado y muestra un mensaje.
Si no se está grabando, crea un nuevo archivo en MediaStore, configura y comienza la grabación con MediaRecorder. Si ocurre un error, muestra un mensaje de error.
Si outputUri no es null, muestra la URI del archivo guardado.
Android proporciona acceso a una variedad de sensores integrados en los dispositivos, como acelerómetros, giroscopios, sensores de luz, proximidad, y otros. Estos sensores permiten que las aplicaciones puedan recopilar datos del entorno y del movimiento del dispositivo para ofrecer experiencias más interactivas y contextuales.
Los sensores se pueden utilizar para una variedad de propósitos, como detectar la orientación del dispositivo, medir la luz ambiental para ajustar el brillo de la pantalla o detectar el movimiento del dispositivo para activar ciertas funciones. Para acceder a los sensores en Android, se utiliza el SensorManager, que proporciona métodos para registrar y desregistrar oyentes de sensores, así como para obtener información sobre los sensores disponibles en el dispositivo.
Obviamente, el uso de sensores puede afectar al consumo de batería del dispositivo, por lo que es importante gestionar adecuadamente el registro y desregistro de los oyentes de sensores para minimizar el impacto en la duración de la batería.
El siguiente composable muestra cómo utilizar el acelerómetro para medir los cambios en la aceleración del dispositivo y mostrar los valores de los ejes X, Y y Z en tiempo real.
1@Composable 2funAccelerometerSensor() {
3valcontxt = LocalContext.current
4valsensorManager = contxt.getSystemService(Context.SENSOR_SERVICE) as SensorManager
5valaccelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
6 7varxby remember { mutableStateOf(0f) }
8varyby remember { mutableStateOf(0f) }
9varzby remember { mutableStateOf(0f) }
1011// Filtro paso-bajo simple, lowPass se usa para suavizar los valores del sensor,
12// evitando cambios bruscos en la UI.
13valalpha = 0.1f14funlowPass(new: Float, old: Float) = old + alpha * (new - old)
1516// Registrar el listener del sensor cuando el Composable entre en composición.
17// DisposableEffect se asegura de que el listener se desregistre cuando el Composable se elimine.
18 DisposableEffect(Unit) {
19if (accelerometer ==null) {
20return@DisposableEffect onDispose {}
21 }
2223vallistener = object: SensorEventListener {
24overridefunonSensorChanged(event: SensorEvent?) {
25if (event?.sensor?.type ==Sensor.TYPE_ACCELEROMETER) {
26 x = lowPass(event.values[0], x)
27 y = lowPass(event.values[1], y)
28 z = lowPass(event.values[2], z)
29 }
30 }
3132overridefunonAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
33 }
3435 sensorManager.registerListener(
36 listener,
37 accelerometer,
38SensorManager.SENSOR_DELAY_NORMAL
39 )
4041// Asegurar que se desregistre el listener cuando el Composable se elimine.
42 onDispose {
43 sensorManager.unregisterListener(listener)
44 }
45 }
4647 Column {
48 Text("Acelerómetro (m/s²):")
49 Text("X: ${"%.2f".format(x)} Y: ${"%.2f".format(y)} Z: ${"%.2f".format(z)}")
50if (accelerometer ==null) Text("No disponible en este dispositivo.")
51 }
52}
Las acciones principales que realiza este composable son:
Obtiene el SensorManager y el sensor de tipo TYPE_ACCELEROMETER.
Define variables de estado x, y y z para almacenar los valores del acelerómetro.
Implementa un filtro de paso bajo simple para suavizar los valores del sensor y evitar cambios bruscos en la UI.
Utiliza DisposableEffect para registrar un SensorEventListener cuando el composable entra en composición, y se asegura de desregistrar el listener cuando el composable se elimina.
Se registra el listener con un retardo normal (SENSOR_DELAY_NORMAL), pero se pueden usar otros valores según la necesidad de la aplicación, SENSOR_DELAY_GAME, por ejemplo, es más rápido y da una respuesta más fluida, pero consume más batería.
En el método onSensorChanged, actualiza los valores de x, y y z utilizando el filtro de paso bajo.
Muestra los valores del acelerómetro en la UI, formateados a dos decimales.
Si el acelerómetro no está disponible en el dispositivo, muestra un mensaje indicando que no está disponible.
Y en lugar de comprobar los valores de X, Y y Z del acelerómetro, lo harás con los del giroscopio, que son las tasas de rotación alrededor de los ejes X, Y y Z, que serían pitch/roll/yaw, valores de rotación en radianes por segundo.
Diferenciar los distintos tipos de almacenamiento en Android y sus casos de uso.
Implementar almacenamiento ligero con SharedPreferences y DataStore.
Gestionar archivos en memoria interna y externa, aplicando las restricciones de seguridad actuales.
Utilizar el Storage Access Framework (SAF) para seleccionar y manipular documentos de forma segura.
Aplicar buenas prácticas de seguridad y eficiencia en el tratamiento de datos.
Desarrollar una aplicación práctica que integre los distintos mecanismos de almacenamiento.
9.1. Introducción al almacenamiento en Android
El almacenamiento en Android permite conservar información más allá del ciclo de vida de la aplicación. Se distinguen distintos tipos:
Memoria interna: espacio privado de la aplicación. Otros programas no pueden acceder.
Memoria externa: almacenamiento compartido (galería, descargas). Puede ser accesible por otras apps, aunque desde Android 10 se restringe con Scoped Storage.
Caché: almacenamiento temporal, puede ser eliminado por el sistema en cualquier momento.
Nota
Desde Android 10, el acceso a memoria externa está limitado por razones de seguridad. Android introduce el concepto de Scoped Storage, que restringe el acceso directo a directorios externos y fomenta el uso de MediaStore o Storage Access Framework (SAF).
9.2. Archivos internos
Los archivos internos son privados de la aplicación. Se eliminan cuando esta se desinstala.
Ejemplo: escribir un archivo en memoria interna
1// Guardar un archivo de texto en memoria interna
2funsaveToFile(context: Context, fileName: String, contenido: String) {
3 context.openFileOutput(fileName, Context.MODE_APPEND).use { output ->4 output.write(contenido.toByteArray())
5 }
6}
Nota
Si utilizas Context.MODE_PRIVATE, el archivo se sobrescribirá cada vez que se guarde.
Ejemplo: leer un archivo de memoria interna
1// Leer un archivo de texto desde memoria interna
2funreadFromFile(context: Context, fileName: String): String {
3returntry {
4 context.openFileInput(fileName).bufferedReader().use { it.readText() }
5 } catch (e: Exception) {
6"Error al leer el archivo: ${e.message}"7 }
8}
Este código abre un archivo en modo lectura y devuelve su contenido como cadena, manejando posibles excepciones, como que el archivo no exista.
Información
Puedes consultar los archivos guardados en memoria interna desde Android Studio: View > Tool Windows > Device File Explorer. Navega a /data/data/<tu_paquete>/files/ para ver los archivos de tu aplicación.
9.3. SharedPreferences
SharedPreferences es un sistema clave-valor para almacenar configuraciones simples.
Datos pequeños: ajustes, flags, preferencias del usuario.
Limitaciones: acceso síncrono y sin soporte para estructuras complejas.
En este ejemplo, se guarda y recupera una cadena asociada a una clave en las preferencias compartidas. Un posible resultado es el siguiente fichero XML:
Puedes consultar las preferencias guardadas en Android Studio: View > Tool Windows > Device File Explorer. Navega a /data/data/<tu_paquete>/shared_prefs/ para ver el archivo XML con las preferencias de tu aplicación.
9.4. DataStore
DataStore es la alternativa moderna a SharedPreferences.
Basado en corrutinas y Flow → asincrónico y seguro.
Tipos:
Preferences DataStore: clave-valor.
Proto DataStore: objetos tipados mediante Protobuf (mecanismo de serialización).
En este ejemplo, se guarda y recupera una preferencia booleana que indica si el tema oscuro está activado. El valor predeterminado es false (tema claro).
Uso desde una Activity:
1// Guardar el modo tema en DataStore
2CoroutineScope(Dispatchers.IO).launch {
3 saveThemeMode(contxt, it)
4}
5 6...
7// Leer el modo tema desde DataStore
8LaunchedEffect(checked) {
9 readThemeMode(contxt).collect { valor ->10 checked = valor
11 }
12}
9.5. Storage Access Framework (SAF)
Los archivos externos (fotos, música, documentos) son accesibles fuera de la aplicación. Desde Android 10, solo es posible acceder mediante rutas específicas o el uso de SAF.
SAF permite a la aplicación abrir, guardar y seleccionar documentos mediante un explorador de archivos seguro, sin acceso directo al almacenamiento.
Este código crea un lanzador para seleccionar un documento. Al seleccionar un archivo, se obtiene su URI y se lee su contenido, permitiendo mostrarlo en la interfaz.
9.6. Almacenamiento seguro de preferencias con Android Keystore + AES-GCM
El almacenamiento puede exponer datos sensibles si no se protege adecuadamente, pero Android ofrece mecanismos de cifrado. Desde 2025 la librería Jetpack Security Crypto (que contenía EncryptedSharedPreferences) está deprecada. La recomendación de Google es apoyarse en APIs de plataforma (Android Keystore + JCA) y en DataStore como reemplazo moderno de SharedPreferences para preferencias no cifradas, si se necesita cifrado, cifra el valor antes de persistirlo.
Para aplicar el siguiente ejemplo se añadirá la dependencia utilizada en el punto 9.4., DataStore. Además, se utilizará el patrón de diseño MVVM y Repository para abstraer la lógica de acceso a datos, manteniendo el código limpio y modular.
Arquitectura propuesta para almacenamiento seguro
+-------------------+
| UI (Compose) |
+-------------------+
|
v
+-------------------+
| ViewModel |
+-------------------+
|
v
+-------------------+
| Repository |
+-------------------+
|
v
+-------------------+
| CryptoManager |
+-------------------+
ui/SecurePrefsScreen: ejemplo Compose para guardar/leer un token/API key.
Paso 1: Implementación de las utilidades criptográficas (Keystore + AES-GCM)
En primer lugar, se creará el objeto CryptoManager en el paquete data/security que gestionará la generación de claves y el cifrado/descifrado de datos.
1importandroid.security.keystore.KeyGenParameterSpec 2importandroid.security.keystore.KeyProperties 3importjava.security.KeyStore 4importjavax.crypto.Cipher 5importjavax.crypto.KeyGenerator 6importjavax.crypto.SecretKey 7importjavax.crypto.spec.GCMParameterSpec 8 9objectCryptoManager {
1011privateconstvalANDROID_KEYSTORE = "AndroidKeyStore"12privateconstvalKEY_ALIAS = "prefs_aes_key"13privateconstvalTRANSFORMATION = "AES/GCM/NoPadding"14privateconstvalGCM_TAG_BITS = 1281516// Obtiene o crea una clave simétrica AES en el Keystore (no exportable).
17fungetOrCreateKey(): SecretKey {
18valks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
19 (ks.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.secretKey?.let { returnit }
2021valspec = KeyGenParameterSpec.Builder(
22 KEY_ALIAS,
23KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
24 )
25 .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
26 .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
27 .setUserAuthenticationRequired(false) // Poner a true si quieres exigir biometría/bloqueo.
28 .build()
2930returnKeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
31 .apply { init(spec) }
32 .generateKey()
33 }
3435// Clase para mantener el IV junto a los datos cifrados.
36dataclassCiphertext(valiv: ByteArray, valbytes: ByteArray)
3738// Cifra datos con AES-GCM y IV aleatorio generado por el Cipher.
39funencrypt(plain: ByteArray, key: SecretKey = getOrCreateKey()): Ciphertext {
40valcipher = Cipher.getInstance(TRANSFORMATION)
41 cipher.init(Cipher.ENCRYPT_MODE, key)
42valiv = cipher.iv
43valenc = cipher.doFinal(plain)
44return Ciphertext(iv, enc)
45 }
4647// Descifra datos con AES-GCM usando el IV asociado.
48fundecrypt(ct: Ciphertext, key: SecretKey = getOrCreateKey()): ByteArray {
49valcipher = Cipher.getInstance(TRANSFORMATION)
50valspec = GCMParameterSpec(GCM_TAG_BITS, ct.iv)
51 cipher.init(Cipher.DECRYPT_MODE, key, spec)
52return cipher.doFinal(ct.bytes)
53 }
54}
El objeto CryptoManager proporciona métodos para generar o recuperar una clave AES almacenada en el Keystore de Android, así como para cifrar y descifrar datos utilizando AES-GCM.
getOrCreateKey(): obtiene o crea una clave AES en el Keystore.
La clase Ciphertext encapsula el IV y los datos cifrados, necesarios para el descifrado.
encrypt(plain: ByteArray): cifra un array de bytes y devuelve un objeto Ciphertext que contiene el IV y los datos cifrados.
decrypt(ct: Ciphertext): descifra los datos utilizando el IV almacenado en el objeto Ciphertext.
Paso 2: Implementación del repositorio para almacenamiento seguro
A continuación, se crea la clase SecurePrefsRepository en el paquete data/repository. Esta clase utilizará DataStore para almacenar los datos cifrados.
1importandroid.content.Context 2importandroidx.datastore.preferences.core.Preferences 3importandroidx.datastore.preferences.core.edit 4importandroidx.datastore.preferences.core.stringPreferencesKey 5importandroidx.datastore.preferences.preferencesDataStore 6importkotlinx.coroutines.flow.Flow 7importkotlinx.coroutines.flow.map 8importandroid.util.Base64 9importes.javiercarrasco.documentationt9_1.data.security.CryptoManager1011privatevalContext.dataStore by preferencesDataStore(name = "secure_prefs")
1213classSecurePrefsRepository(privatevalcontext: Context) {
1415// Claves para el DataStore.
16privatevalKEY_IV = stringPreferencesKey("token_iv_b64")
17privatevalKEY_CT = stringPreferencesKey("token_ct_b64")
1819// Guarda un token cifrado en DataStore (iv + ciphertext, ambos en Base64).
20suspendfunsaveToken(token: String) {
21valct = CryptoManager.encrypt(token.encodeToByteArray())
22valivB64 = Base64.encodeToString(ct.iv, Base64.NO_WRAP)
23valdataB64 = Base64.encodeToString(ct.bytes, Base64.NO_WRAP)
2425// Almacena ambos en DataStore.
26 context.dataStore.edit { prefs ->27 prefs[KEY_IV] = ivB64
28 prefs[KEY_CT] = dataB64
29 }
30 }
3132// Flujo que expone el token descifrado o null si no existe.
33valtokenFlow: Flow<String?> = context.dataStore.data.map { prefs: Preferences ->34valivB64 = prefs[KEY_IV]
35valctB64 = prefs[KEY_CT]
36if (ivB64 ==null|| ctB64 ==null) return@mapnull3738valiv = Base64.decode(ivB64, Base64.NO_WRAP)
39valdata = Base64.decode(ctB64, Base64.NO_WRAP)
40CryptoManager.decrypt(CryptoManager.Ciphertext(iv, data)).decodeToString()
41 }
4243// Flujo que expone el token cifrado en formato "iv|ciphertext" o null si no existe.
44valencryptedFlow: Flow<String?> = context.dataStore.data.map { prefs ->45valiv = prefs[KEY_IV]
46valct = prefs[KEY_CT]
47if (iv !=null&& ct !=null) "$iv|$ct"elsenull48 }
4950// Elimina el token almacenado.
51suspendfunclearToken() {
52 context.dataStore.edit { it.remove(KEY_IV); it.remove(KEY_CT) }
53 }
54}
Esta clase SecurePrefsRepository proporciona métodos para guardar, leer y eliminar un token de autenticación de forma segura utilizando DataStore y el CryptoManager para el cifrado.
saveToken(token: String): cifra el token y lo guarda en DataStore en formato Base64.
tokenFlow: un flujo que emite el token descifrado o null si no existe.
encryptedFlow: un flujo que emite el token cifrado en formato "iv|ciphertext" o null si no existe.
clearToken(): elimina el token almacenado.
Información
DataStore sustituye a SharedPreferences, es asíncrono y consistente (Flow + corutinas). Aquí se utiliza como contenedor del Ciphertext, DataStore no cifra por sí mismo.
Paso 3: Implementación del ViewModel
Se crea la clase SecurePrefsViewModel en el paquete ui, que se encargará de la interacción entre la UI y el repositorio.
1importandroidx.lifecycle.ViewModel 2importandroidx.lifecycle.viewModelScope 3importes.javiercarrasco.documentationt9_1.data.repository.SecurePrefsRepository 4importkotlinx.coroutines.flow.SharingStarted 5importkotlinx.coroutines.flow.StateFlow 6importkotlinx.coroutines.flow.stateIn 7importkotlinx.coroutines.launch 8 9classSecurePrefsViewModel(privatevalrepo: SecurePrefsRepository) : ViewModel() {
1011// StateFlow para observar el token almacenado de forma segura.
12valtoken: StateFlow<String?> = repo.tokenFlow
13 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
1415// StateFlow para observar el token cifrado (iv + ciphertext).
16valencrypted: StateFlow<String?> = repo.encryptedFlow
17 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
1819// Función para guardar el token de forma segura.
20funsaveToken(value: String) = viewModelScope.launch {
21 repo.saveToken(value)
22 }
2324// Función para borrar el token almacenado de forma segura.
25funclearToken() = viewModelScope.launch {
26 repo.clearToken()
27 }
28}
El SecurePrefsViewModel expone un StateFlow para observar el token almacenado y proporciona métodos para guardar y eliminar el token de forma segura.
token: un StateFlow que emite el token actual o null.
encrypted: un StateFlow que emite el token cifrado en formato "iv|ciphertext" o null.
saveToken(value: String): guarda el token de forma segura.
clearToken(): elimina el token almacenado.
Paso 4: Implementación de la UI
Finalmente, se crea una pantalla Compose SecurePrefsScreen en el paquete ui para interactuar con el usuario.