26

After reading this issue How to deal with exception and this Medium Android Networking in 2019 — Retrofit with Kotlin’s Coroutines I've created my solution which consist in a BaseService capable of making the retrofit call and forward the results and exceptions down the "chain":

API

@GET("...")
suspend fun fetchMyObject(): Response<List<MyObject>>

BaseService

protected suspend fun <T : Any> apiCall(call: suspend () -> Response<T>): Result<T> {
    val response: Response<T>
    try {
        response = call.invoke()
    } catch (t: Throwable) {
        return Result.Error(mapNetworkThrowable(t))
    }
    if (!response.isSuccessful) {
        return Result.Error...
    }
    return Result.Success(response.body()!!)
}

ChildService

suspend fun fetchMyObject(): Result<List<MyObject>> {
    return apiCall(call = { api.fetchMyObject() })
}

Repo

    suspend fun myObjectList(): List<MyObject> {
        return withContext(Dispatchers.IO) {
            when (val result = service.fetchMyObject()) {
                is Result.Success -> result.data
                is Result.Error -> throw result.exception
            }
        }
    }

Note: sometimes we need more than throwing an exception or one type of success result. To handle those situations this is how we can achieve that:

sealed class SomeApiResult<out T : Any> {
    object Success : SomeApiResult<Unit>()
    object NoAccount : SomeApiResult<Unit>()
    sealed class Error(val exception: Exception) : SomeApiResult<Nothing>() {
        class Generic(exception: Exception) : Error(exception)
        class Error1(exception: Exception) : Error(exception)
        class Error2(exception: Exception) : Error(exception)
        class Error3(exception: Exception) : Error(exception)
    }
} 

And then in our ViewModel:

when (result: SomeApiResult) {
    is SomeApiResult.Success -> {...}
    is SomeApiResult.NoAccount -> {...}
    is SomeApiResult.Error.Error1 -> {...}
    is SomeApiResult.Error -> {/*all other*/...}
}

More about this approach here.

BaseViewModel

protected suspend fun <T : Any> safeCall(call: suspend () -> T): T? {
    try {
        return call()
    } catch (e: Throwable) {
        parseError(e)
    }
    return null
}

ChildViewModel

fun fetchMyObjectList() {
    viewModelScope.launch {
        safeCall(call = {
            repo.myObjectList()
            //update ui, etc..
        })
    }
}

I think the ViewModel (or a BaseViewModel) should be the layer handling the exceptions, because in this layer lies the UI decision logic, for example, if we just want to show a toast, ignore a type of exception, call another function etc...

What do you think?

EDIT: I've created a medium with this topic

GuilhE
  • 11,591
  • 16
  • 75
  • 116

3 Answers3

17

I think the ViewModel (or a BaseViewModel) should be the layer handling the exceptions, because in this layer lies the UI decision logic, for example, if we just want to show a toast, ignore a type of exception, call another function etc...

What do you think?

Certainly, you are correct. The coroutine should fire on the ViewModel even though the logic is in the Repository/Service. That's why Google has already created a special coroutineScope called viewModelScope, otherwise it would be "repositoryScope" . Also coroutines have a nice feature on exception handling called the CoroutineExceptionHandler. This is where things get nicer because you don't have to implement a try{}catch{} block:

val coroutineExceptionHanlder = CoroutineExceptionHandler{_, throwable -> 
    throwable.printStackTrace()
    toastLiveData.value = showToastValueWhatever()
}

Later in the ViewModel

coroutineScope.launch(Dispatchers.IO + coroutineExceptionHanlder){
      val data = serviceOrRepo.getData()
}

Of course you can still use the try/catch block without the CoroutineExceptionHandler, free to choose.

Notice that in case of Retrofit you don't need the Dispatchers.IO scheduler because Retrofit does that for you (since Retrofit 2.6.0).

Anyways, I can't say the article is bad, but it is not the best solution. If you want to follow the articles' guide, you may want to check Transformations on the LiveData.

EDIT: What you need to know more, is that coroutines are not safe. What I mean with this is that they might cause memory leaks especially in Android lifecycle overall. You need a way to cancel coroutines while the Activity/Fragment doesn't live anymore. Since the ViewModel has the onCleared (which is called when Activity/Fragment is destroyed), this implies that coroutines should fire in one of them. And perhaps this is the main reason why you should start the coroutine in the ViewModel. Notice that with the viewModelScope there is no need to cancel a job onCleared.

A simple example:

viewModelScope.launch(Dispatchers.IO) {
   val data = getDataSlowly()
   withContext(Dispatchers.MAIN) {
      showData();
   }
} 

or without the viewModelScope :

val job = Job()
val coroutineScope = CoroutineContext(Dispatchers.MAIN + job)

fun fetchData() {
   coroutineScope.launch() {
   val data = getDataSlowly()
       withContext(Dispatchers.MAIN) {
          showData();
       }
   }
}

//later in the viewmodel:

override fun onCleared(){
  super.onCleared()
  job.cancel() //to prevent leaks
}

Otherwise, your Service/Repository would leak. Another note is NOT to use the GlobalScope in this cases.

georgiecasey
  • 21,793
  • 11
  • 65
  • 74
coroutineDispatcher
  • 7,718
  • 6
  • 30
  • 58
  • Thanks for your post. Actually as you can see in my sample code I'm using `viewModelScope` to make the repo call, which will use a `withContext(Dispatchers.IO)` to call Retrofit. The only piece of code missing is the case where the activity is the one that start the call. In that case, I expose a `suspend fun` from the viewModel to be called inside a `lifecyleScope`. I guess this choices are correct consider your answer right? When you say Retrofit 2.6.0 uses `Dispatchers.IO` for me, that means that `withContext(Dispatchers.IO)` inside my repo could be removed? – GuilhE Sep 26 '19 at 10:15
  • 2
    the activity will just hold a reference of the viewmodel and say: `myViewModel.fetchMyObjectList()` not as suspending but as a method. then inside the `fetchMyObjectList()` you can just `viewModelScope.launch`. Yes, if you have Retrofit 2.6+ and inside your coroutines are only retrofit calls, no need for `withContext(Dispatchers.IO)` – coroutineDispatcher Sep 26 '19 at 10:19
  • Why shouldn't be inside `lifecycleScope`? I guess it may depend on the use case, but if I move away to other activity (we could have a problem when going into bg no?) and I want to cancel that network call. Putting it inside `lifecycleScope` wouldn't manage that for me? (I'm still learning how to work with coroutines, maybe I'm misinterpreted some concepts ^^) – GuilhE Sep 26 '19 at 10:24
  • That's fine with the `lifecycleScope` but the `View` mustn't know about your calls. The view is there just to render what the viewmodel gives. So you break the first part of the SOLID principles, the S – coroutineDispatcher Sep 26 '19 at 10:27
  • Yeah sure, I use `Data Binding` and all actions start inside the ViewModel, but there are some cases for instance `lifecycleScope.startWhenResumed{}` that have to start in the Activity. If a developer chooses to user `View Binding`those actions will start in the Activity also (`onClickListeners`, etc). Another question that I forgot to ask, you mention - correctly - CoroutineExceptionHandler. I've a BaseViewModel with `protected suspend fun safeCall(call: suspend () -> T): T?`which encapsulates the call inside a `try catch` block. Do I benefit from `CoroutineExceptionHandler`? – GuilhE Sep 26 '19 at 11:17
  • 1
    you can replace try/catch block with `CoroutineExceptionHandler` and it's still the same thing – coroutineDispatcher Sep 26 '19 at 11:24
  • In unit tests `viewModelScope` doesn't propagate the exceptions as test failures. When a call inside of `viewModelScope` throws an exception the test still passes, only displaying a message that exception has been thrown. Because of that I had to go away from `viewModelScope` and instead inject `MainScope()` which later in tests can be replaced by `TestCoroutineScope()`. – Lukasz Kalnik Mar 01 '20 at 20:59
  • hmmm check the docs in testing the `CoroutineExceptionHandler` I guess – coroutineDispatcher Mar 02 '20 at 05:36
  • Why do you need `Dispatchers.MAIN`? Dispatchers.IO automatically switches to main thread. – IgorGanapolsky Mar 30 '20 at 01:41
2

I would suggest to handle exceptions in repository and return a single response in the form of sealed class object to viewmodel. Keeping this in repository makes the repository as single source of truth and your code more clean & readable.

Ashok Kumar
  • 1,226
  • 1
  • 10
  • 14
  • 2
    So what you are suggesting is to move the `return when (result)` from the `BaseService` into my `Repository` (this will make `BaseService` return just `result`) and process the data when `is Result.Success -> {...}` and forward the error with `is Result.Error -> throw result.exception`. Did I get it right? I'm going to edit my code ;) – GuilhE Oct 10 '19 at 11:41
1

as a completion to @coroutineDispatcher excelent answer I would suggest catching the exception and check if it's HttpException, UnknownHostException or SocketException to give the user more feedback on the exception. for example

if (throwable is SocketException) {
     //very bad internet 
     return
}

if (throwable is HttpException) {
     // parse error body message from
     //throwable.response().errorBody()
     return
 }

if (throwable is UnknownHostException) {
     // probably no internet or your base url is wrong
     return
 }
 ShowSomethingWentWrong()

all the above code goes inside coroutineExceptionHanlder

Amr
  • 1,068
  • 12
  • 21