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.
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
lubCompletable
. - 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
alboFlowable
.
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ń.
Ź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:
Polecane dla Ciebie
- Uwaga: tekst linku jest wyświetlany, gdy JavaScript jest wyłączony
- Warstwa domeny
- Tworzenie aplikacji działających offline
- Produkcyjna stanowa wersja UI