Kotlin Coroutines Flow with Room and state handling

…衆ロ難τιáo~ 提交于 2020-07-23 10:16:09

问题


I'm trying out the new coroutine's flow, my goal is to make a simple repository that can fetch data from a web api and save it to db, also return a flow from the db.

I'm using room and firebase as the web api, now everything seems pretty straight forward until i try to pass errors coming from the api to the ui.

Since i get a flow from the database which only contains the data and no state, what is the correct approach to give it a state (like loading, content, error) by combining it with the web api result?

Some of the code i wrote:

The DAO:

@Query("SELECT * FROM users")
fun getUsers(): Flow<List<UserPojo>>

The Repository:

val users: Flow<List<UserPojo>> = userDao.getUsers()

The Api call:

override fun downloadUsers(filters: UserListFilters, onResult: (result: FailableWrapper<MutableList<UserApiPojo>>) -> Unit) {
    val data = Gson().toJson(filters)

    functions.getHttpsCallable("users").call(data).addOnSuccessListener {
        try {
            val type = object : TypeToken<List<UserApiPojo>>() {}.type
            val users = Gson().fromJson<List<UserApiPojo>>(it.data.toString(), type)
            onResult.invoke(FailableWrapper(users.toMutableList(), null))
        } catch (e: java.lang.Exception) {
            onResult.invoke(FailableWrapper(null, "Error parsing data"))
        }
    }.addOnFailureListener {
        onResult(FailableWrapper(null, it.localizedMessage))
    }
}

I hope the question is clear enough Thanks for the help

Edit: Since the question wasn't clear i'll try to clarify. My issue is that with the default flow emitted by room you only have the data, so if i were to subscribe to the flow i would only receive the data (eg. In this case i would only receive a list of users). What i need to achieve is some way to notify the state of the app, like loading or error. At the moment the only way i can think of is a "response" object that contains the state, but i can't seem to find a way to implement it.

Something like:

fun getUsers(): Flow<Lce<List<UserPojo>>>{
    emit(Loading())
    downloadFromApi()
    if(downloadSuccessful)
        return flowFromDatabase
    else
        emit(Error(throwable))
}

But the obvious issue i'm running into is that the flow from the database is of type Flow<List<UserPojo>>, i don't know how to "enrich it" with the state editing the flow, without losing the subscription from the database and without running a new network call every time the db is updated (by doing it in a map transformation).

Hope it's clearer


回答1:


I believe this is more of an architecture question, but let me try to answer some of your questions first.

My issue is that with the default flow emitted by room you only have the data, so if i were to subscribe to the flow i would only receive the data

If there is an error with the Flow returned by Room, you can handle it via catch()

What i need to achieve is some way to notify the state of the app, like loading or error.

I agree with you that having a State object is a good approach. In my mind, it is the ViewModel's responsibility to present the State object to the View. This State object should have a way to expose errors.

At the moment the only way i can think of is a "response" object that contains the state, but i can't seem to find a way to implement it.

I have found that it is easier to have the State object that the ViewModel controls be responsible for errors instead of an object that bubbles up from the Service layer.

Now with these questions out of the way, let me try to propose one particular "solution" to your issue.

As you mention, it is common practice to have a Repository that handles retrieving data from multiple data sources. In this case, the Repository would take the DAO and an object that represents getting data from the network, let's call it Api. I am assuming that you are using FirebaseFirestore, so the class and method signature would look something like this:

class Api(private val firestore: FirebaseFirestore) {

fun getUsers() : Flow<List<UserApiPojo>

}

Now the question becomes how to turn a callback based API into a Flow. Luckily, we can use callbackFlow() for this. Then Api becomes:

class Api(private val firestore: FirebaseFirestore) {

fun getUsers() : Flow<List<UserApiPojo> = callbackFlow {

val data = Gson().toJson(filters)

functions.getHttpsCallable("users").call(data).addOnSuccessListener {
    try {
        val type = object : TypeToken<List<UserApiPojo>>() {}.type
        val users = Gson().fromJson<List<UserApiPojo>>(it.data.toString(), type)
        offer(users.toMutableList())
    } catch (e: java.lang.Exception) {
       cancel(CancellationException("API Error", e))
    }
}.addOnFailureListener {
    cancel(CancellationException("Failure", e))
    }
  }
}

As you can see, callbackFlow allows us to cancel the flow when something goes wrong and have someone donwnstream handle the error.

Moving to the Repository we would now like to do something like:

val users: Flow<List<User>> = Flow.concat(userDao.getUsers().toUsers(), api.getUsers().toUsers()).first()

There are a few caveats here. first() and concat() are operators you will have to come up with it seems. I did not see a version of first() that returns a Flow; it is a terminal operator (Rx used to have a version of first() that returned an Observable, Dan Lew uses it in this post). Flow.concat() does not seem to exist either. The goal of users is to return a Flow that emits the first value emitted by any of the source Flows. Also, note that I am mapping DAO users and Api users to a common User object.

We can now talk about the ViewModel. As I said before, the ViewModel should have something that holds State. This State should represent data, errors and loading states. One way that can be accomplished is with a data class.

data class State(val users: List<User>, val loading: Boolean, val serverError: Boolean)

Since we have access to the Repository the ViewModel can look like:

val state = repo.users.map {users -> State(users, false, false)}.catch {emit(State(emptyList(), false, true)}

Please keep in mind that this is a rough explanation to point you in a direction, there are many ways to accomplish state management and this is by no means a complete implementation. It may not even make sense to turn the API call into a Flow, for example.




回答2:


The answer from Emmanuel is really close to answering what i need, i need some clarifications about some of it.

It may not even make sense to turn the API call into a Flow

You are totally right, in fact i only want to actually make it a coroutine, i don't really need it to be a flow.

If there is an error with the Flow returned by Room, you can handle it via catch()

Yes i discovered this after posting the question. But my problem is more something like:

I'd like to call a method, say "getData", this method should return the flow from db, start the network call to update the db (so that i'm going to be notified when it's done via the db flow) and somewhere in here, i would need to let the ui know if db or network errored, right?. Or should i maybe do a separate "getDbFlow" and "updateData" and get the errors separately for each one?

val users: Flow> = Flow.concat(userDao.getUsers().toUsers(), api.getUsers().toUsers()).first()

This is a good idea, but i'd like to keep the db as the single source of truth, and never return to the ui any data directly from the network



来源:https://stackoverflow.com/questions/59944977/kotlin-coroutines-flow-with-room-and-state-handling

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!