Valores De Preferencias Con PreferenceDataStore

Ahora veamos como guardar y leer valores de preferencias con PreferenceDataStore. Esta clase te permite implementar tu propio almacén de ajustes para reemplazar el uso por defecto de SharedPreferences.

Apariencia de ejemplo de preferencias con PreferenceDataStore

Normalmente usarás a PrefrenceDataStore cuando necesitas almacenar los valores en una base de datos local, un server u otra fuente de datos. O si deseas intervenir la lógica de guardado/lectura de las preferencias para algún fin particular.

Para ilustrar este caso, en este tutorial implementaremos un almacén de datos personalizado para los valores de las preferencias en tus Apps, a través de una base de datos Room.

Nota: Este tutorial es la quinta parte de la guía de ajustes en Android, por lo que te recomiendo leer la parte anterior Preferencias Con SharedPreferences para comprender el proyecto sobre el cual trabajamos.


Ejemplo De Preferencias Con PreferenceDataStore

Esta vez actualizaremos a nuestro aplicativo para insertar y consultar las preferencias en una base de datos, según el usuario que haya iniciado la sesión:

Ejemplo de App Android para guardar preferencias con PreferenceDataStore

Por simplicidad, intercambiaremos entre dos usuarios con un RadioGroup para comprobar el correcto funcionamiento de nuestro almacén de preferencias personalizado. Descarga el proyecto final desde el siguiente enlace:


PreferenceDataStore

Usar la clase abstracta PreferenceDataStore solo requiere de dos cosas:

  1. Crear una clase que herede su interfaz
  2. Habilitar el uso de dicha subclase en el framework de preferencias

La implementación debe sobrescribir a los métodos de interés según el tipo a leer/guardar. Por ejemplo, si usaremos preferencias String, entonces implementamos a getString() y putString().

class SettingsDataStore : PreferenceDataStore() {

    override fun putString(key: String, value: String?) {
        // Guardar en tu fuente de datos
    }

    override fun getString(key: String, defValue: String?): String? {
        TODO("Leer de tu fuente de datos")
    }
}

Y la habilitación significa asignar la instancia del nuevo almacén a una preferencia en concreto (Preference.preferenceDataStore) o a toda la jerarquía (PreferenceManager.preferenceDataStore).

Claramente, el procedimiento depende de tu objetivo de intervención del guardado y lectura:

// Asignar almacén para una preferencia individual
val accountSummary: MultiSelectListPreference? = findPreference("accountSummary")
accountSummary?.preferenceDataStore = SettingsDataStore()
// ó
// Asignar almacén para toda la jerarquía
preferenceManager.preferenceDataStore = store

Veamos cómo materializar estos pasos usando Room y partiendo de nuestro ejemplo base.


1. Crear Base De Datos

El primer paso será crear nuestra base de datos local con la librería Room basado en el siguiente modelo:

Modelo de datos para ajustes de usuario

Tendremos dos tablas para realizar la persistencia, user para guardar usuarios y setting para los ajustes. Debido a su relación muchos a muchos, derivaremos a la tabla user_setting para representar los valores obtenidos en cada ocasión.

Nota: Adapta tu esquema de datos para satisfacer el nivel de escalabilidad, frecuencia de modificación, consultas y esperanza de vida de los ajustes de tus usuarios. La simplicidad del anterior modelo puede que no sea acorde a tus necesidades.

1.1 Crear Entidades Room

Añade una nueva clase para crear el modelo del usuario llamada User. Sus propiedades solo serán su id y nombre de usuario:

@Entity(tableName = "user")
class User(
    @PrimaryKey(autoGenerate = true)
    @ColumnInfo(name = "user_id")
    val userId: Int = 0,
    @ColumnInfo(name = "user_name")
    val userName: String
)

La entidad para cada ajuste del usuario se llamará Settings. Le otorgaremos un identificador y la clave con la que nos referiremos desde la librería androidx.preference:

@Entity(tableName = "setting")
class Setting(
    @PrimaryKey(autoGenerate = true)
    @ColumnInfo(name = "setting_id")
    val settingId: Int = 0,
    val name: String
)

Y por último creamos la tabla del cruce entre usuarios y ajustes con el nombre UserSetting:

@Entity(
    tableName = "user_settings",
    primaryKeys = ["user_id", "setting_id"],
    foreignKeys = [
        ForeignKey(
            entity = User::class,
            parentColumns = ["user_id"],
            childColumns = ["user_id"]
        ),
        ForeignKey(
            entity = Setting::class,
            parentColumns = ["setting_id"],
            childColumns = ["setting_id"]
        )]
)
class UserSetting(
    @ColumnInfo(name = "user_id")
    val userId: Int,
    @ColumnInfo(name = "setting_id")
    val settingId: Int,
    @ColumnInfo(name = "setting_value")
    val settingValue: String
)

Como ves, usamos dos restricciones básicas ForeignKey para asegurar mínimamente las correspondencias del empalme.

1.2 Crear DAOs

Acto seguido, crea una interfaz para el DAO del usuario llamada UserDao y añade una firma para la inserción:

@Dao
interface UserDao {
    @Insert
    fun insert(user:User)
}

Luego crea la interfaz SettingDao con métodos para insertar y consultar:

@Dao
interface SettingDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insert(setting: Setting)

    @Query("SELECT setting_id FROM setting WHERE name=:key")
    suspend fun getSettingId(key:String): Int?
}

Cabe destacar que reemplazaremos el registro a insertar si ya existe. Además, usaremos el modificador suspend para obligar a cualquier cliente a emplear corrutinas con el fin de ejecutar los métodos de forma asincrónica.

1.3 Crear Descendiente De RoomDatabase

Finaliza añadiendo una nueva clase abstracta para el punto de entrada de Room llamada SettingsDatabase. Incluye las entidades y los métodos para proveer las implementaciones de los tres DAOs:

@Database(
    entities = [User::class, UserSetting::class, Setting::class],
    version = 1
)
abstract class SettingsDatabase : RoomDatabase() {

    abstract fun userDao(): UserDao
    abstract fun userSettingDao(): UserSettingDao
    abstract fun settingDao(): SettingDao
}

Luego agregamos un objeto compañero a SettingsDatabase que provea a la base de datos construida:

companion object {
    @Volatile
    private var INSTANCE: SettingsDatabase? = null

    fun getInstance(context: Context): SettingsDatabase =
        INSTANCE ?: synchronized(this) {
            INSTANCE ?: buildDatabase(context).also { db ->
                INSTANCE = db
            }
        }

    private fun buildDatabase(context: Context): SettingsDatabase {
        return Room.databaseBuilder(
            context,
            SettingsDatabase::class.java,
            "settings.db"
        ).build()
    }
}

Nota: Excluiremos a los ajustes de la sección de Ayuda, ya que su construcción es de índole informativa.

1.4 Cargar Usuarios Y Ajustes

A continuación insertaremos a dos usuarios y los ajustes que tenemos en el proyecto. Para ello vamos a onCreate() en MainActivity e iniciamos la inserción en un corrutina:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    setUpUI()

    MainScope().launch {
        initializeDatabase()
    }
}

private suspend fun initializeDatabase() = coroutineScope {
    val data = async(Dispatchers.IO) {
        val db = (application as SettingsApplication).database
        val userDao = db.userDao()
        val settingDao = db.settingDao()

        userDao.insert(User(1, "usuario_1"))
        userDao.insert(User(2, "usuario_2"))

        settingDao.insert(Setting(name = "businessName"))
        settingDao.insert(Setting(name = "currency"))
        settingDao.insert(Setting(name = "distance"))
        settingDao.insert(Setting(name = "accountSummary"))
        settingDao.insert(Setting(name = "latestEvents"))
        settingDao.insert(Setting(name = "latestEventsOrder"))
        settingDao.insert(Setting(name = "theme"))
        settingDao.insert(Setting(name = "animations"))
        settingDao.insert(Setting(name = "collectionsDesign"))
    }

    withContext(Dispatchers.Main) {
        data.await()
        loadPrefs()
    }
}

Como ves, desde MainScope() ejecutamos al método initializeDatabase() que se encarga de invocar a los métodos insert() de los DAOs para usuario y ajustes en un hilo separado de IO.

Una vez se terminan estas operaciones, pasamos al hilo principal con withContext() con el fin de poblar a nuestros TextViews con loadPrefs().

Nota: Lo mejor sería mover esta lógica hacia un ViewModel que permita simplificar la observación los cambios de la base de datos y así pasar a la población de views.


2. Implementar PreferenceDataStore

Ahora bien, disponiendo de nuestra fuente local Room, ya es posible crear una clase que sobrescriba a los métodos de PreferenceDataStore.

Nuestro algoritmo general será determinar la preferencia a procesar según la clave entrante y luego modificar el registro específico con la entidad UserSetting.

Así que preparemos el marco de sobrescritura añadiendo la clase SettingsDataStore:

class SettingsDataStore(
    private val db: SettingsDatabase,
    private val appPrefs: AppPreferences
) : PreferenceDataStore() {

}

Nuestro almacén recibirá en su constructor primario a la base de datos y una fuente de preferencias de la App. Esta fuente nos permitirá guardar y obtener el usuario actual con la API SharedPreferences:

class AppPreferences(context: Context) {

    private val preferences = context.applicationContext
        .getSharedPreferences(BuildConfig.APPLICATION_ID, 0)

    fun saveUser(userId: Int) {
        preferences.edit {
            putInt("user_id", userId)
        }
    }

    fun getUser(): Int {
        return preferences.getInt("user_id", 1)
    }
}

Como ves en su definición, guardamos a lo largo de la App un par ["user_id"-entero] para el identificador del usuario que estará activo desde la interfaz. Este valor le permitirá a SettingsDataStore realizar las operaciones de base de datos apropiadamente.

Veamos como implementar los métodos asociados a cada tipo de preferencia.

EditTextPreference Y ListPreference

Ambas usan el tipo String para establecer su valor. Por lo que sobrescribiremos a getString() y putString():

class SettingsDataStore(
    private val db: SettingsDatabase,
    private val appPrefs: AppPreferences
) : PreferenceDataStore() {

    override fun putString(key: String, value: String?) {
        putSettingValue(key, value)
    }

    override fun getString(key: String, defValue: String?): String? {
        return getSettingValue(key, defValue.orEmpty())
    }
}

Ya que los métodos put*() y get*() usan la misma lógica, hemos creado a putSettingValue() y getSettingValue() para aislar a las invocaciones de los DAOs asociados.

En el caso de putSettingValue() obtenemos el id del usuario, el id de la preferencia según la clave y construimos la instancia UserSetting con el valor entrante. Dichas acciones las encerramos en una corrutina:

private fun putSettingValue(key: String, value: Any?) {
    CoroutineScope(Dispatchers.IO).launch {
        val userId = appPrefs.getUser()
        val settingId = db.settingDao().getSettingId(key)!!
        val setting = UserSetting(userId, settingId, value.toString())
        db.userSettingDao().insert(setting)
    }
}

Para getSettingValue() obtendremos el valor de la tabla user_setting a partir del ID del usuario y el de la preferencia. Si dicho valor aún no existe, entonces insertamos el valor por defecto:

private fun getSettingValue(key: String, defValue: String) = runBlocking {
    val userId = appPrefs.getUser()
    val settingId = db.settingDao().getSettingId(key)!!
    val value = db.userSettingDao().getValue(userId, settingId)

    // Si no existe aún el valor del ajuste, guardamos el valor por defecto
    if (value == null) {
        db.userSettingDao().insert(UserSetting(userId, settingId, defValue))
        defValue
    } else
        value
}

Cabe resaltar, que combinamos a la función runBlocking() con las operaciones de los DAOs, con el fin de obtener el valor específico para retornar.

MultiSelectListPreference

Recuerda que las preferencias MultiSelectListPreference usan una colección Set para sostener a todos los valores seleccionados desde el diálogo. Por ello sobrescribimos a putStringSet() y getStringSet():

override fun putStringSet(key: String, values: MutableSet<String>?) {
    putSettingValue(key, values.multiSelectToString())
}

override fun getStringSet(key: String, defValues: Set<String>?): Set<String> {
    val value = getSettingValue(key, defValues.multiSelectToString())
    return value.stringToMultiSelect()
}

private fun Set<String>?.multiSelectToString(): String {
    return this?.joinToString(",").orEmpty()
}

private fun String?.stringToMultiSelect(): Set<String> {
    return this?.split(",")?.toSet() ?: setOf()
}

Por simplicidad convertiremos a los conjuntos en strings con los valores separados por comas. Esto lo logramos con las funciones de extensión multiSelectToString() y stringToMultiSelect(). Estas hace uso de las utilidades de strings joinToString() y split() para lograr su propósito.

Nota: Puedes conseguir el manejo de los posibles valores de una preferencia añadiendo una nueva tabla que materialice esta relación con los ajustes.

SeekBarPreference

En el caso del deslizador, los valores son enteros. Así que getInt() y putInt() cumplen la misión:

override fun putInt(key: String, value: Int) {
    putSettingValue(key, value)
}

override fun getInt(key: String, defValue: Int): Int {
    return getSettingValue(key, defValue.toString()).toInt()
}

Será necesario la conversión de tipos con toString() y toInt() para interpretar los valores.

SwitchPreferenceCompat

Ya sabemos que las preferencias con Switch persisten valores del tipo Boolean, por lo que repetimos la sobrescritura:

override fun putBoolean(key: String, value: Boolean) {
    putSettingValue(key, value)
}

override fun getBoolean(key: String, defValue: Boolean): Boolean {
    return getSettingValue(key, defValue.toString()).toBoolean()
}

Los literales strings "true" y "boolean" son interpretados al usar el método toBoolean(), por lo que habrá ningún problema cuando almacenemos estos valores en la columna setting_value.


3. Habilitar Almacén Personalizado

Ya que deseamos manejar la persistencia y obtención de toda la jerarquía de preferencias, iremos a SettingsFragment y asignaremos a SettingsDataStore a la propiedad PreferenceManager.preferenceDataStore:

class SettingsFragment : PreferenceFragmentCompat() {

    override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
        setUpDataStore()
        setPreferencesFromResource(R.xml.preferences, rootKey)
        setUpBuildVersion()
        setUpAccountSummary()
    }

    private fun setUpDataStore() {
        val store = (requireActivity().application as SettingsApplication).settingsDataStore
        preferenceManager.preferenceDataStore = store
    }
    
    //...
}

Como ves, la instancia de SettingsDataStore es una propiedad lazy en una subclase de Application. Esto con el fin de usarla como punto de entrada principal, para la construcción de nuestros componentes de infraestructura en el momento que sean requeridos:

class SettingsApplication : Application() {
    val database by lazy { SettingsDatabase.getInstance(this) }
    val appPrefs by lazy { AppPreferences(this) }
    val settingsDataStore by lazy { SettingsDataStore(database, appPrefs) }
}

Desde este momento, cada que el usuario cambie el valor de una preferencia o usemos los métodos get*(), la librería androidx.preference delegará estos comportamientos a nuestra clase SettingsDataStore.

Habiendo así integrado nuestra base de datos Room de forma transparente y sin adaptaciones adicionales.


4. Mostrar Valores De Preferencias

Finalmente modificaremos la presentación de nuestra actividad MainActivity para que actualice su interfaz basada en el evento de marcado en los RadioButtons:

Ejemplo de preferencias con PreferenceDataStore

Esto lo logramos modificando el layout activity_main.xml para añadir el RadioGroup con dos opciones:

<RadioGroup
    android:layout_width="match_parent"
    android:orientation="horizontal"
    android:id="@+id/users_container"
    android:gravity="center"
    android:checkedButton="@id/user_1_button"
    android:layout_height="wrap_content">

    <RadioButton
        android:id="@+id/user_1_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Usuario 1" />

    <RadioButton
        android:id="@+id/user_2_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Usuario 2" />

Luego abrimos MainActivity y manejamos el evento de marcado entre RadioButtons con la escucha OnCheckedChangeListener:

private fun setUpUsers() {
    val appPrefs = (application as SettingsApplication).appPrefs

    // Marcar usuario inicial
    usersContainer = findViewById(R.id.users_container)
    val radioId = if (appPrefs.getUser() == 1) R.id.user_1_button else R.id.user_2_button
    usersContainer.check(radioId)

    // Procesar eventos de cambio de usuario
    usersContainer.setOnCheckedChangeListener { _, checkedId ->
        val userId = if (checkedId == R.id.user_1_button) 1 else 2
        // Guardar usuario seleccionado
        appPrefs.saveUser(userId)

        // Actualizar vista
        loadPrefs()
    }
}

¿De qué consiste el código?

  1. Obtenemos la instancia de las preferencias con el ID del usuario
  2. Marcamos el usuario inicial según el resultado de appPrefs.getUser() y su aplicación en RadioGroup.check()
  3. Añadimos la escucha OnCheckedChangeListener
    • Si el parámetro checkId es R.id.user_1_button, entonces asignamos el valor 1, de lo contrario será 2
    • Guardamos el usuario actual
    • Actualizamos al vista con nuestro método ya existente loadPrefs()

Y para finalizar, actualizamos a loadPrefs(). Antes accedíamos al almacén por defecto con PreferenceManager.getDefaultSharedPreferences(), sin embargo ahora usaremos al nuestro (settingsDataStore):

private fun loadPrefs() {
    ((application as SettingsApplication).settingsDataStore).let { store ->
        businessName.text = getString(
            R.string.business_name,
            store.getString("businessName", "No establecido")
        )
        currency.text = getString(
            R.string.currency,
            store.getString("currency", "cop")
        )
        distance.text = getString(
            R.string.distance,
            store.getInt("distance", 50)
        )
        accountSummary.text =
            getString(
                R.string.account_summary,
                store.getStringSet("accountSummary", setOf("1", "2", "3"))
            )

        val eventsActive = store.getBoolean("latestEvents", true)
        var string = getString(R.string.latest_event, eventsActive)
        if (eventsActive)
            string += getString(
                R.string.latest_events_order,
                store.getString("latestEventsOrder", "date")
            )
        latestEvents.text = string

        themeText.text = getString(
            R.string.theme,
            store.getString("theme", "Claro")
        )
        animations.text = getString(
            R.string.animations,
            store.getBoolean("animations", true)
        )
        collectionsDesign.text = getString(
            R.string.collections_design,
            store.getString("collectionsDesign", "Lista")
        )
    }
}

Con esto, ya podrás correr la App y ver como los valores que asignas en la pantalla de ajustes, son asignados en la pantalla principal según la selección del RadioButton.


Más Contenidos Android

Únete Al Discord De Develou

Si tienes problemas con el código de este tutorial, preguntas, recomendaciones o solo deseas discutir sobre desarrollo Android conmigo y otros desarrolladores, únete a la comunidad de Discord de Develou y siéntete libre de participar como gustes. ¡Te espero!