Saltar al contenido
Todas las entradas

Android local-first en 2026: SQLite, Room y mantener los datos del usuario en el dispositivo

Una guía de 2026 para construir apps Android local-first con Room y SQLite — diseño de esquemas, migraciones, WAL, exportaciones y cuándo (y cuándo no) añadir sincronización.

MFKAPPS 8 min de lectura

La arquitectura de app Android por defecto en 2026 sigue asumiendo un backend. Autenticación, sincronización, push, un servidor al que llamar cuando hay red. Para muchas apps esa es la forma correcta. Para muchas otras es simplemente arquitectura que tienes que mantener para siempre — y un flujo de datos que tu política de privacidad tiene que defender.

Yo construyo lo contrario: apps Android local-first donde el dispositivo es la fuente de verdad. Granyn (presupuesto), Hydrame (hidratación) y Subly (suscripciones) funcionan todas así. Sin cuentas, sin base de datos en la nube guardando tus datos, sin servidores que tenga que mantener en pie a las 3 de la mañana. Este post es la versión práctica de 2026 de cómo lo hago.

Qué significa “local-first” de verdad

Local-first es una afirmación más fuerte que “funciona sin conexión”:

  • El dispositivo es dueño de los datos. Las lecturas y escrituras van primero a la base de datos local; la interfaz nunca se bloquea esperando una llamada de red.
  • La app es totalmente funcional con el modo avión activado. Incluyendo funciones que, en una app cloud-first, se desactivarían silenciosamente.
  • La sincronización, si existe, está cifrada de extremo a extremo entre los propios dispositivos del usuario — el servidor es un relé, no un registro.
  • La exportación es una función, no un truco. Tus datos, en un formato que puedes llevarte a otra parte.

Para una app de productividad de un solo dispositivo — un tracker, un diario, un presupuesto — eso es todo lo que necesitas. El servidor es una carga de mantenimiento que eliges no asumir.

Local-first es la arquitectura del “no podemos perder tus datos porque nunca los tuvimos”.

Room en 2026: la elección aburrida que envejeció bien

Para el almacenamiento local en Android en 2026, Room sigue siendo la respuesta correcta. Es una capa fina sobre SQLite con verificación de SQL en tiempo de compilación, integración con coroutines + Flow, y KSP (se acabaron las esperas del procesador de anotaciones). Ha sido lo bastante estable como para que posts escritos hace años sigan aplicando, y las pequeñas adiciones desde entonces (auto-migraciones, @MapColumn, soporte multiproceso más sencillo) van todas en la dirección correcta.

Una configuración mínima:

@Entity(tableName = "subscriptions")
data class SubscriptionEntity(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val service: String,
    val amountMinor: Long,      // store cents, never floats
    val currency: String,        // "TRY", "EUR", "USD"
    val cycle: BillingCycle,
    val nextChargeDate: LocalDate,
    val createdAt: Instant = Instant.now(),
)

@Dao
interface SubscriptionDao {
    @Query("SELECT * FROM subscriptions ORDER BY nextChargeDate ASC")
    fun observeAll(): Flow<List<SubscriptionEntity>>

    @Query("SELECT SUM(amountMinor) FROM subscriptions WHERE currency = :currency")
    fun totalForCurrency(currency: String): Flow<Long?>

    @Insert
    suspend fun insert(s: SubscriptionEntity): Long

    @Delete
    suspend fun delete(s: SubscriptionEntity)
}

Algunas cosas que siempre hago, y que defendería:

  • Guarda el dinero como una unidad menor entera. amountMinor: Long en lugar de Double. Los floats y la moneda no se mezclan; un solo bug de redondeo te costará una semana.
  • Usa Instant y LocalDate de kotlinx-datetime con un TypeConverter. Los tipos java.time.* del JDK también funcionan, pero kotlinx-datetime es más amigable con multiplataforma por si algún día vas por ahí.
  • Flow en todas partes desde el DAO. La interfaz se suscribe; la app reacciona a las escrituras automáticamente sin que tú cablees listeners.

Migraciones: piensa en la base de datos como una API pública

La razón más común, con diferencia, por la que las apps indie pierden datos es una migración chapucera. Trata tu esquema como una API pública: cada cambio está versionado, probado y es irreversible.

Room lo hace casi fácil:

@Database(
    entities = [SubscriptionEntity::class],
    version = 3,
    autoMigrations = [
        AutoMigration(from = 1, to = 2),
        AutoMigration(from = 2, to = 3, spec = AddNotesField::class),
    ],
    exportSchema = true,
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase()

@RenameColumn(tableName = "subscriptions", fromColumnName = "note", toColumnName = "notes")
class AddNotesField : AutoMigrationSpec

Dos ajustes que debes fijar desde el primer día:

  1. exportSchema = true + confirma los archivos JSON generados en schemas/ en git. Luego añade un RoomDatabaseSchemaTest que cargue cada esquema histórico y migre hacia adelante. Es el seguro más barato que jamás comprarás.
  2. Nunca edites una migración publicada. Si encuentras un bug, escribe una nueva migración que lo arregle. El esquema es un libro contable de solo avance.

El patrón que me ha salvado dos veces: hacer todas las transformaciones destructivas como una MIGRATION con SQL crudo, no como auto-migración, para poder copiar las filas antiguas en una tabla temporal, reconstruir y confirmar que los conteos coinciden antes de confirmar la migración.

WAL y otras pequeñas victorias de SQLite

Room usa por defecto el registro de escritura anticipada (WAL) en Android, pero vale la pena saber qué significa eso: los escritores no bloquean a los lectores, el fsync es más barato, y la app se siente notablemente más ágil en listas que se vuelven a consultar mientras el usuario escribe. Unos pequeños ajustes a nivel de SQLite que todavía valen una línea:

val db = Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
    .setQueryExecutor(Dispatchers.IO.asExecutor())
    .setTransactionExecutor(Dispatchers.IO.asExecutor())
    .addCallback(object : RoomDatabase.Callback() {
        override fun onOpen(db: SupportSQLiteDatabase) {
            super.onOpen(db)
            db.query("PRAGMA journal_mode=WAL;").use { it.moveToFirst() }
            db.query("PRAGMA synchronous=NORMAL;").use { it.moveToFirst() }
        }
    })
    .build()

synchronous=NORMAL con WAL es un valor por defecto lo bastante seguro para una app en el dispositivo donde no estás llevando un libro contable bancario. Si tus datos son literalmente un libro contable bancario, déjalo en FULL.

Para las listas, indexa las columnas por las que ordenas. El número de apps indie que he visto con una consulta WHERE created_at > y sin índice en created_at es demasiado alto. Es rendimiento gratis que no estás aprovechando.

Las exportaciones son una función, no un botón de pánico

Toda app local-first debería incluir una exportación de un solo toque. Tres razones:

  1. Genera confianza. “Tus datos son tuyos” deja de ser marketing en el momento en que un usuario tiene un CSV en su teléfono.
  2. Es portabilidad gratis. Se acabaron los tickets de soporte del tipo “¿pero cómo me paso a otra app?”.
  3. Es una buena historia de copia de seguridad sin que tengas que gestionar un servicio de copias.

Una exportación CSV mínima desde un Flow de entidades:

suspend fun exportCsv(uri: Uri, context: Context, dao: SubscriptionDao) {
    val rows = dao.observeAll().first()
    context.contentResolver.openOutputStream(uri)?.use { out ->
        out.bufferedWriter().use { w ->
            w.appendLine("service,amount,currency,cycle,nextDate")
            rows.forEach {
                w.appendLine("${it.service},${it.amountMinor / 100.0},${it.currency},${it.cycle},${it.nextChargeDate}")
            }
        }
    }
}

Combínalo con importación y el usuario puede moverse entre dispositivos enviándose un CSV por correo. Nada glamuroso; funciona perfectamente.

Cuándo empieza a importar de verdad la sincronización

Local-first no significa sin sincronización. Significa que el dispositivo es la fuente de verdad. La sincronización, cuando la añades, tiene que mantener ese invariante verdadero.

Las opciones maduras en 2026:

  • Sincronización basada en CRDT (Yjs, Automerge) para casos colaborativos o de muchos dispositivos. Pesada para una app de un solo usuario.
  • Sincronización de blobs cifrada de extremo a extremo para el caso de “dos dispositivos, mismo usuario”. Cifra localmente, el servidor guarda bytes opacos, descifra localmente en el otro dispositivo. Identidad estilo Tailscale, APIs de iCloud / Drive como transportes, o tu propio pequeño relé.
  • Simplemente basada en archivos para muchos casos. Exporta a Google Drive según un calendario, restaura en el nuevo dispositivo. No tan fluida como la sincronización en vivo, pero sin servidores que gestionar, sin primitivas de E2EE que equivocar.

Para Granyn y Hydrame lancé sin sincronización. La mayoría de los usuarios tienen un teléfono y lo conservan durante años; la sincronización resulta ser mucho menos importante de lo que la gente asume en las entrevistas de descubrimiento de producto. Prefiero no tenerla a lanzar una versión a medio romper.

La forma de un día local-first

Cada taller indie tiene su propia variante, pero la mía se ve así:

  1. La UI es Compose, observa Flows del repositorio.
  2. El repositorio envuelve el DAO con el mapeo hacia y desde los tipos de dominio.
  3. El DAO es lo único que toca Room.
  4. WorkManager hace trabajos locales periódicos (programación de notificaciones, recálculo del total diario) — nunca red.
  5. DataStore guarda los ajustes, nunca los datos del usuario.

Hay un Repository, una única instancia de database, y el resto de la app habla con coroutines y StateFlow. Nada de LiveData en código nuevo, nada de RxJava en ningún sitio. El grafo de dependencias cabe en una servilleta.

Qué renuncias, qué ganas

Cosas a las que renuncias al ir local-first:

  • Sincronización entre dispositivos sin trabajo de ingeniería
  • Analítica del lado del servidor sobre el contenido real (y las tentaciones que vienen con eso)
  • Funciones fáciles de “compartir con un amigo”
  • La palabra de moda de marketing “IA sobre tus datos” si tu IA está en la nube

Cosas que ganas:

  • Una política de privacidad que prácticamente se escribe sola
  • Cero costes de servidor para siempre
  • Apps que funcionan en modo avión y se sienten instantáneas
  • Usuarios que confían en ti porque la arquitectura se lo gana

Para una app indie que se lanza en 2026, ese es el trato correcto. La pila cloud-first es impresionante, pero también son dos trabajos extra para los que no te apuntaste: SRE y atención al cliente por pérdida de datos. Local-first elimina ambos. Te conviertes en una persona que lanza apps, no en una persona que gestiona servidores y además lanza apps cuando tiene tiempo.

Si has estado dudando sobre una idea de app Android indie porque la parte de la nube te parece abrumadora, yo simplemente me la saltaría. Empieza local. Añade sincronización después, si alguna vez la necesitas de verdad. La mayoría de las veces, no lo harás.