Clean architecture. Explanation & details

В этой статье мы рассмотрим пример реализации чистой архитектуры. На написание данной статьи меня побудил тот факт, что несмотря на то, что многие говорят, что знакомы с понятиями чистой архитектуры, прекрасно понимают принципы SOLID и другие, но все равно они норовят нарушить их, при всем при этом искреннее не понимают этого. Например диалоги между разработчиками такие — да, я знаю, что нужен слой бизнес логики, но зачем он мне, если в нем всего 3 строчки кода? Это всего лишь дублирование слоя репозитория. В данном случае проблема в том, что они реaлизуют совсем неправильно на мой взгляд этот самый слой бизнес логики и именно поэтому зачастую не видят в нем смысла и обращаются к слою данных прямо из слоя представления.

Итак, давайте рассмотрим самый общий случай — функцоинал входа в приложение с помощью логина и пароля. Мы будем двигаться от слоя представления в сторону слоя данных. Начнем, но я уточню, некоторые неважные моменты в этой статье будут опущены. Например такие, как реализация навигации, оставляю это на вкус разработчика. Так же я не буду рассматривать конкретную реализацию DI, оставляю это на усмотрение разработчика.

Первое что у нас будет, это конечно же макет фрагмента (предположим что у нас single activity project). В общем и целом в нем будет 4 вью

<FrameLayout>
    <LinearLayout
        android:id="@+id/contentLayout">

        <EditText
            android:id="@+id/loginEditText"/>

        <EditText
            android:id="@+id/passwordEditText"/>
        <Button
            android:id="@+id/loginButton"/>
    </LinearLayout>
   
    <ProgressBar
        android:id="@+id/progressBar" />
</FrameLayout>

Поле ввода для логина, поле ввода для пароля, кнопка логина и прогресс на весь экран.

Теперь, посмотрим как может выглядеть фрагмент нашего экрана.

class LoginFragment : BaseFragment<LoginViewModel>() {

    override fun getLayoutResId() = R.layout.fragment_login

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        loginButton.setOnClickListener {
            viewModel.login(
                loginEditText.text.toString(),
                 passwordEditText.text.toString()
            )
        }

        viewModel.stateLiveData.observe(viewLifecycleOwner, Observer {
            when (it) {
                is LoginState.Success -> {
                    Navigation.goToPostLogin()
                }
                is LoginState.Error -> {
                    val clickListener = DialogInterface.OnClickListener { dialog, _ >
                        dialog.dismiss()
                        viewModel.removeError()
                    }
                    AlertDialog.Builder(context)
                        .setMessage(it.message)
                        .setPositiveButton(android.R.string.ok, clickListener)
                        .setCancelable(false)
                        .show()
                }
                is LoginState.Progress -> {
                    progressBar.makeVisible()
                    contentLayout.makeGone()
                }
                is LoginState.Initial -> {
                    progressBar.makeGone()
                    contentLayout.makeVisible()
                }
            }
        })
    }
}

В принципе от фрагмента не много чего нужно, он должен обработать нажатие на кнопку, предоставить вьюмодели данные, которые ввел юзер, отобразить прогресс и скрыть контент и конечно же если все удачно завершилось — перейти на следующий экран. Если же что-то пошло не так, то мы просто отобразим ошибку. Все вполне просто. Но я все же поясню почему у нас 1 ливдата, которая отвечает за вью стейт нашего фрагмента. Дело в том, что например когда у нас отображается диалоговое окно, то после скрытия его необходимо вернуть вьюмодели информацию о том, что юзер его скрыл. Тогда мы вернем состояние ливдате на начальное, в котором у нас не отображается прогресс. Если же мы этого не сделаем, то у нас во-первых будет отображаться прогресс и во-вторых при пересоздании экрана будет отображаться диалоговое окно, которое юзер уже видел. Т.е. мы после нажатия на кнопку логина отображаем прогрес, если действие закончилось ошибкой, то отобразим диалоговое окно, а при нажатии на скрытие диалогового окна мы скроем прогресс и вернем видимость контенту, чтобы юзер имел возможность повторить попытку.

Ок. Теперь давайте взглянем на то, как может выглядеть сама вьюмодель (здесь мы используем вьюмодель от гугла, она инициализируется каким-то образом в базовом фрагменте, это не так важно).

class LoginViewModel(app: Application, private val interactor: LoginInteractor) : BaseViewModel(app) {

    var stateLiveData = MutableLiveData<LoginState>()

    fun removeError() {
        stateLiveData.value = LoginState.Initial
    }

    fun login(login: String, password: String) = viewModelScope.launch {
        stateLiveData.value = LoginState.Progress
        when (val result = interactor.login(email, password)) {
            LoginResult.Success -> {
                stateLiveData.postValue(LoginState.Success)
            }
            is LoginResult.Failed -> stateLiveData.postValue(
                LoginState.Error(result.message)
            )
        }
    }
}

sealed class LoginState {
    object Initial : LoginState()
    object Progress : LoginState()
    object Success : LoginState()
    data class Error(val message: String) : LoginState()
}

Итак, у нас 1 главный метод логина, что же он делает? Сначала отображает прогресс, после чего вызывает метод логина у интерактора и после чего, в зависимости от результата работы интерактора меняет состояние экрана. Здесь я использовал sealed class с 4 состояниями, где состояние ошибки имеет в себе текст. Ниже я поясню зачем сделал именно так. Давайте здесь немного остановимся на взаимодействии вьюмодели и интерактора.

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

Итак, наша вьюмодель спрашивает интерактор о том, чтобы произвести логин. И вьюмодель интересует всего 1 вещь, закончилась ли операция успехом или же провалом. От этого у нас и 2 состояния — Success & Failed. Здесь конечно же возникнет вопрос, а почему текст ошибки приходит от интерактора. Чуть позже взглянем на интерактор и будет понятно, почему текст ошибки приходит от него. Здесь конечно же кто-то может сказать, что интерактор должен просто дать некую константу как причину, почему не удалась операция, и только потом мапить ее результат к строковым ресурсам. Но мы в данном случае будем упрощать этот момент и оставим принятие окончательного решения на читателя. А я в свою очередь обьясню почему в данном случае решил вопрос именно пробросом из интерактора текста ошибки.

Итак, взглянем на интерактор.

class LoginInteractorImpl(private val repository: LoginRepository, 
                          private val resourceManager: ResourceManager) : LoginInteractor {

    suspend fun login(login: String, password: String) =
         try {
            if (repository.login(email, password))
                LoginResult.Success
            else
                throw ServerUnavailableException()
        } catch (e: Exception) {
            when (e) {
                is NoNetworkException -> LoginResult.Failed(resourceManager.getString(R.string.no_connection_message))
                is ServerError -> LoginResult.Failed(e.serverMessage)
                is ServerUnavailableException -> LoginResult.Failed(resourceManager.getString(R.string.service_unavailable_message))
                else -> LoginResult.Failed(resourceManager.getString(R.string.generic_error_message))
            }
        }
}

sealed class LoginResult {
    object Success : LoginResult()
    data class Failed(val message: String) : LoginResult()
}

Итак, чем же должен заниматься интерактор? Это очень холиварный вопрос, который задается в чатах андроид и особенно в архитектурном. Итак, вот вам мое видение и моя интерпретация. Единственная обязанность интерактора это получить данные от репозитория и обработать все возможные исходы. В нашем случае, как мы видим, наш репозиторий может сказать, что операция завершилась успешно или же провалиась. Так же наш репозиторий может нам сообщить, что нет соединения с интернетом или же на сервере произошла ошибка (здесь мы предполагаем, что наш сервер имеет возможность обьяснить причину, отправив нам сообщение в ответе и это является той самой причиной, по которой мы отдаем из интерактора сообщение в текстовом виде, так же мапим константы из строковых ресурсов. Иначе бы нам пришлось усложнить состояние ошибки, отправляя внутри или интовый айди строки или же саму строку. Но мы хотим простоты и поэтому даем на вход интерактору ресурс манагер, который на самом деле не хранит ссылку на контекст, а всего лишь на Resources). Итак, как мы видим, в зависимости от исхода операции мы будем отдавать вьюмодели разные ответы.

Теперь, давайте взглянем на наш репозиторий. Его задача получение данных откуда бы ни было и так же обработка этих данных, в том плане, что например наш сервер после логина может вернуть некий токен, который необходимо сохранить локально на устройстве для последующих логинов. Итак, взглянем на код.

class LoginRepositoryImpl(
    private val prefManager: PrefManager,
    private val loginCloudDataStore: LoginCloudDataStore
) : LoginRepository {
    
    suspend fun login(login: String, password: String): Boolean {
        val token = loginCloudDataStore.login(login, password)
        val isTokenValid = !token.isNullOrEmpty()
        if (isTokenValid) 
            prefManager.saveToken(token)
        return isTokenValid
    }
}

Как ни странно, но репозиторий прост до безобразия (но это пока). В нашем случае, мы каждый раз идем в сеть чтобы залогиниться, но если например у нас будет больше логики, предположим будет не 1 облачный дата стор, а еще и локальный кеш, то будет еще и логика выбора дата сорса, хотя для этого можно написать датасорсфактори. Суть репозитория именно в этом, получить данные, их как-то обработать, с ними что-то сделать и передать на следующий уровень то, что нужно интерактору. Интерактору нужно знать только 1 вещь, завершился ли процесс успехом или ошибкой, если ошибкой, то какой. Далее мы рассмотрим источник данных и увидим, что он может бросать исключения, которые обработает наш интерактор. Здесь есть тоже холиварный вопрос о том, хорошo ли использовать метод выброса исключения или же использовать так называемую пару, успех и провал. Это дело каждого разработчика, я остановился на данном этапе на варианте с выбросом исключения, потому что мне кажется это более естественным для языка и более безопасно. Кстати, здесь могла бы быть еще одна сущность, под названием ТокенВалидатор, если предположим наша валидация отличается чем-то от примитивного сравнения строки с пустотой.

Итак, рассмотрим облачный дата сорс для логина.

class LoginCloudDataStore(private val service: LoginService) {

    suspend fun login(login: String, password: String): String? {
        if (Repository.isNetworkAbsent())
            throw NoNetworkException()
        try {
            val result = service.loginAsync(email, password).await()
            if (result.status.equals(STATUS_OK))
                return result.token
            if (!result.message.isNullOrEmpty())
                throw ServerError(result.message)
            throw ServerUnavailableException()
        } catch (e: Exception) {
            if (e is ServerError)
                throw e
            else
                throw ServerUnavailableException()
        }
    }
}

Итак, здесь, исходя из конфигурации нашего сервера мы делаем следующее. Сначала проверяем наличие сети, если его нет, бросаем исключение, которое удобочитаемо для слоя бизнес логики. Дальше мы пробуем получить данные у сервиса. Наш сервер нам говорит, что он отправит нам ответ со статусом ОК и там будет токен. Иначе же он может ответить неким собщением с другим статусом (например эррор). Тогда мы должны обработать и этот вариант. Здесь суть и отличие от интерактора в том, что мы на уровне сети обрабатываем нужные исходы. Интерактор не должен знать ничего о структуре нашего ответа от сервера. Ему не нужно знать о том, что сервер отдал ошибку 502 или 404 или что там еще. Это все должен обработать наш облачный источник данных и отдать нужный результат интерактору через репозиторий. Здесь мы используем котлин-корутины, поэтому и везде пишем suspend.

Думаю дальше все понятно. Сервис может выглядеть так.

interface LoginService {

    @Headers("SOME HEADERS HERE")
    @POST("/login")
    @FormUrlEncoded
    fun loginAsync(
        @Field("login") login:String,
        @Field("password") password:String
    ) : Deferred<LoginResponse>
}

data class LoginResponse(
    @SerializedName("status") val responseStatus: String,
    @SerializedName("message") val serverMessage: String?,
    @SerializedName("token") val loginToken: String?
)

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

Итак, давайте еще раз посмотрим на всю цепочку. Юзер вводит логин и пароль и жмет на кнопку логина. Вьюмодель отображает прогресс на весь экран и дергает метод логина у интерактора, тот в свою очередь дергает метод у репозитория, который обращается к облачному источнику данных, который дергает метод сервера. Дальше, если есть успешный ответ от сервера, то мы отдаем из источника данных токен репозиторию, там его проверяем на валидность и сохраняем, и передаем флаг успеха в интерактор, который отдаст его вьюмодели. Исходя из этого вьюмодель уберет прогресс и перейдет дальше по навигации.

Если же не было интернета например или произошла ошибка сервера, то источник данных бросит исключение, в репозитории конечно же никаких проверок валидности токена не будет, исключение дойдет до интеракора, который поймет какого рода была ошибка и отдаст вьюмодели нужное сообщение и флаг о провале операции. Тогда вьюмодель отобразит сообщение об ошибке. А юзер нажмет на скрытие диалога и вернет видимость контенту чтобы повторить попытку позже.

Если же кому-то покажется, что наша вьюмодель могла бы делать что-то еще кроме как обращаться к интеркатору и обрабатывать ее результаты, то отвечу вам — да, вы правы. Так же в роль вьюмодели может входить первичная обработка данных от юзера. Например у вас может быть задача на клиенте зашить проверку данных от юзера. Тогда вы напишите такие классы как LoginUiValidator или PasswordUiValidator. Т.е. перед тем как дергать метод интерактора вы убедитесь в валидности введенных данных и не будете например посылать на сервер пустые строки или скажем если у вас логин это эл.почта, то вы сразу проверите что она содержит @ и точку.

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

Еще одна причина соблюдать принципы SOLID так это то, что ваш интерактор может переиспользоавться в других вьюмоделях. Сложно представить такое на примере логина, но давайте например подумаем о ситуации, когда у вас например возникает скидка на товары в приложении по получению пуша и эту скидку нужно как-то отобразить на разных экранах. Вы же не будете тогда копипастить тонны кода из репозитория во все вьюмодели? Нет конечно. Вы просто дерните в нужных вьюмоделях метод интерактора для проверки наличия скидки и тогда отобразите где нужно некую кнопку или картинку. А вся подкапотная будет в интеракторе, так же как и вся подкапотная данных в репозитории.

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

Опять же вернемся к вопросу о том, что мы в итоге пишем много классов на 10 строчек, когда можно просто все написать во вьюмодели и все. Согласен, можно так сделать, но это ровно такое же как и в случае когда в 2013 году все писали год обьект активити на 4000 строк. То же самое происходит и сейчас, только в чуть ином масштабе. Не нужно делать огромные файлы. Никто не любит читать много кода. Все ваши классы должны быть просты и понятны для читающего с первого раза и безовсяких джавадоков. И как мне кажется в этой статье мы этого достигли.

А теперь давайте я попробую привести пример из реальной жизни. Например есть семья. В которой есть бабушка(аналогия с вьюмоделью) и она говорит, что у нее разболелась голова. Бабушке абсолютно не интересно как лечиться. Для этого у нее есть дочь (аналог интеркатора). Она уже сама решит что нужно предпринять. Итак, первым делом она попросит сына(аналог репозитория) посмотреть в аптечке есть ли нужная таблетка от головы (аналогия с локальным источником данных). Если же сын скажет ей, что в аптечке нет ничего нужного, тогда она пошлет его в аптеку за нужным лекарством. Матери абсолютно неважно откуда ее сын принесет лекарство (аналогия с сервисом). Ей нужен результат. Результатом же будет или успех — сын купил лекарство или же ошибка — не было ни в одной аптеке в округе лекарства. Тогда мать в свою очередь если сын вернул лекарство перемешает с водой и отдаст бабушке (аналог мапинга данных). Или же если она не могла найти лекарство из одного репозитория (сын), тогда она может обратиться к другому репозиторию (позвонить мужу). И ровно так же наш интерактор — мать, может переиспользоваться и для другой вьюмодели, например в случае с дедушкой.

Надеюсь этот пример был хорошей анлаогией с чистой архитектурой. Всем удачи в разработке! Пишите простой, короткий и понятный код.

Запись опубликована в рубрике Программирование Java. Добавьте в закладки постоянную ссылку.