Запросы в сеть с Clean Architecture и MVVM. Boilerplate ч. 2

от автора

Что такое чистая архитектура, зачем использовать? Сегодня я не отвечу вам на эти вопросы 🙂 (для этого существуют много других хороших статей одна из них Заблуждения Clean Architecture) Но отвечу на то как реализовать Clean Architecture в Android’е по крайней мере покажу вам свою реализацию.

Для общего понятия что здесь будет происходить вам нужно уметь пользоваться такими технологиями как: Coroutines, Retrofit 2, Lifecycle, Hilt. Ну приступим!

Модель приложения будет такая что у нас будет Авторизация (далее SignIn) в котором будет происходит запрос и дальнейшяя навигация на главную страницу (далее Home). Разделим все слои на модули. Зависимость у них будет такая: domain -> data -> app (по совместительству DI и слой presentation).

Создаем Java/Kotlin модуль domain. Модуль будет содержать в себе:

  • Интерфейсы репозиториев. Поможет нам легко подменять репозиторий для тестов и плюс будет возможность взаимодействовать с репозиторием в слое domain.

  • Use case’ы или Interactor’ы. Будет разделять функции репозитория (SOLID, interface segregation), ещё можем внутри проводить простые операции такие как валидация. Хорошая статейка про use case’ы: The Neverending Use Case Story.

  • Класс Either. Можно было использовать и класс по типу Resource или тот же Result, но Either можно будет использовать не только для обработки запросов.

  • Наши модельки.

Подтянем зависимости javax.inject и core-модуль корутин, если со вторым понятно то зачем нужен первый, далее будет объясняться пока просто добавьте. Будем добавлять их как api чтобы работало транзитивно и на другие модули.

dependencies {      // Javax Inject     api("javax.inject:javax.inject:1")      // Kotlin Coroutines     api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1") }

Создадим классы моделек UserSignIn в котором будут отправляться данные и SignIn для того чтобы принимать данные.

class SignIn(     val token: String )  class UserSignIn(     val username: String,     val password: String )

Далее создадим интерфейс SignInRepository в котором у нас будут функции для запроса в сеть.

interface SignRepository {      fun signIn(userSignIn: UserSignIn): Flow<Either<String, SignIn>> }

Продолжаем! Вдобавок ко всему этому ещё создадим use case который будет называться SignInUseCase

class SignInUseCase @Inject constructor(     private val repository: SignInRepository ) {     operator fun invoke(userSignIn: UserSignIn) = repository.signIn(userSignIn) }

Вот теперь нам понадобился javax.inject потому что откуда мы возьмем Inject в слое domain. Вместе с Hilt приходил и Inject, но так как Hilt поддерживает JSR-330 мы просто подтянули Inject отдельно и после это все автоматом переопределиться.

Далее создадим android модуль под названием data. Модуль будет в себе содержать:

  • Создание http-клиента и подключение запросов в сеть.

  • Создание локальных хранилищ, БД и запросы к ним (не будем сегодня разбирать).

  • Реализация репозиториев в котором будет обработка запросов и дальнейший маппинг в слой domain.

Зависимости слоя data будет выглядить вот так:

dependencies {      implementation(project(":domain"))      // Retrofit 2     implementation("com.squareup.retrofit2:retrofit:2.9.0")     implementation("com.squareup.retrofit2:converter-gson:2.9.0")      // OkHttp     implementation("com.squareup.okhttp3:okhttp-bom:5.0.0-alpha.7")     implementation("com.squareup.okhttp3:okhttp")     implementation("com.squareup.okhttp3:logging-interceptor") }

Как использовать Retrofit все знают поэтому давайте сразу перейдем к реализации запросов в сеть. Но для начала нам нужно создать модельки которые будут относиться к слою data то есть создаем DTO. В этих файлах уже будут сразу функции расширения для маппинга.

class SignInResponse(     @SerializedName("token")     val token: String )  fun SignInResponse.toDomain() = SignIn(     token )  class UserSignInDto(     @SerializedName("username")     val username: String,     @SerializedName("password")     val password: String )  fun UserSignIn.fromDomain() = UserSignInDto(     username,     password )

Далее нам нужно создать BaseRepository базовый класс для репозиториев.

abstract class BaseRepository {      /**      * Do network request      *      * @param doSomethingInSuccess for working when request result is success      * @return request result in [flow] with [Either]      */     protected fun <T> doRequest(         doSomethingInSuccess: ((T) -> Unit)? = null,         request: suspend () -> T     ) = flow<Either<String, T>> {         request().also { data ->             doSomethingInSuccess?.invoke(data)             emit(Either.Right(value = data))         }     }.flowOn(Dispatchers.IO).catch { exception ->         emit(Either.Left(value = exception.localizedMessage ?: "Error Occurred!"))     } }

Это абстрактный класс в нем есть метод который поможет нам избежать boilerplate кода и облегчит нашу обработку запросов. Метод очень простой в параметр принимает две функции.

  • request — в эту функцию прокидывается сам запрос, вызывается и далее обрабатывается.

  • doSomethingInSuccess — опциональный параметр который отвечает за обработку данных на уровне репозитория в случае если запрос успешен, в нем мы можем что-то сделать с результатом запроса, например сохранить в БД, DataStore или же SharedPreferences что мы и сделаем

  • Возвращает Either оборачивая все сразу в Flow который в IO Dispatcher‘e.

А теперь перейдем к самому репозиторию. Нужно унаследовать BaseRepository и имплементировать SignInRepository. Выглядит все в результате вот так.

class SignInRepositoryImpl @Inject constructor(     private val service: SignInApiService ) : BaseRepository(), SignInRepository {      override fun signIn(userSignIn: UserSignIn) = doRequest {         service.signIn(userSignIn.fromDomain()).toDomain()     } }

Но по хорошему нам нужно на уровне data обработать запись токена в локальное хранилище и не отправлять лишние данные на уровень presentation. В этом нам поможет функция doSomethingInSuccess.

class SignInRepositoryImpl @Inject constructor(     private val service: SignInApiService ) : BaseRepository(), SignInRepository {      override fun signIn(userSignIn: UserSignIn) = doRequest(this::setupSignInSuccess) {         service.signIn(userSignIn.fromDomain()).toDomain()     }      private fun setupSignInSuccess(signIn: SignIn) {         // save token         signIn.token     } }

С уровнем data мы закончили переходим к app который у нас будет отвечать за presentation слой и за dependency injection. Добавляем зависимости

dependencies {      implementation(project(":data"))     implementation(project(":domain"))      // Kotlin     implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1")      // UI Components     implementation("com.google.android.material:material:1.6.0")     implementation("androidx.constraintlayout:constraintlayout:2.1.3")     implementation("com.github.kirich1409:viewbindingpropertydelegate-noreflection:1.5.6")      // Core     implementation("androidx.core:core-ktx:1.7.0")      // Activity     implementation("androidx.activity:activity-ktx:1.4.0")      // Fragment     implementation("androidx.fragment:fragment-ktx:1.4.1")      // Lifecycle     implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1")     implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.4.1")      // Hilt     implementation("com.google.dagger:hilt-android:2.42")     kapt("com.google.dagger:hilt-compiler:2.42") }

Создаем DI модули в которых у нас будет инициализация нашего апи сервиса и репозиториев.

@Module @InstallIn(SingletonComponent::class) object NetworkModule {      @Singleton     @Provides     fun provideSignInApiService(         retrofitClient: RetrofitClient     ) = retrofitClient.provideSignInApiService() }  @Module @InstallIn(SingletonComponent::class) abstract class RepositoriesModule {      @Binds     abstract fun bindSignInRepository(signInRepositoryImpl: SignInRepositoryImpl): SignInRepository }

В дополнении добавим sealed класс который будет отвечать за состояние в слое UI. Он так и будет называться UIState.

sealed class UIState<T> {     class Idle<T> : UIState<T>()     class Loading<T> : UIState<T>()     class Error<T>(val error: String) : UIState<T>()     class Success<T>(val data: T) : UIState<T>() }

Но тут появляется логичный вопрос зачем нужен Idle. Покажу на примере. Кейс наш такой то что нужно сделать запрос по нажатию на кнопку и скрыть все view и отобразить loader. Есть StateFlow который должен иметь дефолтное значение и которым мы будем пользоваться при передаче данных с ViewModel в Fragment. У StateFlow будет дефолтное значение Loading, а в методе подписки будет стоять проверка если состояние Loading то нужно скрыть все view и показать loader. В результате когда мы открываем страницу уже все view скрыты и отображается loader. Поэтому используем Idle как дефолтное значение. Есть парочка других решений. Это использование SharedFlow либо LiveData.

После создаем базовые классы для ui, это BaseViewModel и BaseFragment.

abstract class BaseViewModel : ViewModel() {      /**      * Creates [MutableStateFlow] with [UIState] and the given initial state [UIState.Idle]      */     @Suppress("FunctionName")     protected fun <T> MutableUIStateFlow() = MutableStateFlow<UIState<T>>(UIState.Idle())      /**      * Collect network request and return [UIState] depending request result      */     protected fun <T, S> Flow<Either<String, 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))                 }             }         }     } }
  • MutableUIStateFlow() — будет отвечать за создание MutableStateFlow с оберткой generic’a сразу в UIState и дефолтным значением UIState.Idle, он нам понадобиться для создание переменной state’a запроса.

  • collectRequest() — функция расширения обрабатывает запрос и сетит данные в наш параметр state которая отвечает за состояние. Второй параметр mappedData это функция которая маппит данные с слоя domain в слой presentation. В нашем случае это не нужно, но бывают такие кейсы, например нам нужно добавить в нашу модельку что-то андроидовское например тот же Parcelable для передачи данных между fragment’ами тогда нам понадобиться моделька которая будет отвечать уже за UI слой.

abstract class BaseFragment<ViewModel : BaseViewModel, Binding : ViewBinding>(     @LayoutRes layoutId: Int ) : Fragment(layoutId) {      protected abstract val viewModel: ViewModel     protected abstract val binding: Binding      final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {         super.onViewCreated(view, savedInstanceState)          initialize()         setupListeners()         setupRequests()         setupSubscribers()     }      protected open fun initialize() {     }      protected open fun setupListeners() {     }      protected open fun setupRequests() {     }      protected open fun setupSubscribers() {     }      /**      * Collect flow safely with [repeatOnLifecycle] API      */     protected fun collectFlowSafely(         lifecycleState: Lifecycle.State,         collect: suspend () -> Unit     ) {         viewLifecycleOwner.lifecycleScope.launch {             viewLifecycleOwner.repeatOnLifecycle(lifecycleState) {                 collect()             }         }     }      /**      * 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)                 }             }         }     } } 

В классе BaseFragment у нас есть сразу методы для обработки определенных инициализаций, слушателей, запросов и подписок. И методы которые помогают обработать запрос на уровне fragment’a.

  • collectFlowSafely() — Отвечает за безопасный сбор flow с помощью repeateOnLifecycle API.

  • collectUIState() — Функция расширения для сбора state’а из ViewModel. Есть два обязательных параметра для обработки состояний при успешном ответе или ошибке. И опциональный параметр который принимает все состояния и позволяет их обработать в некоторых случаях может понадобиться, далее разберем как можно использовать этот параметр.

Перейдем как будет выглядить реализация запроса в общем в слое presentation.

@HiltViewModel class SignInViewModel @Inject constructor(     private val signInUseCase: SignInUseCase ) : BaseViewModel() {      private val _signInState = MutableUIStateFlow<SignIn>()     val signInState = _signInState.asStateFlow()      fun signIn(userSignIn: UserSignIn) {         signInUseCase(userSignIn).collectRequest(_signInState) { it }     }               // Если бы мы маппили бы данные выглядело бы все так     private val _signInState = MutableUIStateFlow<SignInUI>() // моделька UI     val signInState = _signInState.asStateFlow()          fun signIn(userSignIn: UserSignIn) {       signInUseCase(userSignIn).collectRequest(_signInState) { it.toUI } // добавили маппинг     } }

В методе collectRequest() есть недочет связанный с маппингом. Как мы видим в первой реализации если нам не нужен маппинг все равно приходиться писать лишний код который ничего не делает в будущем будет дорабатываться.

@AndroidEntryPoint class SignInFragment : BaseFragment<SignInViewModel, FragmentSignInBinding>(     R.layout.fragment_sign_in ) {      override val viewModel: SignInViewModel by viewModels()     override val binding by viewBinding(FragmentSignInBinding::bind)      override fun setupListeners() {         binding.buttonSignIn.setOnClickListener {             // Код для примера в реалии все знаем какие данные сюда вбивать )             viewModel.signIn(UserSignIn("Shield Hero", "Raphtalia"))         }     }      override fun setupSubscribers() {         viewModel.signInState.collectUIState(             onError = {                 // Отобразить ошибку                 Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()             },             onSuccess = {                 // Перейти на главную страницу                 findNavController().navigate()             }         )     } }

А теперь вернемся к параметру state в методе collectUIState() как мы можем его использовать. Так как нам нужно скрыть все view и отобразить loader при состоянии Loading делаем метод внутри BaseFragment’a:

/**  * Setup views visibility depending on [UIState] states.  * @param isNavigateWhenSuccess is responsible for displaying views depending on whether  * to navigate further or stay this Fragment  */ protected fun <T> UIState<T>.setupViewVisibility(     group: Group, loader: CircularProgressIndicator, isNavigateWhenSuccess: Boolean = false ) {     fun showLoader(isVisible: Boolean) {         group.isVisible = !isVisible         loader.isVisible = isVisible     }      when (this) {         is UIState.Idle -> {}         is UIState.Loading -> showLoader(true)         is UIState.Error -> showLoader(false)         is UIState.Success -> if (!isNavigateWhenSuccess) showLoader(false)     } }

setupViewVisibility() будет нам скрывать и показывать группы вьюшек и loader. Параметр isNavigateWhenSuccess отвечает за то что будет ли наш метод при состоянии Success переходить на следующую страницу если да он обратно view не покажет. Это сделано для того что когда у нас запрос приходит успешный, проходит доли секунды до того происходит переход на следующую страницу в этом промежутке времени вьюшки успевают показаться.

override fun setupSubscribers() {     viewModel.signInState.collectUIState(         state = {             // скрыть показать group и loader             it.setupViewVisibility(binding.groupSignIn, binding.loaderSignIn, true)         },         onError = {             // Отобразить ошибку             Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show()         },         onSuccess = {             // Перейти на главную страницу             navigate()         }     ) }

На этом все! Если есть предложения то отправляйте правки в репозиторий Boilerplate-Android. Ссылка на код в статье Boilerplate-Sample-Android.


ссылка на оригинал статьи https://habr.com/ru/post/667026/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *