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