padmalcom
padmalcom

Reputation: 1469

Pattern to access kotlin DataStore values without using suspend functions

I am using a class to store and load data from a DataStore in my kotlin multiplatform app. The class is simple:

class AuthPersistence(
    private val context: Context
) {
    private val Context.dataStore by preferencesDataStore(context.packageName)

    actual suspend fun saveUsername(username: String) {
        context.dataStore.edit {
            it[usernameKey] = username
        }
    }

    actual suspend fun getUsername(): String? {
        return context.dataStore.data.firstOrNull()?.get(usernameKey)
    }

    private val usernameKey = stringPreferencesKey("user_name")
}

The class AuthPersistence is a singleton that I inject using koin. My problem is, that I don't know the best/cleanest way to make the username accessible without calling the suspend function. (Accessing the username directly from a class without launching a coroutine is less complex and the username should rarely change)

My approach would possible be to create an immutable variable that get's changed everytime I save a new username. But as mentioned; I'm not sure if this is the kotlin way.

Upvotes: 1

Views: 116

Answers (2)

tyg
tyg

Reputation: 15763

You usually want to defer collecting Flows to the UI.

In a layered app your Data Store is located at the lowest layer, the data layer. context.dataStore.data provides you with the stored values in the form of a Flow<Preferences>. You collect that flow right there by calling firstOrNull(). Collecting flows require a coroutine so you had to make getUsername a suspend function.

Instead of collecting the flow on the data layer you should pass the flow through to the layers, up into the UI. You can apply transformations to the flow on the way, if you like. In your case you would want to apply mapLatest:

fun getUsername(): Flow<String?> {
    return context.dataStore.data
        .mapLatest { it[usernameKey] }
}

That transforms the Flow<Preferences> into a Flow<String?> containing just the user name. The flow will always be up-to-date: Whenever the value is changed in the data store the flow will emit a new value.

Since you do not collect the flow you also don't need the suspend modifier anymore.

Now, the easiest way to present this flow to the UI is by converting it to a StateFlow. That is a specially configured flow that always has a value, much like a variable. Unlike a variable it can be easily observed for changes, because it is still a flow: You just need to collect it. To convert the Flow<String?> to a StateFlow<String?> you need to apply stateIn. If you use a view model that will be the best location:

val userName: StateFlow<String?> = authPersistence.getUsername()
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = null,
    )

You need to supply a CoroutineScope here because the StateFlow will (internally) always immediately collect new values, even when there aren't any subscribers collecting the StateFlow itself. The scope should be cancelled when not needed any more. The Android ViewModel already supplies an appropriate scope out-of-the-box, the viewModelScope.

To mitigate that the StateFlow might run the upstream flow even when there is noone collecting the StateFlow this behavior can be fine-tuned. With providing SharingStarted.WhileSubscribed(5_000) for the second parameter the StateFlow starts collecting the upstream flow as soon as the first subscriber of the StateFlow appears. It will stop collecting only 5 seconds after the last subscriber unsubscribed. The 5 seconds is an arbitrary value, high enough to allow subscribers to briefly unsubscribe and resubscribe whithout the StateFlow interrupting the upstream flow.

The last parameter provides the initial value of the flow, until the upstream flow emits its first value. This way the StateFlow will always have a value, independent of the delays of the upstream flows.

This StateFlow can now be handed over to the UI. Since the StateFlow always has a value you don't need a suspend function or a CoroutineScope to access it:

userName.value

That will retrieve the current value. But if you are interested in observing the changes too then you need to collect the StateFlow which, again, requires a coroutine. If you use Compose for your UI there is a ready-to-use function that handles everything you need:

val userName: String? by viewModel.userName.collectAsState()

(Use collectAsStateWithLifecycle() instead if you want it to be lifecycle-aware. You'll need the gradle dependency androidx.lifecycle:lifecycle-runtime-compose for it.)

collectAsState internally obtains an appropriate CoroutineScope and converts the flow into a Compose State. The by keyword is a Kotlin feature (Delegation) which unwraps the State and allows you to directly access its value - without losing the State mechanics.

That's why your composables can now simply access userName as a String?. Whenever the data store provides a new value a new recomposition is triggered with the updated userName and your UI will always be up-to-date.

If you don't use Compose you simply need to collect the StateFlow and trigger an update to your UI from its lambda:

viewModel.userName.collect { userName ->
    // update UI with new userName
}

Upvotes: 2

padmalcom
padmalcom

Reputation: 1469

Thank you for your answers. I found the easiest solution is to wrap the access to a DataStore property in a runBlocking {} block since it is at the end of the day a simple access to a json/protobuf file.

Upvotes: -1

Related Questions