Продолжаем дополнять серию статей. Сегодня мы разберем как легко обработать ошибку с сервера. То что мы будем сегодня разбирать тесно связано с предыдущими статьями и если вы их ещё не читали то настоятельно рекомендую перейти по ссылкам, прочитать и вернуться.
Single Activity с Navigation Component. Или как я мучался с графами. Boilerplate ч. 1
Запросы в сеть с Clean Architecture и MVVM. Boilerplate ч. 2
С этого выпуска мы напишем приложения с такой логикой:
Авторизация - реализуем авторизацию, добавим туда обработку ошибок с сервера, сохраним токены и проверку на авторизованность. И при успешной авторизации навигацию на главную страницу.
Главная страница - на главной странице у нас будет стягивания листа данных с пагинацией, при нажатии на определенны item с данными нас навигирует в детальную страницу.
Детальная страница - реализуем простую детальную страницу куда передается id'шка где мы делаем стягивания по той же id.
Объединим то что мы уже ранее разбирали, навигацию и запросы. После начинаем обработку ошибок. Нужно будет создать sealed
класс NetworkError
в модуле domain
:
sealed class NetworkError {
class Api(val error: MutableMap<String, List<String>>) : NetworkError()
class Unexpected(val error: String) : NetworkError()
}
Думая над обработкой ошибок полей куда юзер вводит данные (далее "инпуты") с сервера в приложениях мы с коллегами пришли к оптимальныму решению. Ошибки будут отправляться в виде ключ значение то есть Map
. В ключе будет приходить название инпута в значении лист из ошибок. Представим простой кейс, ошибка для инпута email. Ключ придет "email"
значение придет ["incorrect email type", "some error"]
. Принимаем лист из ошибок по причине того что ошибок может быть несколько, но вы можете просто этот момент обработать под себя, условно если всегда будет приходить только одна ошибка принимать просто обычную строку то есть String
. А ещё мы будем использовать MutableMap
позже поймете для чего.
Меняем типа обработки в репозитории слоя domain
и обернем наш запрос в Response<T>
для того чтобы получать errorBody()
.
interface SignInRepository {
fun signIn(userSignIn: UserSignIn): Flow<Either<NetworkError, Sign>>
}
interface SignInApiService {
@POST
suspend fun signIn(@Body userSignInDto: UserSignInDto): Response<SignInResponse>
}
Далее мы рефакторим реализацию мапперов с слоя data
в слой domain
для того чтобы могли их использовать вместе с generic'ами, далее поймете зачем нам это. Создаем интерфейс DataMapper<T, S>
в слое data
и дополнительно создаем функции расширения которая поможет нам все реализовать.
interface DataMapper<T, S> {
fun T.mapToDomain(): S
}
fun <T : DataMapper<T, S>, S> T.mapToDomain() = this.mapToDomain()
Имплементируем в SignInResponse
class SignInResponse(
@SerializedName("token")
val token: String
) : DataMapper<SignInResponse, SignIn> {
override fun SignInResponse.mapToDomain() = SignIn(
token
)
}
И после всего этого меняем реализацию метода doRequest
теперь будем называть его doNetworkRequest()
потому что будет он работать только с запросами в сеть. И ещё как посоветовали в комментариях с прошлой статьи был убран параметр doSomethingInSuccess
.
abstract class BaseRepository {
/**
* Do network request
*
* @return result in [flow] with [Either]
*/
protected fun <T : DataMapper<T, S>, S> doNetworkRequest(
request: suspend () -> Response<T>
) = flow<Either<NetworkError, S>> {
request().let {
if (it.isSuccessful && it.body() != null) {
emit(Either.Right(it.body()!!.mapToDomain()))
} else {
emit(Either.Left(NetworkError.Api(it.errorBody().toApiError())))
}
}
}.flowOn(Dispatchers.IO).catch { exception ->
emit(
Either.Left(NetworkError.Unexpected(exception.localizedMessage ?: "Error Occurred!"))
)
}
/**
* Convert network error from server side
*/
private fun ResponseBody?.toApiError(): MutableMap<String, List<String>> {
return Gson().fromJson(
this?.string(),
object : TypeToken<MutableMap<String, List<String>>>() {}.type
)
}
}
Теперь давайте разберем код. Generic T
коим является наша моделька SignInResponse
теперь является имплементирующим интерфейс DataMapper<T, S>
. А generic S
является уже моделькой слоя domain
. В параметре request
теперь возвращается T
с оберткой Response
. Далее обычная проверка на isSuccessful
и null
. При успешной обработке возвращается Either.Right
с нашей моделькой SignInResponse
который с помощью метода mapToDomain()
мапиться в SignIn
модельку слоя domain
. При ошибке с сервера мы возвращаем Either.Left
только уже с NetworkError.Api
в котором мы вызываем errorBody()
метод и превращаем его в наш MutableMap
с помощью функции toApiError()
.
Давайте посмотрим как теперь выглядит SignInRepositoryImpl
.
class SignInRepositoryImpl @Inject constructor(
private val service: SignInApiService
) : BaseRepository(), SignInRepository {
override fun signIn(userSignIn: UserSignIn) = doNetworkRequest {
service.signIn(userSignIn.fromDomain()).also { data ->
data.body()?.let {
// save token
it.token
}
}
}
}
Если что мы на уровне data
сохранили токен, уже не обязательно передавать его в слой presentation
'a.
В UseCase
'ах все остается так же поэтому перейдем во ViewModel
. Там нужно будет подправить функции collectRequest()
. Просто меняем возвращаемый тип с String
на NetworkError
.
abstract class BaseViewModel : ViewModel() {
/**
* Creates [MutableStateFlow] with [UIState] and the given initial value [UIState.Idle]
*/
@Suppress("FunctionName")
fun <T> MutableUIStateFlow() = MutableStateFlow<UIState<T>>(UIState.Idle())
/**
* Collect network request
*
* @return [UIState] depending request result
*/
protected fun <T> Flow<Either<NetworkError, T>>.collectRequest(
state: MutableStateFlow<UIState<T>>,
) {
viewModelScope.launch(Dispatchers.IO) {
state.value = UIState.Loading()
this@collectRequest.collect {
when (it) {
is Either.Left -> state.value = UIState.Error(it.value)
is Either.Right -> state.value = UIState.Success(it.value)
}
}
}
}
/**
* Collect network request with mapping from domain to ui
*
* @return [UIState] depending request result
*/
protected fun <T, S> Flow<Either<NetworkError, T>>.collectRequest(
state: MutableStateFlow<UIState<S>>,
mappedData: (T) -> S
) {
viewModelScope.launch(Dispatchers.IO) {
state.value = UIState.Loading()
this@collectRequest.collect {
when (it) {
is Either.Left -> state.value = UIState.Error(it.value)
is Either.Right -> state.value = UIState.Success(mappedData(it.value))
}
}
}
}
}
Как мы помним из изменений прошлой статьи, у нас появилось две функции collectRequest()
, один с маппингом другой без.
Здесь мы поменяли тип возвращения ошибок на NetworkError
, но теперь у нас UIState.Error
не принимает значение String
, значит там тоже меняем.
sealed class UIState<T> {
class Idle<T> : UIState<T>()
class Loading<T> : UIState<T>()
class Error<T>(val error: NetworkError) : UIState<T>()
class Success<T>(val data: T) : UIState<T>()
}
Далее меняем так же в методе collectUIState()
который находиться в BaseFragment
'e.
abstract class BaseFragment<ViewModel : BaseViewModel, Binding : ViewBinding>(
@LayoutRes layoutId: Int
) : Fragment(layoutId) {
// ...
/**
* Collect [UIState] with [collectFlowSafely] and optional states params
* @param state for working with all states
* @param onError for error handling
* @param onSuccess for working with data
*/
protected fun <T> StateFlow<UIState<T>>.collectUIState(
lifecycleState: Lifecycle.State = Lifecycle.State.STARTED,
state: ((UIState<T>) -> Unit)? = null,
onError: ((error: String) -> Unit),
onSuccess: ((data: T) -> Unit)
) {
collectFlowSafely(lifecycleState) {
this.collect {
state?.invoke(it)
when (it) {
is UIState.Idle -> {}
is UIState.Loading -> {}
is UIState.Error -> onError.invoke(it.error)
is UIState.Success -> onSuccess.invoke(it.data)
}
}
}
}
}
Далее в обработке нам нужно как-то отобразить ошибку из Map
'ы в ошибку внутри инпута. Тот же не правильный username или password, мы вернем ошибку ключ username, а значение лист из строк, в нем может быть ошибки по типу required, incorrect и так далее. Теперь как нам отобразить это, конечно мы можем каждый вручную все прописывать, но лень двигатель прогресса (не пишите повторяющийся код :) ) поэтому мы напишем функцию расширения для этого. Чтобы понимать в какие инпуты что сетить мы будем использовать атрибут tag
у вьюшек. Если будет ошибка с ключем username то и у инпута будет tag
username.
Создаем .kt
файл и назовем его NetworkErrorExtensions
и пишем там методы обработки.
fun NetworkError.setupApiErrors(vararg inputs: TextInputLayout) {
if (this is NetworkError.Api) {
for (input in inputs) {
error[input.tag].also { error ->
if (error == null) {
input.isErrorEnabled = false
} else {
input.error = error.joinToString()
this.error.remove(input.tag)
}
}
}
}
}
fun NetworkError.setupUnexpectedErrors(context: Context) {
if (this is NetworkError.Unexpected) {
Toast.makeText(context, this.error, Toast.LENGTH_LONG).show()
}
}
NetworkError.setupApiErrors()
- Функция расширяетsealed class NetworkError
, принимает в параметрvararg
изTextInputLayout
и далее проверяет этоApi
или нет, если да то идет к дальнейшей логике. Пробегается циклом по инпутам которые пришли в параметр, после по их тегу достает ключ изMap
если такой ключ есть то он отображает ошибку и инпута и удаляет этот ключ иMap
и цикл повторяется.NetworkError.setupUnexpectedErrors()
- Функция расширенияsealed class NetworkError
который просто проверяет типUnexpected
и если да то отображает ошибку в видеToast
.
Далее мы вызываем эти методы в SignInFragment
@AndroidEntryPoint
class SignInFragment : BaseFragment<SignInViewModel, FragmentSignInBinding>(
R.layout.fragment_sign_in
) {
// ...
override fun setupSubscribers() = with(binding) {
viewModel.signInState.collectUIState(
state = {
it.setupViewVisibility(groupSignIn, loaderSignIn, true)
},
onError = {
it.setupApiErrors(
inputLayoutSignInUsername,
inputLayoutSignInPassword
)
it.setupUnexpectedErrors(requireContext())
},
onSuccess = {
findNavController().navigate(R.id.action_signInFragment_to_homeFragment)
}
)
}
}
На этом все в результате все будет выглядить вот так.
P. S. Конечно же это все чисто для примера. На странице авториации никогда нельзя отображать ошибку в инпутах, все должно отображаться в Toast, но это все для примеров :). Снизу ссылки на репозитории.
Репозиторий где будет этот проект: github.com/TheAlisher/Boilerplate-Sample-Android. Код из статьи находиться в этой ветке.
Основной репозиторий самого Boilerplate: github.com/TheAlisher/Boilerplate-Android