Qué es y para qué sirve

SharedPreferences es el mecanismo de Android para guardar pares clave-valor de forma persistente. Los datos sobreviven al cierre de la app, a los reinicios del dispositivo, y no se pierden con las rotaciones de pantalla.

Es ideal para datos simples relacionados con las preferencias del usuario:

  • Si el onboarding ya fue completado
  • El tema elegido (claro / oscuro / sistema)
  • El idioma de la app
  • Si las notificaciones están habilitadas
  • El último tab abierto
  • Un flag de "no mostrar este diálogo de nuevo"

La API es simple. Leés con un SharedPreferences, escribís con un SharedPreferences.Editor:

val prefs = context.getSharedPreferences("mis_prefs", Context.MODE_PRIVATE)

// Leer
val onboardingCompletado = prefs.getBoolean("onboarding_completado", false)

// Escribir
prefs.edit() {
    putBoolean("onboarding_completado", true)
}
// La extensión edit { } de androidx.core.content es equivalente a
// .edit().putBoolean(...).apply() pero más limpia

apply() vs commit()apply() escribe de forma asíncrona (no bloquea el hilo principal). commit() es síncrono y retorna un boolean indicando si tuvo éxito. En la práctica, siempre usá apply() o la extensión edit { } de KTX.

El error más común

El problema no es SharedPreferences en sí — es dónde se accede a él. Este patrón aparece constantemente en apps reales y es incorrecto:

// MAL — el ViewModel accede directamente a SharedPreferences
class OnboardingViewModel(application: Application) : AndroidViewModel(application) {

    fun marcarOnboardingCompletado() {
        // El ViewModel sabe que los datos vienen de SharedPreferences
        // Está acoplado a un detalle de implementación
        application.getSharedPreferences("prefs", Context.MODE_PRIVATE)
            .edit { putBoolean("onboarding_done", true) }
    }
}

// TAMBIÉN MAL — el Fragment accede directamente
class HomeFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        val prefs = requireContext().getSharedPreferences("prefs", MODE_PRIVATE)
        if (!prefs.getBoolean("onboarding_done", false)) {
            findNavController().navigate(R.id.onboardingFragment)
        }
    }
}

¿Por qué está mal? Porque el ViewModel y el Fragment ahora saben cómo están guardados los datos. Si mañana migrás a DataStore, a Room, o a una API remota, tenés que cambiar código en múltiples lugares. Y es imposible testear el ViewModel sin un Context real.

Encapsularlo en un repositorio

La solución es esconder SharedPreferences detrás de una interfaz. El ViewModel solo habla con la interfaz — no sabe ni le importa lo que hay detrás.

// domain/repository/PreferenciasRepository.kt
// Interfaz pura en la capa de dominio — sin imports de Android
interface PreferenciasRepository {
    val onboardingCompletado: Boolean
    var tema: Tema
    var notificacionesHabilitadas: Boolean
    fun marcarOnboardingCompletado()
}

enum class Tema { CLARO, OSCURO, SISTEMA }
// data/repository/PreferenciasRepositoryImpl.kt
class PreferenciasRepositoryImpl(
    context: Context
) : PreferenciasRepository {

    private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)

    override val onboardingCompletado: Boolean
        get() = prefs.getBoolean(KEY_ONBOARDING, false)

    override var tema: Tema
        get() = Tema.valueOf(prefs.getString(KEY_TEMA, Tema.SISTEMA.name) ?: Tema.SISTEMA.name)
        set(value) = prefs.edit { putString(KEY_TEMA, value.name) }

    override var notificacionesHabilitadas: Boolean
        get() = prefs.getBoolean(KEY_NOTIFICACIONES, true)
        set(value) = prefs.edit { putBoolean(KEY_NOTIFICACIONES, value) }

    override fun marcarOnboardingCompletado() {
        prefs.edit { putBoolean(KEY_ONBOARDING, true) }
    }

    companion object {
        private const val PREFS_NAME = "app_preferencias"
        private const val KEY_ONBOARDING = "onboarding_completado"
        private const val KEY_TEMA = "tema"
        private const val KEY_NOTIFICACIONES = "notificaciones_habilitadas"
    }
}

Las claves como constantes privadasDefinir las claves de SharedPreferences como constantes privadas en la implementación tiene un propósito claro: son un detalle de implementación de la capa Data. El resto de la app nunca debería conocer estas strings.

Inyectarlo con Hilt

Con Hilt, la configuración es mínima. Un módulo vincula la interfaz a su implementación, y a partir de ahí se inyecta solo:

@Module
@InstallIn(SingletonComponent::class)
abstract class PreferenciasModule {

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

// PreferenciasRepositoryImpl necesita Context — Hilt lo provee con @ApplicationContext
class PreferenciasRepositoryImpl @Inject constructor(
    @ApplicationContext context: Context
) : PreferenciasRepository {
    // ... igual que antes
}

Ahora el ViewModel recibe la interfaz sin saber de dónde viene:

@HiltViewModel
class ConfiguracionViewModel @Inject constructor(
    private val prefs: PreferenciasRepository
) : ViewModel() {

    val tema = MutableStateFlow(prefs.tema)

    fun cambiarTema(nuevoTema: Tema) {
        prefs.tema = nuevoTema
        tema.value = nuevoTema
    }
}

Ejemplo real: flujo de onboarding

El caso de uso más común es decidir al inicio de la app si mostrar el onboarding o ir directo al home. Con el patrón del repositorio, el ViewModel de la Activity principal lo maneja limpiamente:

@HiltViewModel
class MainViewModel @Inject constructor(
    private val prefs: PreferenciasRepository
) : ViewModel() {

    // El destino inicial se determina una sola vez al crear el ViewModel
    val destinoInicial: Destino = if (prefs.onboardingCompletado) {
        Destino.HOME
    } else {
        Destino.ONBOARDING
    }

    sealed class Destino {
        object HOME : Destino()
        object ONBOARDING : Destino()
    }
}

// MainActivity — navega al destino correcto antes de que el usuario vea nada
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        // Solo navegamos si es la primera vez (no una recreación por rotación)
        if (savedInstanceState == null) {
            val destino = when (viewModel.destinoInicial) {
                MainViewModel.Destino.HOME -> R.id.homeFragment
                MainViewModel.Destino.ONBOARDING -> R.id.onboardingFragment
            }
            findNavController(R.id.navHostFragment).navigate(destino)
        }
    }
}
// OnboardingViewModel — marca el onboarding al completarlo
@HiltViewModel
class OnboardingViewModel @Inject constructor(
    private val prefs: PreferenciasRepository
) : ViewModel() {

    private val _eventos = Channel<UiEvento>(Channel.BUFFERED)
    val eventos = _eventos.receiveAsFlow()

    fun onOnboardingCompletado() {
        prefs.marcarOnboardingCompletado()
        viewModelScope.launch {
            _eventos.send(UiEvento.NavAHome)
        }
    }

    sealed class UiEvento {
        object NavAHome : UiEvento()
    }
}

Cuándo NO usar SharedPreferences

SharedPreferences es una herramienta para un trabajo específico. Usarlo para lo que no es su propósito genera problemas serios:

No guardes datos estructurados

Guardar un objeto serializado a JSON en SharedPreferences es una señal de que necesitás Room. SharedPreferences no tiene queries, no tiene relaciones, y parsear JSON en cada lectura es innecesariamente lento.

// MAL — guardar un objeto complejo como JSON
prefs.edit { putString("usuario", gson.toJson(usuario)) }

// BIEN — Room para datos estructurados, SharedPreferences para flags simples
userDao.insertar(usuario.toEntity())
prefs.edit { putInt("ultimo_usuario_id", usuario.id) }

No guardes datos sensibles

Las preferencias se guardan en texto plano en el filesystem del dispositivo (en /data/data/tu.paquete/shared_prefs/). En dispositivos rooteados son accesibles. Para tokens de sesión, contraseñas o datos sensibles, usá EncryptedSharedPreferences de la librería de seguridad de Jetpack, o mejor aún, el Keystore del sistema.

// Para datos sensibles — EncryptedSharedPreferences
val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()

val encryptedPrefs = EncryptedSharedPreferences.create(
    context,
    "prefs_seguras",
    masterKey,
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)

No lo uses para comunicación entre componentes

SharedPreferences no es reactivo de forma nativa. Si un Fragment escribe un valor y otro Fragment necesita enterarse del cambio, estás parchando con OnSharedPreferenceChangeListener lo que debería ser un StateFlow en un ViewModel compartido.

El sucesor: Jetpack DataStore

Google marcó SharedPreferences como legacy y recomienda migrar a DataStore. Hay dos sabores:

  • Preferences DataStore: reemplaza directamente a SharedPreferences, misma idea de clave-valor pero con una API basada en Flow y suspend functions. Sin lecturas síncronas que bloqueen el hilo principal.
  • Proto DataStore: usa Protocol Buffers para tipado fuerte y schema versionado. Para apps con preferencias complejas.
// Preferences DataStore — lectura reactiva con Flow
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")

val ONBOARDING_KEY = booleanPreferencesKey("onboarding_completado")

// Leer — retorna Flow, se actualiza automáticamente cuando cambia
val onboardingCompletado: Flow<Boolean> = context.dataStore.data
    .map { prefs -> prefs[ONBOARDING_KEY] ?: false }

// Escribir — suspend function, no bloquea el hilo principal
suspend fun marcarOnboardingCompletado() {
    context.dataStore.edit { prefs ->
        prefs[ONBOARDING_KEY] = true
    }
}

¿Migrás ya?Si tu app está en producción y funciona, no migrés por migrar. SharedPreferences sigue funcionando y seguirá haciéndolo. Considerá DataStore en proyectos nuevos o cuando necesitás la reactividad del Flow. La ventaja principal de DataStore no es la API en sí, sino que eliminás las lecturas síncronas en el hilo principal que SharedPreferences permite (y que son una fuente de ANRs).

Conclusión

SharedPreferences sigue siendo una herramienta válida para su caso de uso: persistir preferencias simples del usuario. El problema nunca fue la herramienta — fue cómo se usó.

Los tres puntos que te llevas de este artículo:

  • Encapsulalo siempre detrás de una interfaz de repositorio. El ViewModel no debería saber de dónde vienen los datos.
  • Usalo para lo que es: flags, preferencias primitivas, estado de UI simple. Datos estructurados van en Room, datos sensibles en EncryptedSharedPreferences o Keystore.
  • DataStore es el futuro, pero no migrés por presión. Migrá cuando tengas una razón concreta: necesitás reactividad, tenés lecturas síncronas en el hilo principal, o empezás un proyecto nuevo.