Как стать автором
Обновить

Разбираем чистую архитектуру в Android: от а до я

Уровень сложностиСредний
Время на прочтение12 мин
Количество просмотров2.8K

Дорогой читатель! Если ты оказался тут, то скорее всего ты столкнулся с той же проблемой, что и я: 

ты занимаешься Android разработкой и все вокруг говорят о какой-то «чистой архитектуре» (она же - Clean Architecture), указывают её в требованиях к вакансиям, требуют на практиках в вузе и т.д.

Однако информацию о данном подходе приходится собирать по кусочкам. Так что в данной статье я Даниил Закляков, разработчик WMT Group попробую на своем опыте подробно, доступным языком объяснить с чего начинать и как эту архитектуру строить.

Зачем всё это?

В процессе изучения чистой архитектуры может показаться, что это - чепуха, выгода от которой не оправдывает время, которое затрачивается на её создание. Но давайте просто посмотрим на два проекта: один без архитектуры, другой с архитектурой.

Проект без чистой архитектуры
Проект без чистой архитектуры

Можете сразу сказать, что тут происходит? Скорее всего, ваши мысли сейчас звучат так: «Ага, есть какой-то ui, наверное от отвечает за отображение.. Стоп, тут есть ещё и frontend, чем они отличаются? И почему MainActivity есть и во frontend и в корне... Ладно, вроде бы в ui есть куча фрагментов, они скорее всего как-то связаны с Activity во frontend, и есть ещё какой-то backend, оно всё связано с ним...» Но дальше особо не заходит, не видна логика связей, не понятно что за что отвечает, а самое главное — зависимости. Скорее всего если в этом проекте минимально поменять что-то важное, то полетит весь проект. А теперь посмотрим на проект с чистой архитектурой.

Проект с чистой архитектурой
Проект с чистой архитектурой

Да, тут не уместился весь проект (очень много папок, не влезает), но тут сразу видна структура: репозиторий, главный экран, модули, ниже (не уместилось опять таки) модели, юзкейсы (всё это будет рассмотрено чуть позже!). За доработку можно приниматься в тот же момент как только открыли проект! А ещё можно подменить один репозиторий на другой (например, подставить репозиторий с моковыми данными для теста) и ничего не поломается. Круто же!

Общие принципы

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

Это достигается следующим способом:

  • Приложение разбивается на блоки, которые в свою очередь предоставляют только те интерфейсы, которые необходимы другим блокам для взаимодействия. Другими словами, если у вас в компьютере нет USB-порта, то вы не сможете его использовать. Также и блоки, один блок может использовать только те части из другого блока, к которым последний предоставил доступ. Таким образом можно минимизировать количество зависимостей и снизить риск того, что при изменении одного участка кода придется менять весь остальной проект.

  • Каждый блок выполняет свою конкретную функцию, таким образом если в другом проекте вам понадобится схожая логика, её не придется прописывать заново, можно просто "выдернуть" нужный модуль из одного проекта и вставить в другой

  • Приложение разбито на блоки, то их можно тестировать по-отдельности. Независимые модули тестируются как есть, можно написать unit-тесты, например. Если же блок зависит от других, то можно сделать «моки» других блоков и тестировать этот модуль, не задействуя остальные.

На какие же блоки следует разбивать наше мобильное приложение?

Лично я разбиваю на 3 модуля: app, domain и data, однако есть и другие способы. Например, в каких-то компаниях используют модули-фичи, то есть под каждую новую фичу, будь то авторизация, загрузка данных, сохранение данных, отправка запроса и т.д. пишется отдельный модуль. Но лично я привык уже к своим трем модулям, тем более что для ознакомления чем проще, тем лучше, поэтому остановимся на этом варианте.

Разберем эти модули по-отдельности:

  • Модуль App содержит в себе всё самое главное о приложении: манифест, класс приложения, активности, настройки dependency injection, UI-элементы. То есть то, без чего приложение не запустится вообще. 

  • Модуль Domain отвечает за доменную логику, то есть логику обработки информации, и является связующим звеном между модулем App и Data. Здесь у нас будут юзкейсы (о них - позже) и модели, которыми будут оперировать Data и App.

  • Модуль Data работает с сохранением и получением данных из любых источников: локальная база данных, сеть, кеширование, Shared Preferences - это всё его область деятельности

Окей, а что с зависимостями? Тут слегка сложнее, но дальше будет понятно, почему именно так: модуль App должен зависеть от Domain и Data, Data будет зависеть от Domain, а Domain - независимый модуль.

Итого, получаем следующую структуру:

Диаграмма архитектуры
Диаграмма архитектуры

Исходящая стрелка тут показывает, что от данного элемента или блока зависит другой элемент или блок. Таким образом мы можем, например, заменить блок Data другим блоком, и если он будет реализовывать те же интерфейсы, что и в Domain, то приложение не поломается. Но... Стоп, что за интерфейсы и имплементации тут появились? Об этом чуть позже) А пока, раз уж мы разобрались с основами, предлагаю перейти к реализации.

Реализация

Создание модулей

Итак, начнем с идеи. Немного побреинштормив и почитав каналы в телеграмм я понял, что я устал от некрасивой темы, которая установлена у меня сейчас. Где искать красивые темы я так и не понял, поэтому придумал сделать приложение-магазин тем в Telegram. Идея несложная, как раз подходит под формат статьи. 

Будем использовать следующий стек технологий: 

  • Jetpack Compose для отрисовки интерфейса (потому что это модно, круто и удобно)

  • Koin для dependency injection (потому что он мне нравится)

  • Паттерн MVI (потому что он лучше всего подходит для Compose)

  • Coroutines и Flow для многопоточности и хранения состояния

Вот мы и создали проект в Android Studio. Сразу скажу, что минимальную версию Android я выбрал 11.0, потому что она содержит в себе последние мажорные изменения во фреймворке. Что дальше? Можно было бы сразу начать разработку, создать внутри App модули Data и Domain (так делают многие на начальных этапах), но это плохая идея так как позволяет вам случайно настроить зависимости неправильно, о чем вы сильно пожалеете позже. Чтобы избежать этого можно использовать модули: это отдельные части приложения, не зависящие друг от друга. Это как отдельные комнаты в доме. Вся мебель распределяется между кухней, ванной и спальней, а не «захламляет» зал. Каждый модуль содержит в себе только те зависимости, которые ему необходимы, и выполняет только ту функцию, за которую он отвечает.

Поэтому создадим два модуля: переключаемся в окне проекта на формат отображения Project (по умолчанию он Android), жмем ПКМ по корневому каталогу проекта > New > Module

Добавление модуля
Добавление модуля

Для создания Domain выбираем Java or Kotlin library (так как доменная логика отвечает только за обработку данных, ей не нужно иметь доступа к фичам из андроида), меняем название библиотеки на domain, название класса можно не менять, мы его всё равно удалим. Получаем следующую картину.

Создание модуля domain
Создание модуля domain

Для создания модуля data делаем то же самое, но выбираем Android library, так как он может работать с сетью и другими источниками данных, привязанных к Android.

Создание блока data
Создание блока data

Вот теперь у нас есть отдельные модули, зависимостями которых мы можем управлять. Давайте, кстати, этим и займемся. Начнем с App. Он у нас зависит от data и domain, это надо прописать в build.gradle модуля app. Заходим в модуль app, находим там build.gradle и дополняем его блок dependencies следующими строками:

dependencies {
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.lifecycle.runtime.ktx)
    implementation(libs.androidx.activity.compose)
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.ui)
    implementation(libs.androidx.ui.graphics)
    implementation(libs.androidx.ui.tooling.preview)
    implementation(libs.androidx.material3)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)
    androidTestImplementation(platform(libs.androidx.compose.bom))
    androidTestImplementation(libs.androidx.ui.test.junit4)
    debugImplementation(libs.androidx.ui.tooling)
    debugImplementation(libs.androidx.ui.test.manifest)

    implementation(project(":data"))  // Подключаем дату
    implementation(project(":domain"))  // и домен

}

Теперь data. Этот модуль зависит только от домена. Поэтому build.gradle в модуле data будет дополнен следующим образом:

dependencies {

    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.appcompat)
    implementation(libs.material)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)

    implementation(project(":domain")) // Подключаем домен
}

А domain? А он не зависит ни от чего, так что его мы пока не трогаем

Ну вот, с модулями и зависимостями разобрались. Поехали разрабатывать!

Модуль domain

А, думали начнем с экранчиков? А вот и нет! В первую очередь надо разобраться с тем, что будем отображать. Это как начать строить дом и не решить ни какой он будет формы, ни из чего сделан, ни где будут окна или двери. В первую очередь нам надо наметить, какие данные и как мы будем получать. Для демонстрации пойдет самая простая модель - ThemeModel, которая будет содержать всего два поля - заголовок и описание. Эту модель положим в модуль domain.

package ru.logosph.domain

data class ThemeModel(
    val title: String,
    val description: String
)

Итак, у нас есть модель, теперь опишем юзкейс. Юзкейс (от англ. Use case - вариант использования) - это одна конкретная функция, которую выполняет приложение. Таких функций может быть много. Например, авторизация, регистрация, приостановка комментария, приостановка лайка, удаление лайка, выход из аккаунта, загрузка ленты новостей. Эти юзкейсы мы будем помещать в domain и вызывать их из App, таким образом мы сможем контролировать, какой экран что сможет делать. Итак, вашему вниманию - его величество LoadThemesUseCase.

package ru.logosph.domain
class LoadThemesUseCase {
    suspend fun execute(): List<ThemeModel> {
        return emptyList()
    }
}

Так, у нас есть юзкейс, но он ничего не делает. Помним, что у нас за работу с сетью и вообще получением данных отвечают репозитории в data. Тогда наш юзкейс должен вызывать метод из репозитория и возвращать полученный результат. Только вот есть небольшая проблемка: репозиторий лежит в data, а domain не зависит от data. Тогда как же domain сможет вызывать методы из репозитория, не имея доступа к нему? Тут в ход идет небольшой трюк: мы создадим в domain интерфейс репозитория, в котором опишем, какие методы мы хотим видеть в репозитории, а в data будем хранить реализации интерфейсов. Тогда в конструкторе класса юзкейса укажем, что он принимает экземпляр интерфейса, и при создании объекта юзкейса будем передавать ему конкретную реализацию. На словах может быть сложно, понимаю, давайте сразу к практике. Вот код интерфейса:

package ru.logosph.domain

interface ThemesRepository {

    suspend fun loadThemes(): List<ThemeModel>

}

А вот обновленный юзкейс:

package ru.logosph.domain

class LoadThemesUseCase(
    private val themesRepository: ThemesRepository
) {
    suspend fun execute(): List<ThemeModel> {
        return themesRepository.loadThemes()
    }
}

Смотрите, никакой зависимости от data. Просто будем подсовывать сюда конкретную реализацию при создании класса, а реализации будем хранить в data. Чистенько!

Модуль data

Обещаю, тут быстро! В дате нам надо только реализовать необходимые интерфейсы из domain и по надобности определить клиенты (например, Retrofit 2 или Room ORM). Для упрощения я просто создам класс с моковыми данными - то есть заранее подготовленными, чтобы не грузить статью ответвлениями в сторону конкретных фреймворков. Реализуем наш созданный ранее интерфейс:

package ru.logosph.data

import ru.logosph.domain.ThemeModel
import ru.logosph.domain.ThemesRepository

class ThemesRepositoryImpl : ThemesRepository {
    override suspend fun loadThemes(): List<ThemeModel> {
        return listOf(
            ThemeModel("Светлая", "Просто светлая тема"),
            ThemeModel("Темная", "Просто темная тема"),
            ThemeModel("Кораблики", "Для любителей моря"),
            ThemeModel("Космос", "Для любителей космоса"),
            ThemeModel("Сияние", "Тем, у кого крепкие нервы"),
            ThemeModel("Классика", "Для ценителей классики"),
            ThemeModel("Минимализм", "Для ценителей минимализма"),
        )
    }
}

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

Модуль App

Ну что, самое интересное? Попробуем всё это отобразить! Я тут написал экранчик, давайте разберем его.

Функция, отображающая экранчик:

@Composable
fun MainScreen(
    paddingValues: PaddingValues,
    viewModel: MainScreenViewModel = koinViewModel()
) {

    val state = viewModel.state.collectAsState()

    when (val state = state.value) {
        is MainScreenStates.IdleState -> {
            viewModel.obtainEvent(MainScreenEvents.LoadThemes)
        }
        is MainScreenStates.ErrorState -> {
            Text(state.message)
        }
        is MainScreenStates.MainState -> {
            MainState(paddingValues, state)
        }
        is MainScreenStates.LoadingState -> {
            CircularProgressIndicator()
        }
    }
}

Итак, тут всё тривиально: функция принимает вьюмодель и далее, так как у меня тут всё сделано по технологии MVI, достает состояние из неё и обрабатывает, выводя ту или иную информацию в зависимости от того, что сейчас происходит. Функция MainState просто отрисовывает список тем, если интересна реализация - можете посмотреть на GitHub (ссылка ниже). Про koinViewModel() - позднее)

Теперь вьюмодель. Она принимает юзкейс, хранит в себе стейт и имеет функцию, принимающую ивент. При получении запроса на загрузку тем, запускает юзкейс и сохраняет результат в переменную состояния.

    private val loadThemesUseCase: LoadThemesUseCase
) : ViewModel() {

    private val _state = MutableStateFlow<MainScreenStates>(MainScreenStates.IdleState)
    val state: StateFlow<MainScreenStates>
        get() = _state


    fun obtainEvent(event: MainScreenEvents) {
        when (event) {
            MainScreenEvents.LoadThemes -> loadThemes()
        }
    }

    private fun loadThemes() {
        viewModelScope.launch {
            withContext(Dispatchers.IO) {
                _state.value = MainScreenStates.LoadingState
                val result = loadThemesUseCase.execute()
                _state.value = MainScreenStates.MainState(result)
            }
        }
    }
}

Для полноты повествования: классы MainScreenStates и MainScreenEvents. Тут всё и так понятно.

package ru.logosph.telegramthemes.ui.main_screen

import ru.logosph.domain.ThemeModel

sealed class MainScreenStates {
    data object IdleState : MainScreenStates()
    data object LoadingState : MainScreenStates()
    data class ErrorState(val message: String) : MainScreenStates()
    data class MainState(val data: List<ThemeModel>) : MainScreenStates()
}

sealed class MainScreenEvents {
    data object LoadThemes : MainScreenEvents()
}

Внедрение зависимостей, или Dependency Injection, или DI

Заметили, какая каша творится с зависимостями? Вью зависит от вьюмодели, вьюмодель от юзкейса, юзкейс от репозитория, и это ещё простой случай! Надо бы как-то этим всем управлять. Для этого существуют фреймворки для внедрения зависимостей, такие как Dagger, Hilt, Koin, Kodein. Лично мне больше нравится Koin, он заточен под Kotlin и сделан на основе DSL, а не аннотаций, как Dagger и Hilt, а kodein я ещё не пробовал (я про фреймворк!), ничего про него сказать не могу) Так что будем настраивать всё через Koin. 

Давайте вообще разберемся, что такое DI. Вот есть у нас какая-то куча зависимостей, где сам черт ногу сломит пока разберется во всем этом. Хочется как-то автоматизировать внедрение зависимостей, чтобы не думать самому, в каком месте должны создаваться те или иные классы, а чтобы всё автоматически создавалось и подставлялось. Тут так и получается. Koin сам создаст за нас все экземпляры классов и подставит их в другие классы, выдав нам уже готовую вьюмодель без головной боли. Для этого надо разметить модули и в каждом модуле указать, какие классы ему принадлежат. Вот как это можно сделать в нашем проекте:

package ru.logosph.telegramthemes.ui

import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
import ru.logosph.data.ThemesRepositoryImpl
import ru.logosph.domain.LoadThemesUseCase
import ru.logosph.domain.ThemesRepository
import ru.logosph.telegramthemes.ui.main_screen.MainScreenViewModel

val appModule = module { 
    viewModel<MainScreenViewModel>{ MainScreenViewModel(get()) }
}

val domainModule = module { 
    factory<LoadThemesUseCase>{ LoadThemesUseCase(get()) }
}

val dataModule = module { 
    single<ThemesRepository>{ ThemesRepositoryImpl() }
}

Вот у нас есть три модуля, прямо как в проекте. Модуль даты содержит в себе реализацию репозитория (причем обратите внимание, тип данных в треугольных скобках указан как у интерфейса в domain! Это важно, чтобы Koin понял, что вот тут хранится реализация ThemesRepository). 

domainModule содержит в себе юзкейс. Помните, он у нас зависит от ThemesRepository? Вот функция get() бегает по всем модулям и ищет необходимую реализацию, подставляя на место себя экземпляр из dataModule. Таким образом мы просто говорим, что Koin может найти юзкейс вот тут, и что он принимает что-то из других модулей или может из этого же. Он сам разберется откуда взять реализацию, нам не надо об этом думать. 

Аналогично в appModule, создается вьюмодель, которая сама ищет нужные ей зависимости. А теперь самый интересный момент: из-за того что я пометил MainScreenViewModel в модуле appModule как viewModel, я могу получить необходимую вьюмодель во вью при помощи одной строчки: viewModel: MainScreenViewModel = koinViewModel(), смотрите на три листинга выше. Вот такая магия, компилятор сам поймет что и где мы хотели получить. Осталось по мелочи - прописать все модули в классе Application. 

package ru.logosph.telegramthemes

import android.app.Application
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
import ru.logosph.telegramthemes.ui.appModule
import ru.logosph.telegramthemes.ui.dataModule
import ru.logosph.telegramthemes.ui.domainModule

class App : Application() {

    override fun onCreate() {
        super.onCreate()

        startKoin {
            androidContext(this@App)
            modules(
                appModule,
                dataModule,
                domainModule
            )
        }

    }

}

Можно запускать! Результат работы:

Рабочее приложение
Рабочее приложение

Заключение

Итак, мы написали простенькое приложение на Android, соблюдая все приниципы чистой архитектуры и подключили фреймворк для внедрения зависимостей.

Далее можно с проектом делать всё что угодно:

  • написать сервер

  • заменить моковый репозиторий на реальный, который будет получать данные по API

  • добавить новые экраны

  • позволить пользователям скачивать темы или прикреплять ссылки на них

  • дать пользователям возможность добавлять свои темы в магазин

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

Теги:
Хабы:
+4
Комментарии8

Публикации

Истории

Работа

Ближайшие события

4 – 5 апреля
Геймтон «DatsCity»
Онлайн
8 апреля
Конференция TEAMLY WORK MANAGEMENT 2025
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань
20 – 22 июня
Летняя айти-тусовка Summer Merge
Ульяновская область