Warstwa danych

Warstwa interfejsu zawiera stany i logikę UI, a warstwa danych zawiera dane aplikacji i logikę biznesową. Logika biznesowa nadaje Twojej aplikacji wartość – opiera się na rzeczywistych regułach biznesowych, które określają, w jaki sposób należy tworzyć, przechowywać i zmieniać dane aplikacji.

Dzięki temu można używać warstwy danych na wielu ekranach, udostępniać informacje między różnymi częściami aplikacji i odtwarzać logikę biznesową poza interfejsem na potrzeby testowania jednostkowego. Więcej informacji o zaletach warstwy danych znajdziesz na stronie Przegląd architektury.

Architektura warstwy danych

Warstwa danych składa się z repozytoriów, z których każde może zawierać od 0 do wielu źródeł danych. Utwórz klasę repozytorium dla każdego rodzaju danych, które obsługujesz w swojej aplikacji. Możesz na przykład utworzyć klasę MoviesRepository dla danych związanych z filmami lub PaymentsRepository dla danych związanych z płatnościami.

W typowej architekturze repozytoria warstwy danych udostępniają dane reszcie aplikacji i zależą od źródeł danych.
Rysunek 1. Rola warstwy UI w architekturze aplikacji.

Klasy repozytorium odpowiadają za te zadania:

  • Ujawnienie danych reszcie aplikacji.
  • Centralizacja zmian w danych.
  • rozwiązywanie konfliktów między wieloma źródłami danych;
  • Abstrakcyjne źródła danych z pozostałej części aplikacji.
  • Zawiera logikę biznesową.

Każda klasa źródła danych powinna odpowiadać za pracę z tylko jednym źródłem danych, którym może być plik, źródło sieci lub lokalna baza danych. Klasy źródła danych to most między aplikacją a systemem dla operacji na danych.

Inne warstwy w hierarchii nigdy nie powinny mieć bezpośredniego dostępu do źródeł danych. Punktami wejścia do warstwy danych są zawsze klasy repozytorium. Klasy stanu (patrz przewodnik po warstwach interfejsu) lub klasy przypadków użycia (patrz przewodnik po warstwach domen) nigdy nie powinny mieć źródła danych jako bezpośredniej zależności. Użycie klas repozytorium jako punktów wejścia umożliwia niezależne skalowanie różnych warstw architektury.

Dane udostępniane przez tę warstwę powinny być niezmienne, aby nie mogły zostać zmodyfikowane przez inne klasy, co mogłoby spowodować niespójność wartości. Dane stałe mogą być też bezpiecznie obsługiwane w wielu wątkach. Więcej informacji znajdziesz w sekcji na temat podziału na wątki.

Zgodnie ze sprawdzonymi metodami dotyczącymi wstrzykiwania zależności repozytorium wykorzystuje źródła danych jako zależności w swoim konstruktorze:

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

Udostępnianie interfejsów API

Klasy w warstwie danych zwykle udostępniają funkcje do wykonywania jednorazowych wywołań Create, Read, Update i Delete (CRUD) lub otrzymywania powiadomień o zmianach danych w czasie. W każdym z tych przypadków warstwa danych powinna przedstawiać:

  • Operacje one-shot: warstwa danych powinna udostępniać funkcje zawieszania w języku Kotlin, a w przypadku języka programowania Java warstwa danych powinna udostępniać funkcje zapewniające wywołanie zwrotne powiadamiające o wyniku operacji lub typy RxJava Single, Maybe lub Completable.
  • Aby otrzymywać powiadomienia o zmianach danych w czasie: warstwa danych powinna udostępniać przepływy w Kotlin, a w przypadku języka programowania Java warstwa danych powinna udostępniać wywołanie zwrotne generujące nowe dane albo typ RxJava Observable albo Flowable.
class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) {

    val data: Flow<Example> = ...

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

Konwencje nazewnictwa w tym przewodniku

W tym przewodniku klasy repozytorium noszą nazwy danych, za które są odpowiedzialne. Konwencja jest następująca:

type of data + Repository (Repozytorium).

na przykład: NewsRepository, MoviesRepository lub PaymentsRepository.

Nazwy klas źródeł danych składają się z danych, za które odpowiadają, i źródeł, z których korzystają. Konwencja jest następująca:

typ danych + typ źródła + Źródło danych.

W przypadku tego typu danych użyj opcji Zdalne lub Lokalne, aby sprecyzować typ danych, ponieważ implementacje mogą się zmieniać. np. NewsRemoteDataSource lub NewsLocalDataSource. Aby podać więcej szczegółów, podaj typ źródła. np. NewsNetworkDataSource lub NewsDiskDataSource.

Nie nadawaj nazwy źródłu danych na podstawie szczegółów implementacji, np. UserSharedPreferencesDataSource, ponieważ repozytoria korzystające z tego źródła danych nie powinny wiedzieć, jak zapisywane są dane. Jeśli zastosujesz się do tej reguły, możesz zmienić implementację źródła danych (np. migrację z SharedPreferences do DataStore) nie zmieniając warstwy wywołującej to źródło.

Różne poziomy repozytoriów

W niektórych przypadkach bardziej złożonych wymagań biznesowych repozytorium może zależeć od innych repozytoriów. Przyczyną może być to, że dane, których to dotyczy, są agregowane z wielu źródeł danych lub to zadanie musi być ujęte w inną klasę repozytorium.

Na przykład repozytorium obsługujące dane uwierzytelniania użytkowników (UserRepository) może korzystać z innych repozytoriów, takich jak LoginRepository i RegistrationRepository, w zależności od wymagań.

W tym przykładzie UserRepository zależy od 2 innych klas repozytorium: LoginRepository, który zależy od innych źródeł danych logowania, oraz RegistrationRepository, który zależy od innych źródeł danych rejestracji.
Rysunek 2. Wykres zależności repozytorium, które jest zależne od innych repozytoriów.

Źródło informacji

Ważne, aby każde repozytorium definiuje jedno źródło wiarygodnych danych. Źródło prawdy zawsze zawiera dane, które są spójne, prawidłowe i aktualne. Dane ujawniane z repozytorium powinny zawsze być danymi pochodzącymi bezpośrednio ze źródła informacji.

Źródłem danych może być źródło danych – na przykład baza danych – a nawet pamięć podręczna w pamięci, którą może zawierać repozytorium. Repozytoria łączą różne źródła danych i rozwiązują potencjalne konflikty między źródłami danych, aby regularnie aktualizować jedno źródło danych lub z powodu zdarzeń wejściowych użytkownika.

Różne repozytoria w aplikacji mogą mieć różne źródła informacji. Na przykład klasa LoginRepository może używać swojej pamięci podręcznej jako źródła danych, a klasa PaymentsRepository może używać sieciowego źródła danych.

Aby zapewnić pomoc offline, lokalne źródło danych, takie jak baza danych, jest zalecanym źródłem informacji.

Gwintowanie

Wywoływanie źródeł danych i repozytoriów powinno być bezpieczne – można je wywoływać z wątku głównego. Te klasy odpowiadają za przenoszenie wykonania swojej logiki do odpowiedniego wątku podczas długotrwałych operacji blokujących. Na przykład zasadniczo bezpieczne dla źródła danych powinno być odczyt z pliku, a repozytorium – może przeprowadzić kosztowne filtrowanie na wielkiej liście.

Większość źródeł danych zapewnia już bezpieczne interfejsy API, takie jak wywołania metody zawieszania dostarczane przez Room, Retrofit lub Ktor. Twoje repozytorium będzie mogło korzystać z tych interfejsów API, gdy będą dostępne.

Więcej informacji o wątkach znajdziesz w przewodniku po przetwarzaniu w tle. W przypadku użytkowników Kotlin zalecamy użycie korekty. Opcje zalecane w przypadku języka programowania Java znajdziesz w artykule Uruchamianie zadań Androida w wątkach w tle.

Cykl życia

Instancje klas w warstwie danych pozostają w pamięci, dopóki są dostępne z poziomu głównego katalogu czyszczenia pamięci – zwykle przez odwołania do innych obiektów w aplikacji.

Jeśli klasa zawiera dane w pamięci (np. pamięć podręczną), możesz używać tej samej instancji przez określony czas. Jest on też nazywany cyklem życia instancji klasy.

Jeśli odpowiedzialność klasy ma kluczowe znaczenie dla całej aplikacji, możesz określić zakres instancji tej klasy na klasę Application. Dzięki temu instancja śledzi cykl życia aplikacji. Jeśli chcesz ponownie użyć tej samej instancji tylko w konkretnym procesie aplikacji (np. w procesie rejestracji lub logowania), możesz ustawić zakres instancji na klasę, do której należy cykl życia tego przepływu. Możesz na przykład określić zakres RegistrationRepository, który zawiera dane w pamięci, do elementu RegistrationActivity lub wykresu nawigacyjnego procesu rejestracji.

Cykl życia każdej instancji ma kluczowe znaczenie przy podejmowaniu decyzji o sposobie udostępniania zależności w aplikacji. Zalecamy stosowanie sprawdzonych metod dotyczących wstrzykiwania zależności, które umożliwiają zarządzanie zależnościami i ich zakres na kontenerach zależności. Więcej informacji o określaniu zakresu w Androidzie znajdziesz w poście na blogu Określanie zakresu w Androidzie i Hilt.

Przedstaw modele biznesowe

Modele danych, które chcesz ujawnić z warstwy danych, mogą być podzbiorem informacji uzyskanych z różnych źródeł. W idealnej sytuacji różne źródła danych – zarówno sieciowe, jak i lokalne – powinny zwracać tylko te informacje, których potrzebuje Twoja aplikacja, ale niezbyt często.

Wyobraźmy sobie na przykład serwer News API, który zwraca nie tylko informacje o artykule, ale też historię zmian, komentarze użytkowników i niektóre metadane:

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
)

Aplikacja nie potrzebuje aż tylu informacji o artykule, ponieważ na ekranie wyświetla się tylko jego treść wraz z podstawowymi informacjami o jego autorze. Warto oddzielić klasy modelu i ustawić w repozytoriach tylko te dane, których wymagają pozostałe warstwy hierarchii. Oto jak można na przykład skrócić ArticleApiModel z sieci w celu udostępnienia klasy modelu Article dla domeny i warstw interfejsu użytkownika:

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

Rozdzielanie klas modelu jest korzystne z tych powodów:

  • Oszczędza pamięć aplikacji, zmniejszając ilość danych tylko do tych, które są potrzebne.
  • Dostosowuje on typy danych zewnętrznych do typów danych używanych przez Twoją aplikację – na przykład aplikacja może używać innego typu danych do reprezentowania dat.
  • Pozwala lepiej oddzielić potencjalne problemy – na przykład członkowie dużego zespołu mogą pracować indywidualnie nad warstwami sieci i interfejsu użytkownika, jeśli klasa modelu jest wcześniej zdefiniowana.

Możesz rozszerzyć tę praktykę i definiować osobne klasy modelu w innych częściach architektury aplikacji, np. w klasach źródła danych i modelach ViewModel. Wymaga to jednak zdefiniowania dodatkowych klas i mechanizmów logicznych, które należy odpowiednio udokumentować i przetestować. Zalecamy tworzenie nowych modeli za każdym razem, gdy źródło danych otrzymuje dane, które nie są zgodne z oczekiwaniami reszty aplikacji.

Typy operacji na danych

Warstwa danych może radzić sobie z rodzajami działań, które różnią się w zależności od tego, jak ważne są: działania związane z interfejsem użytkownika, aplikacjami i działaniami biznesowymi.

Operacje związane z interfejsem użytkownika

Operacje związane z interfejsem mają znaczenie tylko wtedy, gdy użytkownik korzysta z określonego ekranu. Są one anulowane, gdy użytkownik odejdzie od tego ekranu. Przykładem może być wyświetlenie niektórych danych uzyskanych z bazy danych.

Operacje związane z interfejsem użytkownika są zwykle aktywowane przez warstwę UI i śledzą cykl życia wywołania, na przykład cykl życia obiektu ViewModel. Przykład operacji ukierunkowanej na interfejs użytkownika znajdziesz w sekcji Wyślij żądanie sieciowe.

Operacje związane z aplikacjami

Działania związane z aplikacją są istotne, dopóki aplikacja jest otwarta. Jeśli aplikacja zostanie zamknięta lub proces zatrzymany, te operacje zostaną anulowane. Przykładem może być zapisywanie wyniku żądania sieciowego w pamięci podręcznej, tak aby w razie potrzeby można go było później użyć. Więcej informacji znajdziesz w sekcji Wdrażanie buforowania danych w pamięci.

Operacje te zazwyczaj są zgodne z cyklem życia klasy Application lub warstwy danych. Przykład znajdziesz w sekcji Wykonywanie operacji dłużej niż na ekranie.

Operacje biznesowe

Działań biznesowych nie można anulować. Powinni przetrwać śmierć. Przykładem może być zakończenie przesyłania zdjęcia, które użytkownik chce opublikować na swoim profilu.

W przypadku działalności biznesowej zalecamy korzystanie z usługi WorkManager. Więcej informacji znajdziesz w sekcji Planowanie zadań za pomocą WorkManagera.

Pokaż błędy

Interakcje z repozytoriami i źródłami danych mogą się udać lub spowodować wystąpienie błędu, gdy wystąpi błąd. W przypadku współużytkowanych i przepływów należy korzystać z wbudowanego mechanizmu obsługi błędów przez Kotlina. W przypadku błędów, które mogą być aktywowane przez funkcje zawieszania, używaj w odpowiednich przypadkach bloków try/catch, a w przepływach używaj operatora catch. Przy tym podejściu warstwa interfejsu powinna obsługiwać wyjątki podczas wywoływania warstwy danych.

Warstwa danych może rozpoznawać i obsługiwać różne typy błędów oraz ujawniać je za pomocą niestandardowych wyjątków, np. UserNotAuthenticatedException.

Więcej informacji o błędach w współprogramach znajdziesz w poście na blogu Wyjątki w kodach.

Częste zadania

W kolejnych sekcjach znajdziesz przykłady użycia i architektury warstwy danych w celu wykonywania określonych zadań typowych dla aplikacji na Androida. Omówiono w nich przykład typowej aplikacji z wiadomościami, o której mowa w przewodniku.

Wyślij żądanie sieciowe

Wysyłanie żądań sieciowych to jedno z najczęstszych działań, jakie może wykonywać aplikacja na Androida. Aplikacja Wiadomości musi przedstawiać użytkownikowi najnowsze wiadomości pobierane z sieci. Dlatego do zarządzania operacjami sieciowymi aplikacja potrzebuje klasy źródła danych: NewsRemoteDataSource. Aby udostępnić te informacje w pozostałej części aplikacji, tworzone jest nowe repozytorium, które obsługuje operacje na danych wiadomości: NewsRepository.

Wymaganie jest takie, aby najnowsze wiadomości były zawsze aktualizowane, gdy użytkownik otwiera ekran. Jest to więc operacja zorientowana na interfejs użytkownika.

Tworzenie źródła danych

Źródło danych musi udostępniać funkcję, która zwraca najnowsze wiadomości: listę instancji ArticleHeadline. Źródło danych musi zapewniać główny bezpieczny sposób pobierania najnowszych wiadomości z sieci. Aby to zrobić, uruchomienie zadania wymaga zależności od: CoroutineDispatcher lub Executor.

Żądanie sieciowe jest wykonywane w ramach wywołań typu „one-shot”, które jest obsługiwane przez nową metodę 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>
}

Interfejs NewsApi ukrywa implementację klienta interfejsu API sieci. Nie ma znaczenia, czy interfejs korzysta z Retrofit czy HttpURLConnection. Poleganie na interfejsach sprawia, że implementacje interfejsów API w aplikacji są wymienne.

Tworzenie repozytorium

Ponieważ klasa repozytorium nie wymaga do tego zadania dodatkowej logiki, NewsRepository działa jako serwer proxy dla sieciowego źródła danych. Zalety dodania tej dodatkowej warstwy abstrakcji zostały opisane w sekcji buforowanie w pamięci.

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

Informacje o tym, jak korzystać z klasy repozytorium bezpośrednio z warstwy interfejsu, znajdziesz w przewodniku dotyczącym warstwy interfejsu.

Wdrażanie buforowania danych w pamięci

Załóżmy, że w przypadku aplikacji Wiadomości wprowadzono nowe wymaganie: gdy użytkownik otworzy ekran, musi wyświetlić się w pamięci podręcznej wiadomości z pamięci podręcznej, jeśli takie żądanie zostało już wcześniej zgłoszone. W przeciwnym razie aplikacja powinna wysłać żądanie sieciowe, by pobrać najnowsze wiadomości.

Ze względu na nowe wymaganie aplikacja musi zachowywać w pamięci najnowsze wiadomości, gdy użytkownik ma ją otwartą. Dlatego jest to działanie związane z aplikacją.

Pamięci podręczne

Możesz zachować dane, gdy użytkownik korzysta z aplikacji, dodając buforowanie danych w pamięci. Pamięć podręczna służy do zapisywania pewnych informacji w pamięci przez określony czas – w tym przypadku tak długo, jak użytkownik korzysta z aplikacji. Implementacje pamięci podręcznej mogą przybierać różne formy. Może to być prosta zmienna zmienna lub bardziej zaawansowana klasa, która chroni przed operacjami odczytu i zapisu w wielu wątkach. W zależności od przypadku użycia buforowanie można wdrożyć w repozytorium lub klasach źródła danych.

Zapisywanie wyniku żądania sieciowego w pamięci podręcznej

Dla uproszczenia NewsRepository używa zmiennej zmiennej do buforowania najnowszych wiadomości. Do ochrony odczytów i zapisów w różnych wątkach używany jest tag Mutex. Więcej informacji o współdzielonym stanie zmiennym i równoczesności znajdziesz w dokumentacji Kotlin.

Ta implementacja zapisuje najnowsze informacje o wiadomościach w pamięci podręcznej w zmiennej w repozytorium, które jest zabezpieczone przed zapisem za pomocą metody Mutex. Jeśli żądanie sieciowe zostanie zrealizowane, dane zostaną przypisane do zmiennej 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 }
    }
}

Działanie operacji trwa dłużej niż ekran

Jeśli użytkownik opuści ekran w trakcie realizacji żądania sieciowego, zostanie ono anulowane, a wynik nie zostanie zapisany w pamięci podręcznej. NewsRepository nie powinien używać funkcji CoroutineScope wywołującego do wykonania tej logiki. Zamiast tego NewsRepository powinien używać atrybutu CoroutineScope dołączonego do swojego cyklu życia. Pobieranie najnowszych wiadomości musi odbywać się w kontekście aplikacji.

Aby zachować zgodność ze sprawdzonymi metodami wstrzykiwania zależności, parametr NewsRepository powinien otrzymać zakres jako parametr w konstruktorze, a nie utworzyć własny parametr CoroutineScope. Repozytoria powinny wykonywać większość swojej pracy w wątkach w tle, dlatego skonfiguruj CoroutineScope za pomocą Dispatchers.Default lub własnej puli wątków.

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

Ponieważ usługa NewsRepository jest gotowa do wykonywania operacji związanych z aplikacją przy użyciu zewnętrznego elementu CoroutineScope, musi wykonać wywołanie do źródła danych i zapisać jego wynik za pomocą nowej współpracy uruchomionej przez ten zakres:

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 }
        } 
    }
}

Parametr async służy do uruchamiania współpracy w zakresie zewnętrznym. Interfejs await jest wywoływany przez nową współpracę do momentu otrzymania żądania sieciowego do momentu otrzymania żądania i zapisania wyniku w pamięci podręcznej. Jeśli użytkownik będzie nadal widoczny na ekranie, zobaczy najnowsze wiadomości. Jeśli użytkownik odejdzie od ekranu, funkcja await zostanie anulowana, ale logika wewnątrz elementu async będzie nadal wykonywana.

Więcej informacji o wzorcach używanych w interfejsie CoroutineScope znajdziesz w tym poście na blogu.

Zapisywanie i pobieranie danych z dysku

Załóżmy, że chcesz zapisywać dane, takie jak wiadomości dodane do zakładek i preferencje użytkownika. Dane tego typu muszą przetrwać śmierć procesu i być dostępne nawet wtedy, gdy użytkownik nie jest połączony z siecią.

Jeśli dane, z którymi pracujesz, muszą przetrwać śmierć, musisz zapisać je na dysku w jeden z tych sposobów:

  • W przypadku dużych zbiorów danych, w przypadku których wymagane jest zapytanie, integralność referencyjna lub częściowe aktualizacje, zapisz dane w bazie danych sal. W przypadku np. aplikacji Wiadomości artykuły lub autorzy mogą być zapisane w bazie danych.
  • W przypadku małych zbiorów danych, które trzeba pobrać i ustawić (bez zapytań ani części), użyj DataStore. W przypadku aplikacji Wiadomości preferowany format daty lub inne ustawienia wyświetlania można zapisać w DataStore.
  • W przypadku fragmentów danych, takich jak obiekt JSON, użyj file.

Jak wspomnieliśmy w sekcji Źródło danych, każde źródło danych działa tylko z jednym źródłem i odpowiada konkretnemu typowi danych (np. News, Authors, NewsAndAuthors lub UserPreferences). Klasy korzystające ze źródła danych nie powinny wiedzieć, jak dane są zapisywane, np. w bazie danych czy w pliku.

Pokój jako źródło danych

Każde źródło danych powinno odpowiadać za pracę z tylko jednym źródłem danych określonego typu, dlatego źródło danych o pokojach otrzyma jako parametr obiekt dostępu do danych (DAO) lub samą bazę danych. Na przykład NewsLocalDataSource może przyjąć wystąpienie elementu NewsDao jako parametru, a AuthorsLocalDataSource – wystąpienie AuthorsDao.

W niektórych przypadkach, jeśli nie potrzebujesz dodatkowej logiki, możesz wstawić DAO bezpośrednio do repozytorium, ponieważ jest to interfejs, który można łatwo zastąpić w testach.

Więcej informacji o korzystaniu z interfejsów API Room znajdziesz w przewodnikach dotyczących sal.

DataStore jako źródło danych

DataStore idealnie nadaje się do przechowywania par klucz-wartość, np. ustawień użytkownika. Może to być na przykład format godziny, ustawienia powiadomień oraz czy wiadomości mają być wyświetlane czy ukryte po ich przeczytaniu przez użytkownika. DataStore może też przechowywać obiekty określonego typu za pomocą buforów protokołów.

Podobnie jak w przypadku każdego innego obiektu, źródło danych obsługiwane przez DataStore powinno zawierać dane związane z określonym typem lub konkretną częścią aplikacji. Dotyczy to jeszcze większej części DataStore, ponieważ odczyty z DataStore są widoczne jako przepływ, który jest emitowany przy każdej aktualizacji wartości. Z tego powodu należy przechowywać preferencje w tym samym magazynie danych.

Możesz na przykład utworzyć NotificationsDataStore, który obsługuje tylko preferencje dotyczące powiadomień, i NewsPreferencesDataStore tylko do obsługi ustawień dotyczących ekranu z wiadomościami. Pozwala to lepiej określić zakres aktualizacji, ponieważ przepływ newsScreenPreferencesDataStore.data jest emitowany, gdy zmieni się ustawienie związane z tym ekranem. Oznacza to również, że cykl życia obiektu może być krótszy, ponieważ może istnieć tylko wtedy, gdy wyświetlany jest ekran z wiadomościami.

Więcej informacji o pracy z interfejsami API DataStore znajdziesz w przewodnikach po DataStore.

Plik jako źródło danych

Podczas pracy z dużymi obiektami, takimi jak obiekt JSON lub bitmapa, musisz korzystać z obiektu File i obsługiwać przełączanie wątków.

Więcej informacji o pracy z miejscem na pliki znajdziesz na stronie Omówienie miejsca na dane.

Planowanie zadań za pomocą WorkManagera

Załóżmy, że w przypadku aplikacji Wiadomości wprowadzono nowy wymóg: aplikacja musi umożliwiać użytkownikowi regularne i automatyczne pobieranie najnowszych wiadomości, gdy tylko urządzenie się ładuje i jest połączone z siecią bez pomiaru użycia danych. Oznacza to, że jest to operacja biznesowa. Ten wymóg sprawia, że nawet jeśli urządzenie nie ma połączenia z internetem, gdy otwiera aplikację, użytkownik może nadal zobaczyć najnowsze wiadomości.

WorkManager ułatwia planowanie asynchronicznej i niezawodnej pracy oraz zarządzanie ograniczeniami. To biblioteka zalecana do stałej pracy. Aby wykonać zadanie zdefiniowane powyżej, tworzona jest klasa Worker: RefreshLatestNewsWorker. Ta klasa wykorzystuje NewsRepository jako zależność, aby pobierać najnowsze wiadomości i zapisywać je w pamięci podręcznej na dysku.

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()
    }
}

Logika biznesowa tego typu zadania powinna być ujęta w osobnej klasie i traktowana jako osobne źródło danych. WorkManager będzie wtedy odpowiedzialny za zapewnienie, że praca będzie wykonywana w wątku w tle tylko po spełnieniu wszystkich ograniczeń. Trzymając się tego wzorca, możesz w razie potrzeby szybko zamieniać implementacje w różnych środowiskach.

W tym przykładzie to zadanie związane z wiadomościami należy wywołać z metody NewsRepository, co sprawi, że zależność od nowego źródła danych to: NewsTasksDataSource, zaimplementowana w ten sposób:

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)
    }
}

Nazwy tych typów klas pochodzą od danych, za które są odpowiedzialne – na przykład NewsTasksDataSource lub PaymentsTasksDataSource. Wszystkie zadania związane z określonym typem danych powinny być zawarte w tej samej klasie.

Jeśli zadanie musi być aktywowane podczas uruchamiania aplikacji, zalecamy aktywowanie żądania WorkManagera za pomocą biblioteki uruchamiania aplikacji, która wywołuje repozytorium z Initializer.

Więcej informacji o pracy z interfejsami API WorkManager znajdziesz w przewodnikach po WorkManager.

Testowanie

Sprawdzone metody dotyczące wstrzykiwania zależności są pomocne podczas testowania aplikacji. Warto też korzystać z interfejsów dla klas, które komunikują się z zasobami zewnętrznymi. Podczas testowania jednostki można wprowadzić fałszywe wersje zależności, aby test był deterministyczny i niezawodny.

Testy jednostkowe

Podczas testowania warstwy danych obowiązują ogólne wskazówki dotyczące testowania. W przypadku testów jednostkowych używaj w razie potrzeby prawdziwych obiektów i fałszuj wszelkie zależności, które docierają do źródeł zewnętrznych, takich jak odczyt z pliku lub odczyt z sieci.

Testy integracji

Testy integracyjne, które uzyskują dostęp do zewnętrznych źródeł, są mniej deterministyczne, bo muszą być uruchamiane na prawdziwym urządzeniu. Zalecamy wykonywanie tych testów w kontrolowanym środowisku, aby testy integracji były bardziej miarodajne.

W przypadku baz danych Room umożliwia tworzenie w pamięci bazy danych, którą możesz w pełni kontrolować podczas testów. Więcej informacji znajdziesz na stronie Testowanie i debugowanie bazy danych.

Istnieją popularne biblioteki do obsługi sieci, takie jak WireMock czy MockWebServer. Pozwalają one fałszywe wywołania HTTP i HTTPS oraz sprawdzać, czy żądania są wysyłane zgodnie z oczekiwaniami.

Próbki

Poniższe przykłady Google ilustrują użycie warstwy danych. Zapoznaj się z nimi, aby zastosować te wskazówki w praktyce: