El problema real con SharedPreferences

Si leíste el artículo anterior sobre SharedPreferences bien usado, ya sabés que la herramienta en sí no es el problema — el problema es cómo se usa. Pero hay un caso donde SharedPreferences tiene una limitación estructural que no se puede resolver con patrones: las lecturas síncronas en el hilo principal.

Cuando llamás a getSharedPreferences() por primera vez, Android lee el archivo XML completo de disco y lo carga en memoria. Si el archivo es grande o el dispositivo es lento, esto puede bloquear el hilo principal y causar un ANR (Application Not Responding). Y lo peor: es difícil de reproducir en desarrollo porque en dispositivos modernos es instantáneo.

// Esto se ejecuta en el hilo principal — puede bloquear
val prefs = getSharedPreferences("config", Context.MODE_PRIVATE)
val tema = prefs.getString("tema", "claro")  // lectura síncrona del disco

// En un Pixel 8 con SSD rápido: ~1ms — nunca lo notás
// En un dispositivo de gama baja con almacenamiento lento: potencialmente >16ms
// Resultado: jank o ANR que solo aparece en producción, en dispositivos que no tenés

Este es el problema real que DataStore resuelve. No es que SharedPreferences esté "deprecado" en el sentido de que deje de funcionar — es que su modelo síncrono es estructuralmente inadecuado para el main thread.

¿Qué es Jetpack DataStore?

DataStore es la librería de Jetpack que reemplaza a SharedPreferences. Hay dos sabores:

  • Preferences DataStore: misma idea que SharedPreferences (clave-valor), pero con API asíncrona basada en Flow y Coroutines. Es la migración directa.
  • Proto DataStore: usa Protocol Buffers para tipado fuerte y schema versionado. Más potente pero más complejo — para cuando las preferencias son suficientemente complejas como para merecer un schema.

En este artículo nos enfocamos en Preferences DataStore, que es el que tiene sentido en el 95% de los casos de migración.

Las diferencias que importan

// ── SHAREDPREFERENCES ────────────────────────────────────────

// Lectura: síncrona, puede bloquear el hilo principal
val valor = prefs.getString("clave", null)  // bloquea hasta tener el valor

// Escritura: apply() es async pero commit() es síncrono
prefs.edit().putString("clave", valor).apply()   // async, no garantía de orden
prefs.edit().putString("clave", valor).commit()  // síncrono, bloquea

// Sin observabilidad reactiva nativa
// (OnSharedPreferenceChangeListener existe pero es manual y propenso a leaks)

// ── DATASTORE ────────────────────────────────────────────────

// Lectura: retorna Flow — reactiva, no bloquea
val valorFlow: Flow<String?> = dataStore.data.map { prefs ->
    prefs[stringPreferencesKey("clave")]
}

// Escritura: suspend function — se ejecuta en un hilo de IO, no bloquea el main
suspend fun guardar(valor: String) {
    dataStore.edit { prefs ->
        prefs[stringPreferencesKey("clave")] = valor
    }
}

// Reactivo por naturaleza — cualquier cambio emite automáticamente al Flow
// Las colecciones en la UI se actualizan solas

El manejo de errores también mejoróDataStore lanza excepciones correctamente — si hay un error al leer o escribir, la excepción llega al colector del Flow o es capturada en la coroutine. SharedPreferences simplemente falla silenciosamente en algunos casos, lo que hace los bugs más difíciles de diagnosticar.

¿Cuándo vale la pena migrar?

La migración tiene un costo: tiempo de desarrollo, riesgo de bugs en producción, y más complejidad en el código. No migrés sin una razón concreta. Estas son las razones válidas:

  • Tenés ANRs o jank relacionados con SharedPreferences. Si Firebase Performance o Android Vitals muestran tiempo en el hilo principal por lecturas de prefs, es hora de migrar.
  • Empezás un proyecto nuevo. En código nuevo, DataStore es la elección correcta sin discusión.
  • Ya usás coroutines y Flow en el proyecto. Si tu arquitectura ya está basada en Flow (StateFlow, collect, etc.), DataStore encaja perfectamente. SharedPreferences en ese contexto es la pieza rara que no encaja.
  • Querés reactividad real. Si tenés varios componentes que necesitan actualizarse cuando cambia una preferencia, el Flow de DataStore lo hace automáticamente. Con SharedPreferences necesitás listeners manuales.

¿Cuándo NO migrar?

Igual de importante. No migrés si:

  • La app funciona y no tenés problemas de performance. Si tus SharedPreferences son pequeñas (menos de 10 claves, valores simples), el impacto es negligible. Migrar para "estar al día" no justifica el riesgo.
  • No usás coroutines todavía. DataStore requiere coroutines — si tu base de código es Java o Java/Kotlin sin coroutines, la inversión para migrar DataStore incluye también migrar a coroutines. Puede ser mucho para una sola tarea.
  • Tenés poco tiempo y muchos tests de regresión. La migración en producción implica leer datos viejos de SharedPreferences y escribirlos en DataStore. Si no tenés tests que cubran los flujos de preferencias, el riesgo es real.

La presión de "está deprecado" no es una razónSharedPreferences no va a dejar de funcionar. Google no va a eliminarla. La presión de "hay que migrar porque está marcado como legacy" es real pero no es una emergencia. Priorizá la migración cuando tenga sentido para tu proyecto, no por FOMO.

Preferences DataStore en la práctica

// build.gradle (app)
dependencies {
    implementation("androidx.datastore:datastore-preferences:1.1.1")
}

// Crear la instancia — una por app, a nivel de Application
// El delegate 'preferencesDataStore' asegura que solo haya una instancia
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
    name = "configuracion"
)

// Definir las claves — tipadas, sin magic strings
object PreferenciasKeys {
    val TEMA = stringPreferencesKey("tema")
    val NOTIFICACIONES = booleanPreferencesKey("notificaciones_habilitadas")
    val ULTIMO_TAB = intPreferencesKey("ultimo_tab_abierto")
    val ONBOARDING_COMPLETADO = booleanPreferencesKey("onboarding_completado")
}

// Leer — retorna Flow, se actualiza automáticamente al cambiar
val temaFlow: Flow<String> = context.dataStore.data
    .catch { exception ->
        // Manejar errores de lectura (archivo corrupto, etc.)
        if (exception is IOException) emit(emptyPreferences())
        else throw exception
    }
    .map { prefs ->
        prefs[PreferenciasKeys.TEMA] ?: "sistema"
    }

// Escribir — suspend function, seguro llamar desde cualquier coroutine
suspend fun guardarTema(tema: String) {
    context.dataStore.edit { prefs ->
        prefs[PreferenciasKeys.TEMA] = tema
    }
}

// Limpiar una clave
suspend fun limpiarUltimoTab() {
    context.dataStore.edit { prefs ->
        prefs.remove(PreferenciasKeys.ULTIMO_TAB)
    }
}

// Limpiar todo
suspend fun limpiarTodo() {
    context.dataStore.edit { it.clear() }
}

La forma correcta con Hilt

Igual que con SharedPreferences, DataStore debe vivir en un repositorio — no accederse directamente desde el ViewModel. Con Hilt la inyección es limpia:

// Interfaz en el domain layer (sin imports de Android ni DataStore)
interface PreferenciasRepository {
    val tema: Flow<String>
    val notificacionesHabilitadas: Flow<Boolean>
    val onboardingCompletado: Flow<Boolean>
    suspend fun guardarTema(tema: String)
    suspend fun marcarOnboardingCompletado()
    suspend fun limpiar()
}

// Implementación en la data layer
class PreferenciasRepositoryImpl(
    private val dataStore: DataStore<Preferences>
) : PreferenciasRepository {

    override val tema: Flow<String> = dataStore.data
        .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
        .map { it[PreferenciasKeys.TEMA] ?: "sistema" }

    override val notificacionesHabilitadas: Flow<Boolean> = dataStore.data
        .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
        .map { it[PreferenciasKeys.NOTIFICACIONES] ?: true }

    override val onboardingCompletado: Flow<Boolean> = dataStore.data
        .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
        .map { it[PreferenciasKeys.ONBOARDING_COMPLETADO] ?: false }

    override suspend fun guardarTema(tema: String) {
        dataStore.edit { it[PreferenciasKeys.TEMA] = tema }
    }

    override suspend fun marcarOnboardingCompletado() {
        dataStore.edit { it[PreferenciasKeys.ONBOARDING_COMPLETADO] = true }
    }

    override suspend fun limpiar() {
        dataStore.edit { it.clear() }
    }
}

// Módulo Hilt
@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {

    @Provides
    @Singleton
    fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> {
        return PreferenceDataStoreFactory.create(
            produceFile = { context.preferencesDataStoreFile("configuracion") }
        )
    }

    @Binds
    @Singleton
    abstract fun bindPreferenciasRepository(
        impl: PreferenciasRepositoryImpl
    ): PreferenciasRepository
}

// ViewModel — limpio, sin saber de DataStore
@HiltViewModel
class ConfiguracionViewModel @Inject constructor(
    private val prefs: PreferenciasRepository
) : ViewModel() {

    val tema: StateFlow<String> = prefs.tema
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "sistema")

    fun cambiarTema(nuevoTema: String) {
        viewModelScope.launch { prefs.guardarTema(nuevoTema) }
    }
}

Migrar datos existentes de SharedPreferences

Este es el paso más crítico — y el más ignorado en los tutoriales. Si ya tenés usuarios con datos en SharedPreferences, necesitás migrarlos a DataStore sin que pierdan su configuración:

// DataStore tiene soporte nativo para migración desde SharedPreferences
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
    name = "configuracion",
    // Migración automática desde el SharedPreferences con ese nombre
    produceMigrations = { context ->
        listOf(
            SharedPreferencesMigration(
                context = context,
                sharedPreferencesName = "mis_prefs"  // el nombre que usabas antes
            )
        )
    }
)

// La migración se ejecuta automáticamente la primera vez que se accede al DataStore:
// 1. Lee los datos de SharedPreferences
// 2. Los escribe en DataStore
// 3. Elimina el archivo de SharedPreferences original

// Si necesitás migración selectiva (no todas las claves):
SharedPreferencesMigration(
    context = context,
    sharedPreferencesName = "mis_prefs",
    keysToMigrate = setOf("tema", "notificaciones")  // solo estas claves
)

// Si necesitás transformar los valores durante la migración:
SharedPreferencesMigration(
    context = context,
    sharedPreferencesName = "mis_prefs"
) { sharedPrefs, currentData ->
    // sharedPrefs: los datos de SP como MutablePreferences
    // currentData: el estado actual del DataStore
    // Retornás el nuevo estado del DataStore
    if (currentData[PreferenciasKeys.TEMA] == null) {
        // SP guardaba el tema como "0" o "1", DataStore usa "claro" / "oscuro"
        val temaViejo = sharedPrefs[intPreferencesKey("tema_index")] ?: 0
        currentData.toMutablePreferences().apply {
            this[PreferenciasKeys.TEMA] = if (temaViejo == 0) "claro" else "oscuro"
        }
    } else {
        currentData
    }
}

La migración es atómicaEl proceso de migración se ejecuta dentro de una transacción. Si falla a mitad, se reintenta en el próximo acceso. No hay riesgo de estado corrupto donde algunos datos están migrados y otros no.

El repositorio como punto de estabilidad

Si implementaste el patrón repositorio que describimos en el artículo anterior, la migración a DataStore es sorprendentemente simple. El ViewModel no cambia nada — solo cambia la implementación del repositorio:

// Antes: PreferenciasRepositoryImpl usaba SharedPreferences
class PreferenciasRepositoryImpl @Inject constructor(
    private val prefs: SharedPreferences  // lo que había
) : PreferenciasRepository {
    override val tema: String get() = prefs.getString("tema", "sistema") ?: "sistema"
    // ...
}

// Después: misma interfaz, implementación con DataStore
class PreferenciasRepositoryImpl @Inject constructor(
    private val dataStore: DataStore<Preferences>  // solo cambia esto
) : PreferenciasRepository {
    override val tema: Flow<String> = dataStore.data.map { ... }
    // ...
}

// Nota: el tipo de retorno cambia de String a Flow<String>
// Eso sí requiere ajustes en el ViewModel (observar en lugar de leer)
// Pero si ya usabas StateFlow en el VM, el cambio es mínimo

El principal cambio en la interfazLa diferencia más notable es que las lecturas pasan de ser síncronas (val tema: String) a reactivas (val tema: Flow<String>). El ViewModel necesita colectarlas con stateIn() en lugar de leerlas directamente. Si ya usabas StateFlow, este cambio es natural.

¿Y Proto DataStore?

Proto DataStore usa Protocol Buffers para definir el schema de tus datos. Las ventajas sobre Preferences DataStore: tipado fuerte garantizado por el compilador, versionado del schema con migraciones explícitas, y mejor rendimiento en objetos complejos.

El costo: tenés que aprender la sintaxis de .proto, configurar el plugin de Protobuf en Gradle, y la curva de aprendizaje es significativamente mayor.

Mi recomendación: usá Proto DataStore si tus "preferencias" empezaron a parecerse más a un modelo de datos complejo — múltiples objetos relacionados, listas, estructuras anidadas. Si son flags, strings y números simples, Preferences DataStore es más que suficiente y mucho más simple.

Conclusión

DataStore resuelve un problema real de SharedPreferences: las lecturas síncronas que pueden afectar el rendimiento. Pero es una herramienta con un costo de adopción — requiere coroutines, Flow, y un cambio de modelo mental de síncrono a reactivo.

Los tres puntos que te llevás:

  • Migrá si tenés problemas reales (ANRs, jank, necesidad de reactividad) o si empezás un proyecto nuevo. No por presión de "está deprecated".
  • El patrón repositorio hace la migración barata. Si encapsulaste SharedPreferences bien, cambiar la implementación a DataStore es cambiar solo una clase.
  • La migración de datos existentes tiene soporte nativo. SharedPreferencesMigration maneja el proceso de forma atómica — no tenés que escribir código de migración manual.