Property syntax for Android's SharedPreferences
/DataStore
or iOS/macOS NSUserDefaults
.
Supported platforms: macOS (x64), iOS (arm32, arm64 & x64), Android.
This library uses Kotlin's property delegation to make using
SharedPreferences
and DataStore<Preferences>
as easy as accessing a property on an object,
and provides an NSUserDefaults
backed implementation for macOS and iOS.
On Android, it relies on the appCtx
module of this library to allow usage in object
,
and can support storage on device encrypted storage for devices
supporting Direct Boot. See the source code for more information.
If you want to use this dependency without using one of the fun packs,
you can use Splitties.preferences
, provided you have refreshVersions added to the project.
For reference, the maven coordinates of this module are com.louiscad.splitties:splitties-preferences
.
- Defining the preferences properties in an object
- Loading the preferences without blocking the main thread
- Using AndroidX DataStore instead of SharedPreferences under the hood
Define your preferences in an object
that extends
splitties.preferences.Preferences
, like in the example below:
import splitties.preferences.Preferences
object GamePreferences : Preferences("gameState") {
var magicNumber by intPref("magicNumber", defaultValue = 0)
var currentLevel by intPref("currentLevel", 1)
var bossesFought by intPref("bossBattleVictories", 0)
var lastTimePlayed by longPref("lastSessionTime", 0L)
val pseudoFlow: Flow<String>
var pseudo by stringPref("playerPseudo", "Player 1").also {
pseudoFlow = it.valueFlow()
}
val favoriteCharacterFlow: Flow<String?>
var favoriteCharacter by stringOrNullPref("favoriteCharacter").also {
favoriteCharacterFlow = it.valueFlow()
}
}
Then just use the properties:
fun setResponseOfTheUltimateQuestionOfLifeTheUniverseAndEverything() {
GamePreferences.magicNumber = 42
}
fun doSomeMagic() {
toast("Magic: ${GamePreferences.magicNumber}!")
}
fun resetProgress() {
GamePreferences.edit { // Batch edit
currentLevel = 1
bossesFought = 0
}
}
The supported types are:
Boolean
Int
Float
Long
String
String?
Set<String>
Set<String>?
For default SharedPreferences, make an object
that extends
DefaultPreferences
instead of Preferences
.
Note that for better encapsulation, you might want to keep the mutable delegated properties private in some cases, and expose functions and flows instead.
Unless you use coroutines (read more about this in next section just below),
a class
instead of an object
is not recommended because it would mean
you can instantiate it multiple times, while the underlying preferences
xml file is cached for the rest of your app's process lifetime once loaded,
so in a class
you'd be allocating the delegates more times than needed,
leading to an additional, unneeded, small pressure on the garbage collector.
However, you may make an abstract subclass of Preferences
for specific
use cases where adding logic to base Preferences
or sharing some
properties may be desirable. (If you do, please open an issue to tell us
about this use case. It may become an example shown here.)
Note that this feature is currently only supported on Android. Feel free to open an issue if you want it on other platforms.
The object
approach described above has several advantages, one of
the most significant being ease of use anywhere in your app, but that
also means you can easily access it from the main thread, and the first
time you access the object, the underlying xml file where the preferences
are stored is loaded, which may block the main thread for longer that you
would want, possibly dropping a few frames.
With coroutines, it's easy to offload something on another thread, and this split embraces this capability.
Let's see a modified version of the GamePreferences
described above,
before passing in review each change.
import splitties.preferences.SuspendPrefsAccessor
import splitties.preferences.Preferences
class GamePreferences private constructor() : Preferences("gameState") {
companion object : SuspendPrefsAccessor<GamePreferences>(::GamePreferences)
var magicNumber by intPref("magicNumber", defaultValue = 0)
var currentLevel by intPref("currentLevel", 1)
var bossesFought by intPref("bossBattleVictories", 0)
var lastTimePlayed by longPref("lastSessionTime", 0L)
val pseudoFlow: Flow<String>
var pseudo by stringPref("playerPseudo", "Player 1").also {
pseudoFlow = it.valueFlow()
}
val favoriteCharacterFlow: Flow<String?>
var favoriteCharacter by stringOrNullPref("favoriteCharacter").also {
favoriteCharacterFlow = it.valueFlow()
}
}
Here are all the changes:
- We moved from
object
toclass
. - We added a
private constructor()
. - We added a
companion object
that extends theSuspendPrefsAccessor
abstract class and calls its constructor with a reference to the constructor.
With this change, we can no longer access the GamePreferences
singleton directly
from anywhere… unless we are in a coroutine!
From any suspend
function, you
just have to call GamePreferences()
like you were calling a constructor, but
in reality, it is a function call that suspends while loading the preferences
for the first time in process life in Dispatchers.IO
.
If the preferences have already been loaded, it immediately returns the now instantiated singleton.
If you have non suspending functions that would need to access the preferences,
you have two options: pass your Preferences
subclass as a parameter, or make
it a suspend
function.
Android's SharedPreferences
has one performance drawback: it can perform disk I/O
on the main thread on its own when the onStop
callback of an Activity
is called.
The only way to ensure your app will not be affected by the potential ANRs
(Application Not Responding) it can cause is to replace it with something else.
The AndroidX team made DataStore just for that, but its API is not compatible, which would require significant code changes in host projects.
If you use this (Splitties Preferences) however, all the hard work has been done for you.
There are just 3 quick steps to perform to use DataStore instead of SharedPreferences:
- Make sure you use
SuspendPrefsAccessor
, as described in the section above (not doing so will crash on first access). - Extend
DataStorePreferences
instead ofPreferences
. - Opt-in by adding
@OptIn(DataStorePreferencesPreview::class)
to your class.
Once you're done, test your app to make sure it still works fine.
Data migration is automatically done if you're moving from SharedPreferences and keep the same name.
If you were using DefaultPreferences
, you can pass null
to name
.
Be sure that there's only one class that uses the same name, or DataStore will throw.
Finally, note that direct boot (aka. deviceProtectedStorage) is not supported yet, but is being considered. Add a 👍 on this issue to raise the priority.