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
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.printStackTrance()
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.