Maksim Dmitriev
Maksim Dmitriev

Reputation: 6209

Kotlin coroutines. Kotlin Flow and shared preferences. awaitClose is never called

I'd love to observe changes of a shared preference. Here is how I Use Kotlin Flow to do it:

Data source.

interface DataSource {

    fun bestTime(): Flow<Long>

    fun setBestTime(time: Long)
}

class LocalDataSource @Inject constructor(
    @ActivityContext context: Context
) : DataSource {

    private val preferences = context.getSharedPreferences(PREFS_FILE_NAME, Context.MODE_PRIVATE)

    @ExperimentalCoroutinesApi
    override fun bestTime() = callbackFlow {
        trySendBlocking(preferences, PREF_KEY_BEST_TIME)
        val listener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
            if (key == PREF_KEY_BEST_TIME) {
                trySendBlocking(sharedPreferences, key)
            }
        }
        preferences.registerOnSharedPreferenceChangeListener(listener)
        awaitClose { // NEVER CALLED
            preferences.unregisterOnSharedPreferenceChangeListener(listener)
        }
    }

    @ExperimentalCoroutinesApi
    private fun ProducerScope<Long>.trySendBlocking(
        sharedPreferences: SharedPreferences,
        key: String?
    ) {
        trySendBlocking(sharedPreferences.getLong(key, 0L))
            .onSuccess { }
            .onFailure {
                Log.e(TAG, "", it)
            }
    }

    override fun setBestTime(time: Long) = preferences.edit {
        putLong(PREF_KEY_BEST_TIME, time)
    }

    companion object {
        private const val TAG = "LocalDataSource"

        private const val PREFS_FILE_NAME = "PREFS_FILE_NAME"
        private const val PREF_KEY_BEST_TIME = "PREF_KEY_BEST_TIME"
    }
}

Repository

interface Repository {

    fun observeBestTime(): Flow<Long>

    fun setBestTime(bestTime: Long)
}

class RepositoryImpl @Inject constructor(
    private val dataSource: DataSource
) : Repository {

    override fun observeBestTime() = dataSource.bestTime()

    override fun setBestTime(bestTime: Long) = dataSource.setBestTime(bestTime)
}

ViewModel

class BestTimeViewModel @Inject constructor(
    private val repository: Repository
) : ViewModel() {

    // Backing property to avoid state updates from other classes
    private val _uiState = MutableStateFlow(0L)
    val uiState: StateFlow<Long> = _uiState

    init {
        viewModelScope.launch {
            repository.observeBestTime()
                .onCompletion { // CALLED WHEN THE SCREEN IS ROTATED OR HOME BUTTON PRESSED
                    Log.d("myTag", "viewModelScope onCompletion")
                }
                .collect { bestTime ->
                    _uiState.value = bestTime
                }
        }
    }

    fun setBestTime(time: Long) = repository.setBestTime(time)
}

Fragment.

@AndroidEntryPoint
class MetaDataFragment : Fragment(R.layout.fragment_meta_data) {

    @Inject
    lateinit var timeFormatter: TimeFormatter

    @Inject
    lateinit var bestTimeViewModel: BestTimeViewModel

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        val bestTimeView = view.findViewById<TextView>(R.id.best_time_value)

        // Create a new coroutine in the lifecycleScope
        viewLifecycleOwner.lifecycleScope.launch {
            // repeatOnLifecycle launches the block in a new coroutine every time the
            // lifecycle is in the STARTED state (or above) and cancels it when it's STOPPED.
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                // Trigger the flow and start listening for values.
                // This happens when lifecycle is STARTED and stops
                // collecting when the lifecycle is STOPPED
                bestTimeViewModel.uiState
                    .map { millis ->
                        timeFormatter.format(millis)
                    }
                    .onCompletion { // CALLED WHEN THE SCREEN IS ROTATED OR HOME BUTTON PRESSED
                        Log.d("MyApp", "onCompletion")
                    }
                    .collect {
                        bestTimeView.text = it
                    }
            }
        }
    }
}

I've noticed that awaitClose is never called. But this is where my clean-up code is. Please advise. If it's not a good idea to use callbackFlow in the first place, please let me know (as you can see some functions are ExperimentalCoroutinesApi meaning their behaviour can change)

Upvotes: 1

Views: 9306

Answers (2)

Maksim Dmitriev
Maksim Dmitriev

Reputation: 6209

I found a solution that allows me to save a simple dataset such as a preference and observe its changes using Kotlin Flow. It's Preferences DataStore. This is the code lab and guide I used: https://developer.android.com/codelabs/android-preferences-datastore#0 https://developer.android.com/topic/libraries/architecture/datastore

and this is my code:

import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException

data class UserPreferences(val bestTime: Long)

private const val USER_PREFERENCES_NAME = "user_preferences"

private val Context.dataStore by preferencesDataStore(
    name = USER_PREFERENCES_NAME
)

interface DataSource {

    fun userPreferencesFlow(): Flow<UserPreferences>

    suspend fun updateBestTime(newBestTime: Long)
}

class LocalDataSource(
    @ApplicationContext private val context: Context,
) : DataSource {

    override fun userPreferencesFlow(): Flow<UserPreferences> =
        context.dataStore.data
            .catch { exception ->
                // dataStore.data throws an IOException when an error is encountered when reading data
                if (exception is IOException) {
                    emit(emptyPreferences())
                } else {
                    throw exception
                }
            }
            .map { preferences ->
                val bestTime = preferences[PreferencesKeys.BEST_TIME] ?: 0L
                UserPreferences(bestTime)
            }

    override suspend fun updateBestTime(newBestTime: Long) {
        context.dataStore.edit { preferences ->
            preferences[PreferencesKeys.BEST_TIME] = newBestTime
        }
    }
}

private object PreferencesKeys {
    val BEST_TIME = longPreferencesKey("BEST_TIME")
}

and the dependency to add to build.gradle:

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

Upvotes: 5

Adrian K
Adrian K

Reputation: 4823

The problem is, that you are injecting your ViewModel as if it was just a regular class, by using

@Inject
lateinit var bestTimeViewModel: BestTimeViewModel

Because of this, the ViewModel's viewModelScope is never cancelled, and therefor the Flow is collected forever.

Per Documentation, you should use

privat val bestTimeViewModel: BestTimeViewModel by viewModels()

This ensures that the ViewModel's onCleared method, which in turn will cancel the viewModelScope, is called when your Fragment is destroyed.

Also make sure your ViewModel is annotated with @HiltViewModel:

@HiltViewModel
class BestTimeViewModel @Inject constructor(...) : ViewModel()

Upvotes: 0

Related Questions