В конце декабря 2021-го Android обновил рекомендации по архитектуре мобильных приложений. Публикуем перевод гайда в пяти частях:

Обзор архитектуры

Слой UI

Cобытия UI

Доменный слой (вы находитесь здесь)

Слой данных

Доменный слой не является обязательным и располагается между слоями UI и данных.

Роль доменного слоя в архитектуре приложения
Роль доменного слоя в архитектуре приложения

Доменный слой отвечает за инкапсуляцию сложной или простой бизнес-логики, которую переиспользуют несколько ViewModel. Этот слой необязателен: не всем приложениям он требуется. Его следует внедрять только при необходимости: например, при работе со сложной логикой или чтобы переиспользовать логику.

Преимущества доменного слоя:

  • Помогает избежать дублирования кода.

  • Улучшает читаемость кода в классах, которые используют классы из доменного слоя.

  • Облегчает тестирование приложения.

  • Позволяет избежать раздутых классов благодаря разделению ответственностей.

Чтобы классы оставались облегчёнными, каждый класс UseCase должен отвечать только за одну функциональность и не должен содержать изменяемых данных. С изменяемыми данными лучше работать в слоях UI или данных.

Правила именования в гайде

В этом гайде классы UseCase именуются тем действием, за которое они отвечают. Правило выглядит следующим образом:

Глагол в настоящем времени + существительное/объект (не обязательно) + UseCase

Например: FormatDateUseCase, LogOutUserUseCase, GetLatestNewsWithAuthorsUseCase или MakeLoginRequestUseCase.

Зависимости

В архитектуре классического приложения UseCase-классы размещают между моделями ViewModel из слоя UI и классами Repository из слоя данных. Следовательно, UseCase-классы обычно зависят от Repository-классов.

Вместе они обмениваются данными со слоем UI так же, как это делают классы Repository — с помощью функций callback (в Java) или корутин (в Kotlin). Подробнее об этом можно почитать в гайде «Слой данных».

Например, у вас в приложении может быть UseCase-класс, который берёт данные из новостного репозитория и репозитория авторов, а затем их объединяет:

class GetLatestNewsWithAuthorsUseCase(

  private val newsRepository: NewsRepository,

  private val authorsRepository: AuthorsRepository

) { /* ... / }

Так как классы UseCase содержат переиспользуемую логику, их могут использовать и другие классы UseCase. Вполне нормально, если у вас в доменном слое есть несколько уровней классов UseCase.

UseCase из следующего примера может воспользоваться классом FormatDateUseCase, если несколько классов из слоя UI используют данные о часовом поясе для отображения верного сообщения на экране:

class GetLatestNewsWithAuthorsUseCase(

  private val newsRepository: NewsRepository,

  private val authorsRepository: AuthorsRepository,

  private val formatDateUseCase: FormatDateUseCase

) { / ... / }
Пример графика зависимостей для класса UseCase, который зависит от других классов UseCase
Пример графика зависимостей для класса UseCase, который зависит от других классов UseCase

Вызов классов UseCase в Kotlin

В Kotlin экземпляр класса UseCase можно сделать вызываемым как функцию, определив функцию invoke() с помощью модификатора operator. Приведём пример:

class FormatDateUseCase(userRepository: UserRepository) {

    private val formatter = SimpleDateFormat(

        userRepository.getPreferredDateFormat(),

        userRepository.getPreferredLocale()

    )

    operator fun invoke(date: Date): String {

        return formatter.format(date)

    }

}

В этом примере метод invoke() в FormatDateUseCase позволяет вызывать экземпляры класса так, как будто это функции. Метод invoke() не ограничивается какой-то одной конкретной сигнатурой. Его можно объявить с любым количеством параметров и с любым типом возвращаемого значения и даже определить в классе перегруженные версии invoke() с разными типами параметров. В таком случае класс UseCase из предыдущего примера можно было бы вызвать следующим образом:

class MyViewModel(formatDateUseCase: FormatDateUseCase) : ViewModel() {

    init {

        val today = Calendar.getInstance()

        val todaysDate = formatDateUseCase(today)

        / ... /

    }

}

Подробнее об операторе invoke() можно почитать в документации по Kotlin.

Жизненный цикл

У классов UseCase нет собственных жизненных циклов: они ограничены жизненным циклом классов, которые их используют. То есть UseCase можно вызывать из классов в слое UI, из служб или даже из класса Application. Так как классы UseCase не должны содержать изменяемые данные, следует создавать новый экземпляр UseCase класса каждый раз, когда вы передаете его в качестве зависимости.

Потоки

Классы UseCase из доменного слоя должны быть main-safe: вызывать их из главного потока должно быть безопасно. Если UseCase-классы выполняют продолжительные операции, которые блокируют поток, они должны отвечать за перенос логики в соответствующий поток. Однако сначала убедитесь, что эти операции нельзя перенести в другие слои иерархии.

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

В примере ниже показан класс UseCase, который работает в фоновом потоке:

class MyUseCase(

    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default

) {

    suspend operator fun invoke(...) = withContext(defaultDispatcher) {

        // Long-running blocking operations happen on a background thread.

    }

}

Типичные для доменного слоя задачи

Простая переиспользуемая бизнес-логика

Повторяющуюся в слое UI бизнес-логику следует инкапсулировать в UseCase класс: будет легче вносить изменения везде, где она используется. Кроме того, так её можно тестировать изолированно.

Вспомним пример FormatDateUseCase, описанный ранее. Если бизнес требования к формату даты изменятся в будущем, весь код, который  потребуется изменить, будет сконцентрирован в одном месте.

Важно: в некоторых случаях логику, находящуюся в классах UseCase, можно сделать частью статических методов в классах Util. Однако такой подход использовать не рекомендуется: классы Util зачастую тяжело найти и тяжело разобраться в их функциональности. Более того, у нескольких классов UseCase может быть одна и та же функциональность (переключение потоков и обработка ошибок в базовых классах), от которой при масштабировании выиграют растущие команды.

Слияние репозиториев

В новостном приложении могут быть классы NewsRepository и AuthorsRepository, которые работают с данными о новостях и авторах соответственно. Класс Article, к которому открывает доступ NewsRepository, содержит только имя автора. Представим, что появится необходимость показать на экране больше информации об авторе. Её можно получить из AuthorsRepository.

Схема зависимостей класса UseCase, который объединяет данные из нескольких классов Repository
Схема зависимостей класса UseCase, который объединяет данные из нескольких классов Repository

Так как требуемая логика задействует несколько классов Repository и может стать довольно сложной, нужно создать класс GetLatestNewsWithAuthorsUseCase, чтобы отделить логику от ViewModel и сделать код читабельнее. Кроме того, так эту логику можно тестировать изолированно и переиспользовать в различных частях приложения.

/**

  This use case fetches the latest news and the associated author.

 */

class GetLatestNewsWithAuthorsUseCase(

  private val newsRepository: NewsRepository,

  private val authorsRepository: AuthorsRepository,

  private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default

) {

    suspend operator fun invoke(): List<ArticleWithAuthor> =

        withContext(defaultDispatcher) {

            val news = newsRepository.fetchLatestNews()

            val result: MutableList<ArticleWithAuthor> = mutableListOf()

            // This is not parallelized, the use case is linearly slow.

            for (article in news) {

                // The repository exposes suspend functions

                val author = authorsRepository.getAuthor(article.authorId)

                result.add(ArticleWithAuthor(article, author))

            }

            result

        }

}

Логика задействует все элементы списка новостей. Несмотря на то, что слой данных является main-safe, нужно позаботиться, чтобы этот процесс не блокировал главный поток, ведь мы не знаем, сколько элементов ему придётся обработать. Поэтому класс UseCase переносит работу в фоновый поток при помощи диспатчера по умолчанию.

Важно: Room library позволяет запрашивать, какие взаимосвязи существуют между различными сущностями в базе данных. Если база данных — source of truth, можно создать запрос, который всю эту работу выполнит за вас. В таком случае лучше создать Repository класс (например, NewsWithAuthorsRepository) вместо класса UseCase.

Переиспользование

Помимо слоя UI, доменный слой могут переиспользовать другие классы: например, классы Service или класс Application. Кроме того, если другие платформы, такие как TV или Wear используют общую кодовую базу с мобильным приложением, их слой UI также может переиспользовать классы UseCase и получить все преимущества доменного слоя.

Тестирование

Общие рекомендации по тестированию применимы к тестированию доменного слоя. В случае с UI-тестами разработчики, как правило, используют фиктивные классы Repository. Их же рекомендуется использовать и для тестирования доменного слоя.

Читайте далее

Обзор архитектуры

Слой UI

Cобытия UI

Слой данных

Комментарии (1)


  1. jesaiah4
    13.03.2022 20:55

    Не очень понял где вы храните модели которые шарятся между UI REQ , И QUEUE system ?

    Или вы имеете три одинаковых копии модели ?