Android preference PreferenceFragmentCompat Jetpack DataStore

Jetpack series of articles

  • Chapter 1 “Jetpack Lifecycle for Android front-end and back-end switching detection”
  • Chapter 2 “Jetpack DataStore of Android PreferenceFragmentCompat”

This is a series of Jetpack articles, and there are still many that I don’t have time to write. Anyway, I will record a few if I have time. If you have any questions, leave a message and I will reply!

Article directory

  • Jetpack series of articles
  • Preface
    • Understand the key difference between DataStore and SharedPreferences
  • 1. DataStore novice configuration
    • 1. Import the library
    • 2.Initialization
  • 2. DataStore reads and writes Value through Key
  • 3. DataStore practical example
    • 1. Convert DataStore into data class Settings
    • 2. Create ViewModel and use PreferencesRepository
    • 3. Use PreferencesViewModel in Activity
    • 4. Display effect
  • 4. PreferenceFragmentCompat displays UI based on Datastore data
    • 1. Import the library
    • 2. Create PreferenceFragmentCompat layout
    • 3. Create PreferenceDataStoreAdapter (key)
    • 4. Create SettingsPreferencesFragment and use PreferenceDataStoreAdapter
    • 5. Modify TestActivity to add SettingsButton to jump to SettingsPreferencesFragment
    • 6.Display effect
  • Summarize

Foreword

This article mainly talks about using DataStore to replace SharedPreferences, and you can use PreferenceFragmentCompat to quickly configure UI to modify preferences. Let me say a few nonsense words here. I noticed DataStore a long time ago. It seems to be a little different from SharedPreferences, but after using it, I feel that it is still good. Well, Flow using kotlin needs to be used with coroutines. The previous projects at hand were all written in java and all used SharedPreferences, I recently upgraded the project and thought about changing SharedPreferences to DataStore. I don’t know how it will be messed up as soon as I use it (liao). It obviously feels better than SharedPreferences It’s a bit more complicated, let me talk about some of the problems I encountered.

  • How does DataStore edit and obtain Value through Key like SharedPreferences?
  • DataStore does not support creating multiple instances?
  • How does DataStore monitor Value changes?
  • How does DataStore use PreferenceFragmentCompat to read and modify preference values?

Understand the key differences between DataStore and SharedPreferences

The Key of DataStore is Preferences.Key<*>, and the key of SharedPreferences is String, the supported storage data types are the same, but the official has upgraded the Key of DataStore, and changed the keyname is tied to the data type, which is equivalent to declaring what data type this key will store when declaring keyname. This approach avoids accidental Save it as other data types. For example, when you obviously want to save an int, you accidentally pass a string when passing the value, so you save it as String (when I just developed Android I have encountered it)

DataStore defaultPreferencesKeys

  • intPreferencesKey("keyname") represents Int type
  • doublePreferencesKey("keyname") represents the Double type
  • stringPreferencesKey("keyname") represents the String type
  • booleanPreferencesKey("keyname") represents the Boolean type
  • floatPreferencesKey("keyname") represents the Float type
  • longPreferencesKey("keyname") represents Long type
  • stringSetPreferencesKey("keyname") represents the Set type

It looks very simple, don’t be afraid, just convert the key of SharedPreferences into Preferences.Key through the function corresponding to the data type above. I don’t understand. If so, let’s go down and see how to actually use it, GOGOGO ~

1. DataStore novice configuration

1. Import library

implementation("androidx.datastore:datastore-preferences:1.0.0")

2.Initialization

Create a new PreferencesRepository.kt file

val Context.settings: DataStore<Preferences> by preferencesDataStore("settings")

class PreferencesRepository(context: Context) {<!-- -->
    
    val settings = context.settings

}

Let me explain, I created a PreferencesRepository class, and created a Context.settings extension variable outside the class with the file name settings Instance of DataStore, this has the advantage that you can get the same instance of DataStore in any place with Context to avoid triggering the same file Error being initialized in multiple places

2. DataStore reads and writes Value through Key

val Context.settings: DataStore<Preferences> by preferencesDataStore("settings")

class PreferencesRepository(context: Context) {<!-- -->

    //Declare all KEYs to be used
    private object PreferencesKeys {<!-- -->

        val USER_NAME = stringPreferencesKey("username")
    }

    //Get settings from Context
    private val settings = context.settings

    /**
     * write
     */
    suspend fun setUserName(text: String) {<!-- -->
        settings.edit {<!-- -->
            it[PreferencesKeys.USER_NAME] = text
        }
    }

    /**
     * read
     */
    fun getUserNameFlow(): Flow<String> {<!-- -->
        return settings.data.map {<!-- -->
            it[PreferencesKeys.USER_NAME] ?: "Nothing"
        }
    }

}

DataStore requires the use of coroutines for reading and writing. You can also use RxJava on the Android official website to use DataStore, I am not used to RxJava, so I just use kotlin to write it. This is just a simple example. I will demonstrate the actual application in more detail below.

3. DataStore practical example

1. Convert DataStore into data class Settings

val Context.settings: DataStore<Preferences> by preferencesDataStore("settings")

data class Settings(val username: String, val themes: String)

class PreferencesRepository(context: Context) {<!-- -->

    //Declare all KEYs to be used
    private object PreferencesKeys {<!-- -->

        val USER_NAME = stringPreferencesKey("username")
        val THEMES = stringPreferencesKey("themes")
    }

    //Get settings from Context
    private val settings = context.settings

    //Integrate settings data into data classes
    val settingsFlow = settings.data
        .catch {<!-- --> exception ->
            if (exception is IOException) {<!-- -->
                //If an IO exception occurs while reading the DataStore file, an empty file will be returned.
                emptyPreferences()
            } else {<!-- -->
                //If it is other exceptions, throw an exception
                throw exception
            }
        }.map {<!-- -->
            //Convert the contents of the DataStore file into the flow Flow<Settings> to facilitate conversion to LiveData
            val username = it[PreferencesKeys.USER_NAME] ?: ""
            val themes = it[PreferencesKeys.THEMES] ?: "0"
            Settings(username, themes)
        }

    /**
     * write
     */
    suspend fun setUserName(text: String) {<!-- -->
        settings.edit {<!-- -->
            it[PreferencesKeys.USER_NAME] = text
        }
    }

    /**
     * read
     */
    fun getUserNameFlow(): Flow<String> {<!-- -->
        return settings.data.map {<!-- -->
            it[PreferencesKeys.USER_NAME] ?: "Nothing"
        }
    }

}

2. Create ViewModel and use PreferencesRepository

class PreferencesViewModel(context: Context) : ViewModel() {<!-- -->

    private val repository = PreferencesRepository(context)

    private val settingsFlow = repository.settingsFlow

    //Convert settingsFlow to LiveData
    val settings = settingsFlow.asLiveData()

    fun setUserName(text: String) {<!-- -->
        viewModelScope.launch {<!-- -->
            repository.setUserName(text)
        }
    }

    //get directly
    fun getUserName(): String {<!-- -->
        return runBlocking {<!-- -->
            repository.getUserNameFlow().first()
        }
    }

    //Observe UserName changes through LiveData
    fun getUserNameLiveData(): LiveData<String> {<!-- -->
        return repository.getUserNameFlow().asLiveData()
    }

    classFactory(
        private val context: Context
    ) : ViewModelProvider.Factory {<!-- -->

        override fun <T : ViewModel> create(modelClass: Class<T>): T {<!-- -->
            if (modelClass.isAssignableFrom(PreferencesViewModel::class.java)) {<!-- -->
                @Suppress("UNCHECKED_CAST")
                return PreferencesViewModel(context) as T
            }
            throw IllegalArgumentException("Unknown ViewModel class")
        }

    }
}

3. Use PreferencesViewModel in Activity

class TestActivity : AppCompatActivity() {<!-- -->

    private lateinit var binding: ActivityTestBinding

    private lateinit var viewModel: PreferencesViewModel

    override fun onCreate(savedInstanceState: Bundle?) {<!-- -->
        super.onCreate(savedInstanceState)
        binding = ActivityTestBinding.inflate(layoutInflater)
        setContentView(binding.root)

        viewModel = ViewModelProvider(
            this,
            PreferencesViewModel.Factory(this)
        )[PreferencesViewModel::class.java]


        //Get username directly
        val userName = viewModel.getUserName()
        Log.d("TestActivity", "getUserName: $userName")

        //Observe userName changes through LiveData
        viewModel.getUserNameLiveData().observe(this) {<!-- --> value ->
            Log.d("TestActivity", "observe userName: $value")
        }

        //Observe changes in settings data class through LiveData
        viewModel.settings.observe(this) {<!-- -->
            Log.d("TestActivity", "observe settings: $it")
        }

        //Hurry up and try setting the username
        viewModel.setUserName("Felix")

        binding.save.setOnClickListener {<!-- -->
            //Save the entered user name
            viewModel.setUserName(binding.username.text.toString())
        }

    }
}

Layout section

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/white">

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:layout_marginTop="200dp"
        android:orientation="horizontal">

        <EditText
            android:id="@ + id/username"
            android:layout_width="200dp"
            android:layout_height="wrap_content"
            android:hint="User Name" />

        <com.google.android.material.button.MaterialButton
            android:id="@ + id/save"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Save" />

    </LinearLayout>

</FrameLayout>

4. Display effect

Felix DataStore storage test

4. PreferenceFragmentCompat displays UI based on Datastore data

1. Import library

implementation("androidx.preference:preference:1.2.1")

2. Create PreferenceFragmentCompat layout

Create a fragment_settings_prefernces.xml in the project app/main/res/layout folder

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:background="@color/white">

    <androidx.appcompat.widget.Toolbar
        android:id="@ + id/toolbar"
        android:layout_width="match_parent"
        app:navigationIcon="@drawable/ic_left_white_back"
        android:layout_height="wrap_content"
        android:background="#ff5722" />

    <FrameLayout
        android:id="@ + id/content"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_marginTop="?actionBarSize" />

</FrameLayout>

Create a preferences_arrays.xml in the project app/main/res/values folder

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string-array name="themes_labels">
        <item>Default</item>
        <item>Saint Laurent Purple</item>
        <item>Pikachu yellow</item>
        <item>Super Vibrant Orange</item>
    </string-array>

    <string-array name="themes_values">
        <item>0</item>
        <item>1</item>
        <item>2</item>
        <item>3</item>
    </string-array>

    <string name="default_themes_value">0</string>
</resources>

Create settings_preferences.xml in the project app/main/res/xml folder

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <ListPreference
        android:defaultValue="@integer/default_themes_value"
        android:entryValues="@array/themes_values"
        android:key="themes"
        app:entries="@array/themes_labels"
        app:title="Theme settings" />

    <EditTextPreference
        android:key="username"
        app:summary="It seems that generally this will not appear in the settings, hahaha, this is an example for reference only"
        app:title="username" />

</PreferenceScreen>

3. Create PreferenceDataStoreAdapter (key)

class PreferenceDataStoreAdapter(
    private val dataStore: DataStore<Preferences>,
    private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
) : PreferenceDataStore() {<!-- -->


    override fun getBoolean(key: String, defValue: Boolean): Boolean {<!-- -->
        return runBlocking {<!-- -->
            dataStore.data.map {<!-- --> it[booleanPreferencesKey(key)] ?: defValue }.first()
        }
    }

    override fun putBoolean(key: String, value: Boolean) {<!-- -->
        scope.launch {<!-- -->
            dataStore.edit {<!-- -->
                it[booleanPreferencesKey(key)] = value
            }
        }
    }

    override fun getString(key: String, defValue: String?): String? {<!-- -->
        return runBlocking {<!-- -->
            dataStore.data.map {<!-- --> it[stringPreferencesKey(key)] ?: defValue }.first()
        }
    }

    override fun putString(key: String, value: String?) {<!-- -->
        scope.launch {<!-- -->
            dataStore.edit {<!-- -->
                it[stringPreferencesKey(key)] = value ?: ""
            }
        }
    }

    override fun getInt(key: String, defValue: Int): Int {<!-- -->
        return runBlocking {<!-- -->
            dataStore.data.map {<!-- --> it[intPreferencesKey(key)] ?: defValue }.first()
        }
    }

    override fun putInt(key: String, value: Int) {<!-- -->
        scope.launch {<!-- -->
            dataStore.edit {<!-- -->
                it[intPreferencesKey(key)] = value
            }
        }
    }

    override fun getFloat(key: String, defValue: Float): Float {<!-- -->
        return runBlocking {<!-- -->
            dataStore.data.map {<!-- --> it[floatPreferencesKey(key)] ?: defValue }.first()
        }
    }

    override fun putFloat(key: String, value: Float) {<!-- -->
        scope.launch {<!-- -->
            dataStore.edit {<!-- -->
                it[floatPreferencesKey(key)] = value
            }
        }
    }

    override fun getLong(key: String, defValue: Long): Long {<!-- -->
        return runBlocking {<!-- -->
            dataStore.data.map {<!-- --> it[longPreferencesKey(key)] ?: defValue }.first()
        }
    }

    override fun putLong(key: String, value: Long) {<!-- -->
        scope.launch {<!-- -->
            dataStore.edit {<!-- -->
                it[longPreferencesKey(key)] = value
            }
        }
    }

    override fun getStringSet(key: String, defValues: MutableSet<String>?): Set<String>? {<!-- -->
        return runBlocking {<!-- -->
            dataStore.data.map {<!-- --> it[stringSetPreferencesKey(key)] ?: defValues }.first()
        }
    }

    override fun putStringSet(key: String, values: MutableSet<String>?) {<!-- -->
        scope.launch {<!-- -->
            dataStore.edit {<!-- -->
                it[stringSetPreferencesKey(key)] = values ?: emptySet()
            }
        }
    }

}

4. Create SettingsPreferencesFragment and use PreferenceDataStoreAdapter

class SettingsPreferencesFragment : PreferenceFragmentCompat() {<!-- -->

    private lateinit var binding: FragmentSettingsPreferncesBinding

    override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {<!-- -->
        preferenceManager.preferenceDataStore =
            PreferenceDataStoreAdapter(requireContext().settings, lifecycleScope)
        setPreferencesFromResource(R.xml.settings_preferences, rootKey)
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {<!-- -->
        //Customize PreferenceFragmentCompat layout
        binding = FragmentSettingsPreferncesBinding.inflate(inflater, container, false)
        binding.content.addView(super.onCreateView(inflater, container, savedInstanceState), -1, -1)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {<!-- -->
        super.onViewCreated(view, savedInstanceState)

        binding.toolbar.setNavigationOnClickListener {<!-- -->
            requireActivity().supportFragmentManager.beginTransaction()
                .remove(this)
                .commitAllowingStateLoss()
        }

    }

}

5. Modify TestActivity to add SettingsButton to jump to SettingsPreferencesFragment

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/white">

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:layout_marginTop="200dp"
        android:orientation="horizontal">

        <EditText
            android:id="@ + id/username"
            android:layout_width="200dp"
            android:layout_height="wrap_content"
            android:hint="User Name" />

        <com.google.android.material.button.MaterialButton
            android:id="@ + id/save"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Save" />

        <com.google.android.material.button.MaterialButton
            android:id="@ + id/settings"
            android:layout_width="wrap_content"
            android:layout_marginStart="50dp"
            android:layout_height="wrap_content"
            android:text="Settings" />
    </LinearLayout>

    <androidx.fragment.app.FragmentContainerView
        android:id="@ + id/fragment_content"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</FrameLayout>
class TestActivity : AppCompatActivity() {<!-- -->

    private lateinit var binding: ActivityTestBinding

    private lateinit var viewModel: PreferencesViewModel

    override fun onCreate(savedInstanceState: Bundle?) {<!-- -->
        super.onCreate(savedInstanceState)
        binding = ActivityTestBinding.inflate(layoutInflater)
        setContentView(binding.root)

        viewModel = ViewModelProvider(
            this,
            PreferencesViewModel.Factory(this)
        )[PreferencesViewModel::class.java]


        //Get username directly
        val userName = viewModel.getUserName()
        Log.d("TestActivity", "getUserName: $userName")

        //Observe userName changes through LiveData
        viewModel.getUserNameLiveData().observe(this) {<!-- --> value ->
            Log.d("TestActivity", "observe userName: $value")
            binding.username.setText(value)
        }

        //Observe changes in settings data class through LiveData
        viewModel.settings.observe(this) {<!-- -->
            Log.d("TestActivity", "observe settings: $it")
        }

        //Hurry up and try setting the username
        viewModel.setUserName("Felix")

        binding.save.setOnClickListener {<!-- -->
            //Save the entered username
            viewModel.setUserName(binding.username.text.toString())
        }

        binding.settings.setOnClickListener {<!-- -->
            supportFragmentManager.beginTransaction()
                .add(
                    R.id.fragment_content,
                    SettingsPreferencesFragment(),
                    SettingsPreferencesFragment::class.simpleName
                )
                .commitAllowingStateLoss()
        }
    }
}

6. Display effect


Summary

It seems troublesome, but it’s actually okay. It’s just that I posted a lot of code in order to write more details. DataStore is more convenient to cooperate with ViewModel than SharedPreferences. DataStore uses asynchronous reading and writing to avoid ANR (although I Never encountered it, official said so). Before writing this article, I had been stuck in the PreferenceFragmentCompat display, because the method provided by PreferenceDataStore did not directly support coroutines. After reading the Android information, I learned that you can use runBlocking to execute the suspend function in a non-coroutine context function, but please note that we must use first() function and cannot use last(), to avoid causing ANR. I won’t go into details here. I don’t want to write all of this in one article. I’m afraid it will be too confusing. I want to understand You can check the information for details.

Finally, I encountered a problem during use:

The ListPreference control in the PreferenceFragmentCompat layout does not support storing int. This is a defect of the official control. I have submitted a report to the official for follow-up. If I want to solve this problem now, there are two ways There are two ways. The first one is to change the original storage of int to storage string. The second one is to customize a ListPreference control. The problem is that ListPreference The >entryValues property is read as CharSequence[] mEntryValues, so no matter what array we pass it will become a string array, and when the value is stored, it will be stored as a string. This is the problem. .

I proposed this solution in an article “Android solves the problem that PreferenceFragmentCompat cannot save Int”

Don’t worry about `ui`, focus on using `DataStore`

If you have any questions or better ideas, please leave a message below and learn from each other.