Desarrollo web y móvil

Fundamentos de Android: activities, Compose, arquitectura y trabajo en segundo plano

Principiante10 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

Android ha acumulado dos décadas de API, y lo difícil para quien empieza es saber cuáles siguen siendo el camino recomendado. Esta guía recorre una app moderna desde el build de Gradle y el manifiesto hasta el ciclo de vida de las activities, Jetpack Compose, las capas de ViewModel y repositorio, las corrutinas y Flow, el almacenamiento, la red y el modelo de permisos en tiempo de ejecución.

Lo que aprenderás

  • Orientarte en un proyecto Android: módulos de Gradle, el manifiesto, los recursos y los source sets
  • Explicar el ciclo de vida de una activity y sobrevivir a los cambios de configuración y a la muerte del proceso
  • Construir pantallas con Jetpack Compose y leer los layouts XML que seguirás heredando
  • Estructurar una app con un ViewModel, un repositorio y un flujo de estado unidireccional
  • Sacar trabajo del hilo principal con corrutinas y programar de forma fiable el trabajo diferible
  • Solicitar permisos en tiempo de ejecución y elegir la opción de almacenamiento adecuada

El desarrollo en Android tiene una curva de aprendizaje peculiar: la plataforma es lo bastante antigua como para que casi cualquier problema tenga tres soluciones documentadas, una recomendada, una heredada y una obsoleta. Quien empieza y busca cómo cargar datos o navegar entre pantallas encontrará respuestas que abarcan quince años de diseño de API, sin nada en el resultado que indique a qué época pertenece cada una. Conocer hoy el camino recomendado es ya media batalla ganada.

Esta guía sigue un único camino de principio a fin: cómo se ensambla un proyecto, qué espera el sistema operativo de tus componentes, cómo dibujar una pantalla con Jetpack Compose y cómo organizar el código para que los datos fluyan en una sola dirección y sobrevivan a las interrupciones que la plataforma te va a lanzar. Los ejemplos están en Kotlin, que es el lenguaje por defecto de Android y el que usa toda la documentación actual.

De qué se compone un proyecto

Un proyecto Android es un build de Gradle. La raíz contiene la configuración y las definiciones de versiones, y cada módulo tiene su propio script de build; el módulo por defecto es app.

// app/build.gradle.kts
android {
    namespace = "com.example.orders"
    compileSdk = 36

    defaultConfig {
        applicationId = "com.example.orders"
        minSdk = 26
        targetSdk = 36
        versionCode = 14
        versionName = "2.3.0"
    }

    buildFeatures { compose = true }
}

dependencies {
    implementation(libs.androidx.activity.compose)
    implementation(libs.androidx.lifecycle.viewmodel.compose)
    implementation(libs.room.runtime)
}

Tres números de SDK cumplen funciones distintas y se confunden constantemente. compileSdk es el nivel de API contra el que se compila tu código: ponlo en el más reciente disponible. minSdk es la versión más antigua en la que se instala la app, lo que determina cuánta API moderna puedes usar sin comprobaciones de versión. targetSdk es una declaración de que has probado la app con una versión concreta; el sistema aplica los cambios de comportamiento de ese nivel y mantiene capas de compatibilidad para todo lo posterior, y las tiendas de aplicaciones exigen que se mantenga actualizado. Las referencias libs. vienen de un version catalog en gradle/libs.versions.toml, que es la forma actual de mantener las versiones de las dependencias en un solo sitio para todos los módulos.

El manifiesto le declara al sistema operativo qué contiene la app y qué necesita:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:name=".OrdersApplication"
        android:icon="@mipmap/ic_launcher"
        android:theme="@style/Theme.Orders">

        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

Cada activity, service, broadcast receiver y content provider debe declararse aquí, junto con los permisos y cualquier requisito de hardware. Un intent-filter anuncia qué puede manejar un componente: el de arriba es el que pone un icono en el launcher, y otros permiten que tu app aparezca en el menú para compartir o abra una categoría concreta de enlaces.

Las activities y su ciclo de vida

Una activity es un punto de entrada a tu app: una pantalla que el sistema puede iniciar, detener, recrear o matar. La mayoría de las apps modernas usan una sola activity que aloja muchos destinos, composables o fragments, en lugar de una activity por pantalla.

El ciclo de vida existe porque tu app no controla cuándo se ejecuta. Entra una llamada, el usuario cambia de app, el dispositivo gira, la memoria escasea. Los callbacks son onCreate, onStart, onResume, onPause, onStop y onDestroy, y funcionan en parejas simétricas: lo que adquieras en onStart lo liberas en onStop.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent { OrdersApp() }
    }
}

Dos consecuencias condicionan todo lo demás. Primero, un cambio de configuración (rotación, modo oscuro, idioma, pasar a pantalla dividida) destruye y vuelve a crear la activity para poder cargar los recursos de la nueva configuración. Todo lo guardado en un campo de la activity desaparece. Segundo, cuando tu app está en segundo plano, el sistema puede matar el proceso por completo y restaurarlo más tarde, cuando el usuario espera seguir justo donde lo dejó.

La respuesta al primer problema es el ViewModel, que está ligado a la pantalla y sobrevive a la recreación. La respuesta al segundo es SavedStateHandle o rememberSaveable, que conservan una pequeña cantidad de estado a través de la muerte del proceso dentro de un Bundle. Bloquear la rotación en el manifiesto no es una solución; solo oculta uno de los muchos desencadenantes.

Intents y navegación entre pantallas

Un intent es un mensaje que describe algo que hay que hacer. Un intent explícito nombra el componente de destino y es la forma de iniciar tu propia activity. Un intent implícito describe una acción y deja que el sistema ofrezca cualquier app instalada que pueda manejarla.

// Explícito
startActivity(Intent(this, DetailActivity::class.java).putExtra("orderId", id))

// Implícito — cualquier app que sepa abrir una dirección web
startActivity(Intent(Intent.ACTION_VIEW, "https://example.com/orders/$id".toUri()))

Para obtener resultados de otra app (elegir una foto, hacer una fotografía, pedir un permiso), usa las Activity Result APIs con registerForActivityResult, que resuelven correctamente el problema de la recreación. Dentro de una app de una sola activity, la navegación entre pantallas la gestiona el componente Navigation: los destinos se declaran en un grafo, los argumentos están tipados y la back stack se gestiona por ti en lugar de hacer malabares con activities. Los fragments precedieron a los composables como unidad de pantalla reutilizable y siguen siendo habituales; tienen su propio ciclo de vida, desfasado respecto al de la activity que los aloja, y otro distinto para su vista, que es el origen de la mayoría de los bugs con fragments.

Dibujar la pantalla

Conviven dos toolkits de interfaz. El sistema de View describe una jerarquía en XML y la infla en tiempo de ejecución; es lo que usa la inmensa mayoría de las apps existentes y lo que leerás en cualquier base de código consolidada.

<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/reference"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:textAppearance="?attr/textAppearanceTitleMedium" />
</androidx.constraintlayout.widget.ConstraintLayout>

Jetpack Compose es el toolkit recomendado para el trabajo nuevo. En lugar de describir un árbol y mutarlo, escribes funciones que convierten estado en interfaz, y el framework vuelve a invocarlas cuando cambia el estado que leen.

@Composable
fun OrderRow(order: Order, onRemove: (String) -> Unit, modifier: Modifier = Modifier) {
    Row(
        modifier = modifier.fillMaxWidth().padding(16.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Column(Modifier.weight(1f)) {
            Text(order.reference, style = MaterialTheme.typography.titleMedium)
            Text(order.formattedTotal, style = MaterialTheme.typography.bodySmall)
        }
        IconButton(onClick = { onRemove(order.id) }) {
            Icon(Icons.Default.Delete, contentDescription = "Remove order")
        }
    }
}

Tres convenciones concentran casi todo el aprendizaje. El estado que pertenece a un composable se guarda con remember { mutableStateOf(...) }, o con rememberSaveable cuando debe sobrevivir a la recreación. El estado que necesitan varios composables se eleva (state hoisting) hasta su ancestro común más cercano, de modo que los hijos reciben un valor y un callback y siguen siendo reutilizables y previsualizables. Y todo composable que renderice algo debe aceptar un parámetro Modifier como primer argumento opcional, para que quien lo llama controle el tamaño, el padding y el comportamiento al hacer clic.

La accesibilidad merece atención aquí por los mismos motivos que en la web: contentDescription es el equivalente del texto alternativo, las zonas táctiles deben medir al menos 48dp y los principios que se ven en lo esencial de HTML se aplican igual a la pantalla de un móvil.

Recursos, configuración y localización

Todo lo que no es código vive bajo res/: values/strings.xml, drawable/, mipmap/ para los iconos del launcher, layout/ para el XML, font/ y las definiciones de temas. El nombre del directorio puede llevar calificadores, y el sistema elige la mejor coincidencia en tiempo de ejecución: values-fr/ para francés, values-night/ para el modo oscuro, drawable-hdpi/ para una densidad, values-sw600dp/ para pantallas grandes. Por eso dejar escrito en el código un texto visible para el usuario es un defecto real: hace que la app no se pueda traducir, y lint lo marcará.

Usa dp para las dimensiones, de modo que un layout ocupe el mismo tamaño físico en cualquier densidad, y sp para el texto, para que respete el tamaño de fuente que prefiere el usuario. Desde el nivel de API 35, las apps se dibujan de borde a borde por defecto, así que debes aplicar los window insets en lugar de suponer un rectángulo libre de barras del sistema.

Listas

Las listas son el caballo de batalla de la interfaz móvil, y ambos toolkits reciclan vistas para que una lista de mil elementos no cueste más que una pantalla llena. En el sistema de View es RecyclerView, que necesita un adapter, un ViewHolder que guarda en caché las referencias a las vistas del elemento y un LayoutManager; ListAdapter con un DiffUtil.ItemCallback calcula el conjunto mínimo de cambios entre dos listas para que los elementos se animen correctamente. En Compose, el mismo comportamiento viene integrado:

LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
    items(orders, key = { it.id }) { order ->
        OrderRow(order = order, onRemove = viewModel::remove)
    }
}

La key equivale al callback de diff: le dice a Compose qué elemento es cuál entre recomposiciones, para que el estado y las animaciones sigan a la fila correcta. Omitirla en una lista reordenable produce la misma categoría de bug que en cualquier framework que compare listas.

Arquitectura: el estado en una sola dirección

La arquitectura recomendada tiene tres capas. La capa de UI (composables más un ViewModel) renderiza el estado y reenvía los eventos. La capa de dominio es opcional y contiene casos de uso cuando, de lo contrario, la lógica de negocio acabaría duplicada. La capa de datos contiene los repositorios, cada uno responsable de un tipo de dato y encargado de decidir entre fuentes locales y remotas.

data class OrdersUiState(
    val orders: List<Order> = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null,
)

class OrdersViewModel(private val repository: OrderRepository) : ViewModel() {
    private val _state = MutableStateFlow(OrdersUiState(isLoading = true))
    val state: StateFlow<OrdersUiState> = _state.asStateFlow()

    init { refresh() }

    fun refresh() = viewModelScope.launch {
        _state.update { it.copy(isLoading = true, error = null) }
        runCatching { repository.load() }
            .onSuccess { orders -> _state.update { it.copy(orders = orders, isLoading = false) } }
            .onFailure { e -> _state.update { it.copy(error = e.message, isLoading = false) } }
    }
}

El flujo es unidireccional: el repositorio produce datos, el ViewModel los integra en un único objeto de estado inmutable, la interfaz renderiza ese objeto y las acciones del usuario viajan de vuelta como llamadas a funciones. Una única clase de estado, en lugar de varios indicadores independientes, es lo que impide combinaciones imposibles como que la carga y el error sean verdaderos a la vez. El ViewModel no debe referenciar una vista, un context ni una activity (eso es lo que provoca fugas de memoria), y la interfaz recoge el flujo teniendo en cuenta el ciclo de vida, de modo que la recolección se detiene cuando la pantalla no está visible.

Corrutinas y el trabajo que debe sobrevivir

El hilo principal renderiza la interfaz, así que cualquier cosa lenta en él produce tirones o un diálogo de «la app no responde». Las corrutinas de Kotlin son la respuesta estándar: las funciones de suspensión liberan el hilo mientras esperan en lugar de bloquearlo.

class OrderRepository(private val api: OrderApi, private val dao: OrderDao) {
    suspend fun load(): List<Order> = withContext(Dispatchers.IO) {
        val fresh = api.fetchOrders()
        dao.replaceAll(fresh)
        fresh
    }

    fun observe(): Flow<List<Order>> = dao.observeAll()
}

viewModelScope cancela sus corrutinas automáticamente cuando se limpia el ViewModel, y eso es lo que impide que una petición sobreviva a la pantalla que la lanzó. Dispatchers.IO es para llamadas bloqueantes, Dispatchers.Default para trabajo de CPU y el dispatcher principal para todo lo que toca la interfaz. Flow transporta flujos de valores a lo largo del tiempo y StateFlow siempre tiene un valor actual, por eso encaja tan bien con el estado de una pantalla. Si vienes del modelo de hilos y executors descrito en concurrencia en Java, las corrutinas son concurrencia estructurada sobre esa misma maquinaria, con una cancelación que de verdad se propaga.

El trabajo que debe completarse aunque el usuario salga de la app (subir un formulario en cola, sincronizar por la noche) corresponde a WorkManager, que persiste la solicitud, aplica restricciones como «solo con red no medida y con el dispositivo cargando», reintenta con backoff y sobrevive a los reinicios. No lo uses para nada que la pantalla actual esté esperando.

Red, almacenamiento y permisos

La mayoría de las apps hablan con una API HTTP, normalmente con Retrofit o Ktor sobre OkHttp, y serializan JSON con kotlinx.serialization o Moshi. Define la API como una interfaz de funciones de suspensión, separa los modelos de transporte de tus modelos de dominio y convierte entre ellos en el repositorio, para que un cambio en el backend no se propague hasta la interfaz. Trata los códigos de estado de forma deliberada en lugar de considerar cualquier respuesta que no sea 2xx como un mismo fallo: las distinciones de la referencia de códigos de estado HTTP se traducen directamente en reintentar, volver a autenticar o mostrar un mensaje, y el contrato del lado del servidor se trata en diseño de API REST.

Para el almacenamiento, las opciones son: DataStore para preferencias clave-valor, que sustituye al antiguo SharedPreferences y es asíncrono y con seguridad de tipos; Room para datos locales estructurados, una capa SQL verificada en tiempo de compilación que devuelve Flow para que la interfaz se actualice cuando cambia la tabla; el directorio de archivos privado de la app para documentos y cachés; y MediaStore o el selector de fotos del sistema para contenido multimedia compartido. Nada sensible debe ir en preferencias en texto plano: usa el Keystore para claves y credenciales.

Los permisos se declaran en el manifiesto y, para todo lo que el sistema considera peligroso (ubicación, cámara, micrófono, contactos, notificaciones), además deben ser concedidos por el usuario en tiempo de ejecución mediante la Activity Result API. Pídelos en el momento en que se usa la funcionalidad y no al arrancar, explica el motivo antes de pedirlos cuando no sea evidente y trata una denegación como un camino normal, no como un error. La plataforma no deja de restringir lo que una app puede alcanzar sin preguntar, así que prefiere las API que no necesitan ningún permiso: el selector de fotos en lugar del acceso al almacenamiento, y el selector de documentos en lugar de rutas de archivo.

Ideas clave

  • Un proyecto Android es un build de Gradle con un manifiesto que declara los componentes ante el sistema; compileSdk, minSdk y targetSdk controlan cada uno algo distinto.
  • El ciclo de vida de las activities existe porque es el sistema quien decide cuándo se ejecuta tu app: cuenta con la recreación en los cambios de configuración y con la muerte del proceso en segundo plano, y guarda el estado en un ViewModel, con SavedStateHandle para el resto.
  • Jetpack Compose es el toolkit de interfaz recomendado: eleva el estado, pasa un Modifier y deja que la recomposición redibuje lo que ha cambiado; el sistema de View sigue siendo imprescindible para leer código existente.
  • Estructura la app en capas de UI, dominio (opcional) y datos, con un único objeto de estado inmutable por pantalla y los eventos volviendo como callbacks.
  • Las corrutinas con viewModelScope y Flow sacan el trabajo del hilo principal y se cancelan con la pantalla; WorkManager es solo para el trabajo que debe sobrevivir al cierre de la app.
  • Elige el almacenamiento según la forma de los datos (DataStore para preferencias, Room para datos estructurados) y pide los permisos en tiempo de ejecución en el momento de uso, prefiriendo las API que no necesitan ninguno.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Debo aprender primero Compose o los layouts XML?
Aprende primero Compose, porque es el toolkit recomendado para interfaces nuevas y hacia donde avanzan la documentación y las bibliotecas. Aun así tendrás que saber leer layouts XML, ya que una cantidad enorme de código en producción los usa y la interoperabilidad es habitual en pantallas mixtas. Trata el sistema de View como algo que sabes mantener, no como algo que elegirías para una pantalla nueva.
¿Por qué mi app pierde el estado al girar la pantalla?
Un cambio de configuración destruye y vuelve a crear la activity para que pueda cargar los recursos de la nueva configuración. Todo lo que guardes en un campo de la activity se pierde. Mantén el estado de la pantalla en un ViewModel, que sobrevive a la recreación, y guarda en SavedStateHandle la pequeña parte que debe sobrevivir a que el sistema mate el proceso. Bloquear la rotación no es una solución: la misma recreación ocurre al cambiar de idioma, activar el modo oscuro o usar multiventana.
¿Qué diferencia hay entre una corrutina y WorkManager?
Una corrutina ejecuta trabajo ahora, ligada a algo que está vivo (normalmente un ViewModel), y se cancela cuando ese scope termina. WorkManager programa trabajo diferible que debe completarse tarde o temprano aunque el usuario salga de la app o el dispositivo se reinicie, como subir un informe que quedó en cola. Usa corrutinas para todo lo que alimenta la pantalla actual y WorkManager solo cuando importa que la ejecución esté garantizada.
¿Necesito un framework de inyección de dependencias?
Para empezar, no. Construir las dependencias a mano en una app pequeña es claro y no añade coste al build. Cuando el grafo crece, pasar una base de datos, un cliente de red y media docena de repositorios por cada constructor se convierte en ruido, y un framework como Hilt genera ese cableado y hace que sustituir dependencias por fakes en los tests sea sencillo. Adóptalo cuando el cableado manual empiece a doler, no antes.