Programación Multimedia y Dispositivos Móviles en DAM: Android con Kotlin y ejercicios resueltos

Programación Multimedia y Dispositivos Móviles (PMDM) es el módulo de segundo de DAM en el que aprendes a programar aplicaciones para móvil, casi siempre Android con Android Studio, en Kotlin o en Java según el centro. Para aprobarlo necesitas dominar el ciclo de vida de una Activity y cinco ejercicios tipo: pasar datos entre pantallas con un Intent, mostrar una lista con RecyclerView, guardar datos con SharedPreferences o Room, pedir un permiso en tiempo de ejecución y reproducir audio liberando el reproductor cuando toca.
Lo difícil de PMDM no es Kotlin ni Java. Es que tu código ya no manda: el sistema crea y destruye tus pantallas cuando quiere, al girar el móvil, al recibir una llamada o al quedarse sin memoria, y si no lo tienes en cuenta la app se cierra o pierde los datos. Súmale Gradle, el emulador y tutoriales con APIs obsoletas. Aquí tienes qué entra, cinco ejercicios resueltos y explicados, los errores que más vemos en clase y cómo prepararlo.
Qué entra en Programación Multimedia y Dispositivos Móviles
PMDM es el módulo 0489 del título de DAM (Real Decreto 450/2010) y se da en segundo curso. El decreto no fija plataforma, pero casi todos los centros dan Android, porque Android Studio es gratis y funciona en Windows, Linux y Mac. Lo que cambia es el lenguaje (cada vez más Kotlin, que es el que recomienda Google, aunque muchos siguen en Java) y la interfaz (vistas XML o Jetpack Compose). Pregunta a tu profesor qué combinación pide.
| Bloque | Qué te piden | Lo que más falla |
|---|---|---|
| Tecnologías móviles | Estructura de un proyecto, Gradle, AndroidManifest.xml, emulador, depuración con Logcat |
Gradle que no sincroniza y emulador que no arranca |
| Activities e interfaz | Layouts en XML o Compose, eventos, ciclo de vida, Intents, navegación entre pantallas | Perder los datos al girar la pantalla |
| Listas | RecyclerView con Adapter o LazyColumn en Compose |
Lista vacía porque falta el LayoutManager |
| Persistencia | SharedPreferences, SQLite o Room, a veces Firebase o una API REST | Acceder a la base de datos o a la red en el hilo principal |
| Multimedia | Audio y vídeo, cámara, permisos en tiempo de ejecución | No liberar el MediaPlayer y olvidar los permisos |
| Videojuegos | Motores de juegos, un juego sencillo en 2D (LibGDX, Unity o Godot, según centro) | Empezar el juego sin haber entendido el bucle de juego |
Videojuegos es lo que más varía entre centros. Los cinco primeros bloques se repiten en todas partes, y los ejercicios de abajo los cubren.
El ciclo de vida de una Activity: lo que siempre preguntan
Una Activity es una pantalla de la app. Android la gestiona llamando a estos métodos en orden: onCreate (se crea; aquí cargas el layout), onStart (se hace visible), onResume (el usuario puede interactuar), onPause (pierde el foco), onStop (deja de verse) y onDestroy (se destruye). Si vuelves a una Activity que estaba en segundo plano, se llama a onRestart y luego a onStart y onResume.
La pregunta trampa es qué pasa al girar el móvil. Por defecto, Android destruye la Activity y la vuelve a crear: pasa por onPause, onStop, onDestroy y otra vez por onCreate. Lo que guardaras en una variable de la Activity se pierde. Hay dos soluciones:
onSaveInstanceStatepara datos pequeños, que se guardan en unBundley se recuperan enonCreate.- Un ViewModel, que sobrevive a la rotación porque no pertenece a la Activity.
class ContadorViewModel : ViewModel() {
var clics = 0
}
class MainActivity : AppCompatActivity() {
// Al girar, la Activity es nueva, pero el ViewModel es el mismo
private val viewModel: ContadorViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val tvClics = findViewById<TextView>(R.id.tvClics)
tvClics.text = viewModel.clics.toString()
findViewById<Button>(R.id.btnSumar).setOnClickListener {
viewModel.clics++
tvClics.text = viewModel.clics.toString()
}
}
}
Como atributo de la Activity, clics volvería a 0 al girar; en el ViewModel se mantiene. Ojo: el ViewModel no sobrevive a que el sistema mate la app por falta de memoria; para eso está onSaveInstanceState.
Cinco ejercicios típicos resueltos
Los ejercicios están en Kotlin con vistas XML, lo más habitual en los apuntes, con la equivalencia en Java o Compose donde cambia algo importante. Los layouts no se muestran: dan por hecho que los id (etNombre, btnEnviar...) existen.
1. Pasar datos entre dos Activities con un Intent
Enunciado: la pantalla principal tiene un campo de texto y un botón. Al pulsarlo, se abre una segunda pantalla que saluda al usuario por su nombre. Si el campo está vacío, se avisa y no se cambia de pantalla.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val etNombre = findViewById<EditText>(R.id.etNombre)
val btnEnviar = findViewById<Button>(R.id.btnEnviar)
btnEnviar.setOnClickListener {
val nombre = etNombre.text.toString().trim()
if (nombre.isEmpty()) {
etNombre.error = "Escribe tu nombre"
return@setOnClickListener
}
val intent = Intent(this, SaludoActivity::class.java)
intent.putExtra(SaludoActivity.EXTRA_NOMBRE, nombre)
startActivity(intent)
}
}
}
class SaludoActivity : AppCompatActivity() {
companion object {
const val EXTRA_NOMBRE = "nombre"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_saludo)
val nombre = intent.getStringExtra(EXTRA_NOMBRE) ?: "desconocido"
findViewById<TextView>(R.id.tvSaludo).text = "Hola, $nombre"
}
}
Y en el AndroidManifest.xml, dentro de <application>:
<activity android:name=".SaludoActivity" />
Lo que hay que entender: un Intent explícito dice exactamente qué Activity abrir (SaludoActivity::class.java). Uno implícito dice qué quieres hacer (abrir una web, marcar un teléfono) y Android busca qué app puede hacerlo. Los datos viajan como pares clave-valor con putExtra. La clave va en una constante porque escribir "nombre" en un sitio y "Nombre" en otro no da error: simplemente llega null, y por eso el ?: pone un valor por defecto.
Si olvidas declarar SaludoActivity en el Manifest, la app se cierra al pulsar el botón con ActivityNotFoundException. Android Studio solo la declara si la creas con New > Activity.
En Java se recibe con getIntent().getStringExtra(...). Si la segunda pantalla tiene que devolver un dato, los apuntes antiguos usan startActivityForResult, que está obsoleto: hoy se usa registerForActivityResult, como en el ejercicio 4.
2. Una lista con RecyclerView y su Adapter
Enunciado: muestra una lista de alumnos con su nombre y su nota. Al pulsar un alumno, aparece un mensaje con sus datos.
data class Alumno(val nombre: String, val nota: Double)
class AlumnoAdapter(
private val alumnos: List<Alumno>,
private val onClick: (Alumno) -> Unit
) : RecyclerView.Adapter<AlumnoAdapter.AlumnoViewHolder>() {
class AlumnoViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val tvNombre: TextView = view.findViewById(R.id.tvNombre)
val tvNota: TextView = view.findViewById(R.id.tvNota)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AlumnoViewHolder {
val vista = LayoutInflater.from(parent.context)
.inflate(R.layout.item_alumno, parent, false)
return AlumnoViewHolder(vista)
}
override fun onBindViewHolder(holder: AlumnoViewHolder, position: Int) {
val alumno = alumnos[position]
holder.tvNombre.text = alumno.nombre
holder.tvNota.text = alumno.nota.toString()
holder.itemView.setOnClickListener { onClick(alumno) }
}
override fun getItemCount(): Int = alumnos.size
}
class ListaActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_lista)
val alumnos = listOf(
Alumno("Laura", 8.5),
Alumno("Marcos", 4.0),
Alumno("Irene", 6.75)
)
val recycler = findViewById<RecyclerView>(R.id.recyclerAlumnos)
recycler.layoutManager = LinearLayoutManager(this)
recycler.adapter = AlumnoAdapter(alumnos) { alumno ->
Toast.makeText(this, "${alumno.nombre}: ${alumno.nota}", Toast.LENGTH_SHORT).show()
}
}
}
Lo que hay que entender: RecyclerView no crea una vista por cada elemento. Crea solo las que caben en pantalla y las recicla al hacer scroll: onCreateViewHolder infla una fila nueva y onBindViewHolder rellena una fila ya existente con los datos de la posición que toca. Por eso todo lo que dependa del dato va en onBindViewHolder. Si pones un color en un if sin su else, al hacer scroll aparecen filas recicladas con el color que no les toca.
Tres trampas: sin layoutManager la lista sale vacía; el false de inflate es obligatorio, porque la fila la añade el RecyclerView; y si cambian los datos hay que avisar al Adapter (notifyItemInserted o, mejor, un ListAdapter con DiffUtil).
¿Y en Compose? Si tu centro usa Jetpack Compose, todo lo anterior se reduce a un LazyColumn, sin Adapter ni ViewHolder:
@Composable
fun ListaAlumnos(alumnos: List<Alumno>, onClick: (Alumno) -> Unit) {
LazyColumn {
items(alumnos) { alumno ->
Text(
text = "${alumno.nombre}: ${alumno.nota}",
modifier = Modifier
.fillMaxWidth()
.clickable { onClick(alumno) }
.padding(16.dp)
)
}
}
}
RecyclerView si el proyecto usa vistas XML, LazyColumn si está en Compose. Si items(alumnos) no compila, falta importar androidx.compose.foundation.lazy.items.
3. Guardar datos con SharedPreferences y con Room
Enunciado: la app recuerda el nombre del usuario entre ejecuciones. Además, guarda una lista de tareas en una base de datos local.
Para un dato suelto, como un nombre o una preferencia, basta con SharedPreferences:
val prefs = getSharedPreferences("ajustes", MODE_PRIVATE)
// Guardar
prefs.edit().putString("usuario", nombre).apply()
// Leer (el segundo parámetro es el valor si no existe)
val usuario = prefs.getString("usuario", "") ?: ""
Para una lista de registros, una base de datos. Room es la librería de Google sobre SQLite: defines tabla, consultas y base de datos, y genera el código.
@Entity(tableName = "tareas")
data class Tarea(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val titulo: String,
val hecha: Boolean = false
)
@Dao
interface TareaDao {
@Insert
suspend fun insertar(tarea: Tarea): Long
@Query("SELECT * FROM tareas ORDER BY id DESC")
suspend fun todas(): List<Tarea>
@Delete
suspend fun borrar(tarea: Tarea)
}
@Database(entities = [Tarea::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun tareaDao(): TareaDao
companion object {
@Volatile
private var instancia: AppDatabase? = null
fun get(context: Context): AppDatabase =
instancia ?: synchronized(this) {
instancia ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"tareas.db"
).build().also { instancia = it }
}
}
}
Y para usarla desde la Activity:
lifecycleScope.launch {
val dao = AppDatabase.get(this@MainActivity).tareaDao()
dao.insertar(Tarea(titulo = "Repasar el ciclo de vida"))
val tareas = dao.todas()
findViewById<TextView>(R.id.tvResumen).text = "Tienes ${tareas.size} tareas"
}
Lo que hay que entender: SharedPreferences guarda pares clave-valor en un fichero privado de la app; sirve para ajustes, no para listas de objetos. apply() guarda en segundo plano y commit() bloquea el hilo hasta terminar. Sin ninguno de los dos, no se guarda nada.
Con Room, la regla es que no se puede acceder a la base de datos en el hilo principal. Si lo intentas con un DAO normal, la app se cierra con Cannot access database on the main thread. Las funciones suspend con lifecycleScope.launch lo resuelven: Room consulta en otro hilo y la corrutina vuelve al principal para tocar la interfaz. El allowMainThreadQueries() de muchos tutoriales es salir del paso, y se suele penalizar.
La base de datos se crea una sola vez (el singleton del companion object). Room necesita sus dependencias y el procesador de anotaciones (KSP) en Gradle; sin el procesador, falla al ejecutar porque no encuentra el código generado.
Si lo dais con SQLite directamente, verás SQLiteOpenHelper y Cursor. La idea es la misma que en Acceso a Datos con JDBC: sentencias con parámetros, nunca concatenando valores, y cerrar lo que abres.
4. Pedir un permiso en tiempo de ejecución
Enunciado: al pulsar un botón, la app hace una foto con la cámara y la muestra en pantalla. Si el usuario no da permiso, se le explica por qué hace falta.
En el Manifest:
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />
class CamaraActivity : AppCompatActivity() {
private val pedirPermiso =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { concedido ->
if (concedido) {
hacerFoto()
} else {
Toast.makeText(this, "Sin permiso no se puede usar la cámara", Toast.LENGTH_LONG).show()
}
}
private val tomarFoto =
registerForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap ->
if (bitmap != null) {
findViewById<ImageView>(R.id.ivFoto).setImageBitmap(bitmap)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_camara)
findViewById<Button>(R.id.btnFoto).setOnClickListener { comprobarPermiso() }
}
private fun comprobarPermiso() {
when {
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED -> hacerFoto()
shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) ->
AlertDialog.Builder(this)
.setMessage("Necesitamos la cámara para la foto de perfil")
.setPositiveButton("Vale") { _, _ ->
pedirPermiso.launch(Manifest.permission.CAMERA)
}
.setNegativeButton("Cancelar", null)
.show()
else -> pedirPermiso.launch(Manifest.permission.CAMERA)
}
}
private fun hacerFoto() {
tomarFoto.launch(null)
}
}
Lo que hay que entender: desde Android 6 hay dos tipos de permisos. Los normales (como INTERNET) basta con declararlos en el Manifest. Los peligrosos (cámara, micrófono, ubicación, contactos) hay que declararlos y además pedirlos mientras la app se ejecuta, porque el usuario puede negarlos o quitarlos desde los ajustes. El flujo siempre es: comprobar si ya lo tienes, explicar por qué lo necesitas si ya lo negó una vez, y pedirlo.
registerForActivityResult es la Activity Result API, la forma actual de pedir permisos y de recibir resultados de otra pantalla. Se registra como atributo, antes de que la Activity arranque; si lo registras dentro del clic, la app se cierra. Los apuntes antiguos usan onRequestPermissionsResult y startActivityForResult: compilan, pero están obsoletos.
TakePicturePreview devuelve una miniatura. Para la foto a tamaño completo se usa TakePicture con una Uri y un FileProvider.
5. Reproducir audio con MediaPlayer y liberarlo
Enunciado: una pantalla con dos botones, reproducir y pausar, que reproduce un audio incluido en la app. Al salir de la pantalla, la música se para y se liberan los recursos.
El audio va en la carpeta res/raw (por ejemplo, res/raw/cancion.mp3; el nombre del fichero en minúsculas y sin espacios).
class ReproductorActivity : AppCompatActivity() {
private var mediaPlayer: MediaPlayer? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_reproductor)
findViewById<Button>(R.id.btnPlay).setOnClickListener { reproducir() }
findViewById<Button>(R.id.btnPausa).setOnClickListener { mediaPlayer?.pause() }
}
private fun reproducir() {
if (mediaPlayer == null) {
mediaPlayer = MediaPlayer.create(this, R.raw.cancion)
mediaPlayer?.setOnCompletionListener { liberar() }
}
mediaPlayer?.start()
}
override fun onStop() {
super.onStop()
liberar()
}
private fun liberar() {
mediaPlayer?.release()
mediaPlayer = null
}
}
Lo que hay que entender: MediaPlayer usa recursos del sistema (decodificadores de audio) que no se liberan solos. Sin release(), la música sigue sonando al salir y cada vez que vuelves se crea otro reproductor encima. Por eso se libera en onStop y se pone a null para saber que hay que crearlo otra vez. El ?. evita un NullPointerException si pulsas pausa antes de reproducir.
Al girar el móvil también se llama a onStop, así que la música se para. Si debe seguir sonando al girar o en segundo plano, hace falta un Service. Para vídeo, la recomendación actual es Media3 (ExoPlayer), aunque los apuntes suelen usar MediaPlayer y VideoView.
Los errores que más suspenden en Programación Multimedia y Dispositivos Móviles
Perder el estado al girar la pantalla. El formulario se vacía o el contador vuelve a 0 porque la Activity se ha destruido y creado de nuevo. Gira el emulador antes de entregar.
Hacer red o base de datos en el hilo principal. Una petición a internet en el hilo de la interfaz lanza NetworkOnMainThreadException; con Room, Cannot access database on the main thread. Se resuelve con corrutinas en Kotlin o con un ExecutorService en Java. Si vienes de hilos en PSP, es la misma idea: el trabajo lento, fuera del hilo que pinta la pantalla.
La Activity no está en el Manifest. ActivityNotFoundException al cambiar de pantalla, y el propio mensaje te pregunta si la has declarado. Pasa siempre que creas la clase a mano.
Permisos olvidados. Sin INTERNET en el Manifest, ninguna conexión funciona. Sin pedir en tiempo de ejecución un permiso peligroso, la app se cierra con SecurityException o la función no hace nada.
lateinit sin inicializar y nulos. En Kotlin, usar una propiedad lateinit antes de asignarla da UninitializedPropertyAccessException. En Java, el equivalente es el NullPointerException de llamar a findViewById antes de setContentView, o con un id de otro layout. El !! en Kotlin es lo mismo: le dices al compilador que confíe en ti y, si te equivocas, la app se cierra.
Pelearse con Gradle sin leer el error. "Gradle sync failed" suele ser una dependencia con una versión que no existe, un plugin que falta o un proyecto creado con un Android Studio muy distinto al tuyo. Lee el error en la pestaña Build desde arriba.
No liberar recursos. MediaPlayer sin release(), la cámara sin cerrar o un Cursor de SQLite sin close(). Funciona en una prueba corta y falla con el uso.
Copiar tutoriales con APIs obsoletas. AsyncTask, startActivityForResult, onRequestPermissionsResult o las vistas sintéticas de kotlin-android-extensions (ya eliminadas, no compilan con versiones actuales de Kotlin). Mira la fecha del ejemplo; si Android Studio tacha un método, está obsoleto.
Cómo prepararlo
Monta el entorno el primer día. Android Studio, un emulador que arranque y, si puedes, tu propio móvil con la depuración USB activada. Si el emulador va lento, activa la aceleración por hardware y usa una imagen del sistema de la misma arquitectura que tu procesador. Con poca memoria, el móvil físico va mucho mejor.
Asegura la orientación a objetos. Adapter, ViewHolder, ViewModel, herencia de AppCompatActivity: Android es orientación a objetos en cada línea. Si clases, interfaces y herencia te cuestan, repasa la programación orientada a objetos explicada.
Aprende a leer Logcat. Cuando la app se cierra, el error está en Logcat, en rojo, con la línea exacta de tu código. Busca la primera línea que mencione una clase tuya. Es el mismo método que en depurar código paso a paso, con otra herramienta.
Haz una app pequeña que lo junte todo. Una lista de tareas con dos pantallas, RecyclerView, Room, una preferencia guardada y un sonido al completar una tarea. Si sabes hacerla sin mirar, tienes la práctica del trimestre y una buena base para el proyecto final de DAM, donde muchos alumnos eligen hacer una app móvil.
Gira, sal y vuelve. Cada vez que termines una pantalla, gira el emulador, pulsa el botón de inicio y vuelve a la app. Si algo se pierde o se cierra, es un fallo de ciclo de vida, y es justo lo que el profesor va a probar.
PMDM comparte segundo con Desarrollo de Interfaces, que trabaja layouts, componentes y usabilidad con otra tecnología. Si lo tienes a la vez, aquí tienes cómo aprobar Desarrollo de Interfaces en DAM.
Cuándo pedir ayuda
Si haces los ejercicios y sabes leer en Logcat por qué se cierra la app, vas bien. Merece la pena buscar ayuda cuando:
- Llevas días sin que Gradle sincronice o sin que el emulador arranque y aún no has escrito código.
- Copias el Adapter de los apuntes, funciona, pero no sabrías hacer otro para una lista distinta.
- Tu app se cierra al girar el móvil o al volver de segundo plano y no sabes por qué.
- Tienes PMDM pendiente y necesitas aprobarlo para hacer las prácticas en empresa o el proyecto final.
En nuestras clases de refuerzo para DAM y DAW trabajamos sobre tu proyecto de Android Studio: abrimos Logcat juntos, buscamos dónde se rompe y lo arreglas tú, para que en el examen no dependas de nadie. Si tu centro da el módulo en Java y te falta base, empezamos por ahí con clases de Java online. Son clases online uno a uno a 25 € la hora, sin matrícula ni permanencia.
Preguntas frecuentes
¿Qué es PMDM en DAM?
PMDM es Programación Multimedia y Dispositivos Móviles, el módulo 0489 de segundo de DAM. Enseña a desarrollar aplicaciones para móviles, normalmente Android con Android Studio, incluyendo interfaz, navegación entre pantallas, almacenamiento de datos, audio, vídeo, cámara y, en muchos centros, una introducción a los motores de videojuegos.
¿PMDM se da en Kotlin o en Java?
Depende del centro. Kotlin es el lenguaje que Google recomienda para Android y cada vez más centros lo usan, pero muchos siguen con Java porque es el lenguaje de primero de DAM. Los conceptos (Activities, Intents, RecyclerView, permisos, ciclo de vida) son los mismos en los dos y solo cambia la sintaxis.
¿Jetpack Compose o vistas XML?
Las vistas XML son el sistema clásico: la interfaz se diseña en un fichero XML y se controla desde el código. Jetpack Compose es el sistema moderno: la interfaz se escribe en Kotlin con funciones @Composable y se redibuja sola cuando cambia el estado. Google recomienda Compose para proyectos nuevos, pero muchos apuntes y exámenes siguen usando XML, así que usa el que pida tu profesor.
¿Qué es el ciclo de vida de una Activity en Android?
Es la secuencia de estados por los que pasa una pantalla de Android: onCreate, onStart, onResume, onPause, onStop y onDestroy. El sistema llama a esos métodos al abrir, ocultar, recuperar o cerrar la pantalla. Al girar el móvil, la Activity se destruye y se vuelve a crear, por eso los datos se guardan en un ViewModel o con onSaveInstanceState.
¿Es difícil Programación Multimedia y Dispositivos Móviles?
Cuesta al principio porque se juntan muchas cosas nuevas: el entorno, Gradle, el Manifest, el ciclo de vida y quizá un lenguaje nuevo. Superado ese arranque, los ejercicios siguen patrones muy repetidos. Si llegas con buena base de orientación a objetos, se aprueba practicando; si llegas arrastrando Programación de primero, se hace cuesta arriba.
¿Dónde encuentro contenido actualizado y ejercicios de Android Studio resueltos?
La documentación oficial en developer.android.com y el curso gratuito de Google "Android Basics with Compose" están al día. Muchos apuntes y vídeos usan APIs obsoletas como AsyncTask o startActivityForResult, así que mira siempre la fecha del material. Para practicar, lo mejor son apps pequeñas con dos pantallas, una lista y una base de datos local.