Introducción: por qué un «SELECT simple» se convierte en un dolor de cabeza
\n\nEstás escribiendo tu primera aplicación en Android con Room y Kotlin. Todo va bien hasta que llega el momento de leer datos de la base de datos. Escribes algo como:
\n\nval user = userDao.getUserById(42)\ntextView.text = user.name\n\nLo ejecutas — y la aplicación falla con la excepción:
\n\njava.lang.IllegalStateException: Cannot access database on the main thread\nsince it may potentially lock the UI for a long period of time.\n\n¿Te suena? Esta pregunta en Stack Overflow ha acumulado miles de visitas y cientos de comentarios. El error asusta a los principiantes, pero en realidad no es un bug, es una protección contra tontos. Room (como cualquier ORM competente) no te permite bloquear la interfaz de usuario accidentalmente.
\n\nEn este artículo analizaremos por qué ocurre esto y cómo ejecutar correctamente consultas SELECT en Android Room con Kotlin. Conocerás tres enfoques probados: LiveData, Coroutines y RxJava. ¡Allá vamos!
\n\nBrevemente sobre lo principal: ¿por qué Room bloquea el hilo principal?
\n\nAndroid es un sistema de un solo hilo por defecto. Toda la UI funciona en el main thread (hilo principal). Si comienzas a leer la base de datos en este hilo, la interfaz se congelará hasta que la consulta termine. Con grandes volúmenes de datos, esto puede tomar segundos — el usuario verá una aplicación «colgada».
\n\nRoom se asegura: prohíbe cualquier operación larga (lectura, escritura) en el hilo principal. La excepción es si lo permites explícitamente mediante .allowMainThreadQueries() al construir la base de datos. Pero no deberías hacerlo — es un antipatrón.
Así es como se ve el problema típico:
\n\n// DAO\n@Dao\ninterface UserDao {\n @Query("SELECT * FROM users WHERE id = :userId")\n suspend fun getUserById(userId: Int): User // suspend — ¡ya es una pista!\n}\n\n// Activity\nclass MainActivity : AppCompatActivity() {\n override fun onCreate(savedInstanceState: Bundle?) {\n super.onCreate(savedInstanceState)\n // ❌ ¡Error!\n val user = database.userDao().getUserById(42)\n }\n}\n\nIncluso si hiciste la función suspend, pero la llamas sin una corrutina — Room igual lanzará la excepción. ¡Lo solucionamos!
Enfoque 1: LiveData — simple y reactivo
\n\nLa forma más sencilla es devolver LiveData desde el DAO. Room mismo gestiona el hilo: la consulta se ejecuta en segundo plano y el resultado llega a la UI.
\n\nPaso 1. Cambiamos el DAO
\n\n@Dao\ninterface UserDao {\n @Query("SELECT * FROM users WHERE id = :userId")\n fun getUserById(userId: Int): LiveData<User>\n}\n\nPaso 2. Nos suscribimos en Activity/Fragment
\n\nclass MainActivity : AppCompatActivity() {\n override fun onCreate(savedInstanceState: Bundle?) {\n super.onCreate(savedInstanceState)\n\n database.userDao().getUserById(42).observe(this) { user ->\n textView.text = user.name\n }\n }\n}\n\nVentajas: mínimo código, cancelación automática de suscripción al destruirse la Activity, reactividad — los datos se actualizan solos al cambiar la tabla.
\n\nDesventajas: LiveData está ligada al ciclo de vida, no se puede combinar fácilmente con otras fuentes de datos (red, SharedPreferences).
\n\nEnfoque 2: Coroutines — la forma moderna de Kotlin
\n\nSi usas Kotlin Coroutines (y deberías), esta es la forma más elegante. La función se marca como suspend y la llamada se envuelve en una corrutina con el Dispatchers.IO correcto.
Paso 1. DAO con suspend
\n\n@Dao\ninterface UserDao {\n @Query("SELECT * FROM users WHERE id = :userId")\n suspend fun getUserById(userId: Int): User\n}\n\nPaso 2. Llamada desde ViewModel (recomendado)
\n\nclass MainVi