Room Database y SQLite en Android Studio con Kotlin: Guía completa

- Andrés Cruz - EN In english

Room Database y SQLite en Android Studio con Kotlin: Guía completa

El enfoque moderno: Room Persistence Library con Jetpack Compose

Con la evolución del desarrollo Android, Google ha introducido y promovido librerías que simplifican y estandarizan tareas comunes. Para la persistencia de datos local, la Room Persistence Library es ahora el estándar de facto: ofrece una capa de abstracción sobre SQLite que reduce el código repetitivo (boilerplate), verifica las consultas en tiempo de compilación y se integra de manera fluida con otras librerías de Jetpack como LiveData, Flow de Kotlin y, por supuesto, Jetpack Compose para la construcción de la UI.

Room estructura la base de datos a través de tres elementos principales: las Entidades (que representan las tablas), los DAOs (Data Access Objects, que definen los métodos de interacción con la base de datos) y la clase Database, que orquesta todo el sistema.

Antes vimos cómo generar códigos QR en Android Studio.

Para instalar Room, sigue siempre la guía oficial de Android Developers, ya que las versiones se actualizan con frecuencia:

https://developer.android.com/training/data-storage/room?hl=es-419#kts

https://developer.android.com/kotlin/multiplatform/room?hl=es-419

Ten en cuenta que el segundo enlace corresponde a la configuración para Kotlin Multiplatform, no para proyectos Android exclusivos.

El primer paso es cargar las librerías de ksp —un plugin que procesa las anotaciones de tu código Kotlin antes de compilar y genera código automáticamente a partir de ellas— y el paquete de Room, que actúa como la capa de abstracción sobre SQLite. En tu archivo libs.versions.toml, la configuración luce así:

[versions]
room = "2.8.4"
sqlite = "2.6.2"
ksp = "<kotlinCompatibleKspVersion>"
[libraries]
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
# Optional SQLite Wrapper available in version 2.8.0 and higher
androidx-room-sqlite-wrapper = { module = "androidx.room:room-sqlite-wrapper", version.ref = "room" }
[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
androidx-room = { id = "androidx.room", version.ref = "room" }

Como puedes ver, el archivo se divide en tres secciones delimitadas por corchetes []: versiones ([versions]), librerías ([libraries]) y plugins ([plugins]).

Para obtener el valor exacto de ksp = "<kotlinCompatibleKspVersion>", consulta el repositorio oficial de releases de KSP:

https://github.com/google/ksp/releases

Encontrarás versiones con un formato como 2.2.21-2.0.5, donde cada segmento tiene su significado:

2.2.21-2.0.5 → corresponde a la versión de Kotlin con la que es compatible.

2.2.21-2.0.5 → corresponde a la versión propia del plugin KSP.

Con esto configurado ya puedes pasar al siguiente apartado, donde verás la estructura que debes seguir para definir tus tablas, consultas y la base de datos completa con Room.

Definiendo una Entidad (Tabla) con Room

Una Entidad es una clase de datos (data class) que representa una tabla en la base de datos. Cada campo de la clase se convierte en una columna, y las anotaciones de Room —como @Entity, @PrimaryKey y @ColumnInfo— le indican al compilador cómo mapear esa clase a la estructura SQL correspondiente:

import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "notifications")
data class Notification(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    @ColumnInfo(name = "notification_id") val notificationId: Int,
    @ColumnInfo(name = "created_at") val createdAt: String,
    val proccess: String,
    val text: String,
    val operation: String,
    @ColumnInfo(name = "operation_id") val operationId: Int,
    @ColumnInfo(name = "user_id") val userId: Int,
    val avatar: String,
    @ColumnInfo(name = "user_name") val userName: String,
    @ColumnInfo(name = "user_surname") val userSurname: String,
    val username: String,
    @ColumnInfo(name = "paid_id") val paidId: String,
    @ColumnInfo(name = "product_id") val productId: String,
    val singer: String,
    @ColumnInfo(name = "dedit_from") val deditFrom: String,
    @ColumnInfo(name = "dedit_to") val deditTo: String,
    val cost: String
)

Definiendo un DAO (Data Access Object) con Room

El DAO es una interfaz donde se declaran los métodos para interactuar con la base de datos: insertar, actualizar, eliminar y consultar. Room genera automáticamente todo el código SQL necesario en tiempo de compilación, lo que elimina errores en las consultas y reduce drásticamente el código manual. Las funciones marcadas con suspend se ejecutan de forma asíncrona en corrutinas, mientras que las que retornan Flow emiten actualizaciones reactivas cada vez que los datos cambian.

NotificationDao.kt (al mismo nivel que MainActivity.kt):

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import androidx.room.Delete
import kotlinx.coroutines.flow.Flow
@Dao
interface NotificationDao {
    @Query("SELECT * FROM notifications ORDER BY created_at DESC")
    fun getAllNotifications(): Flow<List<Notification>>
    @Query("SELECT * FROM notifications WHERE notification_id = :notificationId")
    suspend fun getNotificationById(notificationId: Int): Notification?
    @Insert
    suspend fun insertNotification(notification: Notification)
    @Update
    suspend fun updateNotification(notification: Notification)
    @Delete
    suspend fun deleteNotification(notification: Notification)
}

Configurando la clase Database de Room

La clase principal de la base de datos hereda de RoomDatabase y actúa como el punto central que conecta las Entidades con los DAOs. Es importante implementarla como un Singleton para evitar múltiples instancias abiertas simultáneamente, lo que podría generar condiciones de carrera. La anotación @Volatile garantiza que la instancia sea visible de forma inmediata entre todos los hilos.

Además, Room permite pre-poblar la base de datos directamente desde un archivo .db existente ubicado en la carpeta assets del proyecto, mediante el método .createFromAsset(). Esto es equivalente al antiguo enfoque con SQLiteAssetHelper, pero de una forma más integrada y respaldada por Jetpack.

AppDatabase.kt (al mismo nivel que MainActivity.kt):

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [Notification::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
    abstract fun notificationDao(): NotificationDao
    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null
        fun getDatabase(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "my_database.db" // El nombre de tu base de datos SQLite
                )
                    // Para bases de datos pre-pobladas desde assets
                    .createFromAsset("database/my_prepopulated_database.db")
                    .build()
                INSTANCE = instance
                instance
            }
        }
    }
}

Integración con Jetpack Compose y ViewModel

En una aplicación moderna con Jetpack Compose, el patrón recomendado es utilizar un ViewModel como intermediario entre el DAO y la UI. El ViewModel expone los datos del DAO como un StateFlow, que la UI observa de forma reactiva: cada vez que un dato cambia en la base de datos, la pantalla se recompone automáticamente sin necesidad de recargar manualmente.

NotificationViewModel.kt (al mismo nivel que MainActivity.kt):

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class NotificationViewModel(private val notificationDao: NotificationDao) : ViewModel() {
    val allNotifications: StateFlow<List<Notification>> =
        notificationDao.getAllNotifications().stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )
    fun addNotification(notification: Notification) {
        viewModelScope.launch {
            notificationDao.insertNotification(notification)
        }
    }
    // Otros métodos para actualizar, eliminar, etc.
}

En tu función @Composable, observas el StateFlow utilizando collectAsState(). Cada vez que la lista de notificaciones cambia en la base de datos, el composable se recompone automáticamente:

import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun NotificationScreen(viewModel: NotificationViewModel = viewModel()) {
    val notifications by viewModel.allNotifications.collectAsState()
    LazyColumn {
        items(notifications) { notification ->
            Text(text = notification.text)
        }
    }
}

Las bases de datos son una herramienta fundamental para mantener los datos del usuario —y de la aplicación en general— bien organizados, seguros y accesibles en todo momento. Este último punto es clave: en la era moderna, casi todas las aplicaciones se conectan a una API externa o servidor para traer y presentar datos al usuario. Esto funciona perfectamente mientras haya conexión a Internet, pero si esa conexión se pierde, la app quedaría inutilizable. Una base de datos local como SQLite resuelve exactamente ese problema, permitiendo trabajar en modo offline y sincronizar cuando la conexión se restablece.

Bases de datos en Android: del enfoque Legacy a Jetpack Compose con Room

El manejo de bases de datos locales en Android ha evolucionado significativamente a lo largo de los años. Lo que antes se manejaba directamente con SQLiteOpenHelper del Android SDK —escribiendo SQL a mano y gestionando manualmente los cursores— ahora se simplifica con la Room Persistence Library, parte del ecosistema Android Jetpack. Sumado a esto, la interfaz de usuario moderna se construye con Jetpack Compose, que se integra de forma fluida con Room a través de Flow y StateFlow para crear aplicaciones reactivas y eficientes.

¿Qué es SQLite y por qué Android la usa como base de datos local?

SQLite es un motor de base de datos relacional ligero y de código abierto. Su principal atractivo es que almacena toda la base de datos —estructura y datos— en un único archivo muy compacto, sin necesitar un servidor en ejecución, sin configuración de puertos y sin procesos adicionales. Esto lo diferencia radicalmente de motores como MySQL o PostgreSQL, que requieren una infraestructura de servidor dedicada. Por esa razón, SQLite no es exclusivo de Android: lo encontrarás en aplicaciones de escritorio, aplicaciones web, navegadores, sistemas embebidos y muchísimas otras plataformas donde la simplicidad y la portabilidad son prioritarias.

Guardar datos localmente en Android con SQLite (Enfoque Legacy)

Antes de que Room existiera, la forma de trabajar con SQLite en Android era directamente a través del Android SDK, usando clases como SQLiteOpenHelper. Esta base de datos no deja de ser un motor relacional completo: puedes realizar consultas SELECT, INSERT, UPDATE y DELETE igual que en cualquier otro sistema. La diferencia está en que toda esa capa de acceso debías escribirla tú a mano. Veamos cómo se hacía.

Creando una base de datos SQLite en Android: el Helper (Legacy)

Lo primero es crear una clase que extienda de SQLiteOpenHelper. En ella defines la estructura de tu base de datos: las tablas, los tipos de datos y las claves primarias. Esta clase se encarga de crear la base de datos la primera vez que se ejecuta la app y de migrarla cuando cambias la versión. Aquí el ejemplo en Kotlin:

import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
class ListSQLiteHelper(
    context: Context, name: String,
    factory: SQLiteDatabase.CursorFactory?, version: Int
) :
    SQLiteOpenHelper(context, name, factory, version) {
    private val TABLE_NOTIFICATIONS =
        "CREATE TABLE notifications(id INTEGER PRIMARY KEY, notification_id INTEGER, created_at TEXT, proccess TEXT, text TEXT, operation TEXT, operation_id INT, user_id INT, avatar TEXT, user_name TEXT, user_surname TEXT, username TEXT, paid_id TEXT, product_id TEXT, singer TEXT, dedit_from TEXT, dedit_to TEXT, cost TEXT)"
    private val TABLE_OPERATIONS =
        "CREATE TABLE operations(id INTEGER PRIMARY KEY, operation_id INTEGER, province TEXT, location TEXT, province_id INTEGER, location_id INTEGER, place TEXT, created_at TEXT, proccess TEXT, day TEXT, user_id INT, user_name TEXT, user_surname TEXT, username TEXT, avatar TEXT, amount_to_paid TEXT, active TEXT)"
    override fun onCreate(db: SQLiteDatabase) {
        // Se crean las tablas necesarias
        db.execSQL(TABLE_NOTIFICATIONS)
        db.execSQL(TABLE_OPERATIONS)
    }
    override fun onUpgrade(db: SQLiteDatabase, arg1: Int, arg2: Int) {
        // Se elimina la versión anterior de la BD
        db.execSQL("DROP TABLE IF EXISTS notifications")
        db.execSQL("DROP TABLE IF EXISTS operations")
        // Se crean las tablas de nuevo
        db.execSQL(TABLE_NOTIFICATIONS)
        db.execSQL(TABLE_OPERATIONS)
    }
}

Y su equivalente en Java, que aplica exactamente la misma lógica pero con la sintaxis del lenguaje:

import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
public class ListSQLiteHelper extends SQLiteOpenHelper {
   final private String TABLE_PEDIDO = "CREATE TABLE pedido(pedido_id INTEGER PRIMARY KEY, creado DATETIME DEFAULT CURRENT_TIMESTAMP, procesado DATETIME, cliente_id INTEGER, establecimiento_id INTEGER, mesas_establecimiento_id INTEGER, token TEXT)";
   public ListSQLiteHelper(Context context, String name,
                           SQLiteDatabase.CursorFactory factory, int version) {
       super(context, name, factory, version);
   }
   @Override
   public void onCreate(SQLiteDatabase db) {
       // Se crean las tablas necesarias
       db.execSQL(TABLE_PEDIDO);
   }
   @Override
   public void onUpgrade(SQLiteDatabase db, int arg1, int arg2) {
       // Se elimina la versión anterior de la BD
       db.execSQL("DROP TABLE IF EXISTS pedido");
       // Se crean las tablas de nuevo
       db.execSQL(TABLE_PEDIDO);
   }
}

Como puedes ver, ambas versiones comparten los mismos métodos clave: onCreate(), que se ejecuta una única vez al crear la base de datos, y onUpgrade(), que se dispara cuando incrementas el número de versión y necesitas migrar la estructura existente.

Para consultar un registro por su ID, el enfoque legacy utiliza un Cursor para recorrer los resultados. Aquí un ejemplo en Java de cómo obtener un Pedido a partir de su identificador:

public static Pedido getById(int pedido_id, Context context) {
   Pedido pedido = null;
   ListSQLiteHelper taskSQL = new ListSQLiteHelper(context, TABLE_NAME,
           null, 1);
   SQLiteDatabase db = taskSQL.getReadableDatabase();
   Cursor cursor = db.rawQuery("SELECT " + selectData + " WHERE "
           + ID_NAME + " = " + pedido_id, null);
   if (cursor.moveToFirst()) {
       pedido = new Pedido();
       pedido.setPedido_id(cursor.getInt(0));
       try {
           if (cursor.getString(1) != null)
               pedido.setCreado(dateFormat.parse(cursor.getString(1)));
       } catch (ParseException e) {
           e.printStackTrace();
       }
       try {
           if (cursor.getString(2) != null)
               pedido.setProcesado(dateFormat.parse(cursor.getString(2)));
       } catch (ParseException e) {
           e.printStackTrace();
       }
       pedido.setCliente_id(cursor.getString(3));
       pedido.setEstablecimiento_id(cursor.getInt(4));
       pedido.setMesas_establecimiento_id(cursor.getInt(5));
       pedido.setToken(cursor.getString(6));
   }
   cursor.close();
   db.close();
   return pedido;
}

Desde este Helper tendremos centralizadas todas las tablas de la aplicación. La primera sección de cada constante define la estructura de la tabla: columnas, tipos de datos y clave primaria. El método onCreate() crea esa estructura la primera vez, mientras que onUpgrade() se encarga de las migraciones —en este caso, simplemente eliminando y recreando las tablas.

Definiendo la capa de acceso a los datos (DAO) en el enfoque Legacy

Una vez creada la base de datos, el siguiente paso es definir la capa de acceso a los datos: un archivo dedicado que centraliza todas las consultas, inserciones, actualizaciones y eliminaciones. En el enfoque legacy, esta capa se construye manualmente con ContentValues y Cursor. Aunque podrías escribir estas consultas directamente en tus actividades, fragments o adapters, centralizar todo en una clase DAO es una buena práctica que mantiene el código organizado y reutilizable:

import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.*
import kotlin.collections.ArrayList
class NotificationDao {
   companion object {
       val TABLE_NAME = "notifications"
       val ID = "notification_id"
       val TEXT = "text"
       val CREATED_AT = "created_at"
       val OPERATION = "operation"
       val OPERATION_ID = "operation_id"
       val USERNAME = "username"
       val USER_NAME = "user_name"
       val USER_SURNAME = "user_surname"
       val USER_ID = "user_id"
       val AVATAR = "avatar"
       val PROCCESS = "proccess"
       var PAID_ID = "paid_id"
       var PRODUCT_ID = "product_id"
       var COST = "cost"
       var SINGER = "singer"
       var FROM = "dedit_from"
       var TO = "dedit_to"
       internal var dateFormat = SimpleDateFormat(
           "yyyy-MM-dd HH:mm:ss", Locale.getDefault()
       )
       private val selectData = (ID + ", " + TEXT + ", "
               + CREATED_AT + ", " + PROCCESS + ", " + USERNAME + ", " + OPERATION + ", " + OPERATION_ID + ", " + USER_ID + ", " + AVATAR + ", "
               + USER_NAME + ", " + USER_SURNAME + ", " + PAID_ID + ", " + PRODUCT_ID + ", " + COST + ", " + SINGER + ", " + FROM + ", " + TO + " FROM " + TABLE_NAME)
       fun getAllByOperationId(context: Context, id: Long): ArrayList<NotificationUser> {
           var notification: NotificationUser
           val notifications = ArrayList<NotificationUser>()
           val taskSQL = ListSQLiteHelper(
               context, TABLE_NAME,
               null, 1
           )
           // Traigo la data de BD
           val db = taskSQL.getReadableDatabase()
           val cursor = db.rawQuery("SELECT $selectData WHERE $OPERATION_ID = $id", null)
           // Guardo la data en un ArrayList
           if (cursor.moveToFirst()) {
               do {
                   var i = 0
                   notification = packOperation(cursor)
                   notifications.add(notification)
               } while (cursor.moveToNext())
           }
           cursor.close()
           db.close()
           return notifications
       }
       fun getAll(context: Context): ArrayList<NotificationUser> {
           var notification: NotificationUser
           val notifications = ArrayList<NotificationUser>()
           val taskSQL = ListSQLiteHelper(
               context, TABLE_NAME,
               null, 1
           )
           // Traigo la data de BD
           val db = taskSQL.getReadableDatabase()
           val cursor = db.rawQuery("SELECT $selectData", null)
           // Guardo la data en un ArrayList
           if (cursor.moveToFirst()) {
               do {
                   var i = 0
                   notification = packOperation(cursor)
                   notifications.add(notification)
               } while (cursor.moveToNext())
           }
           cursor.close()
           db.close()
           return notifications
       }
       fun getByID(context: Context, id: Long): NotificationUser? {
           var notification: NotificationUser? = null
           val taskSQL = ListSQLiteHelper(
               context, TABLE_NAME,
               null, 1
           )
           // Traigo la data de BD
           val db = taskSQL.getReadableDatabase()
           val cursor = db.rawQuery("SELECT $selectData WHERE $ID = $id", null)
           // Guardo la data en un ArrayList
           if (cursor.moveToFirst()) {
               notification = packOperation(cursor)
           }
           cursor.close()
           db.close()
           return notification
       }
       fun update(
           contentValues: ContentValues, id: Int,
           context: Context
       ): Long {
           // Abrimos la base de datos en modo escritura
           val sqlHelper = ListSQLiteHelper(
               context, NotificationDao.TABLE_NAME,
               null, 1
           )
           val db = sqlHelper.writableDatabase
           return db.update(
               NotificationDao.TABLE_NAME, contentValues, NotificationDao.ID + "=?",
               arrayOf(id.toString())
           ).toLong()
       }
       fun deleteAll(
           context: Context
       ) {
           // Abrimos la base de datos en modo escritura
           val sqlHelper = ListSQLiteHelper(
               context, NotificationDao.TABLE_NAME,
               null, 1
           )
           val db = sqlHelper.writableDatabase
           db.delete(
               NotificationDao.TABLE_NAME, "1",
               arrayOf()
           )
       }
       fun insert(contentValues: ContentValues, context: Context): Long {
           // Abrimos la base de datos en modo escritura
           val sqlHelper = ListSQLiteHelper(context, TABLE_NAME, null, 1)
           val db = sqlHelper.writableDatabase
           return db.insert(TABLE_NAME, null, contentValues)
       }
       fun packOperation(cursor: Cursor): NotificationUser {
           var i = 0
           var notification = NotificationUser()
           notification.notification_id = cursor.getInt(i)
           notification.text = cursor.getString(++i)
           notification.created_at = cursor.getString(++i)
           notification.proccess = cursor.getString(++i)
           notification.username = cursor.getString(++i)
           notification.operation = cursor.getString(++i)
           notification.operation_id = cursor.getInt(++i)
           notification.user_id = cursor.getString(++i)
           notification.avatar = cursor.getString(++i)
           notification.user_name = cursor.getString(++i)
           notification.user_surname = cursor.getString(++i)
           notification.paid_id = cursor.getString(++i)
           notification.product_id = cursor.getString(++i)
           notification.cost = cursor.getString(++i)
           notification.singer = cursor.getString(++i)
           notification.dedit_from = cursor.getString(++i)
           notification.dedit_to = cursor.getString(++i)
           return notification
       }
       fun populateContentValue(notification: NotificationUser): ContentValues {
           var contentValues = ContentValues()
           contentValues.put(ID, notification.notification_id)
           contentValues.put(CREATED_AT, notification.created_at)
           contentValues.put(TEXT, notification.text)
           contentValues.put(USERNAME, notification.username)
           contentValues.put(OPERATION, notification.operation)
           contentValues.put(OPERATION_ID, notification.operation_id)
           contentValues.put(USER_NAME, notification.user_name)
           contentValues.put(USER_SURNAME, notification.user_surname)
           contentValues.put(USER_ID, notification.user_id)
           contentValues.put(AVATAR, notification.avatar)
           contentValues.put(PROCCESS, notification.proccess)
           contentValues.put(PAID_ID, notification.paid_id)
           contentValues.put(PRODUCT_ID, notification.product_id)
           contentValues.put(SINGER, notification.singer)
           contentValues.put(FROM, notification.dedit_from)
           contentValues.put(TO, notification.dedit_to)
           return contentValues
       }
   }
}

Analizando el código: consultas con Cursores

En los métodos de consulta como getAll() y getByID(), la clave está en los Cursores. Un Cursor en Android es esencialmente un puntero a los resultados de una consulta SQL: apunta al conjunto de filas devueltas y te permite recorrerlas una a una mediante el método moveToNext(), que retorna true mientras haya más registros y false cuando se acaban. Es importante cerrar siempre el cursor con cursor.close() al terminar para liberar recursos.

Analizando el código: insertar y actualizar con ContentValues

Para escrituras, el flujo es directo: se obtiene la base de datos en modo escritura con sqlHelper.writableDatabase y luego se ejecuta la operación. Lo particular es el uso de ContentValues: este objeto funciona como un mapa de pares clave-valor, similar a los Pair de Kotlin, pero diseñado específicamente para mapear columnas de la base de datos con sus valores. Se utiliza tanto en insert() como en update():

Insertar un registro en SQLite:

var id = NotificationDao.insert(
   NotificationDao.populateContentValue(notification),
   this@NotificationActivity
)

Actualizar un registro existente en SQLite:

var contentValues = ContentValues()
contentValues.put(NotificationDao.AVATAR, noti.avatar)
contentValues.put(NotificationDao.PROCCESS, noti.proccess)
contentValues.put(NotificationDao.COST, noti.cost)
contentValues.put(NotificationDao.PAID_ID, noti.paid_id)
contentValues.put(NotificationDao.PRODUCT_ID, noti.product_id)
NotificationDao.update(contentValues, noti.notification_id, this@NotificationActivity)

Y con esto tenemos completa la capa de acceso a datos. La recomendación es que todas las consultas vivan aquí y que las actividades o fragments solo llamen a esta capa sin conocer los detalles de SQL. Para lograr eso, implementar el DAO como Singleton es la mejor estrategia: en Java se logra con métodos static y en Kotlin con companion object, como en el ejemplo anterior.

Usando una base de datos SQLite externa en Android (Enfoque Legacy)

Una de las características más interesantes de SQLite es su portabilidad: como toda la base de datos vive en un único archivo, puedes tomar ese archivo generado en otra aplicación —ya sea una app web, una aplicación de escritorio en PHP, JavaScript u otro entorno— y usarlo directamente en Android. No hace falta leer tabla por tabla ni migrar los datos manualmente.

Esto resulta especialmente útil cuando necesitas distribuir tu app con datos precargados: un catálogo de productos, una base de conocimiento, configuraciones de fábrica, etc. SQLite no es una invención de Android; es una tecnología transversal que puedes encontrar en navegadores, sistemas embebidos, aplicaciones móviles y de escritorio por igual.

SQLite más allá de Android

Muchas aplicaciones fuera del ecosistema Android utilizan SQLite como motor de persistencia: desde aplicaciones PHP hasta JavaScript en el cliente (con APIs como openDatabase y executeSql). Es muy probable que en algún momento necesites exportar una base de datos desde una aplicación web u otra plataforma e importarla directamente en tu proyecto Android, y SQLite lo permite sin mayor fricción.

Importando una base de datos SQLite externa en Android Studio (Legacy)

Si decides importar una base de datos SQLite externa, no es necesario ningún proceso complejo de migración manual. No tienes que leer el archivo, crear las tablas a mano ni copiar registro a registro. Basta con copiar el archivo .db en la carpeta assets/databases de tu proyecto en Android Studio:

Carpeta assets/databases en Android Studio para base de datos SQLite externa

Una vez copiado el archivo allí, crea una clase que extienda de SQLiteAssetHelper en lugar del habitual SQLiteOpenHelper:

public class ListSQLiteHelper extends SQLiteAssetHelper {
    private static final String DATABASE_NAME = "main";
    private static final int DATABASE_VERSION = 1;
    public ListSQLiteHelper(Context context, String name,
                            SQLiteDatabase.CursorFactory factory, int version) {
        super(context, DATABASE_NAME, context.getExternalFilesDir(null).getAbsolutePath(), null, DATABASE_VERSION);
        //super(context, name, factory, version);
    }
}

Nota importante: Aunque SQLiteAssetHelper es una solución válida para proyectos legacy, en el desarrollo moderno se recomienda usar el método .createFromAsset() de la Room Persistence Library, como se muestra en la sección "El enfoque moderno". Room ofrece una integración más robusta, verificación de consultas en compilación y es parte de la arquitectura Jetpack oficial.

La diferencia clave con SQLiteOpenHelper es que al usar SQLiteAssetHelper no necesitas sobreescribir los métodos onCreate(SQLiteDatabase db) ni onUpgrade(SQLiteDatabase db, int arg1, int arg2), ya que la base de datos ya existe y la librería se encarga de copiarla en la ubicación correcta dentro del dispositivo automáticamente.

La constante DATABASE_NAME debe contener el nombre exacto del archivo .db que copiaste en assets/databases.

Con esto tienes lista la conexión y puedes realizar consultas sobre la base de datos externa exactamente igual que si fuera una base de datos creada de forma nativa por Android.

Puedes consultar la documentación oficial en el siguiente enlace: Android SQLiteAssetHelper.

Siguiente paso: aprende a configurar Google Maps con Android Studio y Compose.

Aprende a implementar Room Database (SQLite) en Android Studio con Kotlin. Incluye ejemplos completos de Entity, DAO, consultas, inserciones, migraciones y Jetpack Compose.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

Acepto recibir anuncios de interes sobre este Blog.