🤔 ¿Por qué necesitamos almacenar datos?
Imagina una app de notas que pierde todo al cerrarla, o un juego que olvida tu puntuación. Toda app útil necesita persistir datos. Android ofrece varias opciones según la complejidad y ubicación de los datos:
¿Datos simples de configuración? → DataStore. ¿Listas o datos estructurados? → Room. ¿Varios usuarios o tiempo real? → Firebase.
📦 SharedPreferences vs DataStore
Ambos guardan pares clave → valor, pero con diferencias importantes:
SharedPreferences (clásico)
- API antigua, síncrona
commit()bloquea el hiloapply()es asíncrono pero sin confirmación- No apto para operaciones complejas
- Todavía válido para casos simples
DataStore ✅ Recomendado
- Basado en corrutinas y Flow
- Operaciones siempre asíncronas y seguras
- Manejo de errores con
Result - Preferences DataStore o Proto DataStore
- Reemplaza SharedPreferences
// SharedPreferences — forma clásica val prefs = getSharedPreferences("mi_app", Context.MODE_PRIVATE) prefs.edit().apply { putString("nombre", "Juan") putBoolean("sesion", true) }.apply() // asíncrono // DataStore — forma moderna (recomendada) val NAME_KEY = stringPreferencesKey("nombre") // Guardar lifecycleScope.launch { dataStore.edit { prefs -> prefs[NAME_KEY] = "Juan" } } // Leer como Flow reactivo val nombre: Flow<String> = dataStore.data .map { prefs -> prefs[NAME_KEY] ?: "" }
Usar commit() en el hilo principal bloquea la UI y puede causar ANR (App Not Responding). Usar siempre apply() o DataStore con corrutinas.
🗄️ Room Database: el ORM oficial de Android
Room es la capa de abstracción sobre SQLite que Google recomienda oficialmente. En lugar de escribir SQL directamente, usas anotaciones Kotlin y Room genera el código SQL por ti.
Analogía: Room es como un archivero automático. Tú le dices qué datos guardar (@Entity), qué operaciones hacer (@Dao) y él gestiona el archivador (SQLite) por ti.
@Entity → Una clase Kotlin que representa una tabla.
@Dao → Interface con los métodos de acceso (SELECT, INSERT, DELETE).
@Database → La clase que gestiona la BD, es el punto de entrada.
Repository → Patrón de diseño para abstraer el origen de datos (no es de Room).
Arquitectura Room
// 1. ENTITY — representa una tabla @Entity(tableName = "usuarios") data class Usuario( @PrimaryKey(autoGenerate = true) val id: Int = 0, val nombre: String, val email: String ) // 2. DAO — operaciones sobre la tabla @Dao interface UsuarioDao { @Query("SELECT * FROM usuarios") fun getAll(): Flow<List<Usuario>> @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(u: Usuario) @Delete suspend fun delete(u: Usuario) }
Acceder a la base de datos Room desde el hilo principal causa una excepción. Siempre usa suspend fun en el DAO y llámalo desde una corrutina (viewModelScope.launch).
Si tienes una app de lista de la compra, ¿qué sería la @Entity? ¿Qué métodos necesitarías en el @Dao?
☁️ Firebase: base de datos en la nube
Firebase es la plataforma de Google para apps en la nube. Para bases de datos ofrece dos opciones:
🔴 Realtime Database
- Árbol JSON en tiempo real
- Sincronización offline automática
- Ideal para: chats, presencia, datos colaborativos
- Estructura simple pero rígida
🔵 Cloud Firestore ✅
- Colecciones y documentos (como MongoDB)
- Consultas avanzadas con filtros y ordenación
- Escalabilidad superior
- Ideal para apps de producción
Una app de chat usa Realtime Database para que los mensajes aparezcan al instante en todos los dispositivos. Una app de e-commerce usa Firestore para consultar productos por categoría y precio.
Almacenamiento simple: DataStore. BD local: Room (ORM sobre SQLite, con @Entity + @Dao + @Database). Nube: Firebase Realtime (tiempo real y chats) o Firestore (apps complejas). Patrón: ViewModel → Repository → DAO → Room/Firebase.
🗺️ ¿Qué podemos hacer con mapas en Android?
Android permite integrar mapas interactivos completos en cualquier app gracias al Google Maps SDK. Para usarlo necesitas una API Key gratuita de Google Cloud Console.
// Implementación básica — abrir mapa y añadir marcador class MapActivity : AppCompatActivity(), OnMapReadyCallback { override fun onMapReady(map: GoogleMap) { val madrid = LatLng(40.4168, -3.7038) map.addMarker( MarkerOptions().position(madrid).title("Madrid") ) map.animateCamera( CameraUpdateFactory.newLatLngZoom(madrid, 12f) ) } }
| Funcionalidad | Método | Descripción |
|---|---|---|
| Marcador | addMarker(MarkerOptions()) | Punto con título e icono |
| Tipo de mapa | mapType = MAP_TYPE_* | Normal, Satélite, Terreno, Híbrido |
| Cámara/Zoom | CameraUpdateFactory.newLatLngZoom() | Mover y enfocar el mapa |
| Polilínea | addPolyline(PolylineOptions()) | Trazar rutas |
| Polígono | addPolygon(PolygonOptions()) | Delimitar áreas |
| Círculo | addCircle(CircleOptions()) | Radio alrededor de un punto |
📡 Geolocalización: FusedLocationProviderClient
Analogía: Es como el GPS de un coche, pero inteligente. En lugar de usar solo satélites (lento al arrancar), combina GPS + Wi-Fi + redes móviles para darte la posición más rápida y precisa posible con el menor consumo de batería.
FusedLocationProviderClient es la forma recomendada por Google para obtener la ubicación. No uses directamente LocationManager, GpsProvider ni WiFiProvider — FusedLocation lo gestiona todo por ti.
// Obtener la última ubicación conocida val fusedClient = LocationServices.getFusedLocationProviderClient(this) fusedClient.lastLocation.addOnSuccessListener { location -> location?.let { val lat = it.latitude // ej: 40.4168 val lng = it.longitude // ej: -3.7038 } } // Actualizaciones periódicas (cada 5 segundos) val request = LocationRequest.Builder( Priority.PRIORITY_HIGH_ACCURACY, 5000L) .setMinUpdateIntervalMillis(2000L) .build() fusedClient.requestLocationUpdates(request, locationCallback, Looper.getMainLooper())
Olvidar declarar el permiso ACCESS_FINE_LOCATION en el AndroidManifest y también solicitarlo en runtime. Ambas cosas son necesarias. Sin el permiso runtime, la app fallará en Android 6+.
🔔 Geofencing: zonas con alarma geográfica
Analogía: Es como un detector de presencia pero en el mundo real. Defines un círculo en el mapa (la "valla") y Android te avisa automáticamente cuando el usuario entra o sale de esa zona, incluso con la app cerrada.
Una app de delivery activa una notificación al repartidor cuando entra en la zona del restaurante. Una app de control de asistencia registra la entrada/salida automáticamente al llegar al trabajo.
// Crear una geovalla circular de 150 metros val geofence = Geofence.Builder() .setRequestId("oficina") .setCircularRegion(lat, lng, 150f) // radio en metros .setTransitionTypes( Geofence.GEOFENCE_TRANSITION_ENTER or Geofence.GEOFENCE_TRANSITION_EXIT) .setExpirationDuration(Geofence.NEVER_EXPIRE) .build()
Piensa en 3 apps del día a día que podrían usar Geofencing. ¿Qué zona definiría cada una y qué acción realizaría al entrar/salir?
Google Maps SDK para mapas visuales. FusedLocationProvider para ubicación precisa y eficiente (GPS+WiFi+red). Geofencing para detectar entrada/salida de zonas geográficas. Siempre pedir permiso ACCESS_FINE_LOCATION en runtime.
📡 Tipos de sensores disponibles en Android
El móvil es un laboratorio de sensores. Android los clasifica en cuatro categorías. No todos los dispositivos tienen todos los sensores, por lo que siempre hay que comprobar su disponibilidad.
| Categoría | Sensores | Aplicaciones típicas |
|---|---|---|
| 🏃 Movimiento | Acelerómetro, Giroscopio, Gravedad, Rotación lineal | Juegos, gestos, orientación, step counter |
| 🧭 Posición | Magnetómetro (brújula), Proximidad | Brújula digital, apagar pantalla en llamadas |
| 🌡️ Entorno | Luz ambiental, Barómetro, Temperatura, Humedad | Brillo automático, altitud, predicción del tiempo |
| ❤️ Biométrico | Ritmo cardíaco, SpO2, Huella dactilar | Salud, fitness, autenticación |
⚙️ Cómo acceder a los sensores: el patrón SensorManager
Analogía: SensorManager es como el controlador de una placa de sensores. Le dices qué sensor quieres escuchar, a qué velocidad, y él te llama cuando hay nuevos datos.
El flujo siempre es el mismo: obtener el SensorManager → obtener el sensor → registrar un listener → recibir datos en onSensorChanged → desregistrar cuando no se necesite.
class SensorActivity : AppCompatActivity(), SensorEventListener { private lateinit var sensorManager: SensorManager private var sensor: Sensor? = null override fun onCreate(b: Bundle?) { super.onCreate(b) sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager sensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) } override fun onResume() { // Registrar al volver a primer plano super.onResume() sensorManager.registerListener(this, sensor, SensorManager.SENSOR_DELAY_NORMAL) } override fun onPause() { // SIEMPRE desregistrar al pausar super.onPause() sensorManager.unregisterListener(this) } override fun onSensorChanged(event: SensorEvent) { val x = event.values[0] // Eje X val y = event.values[1] // Eje Y val z = event.values[2] // Eje Z } }
Olvidar unregisterListener(this) en onPause(). Si el sensor sigue activo en segundo plano, consume batería constantemente aunque el usuario no esté usando la app.
🏎️ Velocidades de muestreo (SENSOR_DELAY)
| Constante | Aprox. (µs) | Uso recomendado |
|---|---|---|
| SENSOR_DELAY_NORMAL | 200.000 | Cambios de orientación y UI |
| SENSOR_DELAY_UI | 60.000 | Actualizaciones de interfaz |
| SENSOR_DELAY_GAME | 20.000 | Juegos y realidad aumentada |
| SENSOR_DELAY_FASTEST | 0 (máxima) | Análisis en tiempo real |
Cuanto más rápido el muestreo, más batería consume. Usa la velocidad mínima que satisfaga las necesidades de tu app. Para un contador de pasos, NORMAL es más que suficiente.
🛠️ Casos de uso prácticos
¿En qué ciclo de vida debes registrar el listener del sensor? ¿Por qué es peligroso registrarlo en onCreate() y no desregistrarlo en onPause()?
SensorManager obtiene el sensor. registerListener en onResume(). unregisterListener en onPause(). onSensorChanged recibe los datos en values[0/1/2]. Velocidad de muestreo según necesidad.
🎵 MediaPlayer: reproducción de audio básica
MediaPlayer permite reproducir audio desde recursos locales (carpeta res/raw/) o desde URLs remotas. Requiere gestión cuidadosa del ciclo de vida.
MediaPlayer es como un reproductor de casete: simple, hace lo básico, pero hay que rebobinarlo manualmente, liberarlo cuando no se usa y gestionar cada estado.
// Desde recurso local (res/raw/musica.mp3) val player = MediaPlayer.create(this, R.raw.musica) player?.start() // Desde URL — no bloqueante MediaPlayer().apply { setDataSource("https://ejemplo.com/audio.mp3") setOnPreparedListener { start() } prepareAsync() // No bloquea el hilo principal } // IMPORTANTE: siempre liberar en onDestroy() override fun onDestroy() { super.onDestroy() player?.release() player = null }
No llamar a release() en onDestroy() provoca fugas de memoria (memory leak). El MediaPlayer sigue ocupando recursos aunque la pantalla se cierre.
🎬 ExoPlayer (Media3): vídeo y streaming profesional
ExoPlayer, ahora incluido en AndroidX Media3, es el reproductor de vídeo de referencia en Android. Lo usan YouTube, Spotify y la mayoría de grandes apps.
MediaPlayer
- Solo formatos básicos (MP3, MP4, WAV)
- Sin soporte HLS/DASH nativo
- Difícil de personalizar
- OK para audio simple
ExoPlayer / Media3 ✅
- HLS, DASH, SmoothStreaming
- PlayerView con controles integrados
- Altamente personalizable
- Recomendado para vídeo
// ExoPlayer con Media3 private var player: ExoPlayer? = null override fun onStart() { super.onStart() player = ExoPlayer.Builder(this).build().also { binding.playerView.player = it val item = MediaItem.fromUri("https://ejemplo.com/video.m3u8") it.setMediaItem(item) it.prepare() it.playWhenReady = true } } override fun onStop() { super.onStop() player?.release() player = null }
📷 CameraX: la cámara moderna en Android
CameraX es la API de cámara recomendada por Google. Proporciona una interfaz consistente en todos los dispositivos y versiones de Android, eliminando la fragmentación del API de cámara anterior.
CameraX se vincula automáticamente al ciclo de vida de la Activity o Fragment con bindToLifecycle(). Cuando la app va a segundo plano, la cámara se libera sola. No hay que gestionar el ciclo manualmente.
🔐 Permisos de cámara y micrófono
CAMERA y RECORD_AUDIO son permisos peligrosos. Deben declararse en el Manifest y solicitarse en runtime. Sin el permiso en runtime, la app fallará con una excepción en Android 6+.
// Solicitar permisos en runtime val requestPermissions = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions()) { perms -> val camOk = perms[Manifest.permission.CAMERA] == true val micOk = perms[Manifest.permission.RECORD_AUDIO] == true if (camOk && micOk) startCamera() } requestPermissions.launch(arrayOf( Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO ))
¿Qué pasa si el usuario deniega el permiso de cámara? ¿Cómo debería responder la app? ¿Debe bloquearse o puede funcionar parcialmente?
MediaPlayer para audio básico (liberar en onDestroy). ExoPlayer/Media3 para vídeo y streaming HLS/DASH. CameraX con 4 use cases (Preview, Capture, Video, Analysis). Siempre solicitar permisos CAMERA y RECORD_AUDIO en runtime.
🧩 ¿Qué es un AppWidget?
Un AppWidget es una mini-vista de tu app que se muestra directamente en el escritorio del móvil. Piensa en el widget del tiempo, del reloj o de Spotify: muestran información sin que el usuario tenga que abrir la app.
Los widgets no usan Views normales. Usan RemoteViews, que es una descripción de una vista que se ejecuta en el proceso del lanzador del escritorio (no en el proceso de tu app). Por eso los controles disponibles son limitados: solo TextView, ImageView, Button, ProgressBar, y colecciones (ListView, GridView).
Los 4 componentes que necesita cualquier widget:
| Componente | Descripción |
|---|---|
| Layout XML | El diseño visual del widget (solo Views compatibles con RemoteViews) |
| Widget Info XML | Configuración: tamaño mínimo, periodo de actualización, preview |
| AppWidgetProvider | BroadcastReceiver que recibe eventos del widget (onUpdate, onEnabled…) |
| AndroidManifest | Registro del receptor con el filtro ACTION_APPWIDGET_UPDATE |
⚙️ AppWidgetProvider y RemoteViews
// res/xml/widget_info.xml — configuración del widget <appwidget-provider android:minWidth="110dp" android:minHeight="40dp" android:updatePeriodMillis="1800000" // 30 minutos (mínimo) android:previewImage="@drawable/widget_preview" android:initialLayout="@layout/widget_layout"/> // AppWidgetProvider — lógica del widget class MiWidget : AppWidgetProvider() { override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) { ids.forEach { id -> val views = RemoteViews(context.packageName, R.layout.widget_layout) views.setTextViewText(R.id.tv_widget, "¡Actualizado!") // Acción al tocar el widget val intent = Intent(context, MainActivity::class.java) val pi = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) views.setOnClickPendingIntent(R.id.tv_widget, pi) manager.updateAppWidget(id, views) } } }
El updatePeriodMillis mínimo es 30 minutos (1.800.000 ms). Si necesitas actualizaciones más frecuentes, usa WorkManager o AlarmManager. Android ignorará períodos inferiores a 30 minutos.
- Mantener el código de
onUpdate()ligero y rápido - Para listas en widgets: usar RemoteViewsService + RemoteViewsFactory
- El widget vive en un proceso separado → no acceder a variables de la Activity
- Para actualización frecuente → WorkManager que llama al widgetManager
¿Por qué no puedes usar cualquier View en un widget? ¿Qué tiene de especial RemoteViews comparado con una View normal?
AppWidget = mini-app en el escritorio. Requiere Layout XML + Widget Info XML + AppWidgetProvider (BroadcastReceiver) + Manifest. RemoteViews limita las vistas disponibles. Actualización mínima automática: 30 min. Para más frecuencia: WorkManager.
🌐 ¿Qué es una API REST y por qué la necesito?
La mayoría de apps modernas obtienen datos de internet: el tiempo, las noticias, el perfil del usuario... Todo eso viene de un servidor web a través de una API REST.
Una API REST es como un camarero de restaurante: tú haces una petición (GET, POST, PUT, DELETE), él va a la cocina (servidor) y te trae la respuesta (JSON). No necesitas saber cómo funciona la cocina, solo qué pedir y cómo interpretarlo.
El stack recomendado para Android:
⚙️ Retrofit: definición de endpoints con anotaciones
Con Retrofit defines una interface Kotlin donde cada función representa un endpoint. Retrofit genera automáticamente el código de red.
// Interface de la API — describe los endpoints interface ApiService { @GET("users") suspend fun getUsers(): Response<List<User>> @GET("users/{id}") suspend fun getUser(@Path("id") id: Int): Response<User> @POST("users") suspend fun createUser(@Body user: UserRequest): Response<User> @GET("products") suspend fun searchProducts( @Query("q") query: String, @Query("page") page: Int = 1 ): Response<List<Product>> } // Uso en ViewModel con corrutinas fun loadUsers() { viewModelScope.launch { try { val response = RetrofitClient.instance.getUsers() if (response.isSuccessful) { _users.value = response.body() ?: emptyList() } } catch (e: IOException) { Log.e("VM", "Sin conexión: ${e.message}") } } }
🔐 Autenticación con JWT
JWT (JSON Web Token) es el estándar para autenticar peticiones a APIs. Funciona así: el usuario hace login → el servidor devuelve un token → la app adjunta ese token a todas las peticiones siguientes.
JWT es como una pulsera de acceso en un festival. Te la dan al entrar (login), y la muestras en cada zona para demostrar que tienes permiso. No tienes que volver a la entrada cada vez.
// Interceptor OkHttp — añade el token a todas las peticiones class AuthInterceptor(private val tokenProvider: () -> String?) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val token = tokenProvider() return if (token != null) { val request = chain.request().newBuilder() .header("Authorization", "Bearer $token") .build() chain.proceed(request) } else { chain.proceed(chain.request()) } } }
Nunca guardes el token JWT en SharedPreferences sin cifrar. Usa EncryptedSharedPreferences o el Android Keystore para guardar datos sensibles de forma segura.
¿Por qué las llamadas a la API deben hacerse desde el ViewModel y no directamente desde la Activity? ¿Qué pasaría si giras el teléfono y la petición estaba en curso?
Retrofit + OkHttp + Gson = stack de red estándar Android. La interface define endpoints con @GET/@POST/@Path/@Query. Usar suspend fun + corrutinas para no bloquear la UI. JWT con interceptor OkHttp. Patrón: ViewModel → Repository → ApiService.
📶 Bluetooth Clásico vs BLE
Bluetooth existe en dos variantes con casos de uso muy diferentes. Elegir la correcta es fundamental para el consumo de batería y el tipo de comunicación:
| Característica | Bluetooth Clásico | BLE (Low Energy) |
|---|---|---|
| Velocidad datos | Alta (hasta 3 Mbps) | Baja (~1 Mbps) |
| Consumo energía | Alto | Muy bajo (años con pila de botón) |
| Rango | Hasta 100 metros | Hasta 100 metros |
| Latencia conexión | Alta (~2 seg) | Muy baja (~3 ms) |
| Casos de uso | Audio, transferencia de archivos | IoT, wearables, sensores |
| Arquitectura | Perfiles (A2DP, SPP, HFP...) | GATT (Servicios + Características) |
¿Necesitas transmitir audio o archivos grandes? → BT Clásico. ¿Conectar a un sensor de temperatura, pulsera o baliza? → BLE. La batería importa más que la velocidad para IoT.
🏗️ Arquitectura GATT de BLE
GATT (Generic Attribute Profile) es cómo BLE organiza los datos. Funciona como un sistema de archivos jerárquico:
// 1. Escanear dispositivos BLE cercanos val scanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { val device = result.device Log.d("BLE", "Encontrado: ${device.name} / ${device.address}") // RSSI = intensidad de señal en dBm (más cercano a 0 = más fuerte) Log.d("BLE", "Señal: ${result.rssi} dBm") } } scanner.startScan(filters, settings, scanCallback) // 2. Conectar y leer una característica val gatt = device.connectGatt(context, false, object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt, status, newState) { if (newState == BluetoothProfile.STATE_CONNECTED) gatt.discoverServices() } override fun onCharacteristicRead( gatt, characteristic, value: ByteArray, status) { val datos = String(value) runOnUiThread { actualizarUI(datos) } } } ) // 3. Siempre cerrar al terminar gatt?.close()
Desde Android 12 (API 31), los permisos de Bluetooth cambiaron completamente. Necesitas BLUETOOTH_SCAN, BLUETOOTH_CONNECT (y BLUETOOTH_ADVERTISE si emites). Para versiones anteriores: BLUETOOTH, BLUETOOTH_ADMIN y ACCESS_FINE_LOCATION.
Una app de salud se conecta por BLE a una pulsera inteligente para leer la frecuencia cardíaca cada 5 segundos. La pulsera actúa como servidor GATT con un servicio de frecuencia cardíaca (UUID 0x180D) y una característica (UUID 0x2A37) que notifica automáticamente al móvil.
¿Por qué es importante llamar a scanner.stopScan()? ¿Qué consecuencias tiene dejarlo activo continuamente?
BT Clásico para audio/archivos. BLE para IoT/sensores con muy bajo consumo. GATT organiza los datos en Servicios → Características con UUIDs. Flujo: escanear → conectar → discoverServices → leer/suscribirse → cerrar. Permisos cambiaron en API 31+.
Estos ejercicios pueden usarse como práctica de aula, tarea o proyecto. Están ordenados de menor a mayor complejidad.
Conceptos: @Entity, @Dao, Repository, StateFlow, RecyclerView
Conceptos: Google Maps SDK, FusedLocationProvider, PolylineOptions
Conceptos: SensorManager, magnitud del vector, umbral
Conceptos: ExoPlayer, Media3, PlayerView, MediaItem
Conceptos: AppWidgetProvider, RemoteViews, WorkManager
Conceptos: Retrofit, Repository, Room, offline-first
Conceptos: BluetoothLeScanner, GATT, ScanCallback
💬 Actividades de reflexión para clase
Al dominar estos siete bloques, eres capaz de construir aplicaciones Android completas y de nivel profesional: con datos persistentes, mapas, sensores, multimedia, widgets, comunicación con servidores y dispositivos Bluetooth.
Android es mucho más que una plataforma de formularios y listas. Con los conocimientos de este tema puedes construir apps que se conectan al mundo físico (sensores, GPS, Bluetooth), gestionan datos local y en la nube, y se comunican con servicios externos. Eso es desarrollo móvil profesional.