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
.
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:
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:
- Crear una clase que herede su interfaz
- 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:
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:
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?
- Obtenemos la instancia de las preferencias con el ID del usuario
- Marcamos el usuario inicial según el resultado de
appPrefs.getUser()
y su aplicación enRadioGroup.check()
- Añadimos la escucha
OnCheckedChangeListener
- Si el parámetro
checkId
esR.id.user_1_button
, entonces asignamos el valor1
, de lo contrario será2
- Guardamos el usuario actual
- Actualizamos al vista con nuestro método ya existente
loadPrefs()
- Si el parámetro
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.