zsmb.coEst. 2017



Krate, a better SharedPreferences experience

2020-09-09 • Márton Braun

SharedPreferences is a handy tool for saving small amounts of data in key-value pairs. However, accessing its API directly isn’t as practical as it could be.

Krate is a wrapper library built on delegated properties, and it makes storing values in SharedPreferences simple and convenient.

You can read about the API design of the library in depth in Delightful Delegate Design.

Getting started in two minutes

The simplest way to use Krate is by extending the SimpleKrate base class that the library provides. This class takes a Context as its parameter, and inside it, you can list the various values that you want to store in your Krate.

class UserSettings(context: Context) : SimpleKrate(context) {
    var notificationsEnabled by booleanPref("notifications_enabled", false)
    var loginCount by intPref("login_count", 0)
    var nickname by stringPref("nickname")
}

Each value is a separate property, delegated into function calls from Krate. For each type that SharedPreferences can handle, there’s a separate function to use, such as booleanPref, intPref, and so on. The first parameter passed to the function is the key that will be used to store the value.

Whenever you read / write these properties, their values will be read from / written into SharedPreferences under the hood. The syntax for accessing them is completely seamless, thanks to delegates. It simply looks like a regular property access on the object, but the values saved in it will persist through app restarts:

val settings = UserSettings(context)
settings.loginCount = settings.loginCount + 1
Log.d("LOGIN_COUNT", "Count: ${settings.loginCount}")

That’s it for the basics of Krate! Now let’s look at some of the finer details.

Nullability and default values

You might notice above that not all delegate functions took default values as a second parameter. This is part of Krate’s design to play nicely with Kotlin’s nullability.

If you provide a default value for a property, then reading it when no value has been written into it before will - as expected - return that default value. This means you’ll always receive a valid value from it. Accordingly, the property will have a non-nullable type:

var loginCount: Int by intPref("login_count", 0)

If you omit the default value, you still might read the property before writing something into it. In this case, Krate will return null, therefore properties like this will have a nullable type:

var nickname: String? by stringPref("nickname")

Custom implementations

SimpleKrate is the way to go most of the time. Under the hood, this uses the default SharedPreferences instance for the provided Context by default. If you want to access a different, named instance, you can pass in a second parameter to SimpleKrate:

class UserSettings(context: Context) 
        : SimpleKrate(context, name = "user_settings") { ... }

If you want even more control over how the SharedPreferences used by Krate is created, you can implement the Krate interface manually. It’s as simple as it can possibly be:

interface Krate {
    val sharedPreferences: SharedPreferences
}

With your own implementation, you get full control on when and how to create the SharedPreferences:

class MyCustomKrate(context: Context) : Krate {
    override val sharedPreferences: SharedPreferences

    init {
        sharedPreferences = context.applicationContext
            .getSharedPreferences("custom_pref", Context.MODE_PRIVATE)
    }

    var someValue by intPref("some_value")
}

Note that all the delegate functions used with Krate are extensions on the Krate type, so they’ll be available inside any Krate.

This kind of customization also allows you to use special implementations of SharedPreferences with Krate, such as the EncryptedSharedPreferences API, or perhaps your own mock or fake implementations of the SharedPreferences interface in tests.

A best practice for dependency injection

Krate is convenient to use with dependency injection setups as well. You can create an interface that will list the values you want to store - this interface is very easy to implement with a fake or a mock for testing.

interface AppSettings {
    var notificationsEnabled: Boolean
}

Then, in the production app, the interface can be implemented by a Krate (either a SimpleKrate or a custom one), by overriding each property and delegating them into Krate’s delegate functions:

class AppSettingsKrate @Inject constructor (
    context: Context
) : SimpleKrate(context, "app_settings"), AppSettings {

    override var notificationsEnabled by booleanPref("noti_enabled", false)

}

Finally, you’d bind the implementation to the interface (e.g. with @Binds in the case of Dagger), and inject your interface to wherever you need to use it:

class SettingsViewModel @Inject constructor(
    private val appSettings: AppSettings
) {
    fun sendNotification() {
        if (appSettings.notificationsEnabled) { ... }
    }
}

Conclusion

That’s a brief intro to Krate! The library includes additional features such as validation support for the values you’re writing into the properties, and serialization support (either via Gson or Moshi) for storing complex objects as JSON strings.

Check out Krate on GitHub for more details, and info on how to include it in your project. Takes just a minute to get rolling with it!


There is, of course, a bit of irony in promoting the usage of SharedPreferences when Jetpack Datastore is emerging. However, that library is still in alpha, likely a while away from being stable. And even in a world where it exists as stable, SharedPreferences might still be the way to go for a lot of applications.



You might also like...

Effective Class Delegation

One of the most significant items of the Effective Java book is Item 18: Favor composition over inheritance. Let's see how Kotlin promotes this with class delegation.

All About Opt-In Annotations

Have you ever encountered APIs that show warnings or errors when you use them, saying that they're internal or experimental? In this guide, you'll learn everything you need to know about opt-in APIs in Kotlin: how to create and use them, and all their nuances.

The conflation problem of testing StateFlows

StateFlow behaves as a state holder and a Flow of values at the same time. Due to conflation, a collector of a StateFlow might not receive all values that it holds over time. This article covers what that means for your tests.

Wrap-up 2021

Another year over, a new one's almost begun. Here's a brief summary of what I've done in this one.