Home > Blockchain >  How to manage dependent Kotlin coroutines in Android
How to manage dependent Kotlin coroutines in Android

Time:04-04

My use case is as follows:

Imagine that there is an Android Fragment that allows users to search for Grocery items in a store. There's a Search View, and as they type, new queries are sent to the Grocery item network service to ask for which items match the query. When successful, the query returns a list of Grocery items that includes the name, price, and nutritional information about the product.

Locally on the Android device, there is a list of known for "items for sale" stored in a raw file. It's in the raw resources directory and is simply a list of grocery item names and nothing else.

The behavior we wish to achieve is that as the user searches for items, they are presented with a list of items matching their query and a visual badge on the items that are "For Sale"


The constraints I am trying to satisfy are the following:

  1. When the user loads the Android Fragment, I want to parse the raw text file asynchronously using a Kotlin coroutine using the IO Dispatcher. Once parsed, the items are inserted into the Room database table for "For Sale Items" which is just a list of names where the name is the primary key. This list could be empty, it could be large (i.e. >10,0000).

  2. Parallel, and independent of #1, as the user types and makes different queries, I want to be sending out network requests to the server to retrieve the Grocery Items that match their query. When the query comes back successfully, these items are inserted into a different table in the Room database for Grocery Items

  3. Finally, I only want to render the list returned from #2 once I know that the text file from #1 has been successfully parsed. Once I know that #1 has been successfully parsed I want to join the tables in the database on name and give that LiveData to my ViewModel to render the list. If either #1 or #2 fail, I want the user to be given an "Error occurred, Retry" button


Where I am struggling right now:

  1. Seems achievable by simply kicking off a coroutine in ViewModel init that uses the IO Dispatcher. This way I only attempt to parse the file once per ViewModel creation (I'm okay with reparsing it if the user kills and reopens the app)

  2. Seems achievable by using another IO Dispatcher coroutine Retrofit Room.

  3. Satisfying the "Only give data to ViewModel when both #1 and #2 are complete else show error button" is the tricky part here. How do I expose a LiveData/Flow/something else? from my Repository that satisfies these constraints?

CodePudding user response:

You could do this by having the ViewModel monitor when the two tasks are complete and set loading state LiveData variable to indicate that the UI should only update once both tasks are complete. For example:

class MainViewModel : ViewModel() {

    private var completedA = false
    private var completedB = false

    private val dataALiveData = MutableLiveData("")
    val dataA: LiveData<String>
        get() = dataALiveData

    private val dataBLiveData = MutableLiveData("")
    val dataB: LiveData<String>
        get() = dataBLiveData

    private val dataIsReadyLiveData = MutableLiveData(false)
    val dataIsReady: LiveData<Boolean>
        get() = dataIsReadyLiveData

    // You can trigger a reload of some of this data without having to reset
    // any flags - the UI will be updated when the task is complete
    fun reloadB() {
        viewModelScope.launch { doTaskB() }
    }

    private suspend fun doTaskA() {
        // Fake task A - once it's done post relevant data
        // (if applicable), indicate that it is completed, and
        // check if the app is ready
        delay(3200)
        dataALiveData.postValue("Data A")
        completedA = true
        checkForLoaded()
    }

    private suspend fun doTaskB() {
        // Fake task B - once it's done post relevant data
        // (if applicable), indicate that it is completed, and
        // check if the app is ready
        delay(2100)
        dataBLiveData.postValue("Data B")
        completedB = true
        checkForLoaded()
    }

    private fun checkForLoaded() {
        if( completedA && completedB ) {
            dataIsReadyLiveData.postValue(true)
        }
    }

    // Launch both coroutines upon creation to start loading
    // the two data streams
    init {
        viewModelScope.launch { doTaskA() }
        viewModelScope.launch { doTaskB() }
    }
}

The activity or fragment could observe these three sets of LiveData to determine what to show and when, for example to hide the displayed elements and show a progress bar or loading indicator until it is done loading both.

If you wanted to handle error states, you could have the dataIsReady LiveData hold an enum or string to indicate "Loading", "Loaded", or "Error".

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    binding = ActivityMainBinding.inflate(layoutInflater)
    setContentView(binding.root)

    val model: MainViewModel by viewModels()

    binding.textA.visibility = View.INVISIBLE
    binding.textB.visibility = View.INVISIBLE
    binding.progressBar.visibility = View.VISIBLE

    model.dataA.observe(this) { data ->
        binding.textA.text = data
    }

    model.dataB.observe(this) { data ->
        binding.textB.text = data
    }

    // Once the data is ready - change the view visibility state
    model.dataIsReady.observe(this) { isReady ->
        if( isReady ) {
            binding.textA.visibility = View.VISIBLE
            binding.textB.visibility = View.VISIBLE
            binding.progressBar.visibility = View.INVISIBLE

            // alternately you could read the data to display here
            // by calling methods on the ViewModel directly instead of
            // having separate observers for them
        }
    }
}

CodePudding user response:

When you launch coroutines, they return a Job object that you can wait for in another coroutine. So you can launch Jobs for 1 and 2, and 3 can await them both.

When working with Retrofit and Room, you can define your Room and Retrofit DAOs/interfaces with suspend functions. This causes them to generate implementations that internally use an appropriate thread and suspend (don't return) until the work of inserting/updating/fetching is complete. This means you know that when your coroutine is finished, the data has been written to the database. It also means it doesn't matter which dispatcher you use for 2, because you won't be calling any blocking functions.

For 1, if parsing is a heavy operation, Dispatchers.Default is more appropriate than Dispatchers.IO, because the work will truly be tying up a CPU core.

If you want to be able to see if the Job from 1 had an error, then you actually need to use async instead of launch so any thrown exception is rethrown when you wait for it in a coroutine.

If you break these functions out into functions, it is conventional to make them suspend functions use withContext in them to specify appropriate dispatchers.

3 can be a Flow from Room (so you'd define the query with the join in your DAO), but you can wrap it in a flow builder that awaits 1. It can return a Result, which contains data or an error, so the UI can show an error state.

2 can operate independently, simply writing to the Room database by having user input call a ViewModel function to do that. The repository flow used by 3 will automatically pick up changes when the database changes.

Here's an example of ViewModel code to achieve this task.

private val parsedTextJob = viewModelScope.async {
    parseRawTextToDatabase()
}

val theRenderableList: SharedFlow<Result<List<SomeDataType>>> = flow {
    try {
        parsedTextJob.await()
    } catch (e: Exception) {
        emit(Result.failure(e)
        return@flow
    }
    emitAll(
        repository.getTheJoinedTableFlowFromDao()
            .map { Result.success(it) }
    )
}.shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1)

private suspend fun parseRawTextToDatabase() = withContext(Dispatchers.Default) {
    // read file, parse it and write to a database table
}

fun onNewUserInput(someTextFromUser: String) {
    viewModelScope.launch {
        // Do query from Retrofit.
        // Parse results and write to database.
    }
}

If you prefer LiveData to SharedFlow, you can replace theRenderableList above with:

val theRenderableList: LiveData<Result<List<SomeDataType>>> = liveData {
    try {
        parsedTextJob.await()
    } catch (e: Exception) {
        emit(Result.failure(e)
        return@liveData
    }
    emitSource(
        repository.getTheJoinedTableFlowFromDao()
            .map { Result.success(it) }
            .asLiveData()
    )
}
  • Related