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

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

Слой UI

Обработка событий UI

Доменный слой

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

Слой UI содержит UI-состояние и логику UI, а слой данных — данные приложения и бизнес-логику. Бизнес логика наделяет приложение ценностью: она состоит из бизнес-правил, решающих практические задачи. Эти бизнес-правила определяют процесс создания, хранения и изменения данных приложения.

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

Архитектура слоя данных

Слой данных состоит из классов Repository. В каждом из них может содержаться множество классов data source — или не быть ни одного. Отдельный класс Repository следует создать для каждого уникального типа данных в приложении. Например, у вас может быть класс MoviesRepository для данных, связанных с фильмами, или класс PaymentsRepository для данных, связанных с платежами.

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

Классы Repository выполняют следующие задачи:

  • Предоставляют доступ к данным для остальных элементов приложения.

  • Централизуют изменения в данных.

  • Разрешают конфликты между несколькими классами DataSource.

  • Абстрагируют источники данных от остальных элементов приложения.

  • Содержат бизнес-логику.

Каждый data source-класс должен отвечать за работу только с одним источником данных. Им может оказаться файл, сетевой источник или локальная база данных. Data source-классы — это связующее звено между приложением и системой для работы с данными.

Остальные слои в иерархии ни в коем случае не должны получать доступ к классам data source напрямую. Переход на слой данных всегда должен осуществляться через Repository классы. State holder классы (см. гайд «Слой UI») или UseCase классы (см. гайд «Доменный слой») ни в коем случае не должны иметь в непосредственной зависимости класс data source. Используя Repository-классы в качестве точек входа, мы даём различным слоям архитектуры возможность масштабироваться независимо от остальных слоёв.

Данные, к которым открывается доступ в этом слое, должны быть неизменяемыми, чтобы их не смогли исказить остальные классы. Иначе могут возникнуть противоречия. Кроме того, неизменяемые данные можно безопасно передавать в нескольких потоках. Более подробно об этом рассказано в разделе «Потоки».

Согласно рекомендациям по Dependency Injection, в конструкторе репозитория источники данных располагаются в виде зависимостей:

class ExampleRepository(

    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network

    private val exampleLocalDataSource: ExampleLocalDataSource // database

) { /* ... / }

Важно: Зачастую, когда класс Repository содержит только один класс data source и не зависит от других классов Repository, разработчики объединяют ответственности классов Repository и data source в класс Repository. Если будете так делать, не забудьте разделить функциональности, когда в более поздней версии приложения классу Repository потребуется обрабатывать данные из другого источника.

Как предоставить доступ к API

Классы в слое данных, как правило, предоставляют доступ к функциям в случае однократных вызовов Create, Read, Update и Delete (CRUD) или чтобы получить уведомления о периодических изменениях данных. Слой данных должен предоставить доступ к следующим сущностям в каждом из двух случаев ниже:

  • Однократные операции. В Kotlin слой данных должен открывать доступ к suspend-функциям, а в случае с языком программирования Java — к функциям, которые с помощью callback уведомляют класс о результате операции, или к типам RxJava Single, Maybe или Completable.

  • Уведомления о периодических изменениях данных. В Kotlin слой данных должен предоставлять доступ к flow, а в случае с Java – к callback, который отправляет новые данные, или к типам RxJava Observable или Flowable.

class ExampleRepository(

    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network

    private val exampleLocalDataSource: ExampleLocalDataSource // database

) {

    val data: Flow<Example> = ...

    suspend fun modifyData(example: Example) { ... }

}

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

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

тип данных + Repository

Например: NewsRepository, MoviesRepository и PaymentsRepository.

Data source-классы именуются с указанием данных, за которые они отвечают, и используемого ими источника. Правило:

тип данных + тип источника + DataSource

В типе данных рекомендуем указывать Remote или Local, так как со временем реализации могут меняться. Пример: NewsRemoteDataSource или NewsLocalDataSource. Если источник имеет крайне важное значение, можно дать ему более подробное наименование. В таком случае укажите тип источника. Например: NewsNetworkDataSource или NewsDiskDataSource.

Не называйте класс data source, исходя из детали реализации (антипример: UserSharedPreferencesDataSource). Классам Repository, которые используют этот класс data source, не нужно знать, как сохраняются эти данные. Если следовать этому правилу, можно изменить реализацию класса data source (например, мигрировать с SharedPreferences на DataStore) без ущерба для слоя, вызывающего этот источник.

Важно: в процессе миграции на новую реализацию класса data source некоторые создают для него интерфейс и оставляют две реализации: одну для старой технологии резервирования, а вторую для новой. В таком случае нормально использовать название технологии в названии data source-класса (хоть это и деталь реализации), так как Repository-класс видит только интерфейс, а не сами data source-классы. Завершив миграцию, новый класс можно переименовать, чтобы не держать в названии деталь реализации.

Несколько уровней классов Repository

В некоторых случаях с более сложными бизнес-требованиями нужно, чтобы один класс Repository зависел от других. Так бывает, когда данные собираются из нескольких источников, а затем объединяются, или когда ответственность необходимо инкапсулировать в другой Repository-класс.

К примеру, чтобы выполнить поставленные перед ним требования, класс Repository, который работает с данными аутентификации пользователей (UserRepository), можно поместить в зависимость от других классов Repository: LoginRepository и RegistrationRepository.

Схема зависимости одного класса Repository от других
Схема зависимости одного класса Repository от других

Важно: по сложившейся традиции некоторые разработчики называют одни Repository-классы, которые зависят от других, менеджерами: к примеру, UserManager вместо UserRepository. Если хотите, можете использовать это правило именования.

Source of truth

Каждый класс Repository обязательно должен описывать только один source of truth. Данные, содержащиеся в source of truth, всегда консистентны, верны и актуальны. Собственно, данные, к которым открывает доступ класс Repository, всегда должны быть получены напрямую из source of truth.

Source of truth может представлять собой класс data source. Например, базу данных или даже расположенный в памяти кэш, который может содержаться в классе Repository. Для того, чтобы регулярно обновлять единственный source of truth, или в случаях, когда пользователь вводит новую информацию, классы Repository объединяют различные классы data source и разрешают потенциальные конфликты между ними.

У разных классов Repository в вашем приложении могут быть разные source of truth. К примеру, класс LoginRepository может использовать в качестве source of truth свой кэш, а класс PaymentsRepository — сетевой источник данных.

Чтобы приложение поддерживало подход offline-first, рекомендуется использовать в качестве source of truth локальный источник данных — например, базу данных.

Потоки

Вызов классов data source и repository должен быть main-safe. Другими словами, вызывать их из главного потока должно быть безопасно. Данные классы отвечают за перенос выполнения своей логики в соответствующий поток, когда выполняемые ими операции занимают продолжительное время и блокируют поток. К примеру, если класс data source считывает файл или если класс Repository выполняет ресурсозатратную фильтрацию длинного списка, эти процессы должны быть main-safe.

Заметьте: большинство классов data source предоставляют готовые main-safe API: например, вызовы suspend методов в Room или Retrofit. Если вам доступны такие API, имеет смысл воспользоваться их преимуществами в своих классах Repository.

Более подробно о потоках можно почитать в гайде «Фоновая обработка данных». Пользователям Kotlin рекомендуется использовать корутины. Чтобы ознакомиться с рекомендациями для языка Java, почитайте «Выполнение задач Android в фоновых потоках».

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

Экземпляры классов в слое данных остаются в памяти до тех пор, пока до них можно добраться из корня графа сборщика мусора — как правило, когда ссылку на него хранит другой объект в приложении.

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

Если ответственность класса имеет ключевое значение для приложения в целом, экземпляр этого класса можно ограничить жизненным циклом класса Application. Таким образом, у экземпляра класса и приложения будут равные жизненные циклы. Однако, если в приложении вам нужно переиспользовать один и тот же экземпляр в определённом flow экранов (например, registration flow или login flow), ему следует присвоить жизненный цикл класса, который распоряжается жизненным циклом данного flow. К примеру, жизненный цикл RegistrationRepository, содержащего сохранённые в памяти данные, можно ограничить циклом RegistrationActivity или навигационного графа registration flow.

Жизненный цикл каждого экземпляра играет крайне важную роль, если нужно решить, как предоставлять зависимости в своём приложении. Следуйте рекомендациям по Dependency Injection, в которых зависимости управляются и могут быть ограничены жизненным циклом контейнера зависимостей. Более подробно об  ограничении жизненного цикла зависимостей в Android можно почитать в посте «Ограничение жизненного цикла зависимостей в Android и Hilt».

Как представлять бизнес-модели

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

К примеру, представим сервер новостного API, который возвращает не только информацию о статье, но и историю изменений, комментарии пользователей и ещё какие-то метаданные:

data class ArticleApiModel(

    val id: Long,

    val title: String,

    val content: String,

    val publicationDate: Date,

    val modifications: Array<ArticleApiModel>,

    val comments: Array<CommentApiModel>,

    val lastModificationDate: Date,

    val authorId: Long,

    val authorName: String,

    val authorDateOfBirth: Date,

    val readTimeMin: Int

)

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

К примеру, чтобы предоставить доменному слою и слою UI доступ к классу модели Article, можно вырезать ArticleApiModel из сетевого источника данных следующим образом:

data class Article(

    val id: Long,

    val title: String,

    val content: String,

    val publicationDate: Date,

    val authorName: String,

    val readTimeMin: Int

)

Разделять классы моделей рекомендуют, потому что это:

  • Экономит память приложения, сводя объём данных к необходимому минимуму.

  • Подстраивает типы внешних данных под типы данных в приложении. К примеру, приложение может использовать другой тип данных для отображения дат.

  • Обеспечивает более качественное разделение ответственностей. К примеру члены большой команды могут заниматься сетевым слоем и слоем UI по отдельности, если определили класс модели заранее.

Можно пойти дальше и точно так же определять отдельные классы моделей в других частях архитектуры своего приложения, например, в data source-классах и ViewModel. Однако для этого потребуется описать дополнительные классы и логику, которую нужно тщательно задокументировать и протестировать. Как минимум, новые модели рекомендуется создавать, когда данные, которые получает класс data source, не совпадают с тем, чего ожидают остальные элементы приложения.

Типы операций над данными

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

  • UI (UI-oriented),

  • приложение (app-oriented),

  • бизнес-процессы (business-oriented).

UI-oriented операции

UI-oriented операции выполняются только тогда, когда пользователь находится на определённом экране, и отменяются, когда он покидает экран. Пример: отображение данных, полученных из базы данных.

UI-oriented операции, как правило, запускаются из слоя UI и ограничены жизненным циклом вызывающего компонента: к примеру, жизненным циклом ViewModel. Пример UI-oriented операции можно посмотреть в разделе «Как выполнить сетевой запрос».

App-oriented операции

App-oriented операции выполняются, пока приложение открыто. Если приложение закрывают или процесс завершается, операции отменяются. Пример: кэширование результата сетевого запроса, чтобы использовать его позднее при необходимости. Более подробно об этом можно почитать в разделе «Как реализовать кэширование в памяти».

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

Business-oriented операции

Business-oriented операции нельзя отменить. Они должны переживать смерть процесса. Пример: успешная загрузка фотографии, которую пользователь хочет запостить в профиле.

Для business-oriented операций рекомендуется использовать WorkManager. Подробнее об этом можно почитать в разделе «Как планировать задачи с помощью WorkManager».

Обработка ошибок

Взаимодействия с классами Repository и источниками данных могут пройти успешно или, в случае неудачи, выбросить исключение. В случае с корутинами и flow рекомендуем использовать встроенный механизм обработки ошибок Kotlin. В случае с ошибками, которые могут быть спровоцированы suspend функциями, при возможности используйте блоки try/catch, а с flow используйте оператор catch. Предполагается, что при использовании этого подхода слой UI сможет обработать исключения, когда он обращается к слою данных.

Слой данных способен распознавать различные виды ошибок, обрабатывать их при помощи собственных исключений, таких как UserNotAuthenticatedException.

Важно: смоделировать результат взаимодействия со слоем данных также можно при помощи класса Result. Данный шаблон моделирует ошибки и прочие сигналы, способные возникнуть в процессе обработки результата. В этом шаблоне слой данных возвращает тип Result<T> вместо T, таким образом сообщая UI об известных ошибках, которые могут возникнуть при определённых сценариях. Использовать такой шаблон необходимо с API реактивного программирования, у которых нет надлежащих механизмов обработки исключений, например, с LiveData.

Подробнее об ошибках в корутинах можно почитать в посте «Исключения в корутинах».

Распространённые задачи

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

Как выполнить сетевой запрос

Сетевые запросы — одна из самых распространённых задач в приложении для Android. Новостному приложению нужно показать пользователю свежие новости, которые оно нашло в сети. Следовательно, приложению нужен data source-класс для управления сетевыми операциями: NewsRemoteDataSource. Чтобы предоставить доступ к информации всему приложению, нужно создать новый класс Repository, который будет обрабатывать операции с новыми данными: NewsRepository.

Свежие новости должны обновляться каждый раз, когда пользователь открывает экран. Следовательно это UI-oriented операция.

Как создать класс data source

Класс data source должен предоставлять доступ к функции, которая возвращает свежие новости: список экземпляров ArticleHeadline. Этот класс data source должен обеспечивать безопасную для главного потока возможность получать свежие новости из сети. Для этого ему нужно получить в качестве зависимости CoroutineDispatcher или Executor и на них выполнить задачу.

Сетевой запрос – это однократный вызов, который обрабатывается новым методом fetchLatestNews():

class NewsRemoteDataSource(

  private val newsApi: NewsApi,

  private val ioDispatcher: CoroutineDispatcher

) {

    /**

      Fetches the latest news from the network and returns the result.

     * This executes on an IO-optimized thread pool, the function is main-safe.

     /

    suspend fun fetchLatestNews(): List<ArticleHeadline> =

        // Move the execution to an IO-optimized thread since the ApiService

        // doesn't support coroutines and makes synchronous requests.

        withContext(ioDispatcher) {

            newsApi.fetchLatestNews()

        }

    }

}

// Makes news-related network synchronous requests.

interface NewsApi {

    fun fetchLatestNews(): List<ArticleHeadline>

}

Интерфейс NewsApi скрывает реализацию сетевого клиента API; вне зависимости от того, реализован этот интерфейс с помощью Retrofit или HttpURLConnection. Благодаря зависимости от интерфейсов, API реализации становятся взаимозаменяемыми.

Ключевой момент: благодаря зависимости от интерфейсов API реализации становятся взаимозаменяемыми. Помимо того, что данный подход обеспечивает масштабируемость и даёт возможность с большей лёгкостью заменять зависимости, он облегчает тестирование, так как в тесты можно внедрить фиктивные реализации класса data source.

Как создать класс Repository

Так как для этой задачи Repository классу не требуется дополнительной логики, NewsRepository выступает в роли посредника для сетевого источника данных. Преимущества такого дополнительного слоя абстракции рассмотрим в разделе о кэшировании в памяти.

// NewsRepository is consumed from other layers of the hierarchy.

class NewsRepository(

    private val newsRemoteDataSource: NewsRemoteDataSource

) {

    suspend fun fetchLatestNews(): List<ArticleHeadline> =

        newsRemoteDataSource.fetchLatestNews()

}

Чтобы научиться использовать Repository-класс непосредственно из слоя UI, можете почитать гайд «Слой UI».

Как реализовать кэширование в памяти

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

С учетом нового требования приложение должно хранить свежие новости в памяти, пока оно открыто. Следовательно, это app-oriented операция.

Кэш

Чтобы хранить данные, пока приложение открыто, нужно реализовать в нём кэширование в памяти. Кэш сохраняет информацию в памяти на определённый срок (в данном случае — пока приложение открыто). Кэширование можно реализовать различными способами: от простой изменяемой переменной до более сложного класса, который защищает данные от выполнения операций read/write в нескольких потоках. В зависимости от ситуации, кэширование можно реализовать в Repository или классах data source.

Как кэшировать результат сетевого запроса

Для простоты NewsRepository использует для кэширования свежих новостей изменяемую переменную. Чтобы данные нельзя было считать или переписать из различных потоков, используйте Mutex. Подробнее об общем изменяемом состоянии и многопоточности можно почитать в документации к Kotlin.

Представленная ниже реализация кэширует свежие новости в переменной, расположенной в классе Repository, который защищён от записи мьютексом. Если результат сетевого запроса успешен, данные присваиваются переменной latestNews.

class NewsRepository(

  private val newsRemoteDataSource: NewsRemoteDataSource

) {

    // Mutex to make writes to cached values thread-safe.

    private val latestNewsMutex = Mutex()

    // Cache of the latest news got from the network.

    private var latestNews: List<ArticleHeadline> = emptyList()

    suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {

        if (refresh || latestNews.isEmpty()) {

            val networkResult = newsRemoteDataSource.fetchLatestNews()

            // Thread-safe write to latestNews

            latestNewsMutex.withLock {

                this.latestNews = networkResult

            }

        }

        return latestNewsMutex.withLock { this.latestNews }

    }

}

Как сделать так, чтобы операция жила дольше, чем экран

Если пользователь уходит с экрана, пока выполняется сетевой запрос, запрос отменится, а результат не кэшируется. NewsRepository не должен использовать для этой логики CoroutineScope вызывающего компонента. Вместо этого ему нужен CoroutineScope, который ограничен его же жизненным циклом. Получение свежих новостей должно быть app-oriented операцией.

В соответствии с рекомендациями по Dependency Injection, NewsRepository должен получать CoroutineScope в качестве параметра в конструкторе, а не создавать свой собственный CoroutineScope. Так как классы Repository должны выполнять основную часть работы в фоновых потоках, рекомендуем добавить в CoroutineScope Dispatchers.Default или использовать в качестве диспетчера собственный пул потоков.

class NewsRepository(

    ...,

    // This could be CoroutineScope(SupervisorJob() + Dispatchers.Default).

    private val externalScope: CoroutineScope

) { ... }

Теперь NewsRepository готов к выполнению app-oriented операций с внешним CoroutineScope, а значит он должен отправить запрос в класс data source и сохранить результат в новой корутине, которая запущена в том же внешнем scope:

class NewsRepository(

    private val newsRemoteDataSource: NewsRemoteDataSource,

    private val externalScope: CoroutineScope

) {

    / ... */

    suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {

        return if (refresh) {

            externalScope.async {

                newsRemoteDataSource.fetchLatestNews().also { networkResult ->

                    // Thread-safe write to latestNews.

                    latestNewsMutex.withLock {

                        latestNews = networkResult

                    }

                }

            }.await()

        } else {

            return latestNewsMutex.withLock { this.latestNews }

        } 

    }

}

async используется, чтобы запустить корутину во внешнем scope. await вызывается на новой корутине, чтобы приостановить выполнение, пока сетевой запрос не вернётся и результат не сохранится в кэш. Если к тому моменту пользователь ещё не ушёл с экрана, приложение покажет ему свежие новости; если он уже перешёл на другой экран, await отменяется, но логика внутри async продолжает выполняться.

Подробнее о паттернах CoroutineScope>>

Как сохранить данные на диске и извлечь их оттуда

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

Если нужно, чтобы данные пережили смерть процесса, нужно хранить их на диске одним из следующих способов:

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

  • Если это небольшие массивы данных, которые нужно только извлекать или устанавливать (без запросов и частичных обновлений), используйте DataStore. В новостном приложении из примера формат времени и другие настройки отображения, выбранные пользователем, можно сохранить в DataStore.

  • Если это фрагмент данных (например, JSON-объект), используйте файл.

Как уже упоминалось в разделе «Source of truth», каждый класс data source работает только с одним источником и соответствует определённому типу данных (например, News, Authors, NewsAndAuthors или UserPreferences). Классы, использующие этот источник, не должны знать, как сохраняются данные (скажем, в базе данных или в файле).

Room как источник данных

Так как каждый класс data source должен отвечать за работу только с одним источником с определённым типом данных, источник данных Room получает в качестве параметра объект доступа к данным (DAO) или саму базу данных. Например, NewsLocalDataSource может получить в качестве параметра экземпляр NewsDao, а AuthorsLocalDataSource – экземпляр AuthorsDao.

В некоторых случаях, если не требуется дополнительной логики, можно внедрить DAO непосредственно в репозиторий,  так как DAO – это интерфейс, который легко можно заменить в тестах.

Подробнее о том, как работать с Room API, можно почитать в гайдах по Room.

DataStore как источник данных

DataStore идеально подходит для хранения таких пар ключ-значение, как пользовательские настройки. К примерам можно отнести формат времени, настройки уведомлений и флаг «скрыть/показывать» для статей, которые пользователь уже прочитал. DataStore также умеет хранить типизированные объекты с буферами протоколов.

Как и в случае с любым другим объектом, источник данных, хранящихся в DataStore, должен содержать данные, соответствующие определённому типу или определённой части приложения. В DataStore это правило ещё более важно выполнять, так как доступ к чтению из DataStore предоставляется как к flow, который отправляется каждый раз, когда значение обновляется. По этой причине связанные между собой настройки лучше хранить в одном DataStore.

К примеру, у вас будет NotificationsDataStore, который работает только с настройками уведомлений, и NewsPreferencesDataStore, который работает только с настройками экрана с новостями. Таким образом вы сможете сделать обновления данных более концентрированным, так как условный поток newsScreenPreferencesDataStore.data будет отправляться только когда пользователь изменит настройку экрана. Кроме того, это значит, что вы сможете сократить жизненный цикл объекта: он будет жить только пока у пользователя открыт экран с новостями.

Подробнее о том, как работать с DataStore API, читайте в гайдах к DataStore.

Файл как источник данных

При работе с крупными объектами вроде объекта JSON или растрового изображения вам потребуется использовать объект File и переключать потоки.

Подробнее о работе с файловым хранением можно почитать в гайде «Обзор методов хранения».

Как планировать задачи с помощью WorkManager

Представим, что для новостного приложения ввели ещё одно требование: у пользователя должна быть возможность получать свежие новости регулярно и автоматически, если устройство заряжается и подключено к сети без ограничений по трафику. В этом случае операция становится business-oriented. Согласно новому требованию, даже если устройство не подключено к интернету, открыв приложение, пользователь всё равно сможет прочитать свежие новости.

С WorkManager легче планировать асинхронные и бесперебойные процессы, кроме того, он сам сможет проверить, выполняются ли условия. Настоятельно рекомендуется использовать эту библиотеку, если вы хотите, чтобы приложение работало надёжно. Для выполнения указанной выше задачи создаётся класс Worker: FetchLatestNewsWorker. Этот класс получает NewsRepository в качестве зависимости, извлекает свежие новости и кэширует их на диск.

class RefreshLatestNewsWorker(

    private val newsRepository: NewsRepository,

    context: Context,

    params: WorkerParameters

) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result = try {

        newsRepository.refreshLatestNews()

        Result.success()

    } catch (error: Throwable) {

        Result.failure()

    }

}

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

В этом примере задачу (task), связанную с новостями, нужно вызывать из NewsRepository, который получит в качестве зависимости новый класс DataSource (NewsTasksDataSource), реализованный следующим образом:

private const val REFRESH_RATE_HOURS = 4L

private const val FETCH_LATEST_NEWS_TASK = "FetchLatestNewsTask"

private const val TAG_FETCH_LATEST_NEWS = "FetchLatestNewsTaskTag"

class NewsTasksDataSource(

    private val workManager: WorkManager

) {

    fun fetchNewsPeriodically() {

        val fetchNewsRequest = PeriodicWorkRequestBuilder<RefreshLatestNewsWorker>(

            REFRESH_RATE_HOURS, TimeUnit.HOURS

        ).setConstraints(

            Constraints.Builder()

                .setRequiredNetworkType(NetworkType.TEMPORARILY_UNMETERED)

                .setRequiresCharging(true)

                .build()

        )

            .addTag(TAG_FETCH_LATEST_NEWS)

        workManager.enqueueUniquePeriodicWork(

            FETCH_LATEST_NEWS_TASK,

            ExistingPeriodicWorkPolicy.KEEP,

            fetchNewsRequest.build()

        )

    }

    fun cancelFetchingNewsPeriodically() {

        workManager.cancelAllWorkByTag(TAG_FETCH_LATEST_NEWS)

    }

}

Эти типы классов именуются согласно данным, за которые они отвечают, например, NewsTasksDataSource и PaymentsTasksDataSource. Все задачи, относящиеся к определённому типу данных, лучше инкапсулировать в один класс.

Если задачу необходимо запустить, когда приложение стартует, рекомендуется активировать запрос WorkManager с помощью библиотеки App Startup, которая обращается к репозиторию из инициализатора.

Подробнее о работе с WorkManager API можно почитать в гайдах к WorkManager.

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

Следуя рекомендациям по Dependency Injection, можно облегчить тестирование приложения. Также можно использовать интерфейсы классов, которые обмениваются данными с внешними ресурсами. В unit-тестах можно сделать тест надёжнее, а его результаты однозначнее, если внедрить фиктивные версии зависимостей модуля.

Unit-тесты

Если вы тестируете слой данных, можно воспользоваться общим руководством по тестированию. Для unit-тестов при необходимости используйте реальные объекты и внедряйте фиктивные зависимости, которые обращаются к внешним источникам, к примеру считывают информацию из файла или сети.

Интеграционное тестирование

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

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

Для работы с сетью существуют популярные библиотеки вроде WireMock или MockWebServer, которые позволяют сымитировать HTTP- и HTTPS-вызовы и подтвердить, что запросы выполнены ожидаемым образом.

Предыдущие статьи

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

Слой UI

Обработка событий UI

Доменный слой

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