Слой данных

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

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

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

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

В типичной архитектуре репозитории уровня данных предоставляют данные остальной части приложения и зависят от источников данных.
Рисунок 1. Роль уровня данных в архитектуре приложения.

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

  • Предоставление данных остальной части приложения.
  • Централизация изменений в данных.
  • Разрешение конфликтов между несколькими источниками данных.
  • Абстрагирование источников данных от остальной части приложения.
  • Содержит бизнес-логику.

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

Другие уровни иерархии никогда не должны иметь прямой доступ к источникам данных; точками входа на уровень данных всегда являются классы репозитория. Классы держателей состояний (см. руководство по уровню пользовательского интерфейса ) или классы вариантов использования (см. руководство по уровню предметной области ) никогда не должны иметь источник данных в качестве прямой зависимости. Использование классов репозитория в качестве точек входа позволяет различным уровням архитектуры масштабироваться независимо.

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

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

class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) { /* ... */ }

Предоставлять API

Классы на уровне данных обычно предоставляют функции для выполнения одноразовых вызовов создания, чтения, обновления и удаления (CRUD) или для уведомления об изменениях данных с течением времени. Уровень данных должен предоставлять следующую информацию для каждого из этих случаев:

  • Одноразовые операции: уровень данных должен предоставлять функции приостановки в Kotlin; а для языка программирования Java уровень данных должен предоставлять функции, которые обеспечивают обратный вызов для уведомления о результате операции, или типы RxJava Single , Maybe или Completable .
  • Чтобы получать уведомления об изменениях данных с течением времени: уровень данных должен предоставлять потоки в Kotlin; а для языка программирования Java уровень данных должен предоставлять обратный вызов, который генерирует новые данные, или тип RxJava Observable или Flowable .
class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) {

    val data: Flow<Example> = ...

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

Соглашения об именах в этом руководстве

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

тип данных + Репозиторий .

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

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

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

Для типа данных используйте «Удаленный» или «Локальный» , чтобы быть более общим, поскольку реализации могут меняться. Например: NewsRemoteDataSource или NewsLocalDataSource . Чтобы быть более конкретным, если источник важен, используйте тип источника. Например: NewsNetworkDataSource или NewsDiskDataSource .

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

Несколько уровней репозиториев

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

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

В этом примере UserRepository зависит от двух других классов репозитория: LoginRepository, который зависит от других источников данных для входа; и RegistrationRepository, который зависит от других источников регистрационных данных.
Рисунок 2. Граф зависимостей репозитория, зависящего от других репозиториев.

Источник истины

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

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

Разные репозитории вашего приложения могут иметь разные источники истины. Например, класс LoginRepository может использовать свой кэш в качестве источника истины, а класс PaymentsRepository может использовать сетевой источник данных.

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

Резьба

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

Обратите внимание, что большинство источников данных уже предоставляют API-интерфейсы, безопасные для основного типа, такие как вызовы методов приостановки, предоставляемые Room , Retrofit или Ktor . Ваш репозиторий может воспользоваться этими API, когда они будут доступны.

Дополнительные сведения о многопоточности см. в руководстве по фоновой обработке . Для пользователей Kotlin рекомендуется использовать сопрограммы . Рекомендуемые параметры для языка программирования Java см. в разделе «Выполнение задач Android в фоновых потоках» .

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

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

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

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

Жизненный цикл каждого экземпляра является решающим фактором при принятии решения о том, как предоставлять зависимости в вашем приложении. Рекомендуется следовать рекомендациям по внедрению зависимостей , где зависимости управляются и могут быть ограничены контейнерами зависимостей. Чтобы узнать больше об области действия в Android, прочтите публикацию в блоге «Область действия в Android и Hilt» .

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

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

Например, представьте себе сервер News 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
)

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

data class Article(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val authorName: String,
    val readTimeMin: Int
)

Разделение классов моделей выгодно по следующим причинам:

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

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

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

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

Операции, ориентированные на пользовательский интерфейс

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

Операции, ориентированные на пользовательский интерфейс, обычно запускаются на уровне пользовательского интерфейса и следуют жизненному циклу вызывающего объекта, например жизненному циклу ViewModel. См. раздел «Создание сетевого запроса», где приведен пример операции, ориентированной на пользовательский интерфейс.

Операции, ориентированные на приложения

Операции, ориентированные на приложение, актуальны, пока приложение открыто. Если приложение закрыто или процесс уничтожен, эти операции отменяются. Примером является кэширование результата сетевого запроса, чтобы при необходимости его можно было использовать позже. Дополнительную информацию см. в разделе «Реализация кэширования данных в памяти» .

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

Бизнес-ориентированные операции

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

Для бизнес-ориентированных операций рекомендуется использовать WorkManager. Дополнительную информацию см. в разделе «Планирование задач с помощью WorkManager» .

Выявление ошибок

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

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

Дополнительные сведения об ошибках в сопрограммах см. в записи блога «Исключения в сопрограммах» .

Общие задачи

В следующих разделах представлены примеры использования и проектирования уровня данных для выполнения определенных задач, которые часто встречаются в приложениях Android. Примеры основаны на типичном приложении «Новости», упомянутом ранее в руководстве.

Сделать сетевой запрос

Выполнение сетевого запроса — одна из наиболее распространенных задач, которые может выполнять приложение Android. Приложение «Новости» должно предоставлять пользователю последние новости, полученные из сети. Поэтому приложению необходим класс источника данных для управления сетевыми операциями: NewsRemoteDataSource . Чтобы предоставить информацию остальной части приложения, создается новый репозиторий, который обрабатывает операции с данными новостей: NewsRepository .

Требование состоит в том, чтобы последние новости всегда обновлялись, когда пользователь открывает экран. Таким образом, это UI-ориентированная операция .

Создайте источник данных

Источник данных должен предоставить функцию, возвращающую последние новости: список экземпляров ArticleHeadline . Источник данных должен обеспечивать безопасный способ получения последних новостей из сети. Для этого ему необходимо получить зависимость от 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 в вашем приложении.

Создать репозиторий

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

// NewsRepository is consumed from other layers of the hierarchy.
class NewsRepository(
    private val newsRemoteDataSource: NewsRemoteDataSource
) {
    suspend fun fetchLatestNews(): List<ArticleHeadline> =
        newsRemoteDataSource.fetchLatestNews()
}

Чтобы узнать, как использовать класс репозитория непосредственно из уровня пользовательского интерфейса, см. руководство по уровню пользовательского интерфейса .

Реализация кэширования данных в памяти.

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

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

Тайники

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

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

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

Следующая реализация кэширует информацию о последних новостях в переменную в репозитории, защищенную от записи с помощью Mutex . Если результат сетевого запроса успешен, данные присваиваются переменной 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 вызывающего объекта для выполнения этой логики. Вместо этого NewsRepository должен использовать CoroutineScope , привязанный к его жизненному циклу. Получение последних новостей должно быть операцией, ориентированной на приложения.

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

class NewsRepository(
    ...,
    // This could be CoroutineScope(SupervisorJob() + Dispatchers.Default).
    private val externalScope: CoroutineScope
) { ... }

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

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 используется для запуска сопрограммы во внешней области. await вызывается в новой сопрограмме для приостановки до тех пор, пока сетевой запрос не вернется и результат не будет сохранен в кеш. Если к этому моменту пользователь все еще будет на экране, то он увидит последние новости; если пользователь отходит от экрана, await отменяется, но логика внутри async продолжает выполняться.

См. эту публикацию в блоге , чтобы узнать больше о шаблонах для CoroutineScope .

Сохранение и получение данных с диска

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

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

  • Для больших наборов данных , которые необходимо запрашивать, требовать ссылочной целостности или частичных обновлений, сохраните данные в базе данных Room . В примере приложения «Новости» новостные статьи или авторы могут быть сохранены в базе данных.
  • Для небольших наборов данных , которые необходимо только получить и установить (а не запрашивать или частично обновлять), используйте DataStore . В примере приложения «Новости» предпочтительный формат даты пользователя или другие настройки отображения могут быть сохранены в DataStore.
  • Для фрагментов данных, таких как объект JSON, используйте файл .

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

Комната как источник данных

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

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

Дополнительные сведения о работе с API-интерфейсами Room см. в руководствах по Room .

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

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

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

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

Дополнительные сведения о работе с API DataStore см. в руководствах DataStore .

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

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

Дополнительную информацию о работе с файловым хранилищем см. на странице Обзор хранилища .

Планируйте задачи с помощью WorkManager

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

WorkManager упрощает планирование асинхронной и надежной работы и может позаботиться об управлении ограничениями. Это рекомендуемая библиотека для постоянной работы. Для выполнения задачи, определенной выше, создается класс Worker : RefreshLatestNewsWorker . Этот класс принимает 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()
    }
}

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

В этом примере эта задача, связанная с новостями, должна быть вызвана из NewsRepository , которая будет использовать новый источник данных в качестве зависимости: 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 с помощью библиотеки запуска приложения , которая вызывает репозиторий из Initializer .

Дополнительные сведения о работе с API WorkManager см. в руководствах WorkManager .

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

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

Модульные тесты

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

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

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

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

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

Образцы

Следующие примеры Google демонстрируют использование уровня данных. Изучите их, чтобы увидеть это руководство на практике:

{% дословно %} {% дословно %} {% дословно %} {% дословно %}