Desarrollo móvil
Persistencia local en móvil: Room, Content Providers y Core Data, cara a cara
7 min de lectura
Persistencia local en móvil: Room, Content Providers y Core Data, cara a cara
Durante el máster de desarrollo móvil tuve que guardar datos en local tanto en Android como en iOS, a menudo en la misma semana. Ponerlo por escrito lado a lado me ayudó a ver que las dos plataformas resuelven los mismos problemas (esquema, hilos, migraciones, compartir datos) con herramientas distintas, y que la mayoría de los errores salen de los mismos pocos sitios en ambas.
Primero, elegir el nivel de almacenamiento adecuado
No todo necesita una base de datos. En Android, los datos pequeños de tipo clave-valor, como ajustes o un indicador de "sesión iniciada", van en SharedPreferences o DataStore, e iOS tiene la misma separación con las preferencias de usuario. Una base de datos tiene sentido cuando hay registros estructurados que consultar, relacionar y actualizar.
SQLite o Room en Android
Android trae SQLite de serie, así que se puede usar directamente sin librerías adicionales. El coste aparece enseguida:
- Mucho código repetitivo:
ContentValues, cursores y conversión manual de filas a objetos. - El SQL solo se comprueba en tiempo de ejecución. Una errata como
SELETCcompila sin problema y hace que la app se cierre cuando el usuario llega a esa pantalla.
Room es una capa de abstracción sobre SQLite que resuelve justo esos dos problemas: valida las consultas al compilar, convierte las filas en objetos por ti y se integra con Flow y LiveData. Para casi cualquier base de datos local en Android es la opción razonable por defecto.
Room necesita tres piezas: una entidad (la tabla), un DAO (las consultas) y la clase de base de datos (el punto de entrada).
@Entity(tableName = "notes")
data class Note(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "text") val text: String
)
@Dao
interface NoteDao {
@Query("SELECT * FROM notes ORDER BY id DESC")
fun observeAll(): Flow<List<Note>>
@Insert
suspend fun insert(note: Note): Long
}
El DAO es un contrato: dice qué se puede hacer con los datos, no cómo se hace. No contiene lógica de negocio, solo definiciones de métodos que Room traduce a SQL.
Una sola instancia de la base de datos, y nunca en el hilo principal
Dos reglas me ahorraron la mayoría de los problemas.
La primera: abrir la base de datos una sola vez. Si varias partes de la app crean su propia instancia y dos hilos escriben a la vez por conexiones distintas, aparecen bloqueos o algo peor. Un singleton en el companion object lo resuelve:
@Database(entities = [Note::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
companion object {
@Volatile private var instance: AppDatabase? = null
fun get(context: Context): AppDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app.db"
).build().also { instance = it }
}
}
}
La segunda: nunca consultar en el hilo principal. Room lo impide por defecto, y la vía de escape allowMainThreadQueries() resulta tentadora en un prototipo rápido. No la uses: en cuanto la tabla crece, la interfaz se congela mientras lee y Android acaba mostrando el diálogo de "La aplicación no responde". Con funciones suspend en el DAO y corrutinas, el trabajo sale del hilo de la interfaz casi sin código adicional.
Compartir datos entre apps: Content Providers
Una base de datos es privada de su app. Cuando otra app necesita tus datos, el mecanismo estándar de Android es un Content Provider: un componente que se sitúa entre tu almacenamiento y el exterior, expone los datos mediante URIs y aplica permisos.
Una URI de contenido sigue el patrón content://authority/path/id: la authority identifica al provider (normalmente el nombre del paquete), el path identifica la tabla y el id opcional apunta a un registro concreto.
La pieza que no me saltaría es la clase Contract: un objeto que solo contiene constantes (authority, URI base, nombres de tablas y columnas, tipos MIME) y que comparten el provider y sus clientes, de modo que ambos lados están de acuerdo en el esquema sin copiar cadenas de un sitio a otro.
object NotesContract {
const val AUTHORITY = "com.example.notes.provider"
val BASE_URI: Uri = Uri.parse("content://$AUTHORITY")
object NoteEntry : BaseColumns {
const val TABLE = "notes"
const val COLUMN_TEXT = "text"
val CONTENT_URI: Uri = BASE_URI.buildUpon().appendPath(TABLE).build()
}
}
El provider hereda de ContentProvider e implementa onCreate, getType, query, insert, update y delete, usando un UriMatcher para distinguir una URI de lista de una de elemento único. Después de insertar, hay que llamar a notifyChange para que los observadores se actualicen.
Los clientes acceden a través de ContentResolver:
contentResolver.query(
NotesContract.NoteEntry.CONTENT_URI,
arrayOf(BaseColumns._ID, NotesContract.NoteEntry.COLUMN_TEXT),
null, null, null
)?.use { cursor ->
while (cursor.moveToNext()) {
val text = cursor.getString(1)
}
}
Dos detalles me costaron tiempo. Hay que proteger el provider con readPermission y writePermission en el manifest, y usar grantUriPermissions cuando solo quieres compartir una URI concreta de forma temporal. Y desde Android 11 la app cliente debe declarar el provider en un bloque <queries>; si no, sencillamente no lo encuentra y no hay ningún error claro que explique por qué.
Core Data en iOS
Core Data no es una base de datos, sino un framework de grafo de objetos y persistencia que normalmente se apoya en SQLite (también existe un almacenamiento en memoria para datos fáciles de recrear). En el día a día se tocan sobre todo tres cosas: el modelo de datos (.xcdatamodeld), el NSPersistentContainer que monta la pila y el NSManagedObjectContext, por el que pasa toda lectura y escritura. Las consultas son NSFetchRequest filtradas con NSPredicate; CONTAINS[cd] ignora mayúsculas y diacríticos, que es justo lo que se necesita detrás de un buscador.
Concurrencia
Se aplica la misma regla que en Android: el trabajo pesado no va en el hilo principal. En Core Data eso significa trabajar con más de un contexto, cada uno asociado a su propia cola:
container.performBackgroundTask { context in
let request: NSFetchRequest<Note> = Note.fetchRequest()
request.predicate = NSPredicate(format: "text CONTAINS[cd] %@", term)
let notes = (try? context.fetch(request)) ?? []
// aquí el trabajo costoso: exportar, subir...
}
Los objetos gestionados pertenecen a su contexto. Para usar en el contexto principal algo obtenido en segundo plano, se pasa su objectID y se carga allí, o se escucha la notificación NSManagedObjectContextDidSave y se llama a mergeChanges(fromContextDidSave:) en el contexto principal.
Migraciones
Cada cambio de esquema implica una nueva versión del modelo. Las migraciones ligeras resuelven muchos cambios de forma automática, pero hay una trampa fácil de pasar por alto: si renombras un atributo o una entidad, Core Data lo interpreta como borrar el antiguo y crear uno nuevo, y los valores antiguos se pierden. Hay que indicarle de forma explícita que se trata de un renombrado. Para las transformaciones que no puede deducir, hace falta una migración pesada que describa cómo pasar los datos antiguos al nuevo formato.
Android tiene la misma trampa en otro sitio: en SQLiteOpenHelper.onUpgrade, un DROP TABLE para "empezar de cero" borra los datos de todos los usuarios en la siguiente actualización.
Depurar lo que realmente se guarda
En Android, el Database Inspector de Android Studio muestra las tablas en vivo y permite lanzar consultas contra la app en ejecución. En iOS, el fichero SQLite vive en el sandbox de la app, dentro de Library/Application Support, y se puede abrir con sqlite3. Core Data añade el prefijo Z a las tablas, y las escrituras recientes pueden seguir en el fichero .wal, lo que explica filas "que faltan" cuando se inspecciona el fichero directamente.
Con qué me quedo
Elegir el nivel de almacenamiento según la necesidad, tener un único punto de entrada a la base de datos, sacar las consultas del hilo principal, definir un contrato compartido cuando los datos cruzan la frontera entre apps y tratar cada cambio de esquema como una migración que puede perder datos. Las APIs cambian; los fallos son los mismos.