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


Что такое чистая архитектура, зачем использовать? Сегодня я не отвечу вам на эти вопросы :) (для этого существуют много других хороших статей одна из них Заблуждения 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.




Комментарии (6):

  1. anegin
    /#24364704

    Зачем для разового запроса в doRequest возвращается flow (с единственным emit-ом)? почему бы не сделать просто suspend-функцию

    • TheAlisher
      /#24364740

      Думаю не совсем понял вопрос, но здесь код это просто общий пример как сделать запрос

      • anegin
        /#24365228

        Если запрос предполагает один ответ, то flow там избыточен

        suspend fun <T> doRequest(
        		...
        ): Either<String, T> = withContext(Dispatchers.IO) {
        		// ...
            // Either.Left() or Either.Right()
        }

        К тому же doSomethingInSuccess - это лишний side effect. Зачем возвращать результат (который игнорится) отдельно через функцию и через колбэк

        • TheAlisher
          /#24365396

          А почему результат игнориться вы внимательно если посмотрите там нужно сохранять определенные данные в SharedPreferences, думаю это не обоснованные придирки ). Насчет flow избытычен можно и без этого return'ить, но как уже добавлялось это самый простой кейс для того чтобы показать сами запросы ;)

  2. addewyd
    /#24365130 / +3

    Заглянул из-за картинки. А тут куча какого-то кода.