
Что такое чистая архитектура, зачем использовать? Сегодня я не отвечу вам на эти вопросы :) (для этого существуют много других хороших статей одна из них Заблуждения 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с помощьюrepeateOnLifecycleAPI.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.
Обновление 25 мая 2022: Фикс недочета с маппингом в BaseViewModel.
Изначально думал реализовать функцию так что параметр mappedData будет опциональным и через elvis оператор проверять если он null то берем значение и кастим (вкратце упился в какие-то дебри). Решение оказалось легче просто создал метод без маппинга. Обновление так же можете посмотреть в репозитории Boilerplate-Android.
/** * Collect network request and return [UIState] depending request result */ protected fun <T> Flow<Either<String, 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 and return [UIState] depending request result * with mapping model from domain to ui */ 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)) } } } }
Обновление 30 мая 2022: Рефакторинг метода doRequest
В реализации функции doRequest по подсказке комментатора @xISRAPILx произвел рефакторинг. Убрал лишний параметр doSomethingInSuccess, и ещё можно вывести функцию как internal top level function. Нынешняя реализация выглядит таким образом:
protected fun <T> doRequest(request: suspend () -> T) = flow<Either<String, T>> { emit(Either.Right(value = request())) }.flowOn(Dispatchers.IO).catch { exception -> emit(Either.Left(value = exception.localizedMessage ?: "Error Occurred!")) } // Старая реализация 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) { // Сохраняем токен signIn.token } } // Новая реализация class SignInRepositoryImpl @Inject constructor( private val service: SignInApiService ) : BaseRepository(), SignInRepository { override fun signIn(userSignIn: UserSignIn) = doRequest { service.signIn(userSignIn.fromDomain()).also { // Сохраняем токен signIn.token }.toDomain() } }
