Pull to refresh

Reaction — обработка результатов методов в Kotlin

Reading time4 min
Views8K

Каждый, кто использовал Чистую архитектуру в разработке, сталкивался с проблемой передачи данных между слоями. Суть проблемы всегда одинакова: необходимо вернуть либо результат, либо ошибку. Представить это можно, например, так:

interface Reaction

data class Success(val data: String) : Reaction
data class Error(message: String) : Reaction

В зависимости от задачи, такие Reaction’ы могут быть самые разные, поэтому давайте объединим его в один класс, используя Generics и Sealed class’ы.

sealed class Reaction<out T> {
   class Success<out T>(val data: T) : Reaction<T>()
   class Error(val exception: Throwable) : Reaction<Nothing>()
}

Разберем пример как это можно использовать

class MyViewModel : ViewModel {
	private val repository: Repository
	
	fun doSomething() {
		viewModelScope.launch(Dispatchers.IO) {
			val result = repository.getData()
			when (result) {
				is Success -> //do something
				is Error -> // show error
			}
		}
	}
}

Выглядит неплохо. Мы можем возвращать данные и обрабатывать ошибку.
Теперь посмотрим как выглядит репозиторий в текущем варианте

class RepositoryImpl(private val dataSource: DataSource) : Repository {
  
	override suspend fun getData(): Reaction<Int> {
		return try {
			Reaction.Success(dataSource.data)
		} catch(e: Exception) {
			Reaction.Error(e)
		}
	}
}

Из-за того, что каждый метод репозитория должен возвращать Reaction, придется каждый метод оборачивать в try-catch, что выглядит некрасиво из-за огромного количества бойлерплейт кода. Попробуем сделать код чище, выносом try-catch в метод.

sealed class Reaction<out T> {

   class Success<out T>(val data: T) : Reaction<T>()
   class Error(val exception: Throwable) : Reaction<Nothing>()

   companion object {
       inline fun <T> on(f: () -> T): Reaction<T> = try {
           Success(f())
       } catch (ex: Exception) {
           Error(ex)
       }
   }
}

После этого репозиторий начнет выглядеть так:

class RepositoryImpl(private val dataSource: DataSource) : Repository {

	suspend fun getData(): Reaction<Int> = Reaction.on { dataSource.data }
}

Видно, что код стал гораздо чище и только в этом примере мы сэкономили 4 строки кода.

Теперь вернемся к ViewModel и постараемся убрать бойлерплэйт when для каждого запроса. Сейчас мы получаем данные, обрабатываем и отдаем во View.

class MyViewModel : ViewModel {
	private val repository: Repository
	private val _onData = MutableLiveData<State>()
	val onData: LiveData<State> = _onData

	fun doSomething() {
		viewModelScope.launch(Dispatchers.IO) {
			val result = repository.getData()
			when (result) {
				is Success -> _onData.postValue(State.Success)
				is Error -> onData.postValue(State.Error(result.message))
			}
		}
	}

	sealed class State {
  	object Progress : State()
  	object Success : State()
  	data class Error(message: String) : State()
	}
}

Решение уже подсказывает опыт RxJava, Coroutines и LiveData.
Исходя из того, что данные, которые вернулись в ViewModel обычно надо показать пользователю в виде результата запроса, либо ошибки, давайте добавим метод zip, который будет приводить Reaction к объекту, который будет передаваться в LiveData

inline fun <T, R> Result<T>.zip(success: (T) -> R, error: (Exception) -> R): R =
   when (this) {
       is Reaction.Success -> success(this.data)
       is Reaction.Error -> error(this.exception)
   }

Наша MyViewModel преобразится в

class MyViewModel : ViewModel {
	private val repository: Repository
	private val _onData = MutableLiveData<State>()
	val onData: LiveData<State> = _onNewDirectory

	fun doSomething() {
		viewModelScope.launch(Dispatchers.IO) {
			repository.getData()
				.zip(
        	{ State.Success }, 
        	{ State.Error(result.message) }
        )
				.let { onData.postValue(it) }
		}
	}

	//...
}

Также есть частый случай, когда метод в ViewModel делает несколько последовательных запросов к репозиторию, где одни данные зависят от ранее полученных. При получении ошибки необходимо прервать цепочку запросов и вернуть ошибку во View

Рассмотрим следующий пример:

class MyViewModel : ViewModel {
	//...
	fun doSomething() {
		viewModelScope.launch(Dispatchers.IO) {
			var firstData: Int = 0

			val reaction = repository.getData()
			when (reaction) {
				is Success -> firstData = reaction.data 
				is Error -> {
					onData.postValue(State.Error(reaction.message))
					return@launch
				}
			}

			val nextReaction = repository.getNextData(firstData)
      
			//..
		}		
	}
  
	//...
}

Решений можно придумать множество, но я здесь представлю решение без callback hell, оставляя преимущество, которое предоставляет использование Coroutines

class MyViewModel : ViewModel {
  //...
	fun doSomething() {
		viewModelScope.launch(Dispatchers.IO) {
			val firstData = repository.getData()
				.takeOrReturn {
					onData.postValue(State.Error(result.message)
					return@launch
				}
			val nextReaction= repository.getNextData(firstData)
      
			//..
		}		
	}
}

В итоге мы имеем легко расширяемое решение, в которой уже есть такие популярные методы обработки полученных данных как:

  • on - Создает Reaction из выражения

  • map - Трансформирует успешный результат

  • flatMap - Трансформирует успешный результат в новую Reaction

  • doOnSuccess - Выполняется, если Reaction - успешный результат

  • и др

Полный список и дополнительные примеры можно найти в Github

Сравнение с аналогами

Было найдено 3 аналога. Ниже представлены сами аналоги и их преимущества и недостатки

  • Railway Kotlin
    Преимущества:

    • Легко освоить

    • Состоит из 1 файла

    Недостатки:

    • Нет возможности инкапсулировать try-catch

    • Использование infix методов

    • Неинтуитивные названия методов

  • Arrow-KT
    Преимущества:

    • Популярная библиотека

    Недостатки:

    • Из описания непонятно что библиотека может

    • Высокий порог вхождения по сравнению с аналогами

    • Оставляет ощущение, что является слишком сложной для решения такой простой проблемы

  • Result (Kotlin)
    Преимущества:

    • Является почти полной копией предлагаемого мной решения

    Недостатки:

Итог

Reaction - это легковесная библиотека с минимальным порогом вхождения, т.к. она состоит из 1 файла, предоставляющая такие же мощности, как решение от Kotlin, но не содержит всех его минусов.

GitHub

https://github.com/taptappub/Reaction/

Tags:
Hubs:
Total votes 17: ↑16 and ↓1+15
Comments15

Articles