Просто об архитектуре в Android
В современном мире разработки выбор подходящей архитектуры — сложная задача. Все разработчики стремятся к тому, чтобы их код был чистым, поддерживаемым и масштабируемым.
В нашем скромном мире разработки Android есть общепринятый подход к проектированию приложений — Clean Architecture, который рекомендуется Google. Несмотря на множество статей, посвященных этому стандарту, вопросы и споры вокруг того, как «правильно готовить» Clean Architecture, остаются актуальными.
Меня зовут Артем, я Android developer BSL. В данной статье я рассмотрю один из возможных путей — простота и гармоничность на основе Clean Architecture. Важно понимать, что это всего лишь один из вариантов, который основан на моем личном видении. В мире разнообразных подходов не существует идеала, и, возможно, именно в этом заключается привлекательность процесса разработки — в бесконечных спорах и поиске оптимального решения.
Почему именно Clean Architecture
При проектировании архитектуры следует обращать внимание на тип вашего приложения, объем планируемой бизнес-логики в нем или наоборот её отсутствие. Немаловажными факторами являются отведенные сроки, размер команды и перспектива интеграции в нее новых членов.
Вне зависимости от специфики вашего проекта, Clean Architecture становится превосходным выбором. Вот почему:
Разделение ответственности (масштабируемость, минимизация проблем с зависимостями)
Тестируемость
Устойчивость к изменениям
Популярность (большое кол-во гайдлайнов, шаблонов)
Основополагающие принципы
Рассмотрим самые базовые принципы Clean Architecture, за счет которых она таковой и является:
Единственная ответственность
Каждый слой (модуль), класс или функция выполняет только одну задачу. Данный принцип призван конкретизировать ответственность отдельных по смыслу классов или слоев, что в свою очередь придает ясность в код и снижает его связанность.
Разделение на слои
Приложение должно быть разбито на слои, у каждого слоя своя зона ответственности. Обычно выделяют следующие уровни: presentation, domain, data.
Presentation: отвечает за отображение пользовательского интерфейса и реагирование на его события
Domain: бизнес-логика, изолированная от деталей реализации, определяет правила и операции, как приложение должно взаимодействовать с данными
Data: хранилище данных
Схематичное представление слоев и их взаимодействия
Инверсия зависимостей
Один из важнейших принципов который гласит о том, что стоит использовать общий контракт, такой как интерфейс или абстрактный класс, вместо прямой зависимости слоя верхнего уровня от компонентов слоя нижнего. Таким образом, каждый слой использует этот контракт, что обеспечивает изоляцию изменений в верхнем слое.
В Clean Architecture центральным слоем является domain, тогда можно схематично представить подобную связь зависимостей:
Проектируем
Начнем практическую часть, в которой рассмотрим каждый слой и его особенности на примере мини-приложения ленты с «котами», используя Cat API.
Будем использовать многомодульный подход по структуре слоев в Clean Architecture, добавив опциональный модуль common.
P.S: buildSrc рассматриваться не будет, это модуль для централизации зависимостей и определения тасок для gradle.
Так как инверсия зависимости является архитектурным принципом, для его реализации будем использовать практическую реализацию (Hilt).
Domain слой
Содержит бизнес-логику, должен быть независим от деталей реализации приложения и внешних библиотек (можно делать исключения, пример RxJava, DI Framework). Это делает его высокоуровневым слоем.
Исходя из описания, определим две зависимости для модуля domain: зависимость на Hilt и опциональный модуль common.
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
kotlin("kapt")
}
android {
namespace = "com.bsl.domain"
}
dependencies {
implementation(project(":common"))
// DI
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
}
domain: build.gradle.kts
Определимся с основными компонентами:
Entity: представляет объекты данных, с которыми работает бизнес-логика
Interactor или UseCase: содержитфункциональность или операции
Repository: обеспечиваетконтракт для доступа к данным
Теперь, соответствуя нашей задаче, реализуем эти компоненты, определим бизнес-логику для получения списка котов.
В начале создадим сущность, определим класс CatModel и объявим в нем следующие поля: идентификатор, ссылка на изображение и наличие лайка.
package com.bsl.domain.cat.model
data class CatModel(
val id: String,
val imageUrl: String,
val isLiked: Boolean,
)
Использование постфикса Model. Добавляем его к нашим сущностям (Entity). В принципе это не обязательно, и мы можем вместо этого, к примеру, использовать CatEntity или просто Cat.
Приступим к созданию контракта для получения данных. Определим CatRepository с методом получения всех котов и отметки лайка для определенного кота.
package com.bsl.domain.cat.repository
import com.bsl.domain.cat.model.CatModel
interface CatRepository {
suspend fun getCats(limit: Int): List
suspend fun setLike(value: Boolean, id: String)
}
Обычно в Clean Architecture отдельный репозиторий предоставляет чтение или запись данных в рамках определенной сущности и именуется соответственно ей.
Теперь мы готовы описать саму логику, она будет достаточно простая:
Вытащить данные
Поставить лайк и наоборот
Но сначала следует разобраться, что стоит использовать, Interactor или UseCase?
По сути различие в том, как мы хотим разделить наши бизнес-операции.
На этом вся работа в domain модуле выполнена и мы сюда больше не вернемся. По итогу у нас получилась такая структура модуля:
Data модуль
Отвечает за доступ к данным (локальным и удаленным) и их управлением. Модуль является низкоуровневым, допустимы любые зависимости.
Определим зависимости модуля, где выбор библиотек для управления данными остается за вами:
plugins {
id("com.android.library")
kotlin("android")
kotlin("plugin.serialization")
id("com.google.dagger.hilt.android")
kotlin("kapt")
}
android {
namespace = "com.bsl.data"
}
dependencies {
implementation(project(":common"))
implementation(project(":domain"))
// DI
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
// Ваши библиотеки для работы с данными
}
data: build.gradle.kt
Базовыми компонентами данного модуля являются:
Repository Implementation: реализованные контракты репозиториев, которые мы определили в domain модуле
Data Sources: конкретные реализации, обеспечивающие доступ к данным из различных источников (база данных, API)
Data Models: объекты данных, представляющие хранимую информацию. Включает в себя DTO (Data Transfer Objects), объекты для операций CRUD (Create, Read, Update, Delete), а также объекты запросов (Query) и т.д
Mappers: преобразование объектов данных, к примеру в Entity
Начнем с определения объектов данных API
internal typealias CatResponseList = List
@Serializable
internal data class CatResponseModel(
@SerialName("id") val id: String,
@SerialName("url") val url: String,
)
После этого стоит сделать маппер для трансформации объекта данных в сущность бизнес-логики. Я приведу пример с использованием экстеншен подхода, так как он является достаточно лаконичным в контексте языка Kotlin и простым.
internal fun CatResponseModel.mapToDomainModel(isLiked: Boolean) =
CatModel(id, url, isLiked)
internal fun CatResponseList.mapToDomainModels(
isLikedGetter: (String) -> Boolean,
) = map { it.mapToDomainModel(isLiked = isLikedGetter(it.id)) }
Т.к isLiked будет предоставляться внешним компонентом, то мы прокидываем его с помощью параметров
Пора определить наши источники данных (Data Source), которые будут предоставлять объекты или их частицы данных. Они могут быть разделены на три типа:
Remote: обращения к внешнему API для получения данных
Memory: взаимодействие с данными, хранящимися в оперативной памяти
Local: чтение данных из локальной базы данных
Проиллюстрирую пример использование источников данных типа Memory и Remote. Для хранения данных в памяти применю структуру HashMap, в то время как для обращения к API используется библиотека Ktor.
internal class CatMemoryDataSource @Inject constructor(
private val referencesCache: ReferencesCache,
) {
fun setLike(value: Boolean, id: String) =
referencesCache.setValue(id, Reference.CatLike, value)
fun getLike(id: String, defaultValue: Boolean = false) =
referencesCache.getValue(id, Reference.CatLike) ?: defaultValue
}
internal class CatRemoteDataSource @Inject constructor(private val httpClient: HttpClient) {
suspend fun getCats(limit: Int) = httpClient.get {
url(GET_CATS_PATH)
parameter(LIMIT_PARAM, limit)
}.body()
companion object {
private const val ROOT = "https://api.thecatapi.com/v1/images"
private const val GET_CATS_PATH = "$ROOT/search"
private const val LIMIT_PARAM = "limit"
}
}
На практике код можно сделать чище, но в рамках показательности выбран именно такой подход.
Имея сущность данных и её источники, мы можем реализовать репозиторий.
internal class CatRepositoryImpl @Inject constructor(
private val catRemoteDataSource: CatRemoteDataSource,
private val catMemoryDataSource: CatMemoryDataSource,
) : CatRepository {
override suspend fun getCats(limit: Int): List =
catRemoteDataSource.getCats(limit)
.mapToDomainModels(isLikedGetter = { catMemoryDataSource.getLike(it) })
override suspend fun setLike(value: Boolean, id: String) =
catMemoryDataSource.setLike(value, id)
}
На этом наша работа здесь завершена, так выглядит наша структура модуля:
Presentation
Отвечает за отрисовку пользовательского интерфейса и управление его состояния на основе бизнес-сущностей. Модуль является низкоуровневым, допустимы любые зависимости.
Основные компоненты:
View: сам пользовательский UI в виде Fragment, Activity, Composable
Presenter / ViewModel: посредник между UI и бизнес-логикой в модельном виде MVVM, MVP, MVI
Определим зависимости модуля:
plugins {
id("com.android.library")
kotlin("android")
kotlin("plugin.serialization")
id("com.google.dagger.hilt.android")
kotlin("kapt")
id("com.google.devtools.ksp")
}
android {
namespace = "com.bsl.presentation"
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = compose.versions.compiler.get()
}
packagingOptions {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(project(":domain"))
implementation(project(":common"))
// ...
}
Начнем с реализации ViewModel. Я буду использовать презентационный паттерн MVI, используя его примитивную реализацию с референсом на MVI Orbit (Github)
Определим состояние экрана:
data class CatListState(val cats: ImmutableList = persistentListOf())
Использование ImmutableList оправдано поддержанием стабильности типов для Compose. Если у вас XML верстка, то, думаю, можно обойтись обычным List
Теперь определяем нашу ViewModel:
@HiltViewModel
class CatListViewModel @Inject constructor(private val catInteractor: CatInteractor) :
StatefulViewModel(CatListState()) {
fun onLoad() = intent {
val cats = catInteractor.getRandomCats()
reduceState { state.copy(cats = cats.toImmutableList()) }
}
}
Часть с View будет самостоятельной на ваш вкус.
Модуль common
Модуль предназначен для распространения утилит во все остальные модули. Если требуется, чтобы экстеншен/класс был доступен везде, то здесь ему место.
Плохой практикой является добавление зависимостей относящихся к другим слоям (room, ktor, compose, view). Зависимости, которые могут относиться ко всем слоям — разрешены и должны распространятся через api dependency
(logcat, coroutines, DI framework). Рекомендую следовать данному правилу, иначе в domain слое будут доступны экстеншены, к примеру, над View, что является грубой ошибкой.
В целом, все утилиты можно хранить и в domain слое, в зависимости от предпочтений и потребностей проекта.
Делюсь опытом (Best Practices)
Мной был продемонстрирован в некотором роде базовый взгляд на Clean Architecture, который, по моим наблюдениям, применяется в большинстве проектов разных размеров. Теперь несколько советов, как нам облегчить или, наоборот, усложнить наши задачи.
Интерфейсы ради интерфейсов
Не создавайте интерфейсы, если в этом нет практической необходимости, так как это может усложнить структуру кода. Например, если маппер предназначен исключительно для конкретной задачи и маловероятно, что он будет иметь несколько реализаций в будущем, не стоит создавать интерфейс для него. Если вдруг возникнет потребность в нескольких реализациях, рекомендую создавать интерфейс только для конкретного случая. В общем, никто вам не запрещает опустить интерфейсы для Interactor или UseCase.
Данные
Данные, предназначенные для отображения в пользовательском интерфейсе, должны поставляться уже готовыми. Для этого следует использовать мапперы для преобразования данных из формата data → domain → ui.
К примеру, не следует форматировать дату в определенном виде в domain слое или непосредственно во View. Лучше сделать это в ViewModel/Presenter в контексте стейта экрана или другого POJO объекта.
Многогранность
Избегайте монотонного использования одного и того же паттерна или подхода во всем приложении, так как это может нанести вред. Например, если проще реализовать что-то с использованием MVVM, чем MVI, то стоит выбрать наиболее удобный вариант.
Организация функциональности приложения
Всегда стоит разбивать функциональность приложения на фичи. Это может быть отдельный экран, диалог, или даже логика без UI составляющей. Определенно бывает так, что какой нибудь DateUtils с десятками методов форматирования даты может вполне себе зайти за фичу.
Документирование
Хотя говорят, что хороший код не требует документации, все же целесообразно предоставлять краткие описания для понимания участков кода. Даже если код написан чисто и эффективно, добавление коротких комментариев может значительно облегчить восприятие его назначения и функциональности.
Заключение
Я продемонстрировал вам, как реализовать Clean Architecture на основе многомодульной архитектуры, мы рассмотрели зону ответственности и основные компоненты каждого слоя. В этой статье на Хабр я постарался предоставить простое и понятное объяснение Clean Architecture. Надеюсь, что она поможет вам лучше понять этот подход к проектированию приложений.
Простота — это нечто большее, чем отсутствие сложности. Простота — это величайшее оружие.