Data-Ebene

Während die UI-Ebene UI-bezogene Status und UI-Logik enthält, enthält die Datenschicht Anwendungsdaten und Geschäftslogik. Die Geschäftslogik ist das, was Ihrer Anwendung einen Mehrwert verleiht. Sie besteht aus realen Geschäftsregeln, die bestimmen, wie Anwendungsdaten erstellt, gespeichert und geändert werden müssen.

Durch diese Trennung kann die Datenschicht auf mehreren Bildschirmen verwendet werden, Informationen zwischen verschiedenen Teilen der App teilen und die Geschäftslogik außerhalb der UI für Einheitentests reproduzieren. Weitere Informationen zu den Vorteilen der Datenschicht finden Sie auf der Seite Architekturübersicht.

Datenschichtarchitektur

Die Datenschicht besteht aus Repositories, die jeweils null bis viele Datenquellen enthalten können. Sie sollten für jeden Datentyp, den Sie in Ihrer Anwendung verarbeiten, eine Repository-Klasse erstellen. Beispielsweise könnten Sie eine MoviesRepository-Klasse für Daten zu Filmen oder eine PaymentsRepository-Klasse für Daten im Zusammenhang mit Zahlungen erstellen.

In einer typischen Architektur stellen die Repositories der Datenschicht Daten für den Rest der Anwendung bereit und sind von den Datenquellen abhängig.
Abbildung 1. Die Rolle der UI-Ebene in der App-Architektur.

Repository-Klassen sind für die folgenden Aufgaben verantwortlich:

  • Daten werden für den Rest der App freigegeben.
  • Änderungen an den Daten zentralisieren.
  • Konflikte zwischen mehreren Datenquellen beheben
  • Abstraktion von Datenquellen aus dem Rest der App.
  • Enthält Geschäftslogik.

Jede Datenquellenklasse sollte nur mit einer Datenquelle arbeiten. Dies kann eine Datei, eine Netzwerkquelle oder eine lokale Datenbank sein. Datenquellenklassen sind das Bindeglied zwischen der Anwendung und dem System für Datenvorgänge.

Andere Ebenen in der Hierarchie sollten niemals direkt auf Datenquellen zugreifen. Die Einstiegspunkte zur Datenschicht sind immer die Repository-Klassen. Für Status-Herstellerklassen (siehe Leitfaden zur UI-Ebene) oder Anwendungsfallklassen (siehe Leitfaden zur Domainebene) sollten Datenquellen nie als direkte Abhängigkeit bestehen. Wenn Sie Repository-Klassen als Einstiegspunkte verwenden, können die verschiedenen Ebenen der Architektur unabhängig skaliert werden.

Die von dieser Ebene bereitgestellten Daten sollten unveränderlich sein, damit sie nicht von anderen Klassen manipuliert werden können, da dies dazu führen könnte, dass ihre Werte inkonsistent sind. Unveränderliche Daten können auch sicher von mehreren Threads verarbeitet werden. Weitere Informationen finden Sie im Abschnitt zu Threads.

Gemäß den Best Practices für die Abhängigkeitsinjektion nimmt das Repository Datenquellen als Abhängigkeiten in seinem Konstruktor an:

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

APIs freigeben

Klassen in der Datenschicht stellen im Allgemeinen Funktionen bereit, mit denen Sie einmalige CRUD-Aufrufe (Erstellen, Lesen, Aktualisieren und Löschen) ausführen oder über Datenänderungen im Laufe der Zeit benachrichtigt werden können. Die Datenschicht sollte in jedem dieser Fälle Folgendes enthalten:

  • One-Shot-Vorgänge:Die Datenschicht sollte Haltefunktionen in Kotlin bereitstellen. Für die Programmiersprache Java sollte die Datenschicht Funktionen zur Verfügung stellen, die einen Callback zur Benachrichtigung über das Ergebnis des Vorgangs bereitstellen, sowie die RxJava-Typen Single, Maybe oder Completable.
  • Um über Datenänderungen im Laufe der Zeit informiert zu werden:Die Datenschicht sollte Abläufe in Kotlin bereitstellen. Für die Programmiersprache Java sollte die Datenschicht einen Callback bereitstellen, der die neuen Daten oder den RxJava-Typ Observable oder Flowable ausgibt.
class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) {

    val data: Flow<Example> = ...

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

Namenskonventionen in diesem Leitfaden

In dieser Anleitung werden Repository-Klassen nach den Daten benannt, für die sie zuständig sind. Dafür gilt folgende Konvention:

Datentyp + Repository

Beispiel: NewsRepository, MoviesRepository oder PaymentsRepository.

Datenquellenklassen werden nach den Daten, für die sie verantwortlich sind, und der von ihnen verwendeten Quelle benannt. Dafür gilt folgende Konvention:

Datentyp + Art der Quelle + Datenquelle

Verwenden Sie als Datentyp Remote oder Local, um allgemeiner zu sein, da sich Implementierungen ändern können. Beispiel: NewsRemoteDataSource oder NewsLocalDataSource. Falls die Quelle wichtig ist, können Sie deren Typ verwenden. Beispiel: NewsNetworkDataSource oder NewsDiskDataSource.

Benennen Sie die Datenquelle nicht nach einem Implementierungsdetail (z. B. UserSharedPreferencesDataSource), da Repositories, die diese Datenquelle verwenden, nicht wissen, wie die Daten gespeichert werden. Wenn Sie dieser Regel folgen, können Sie die Implementierung der Datenquelle ändern (z. B. von SharedPreferences zu DataStore) migrieren, ohne dass sich dies auf die Ebene auswirkt, auf der diese Quelle aufgerufen wird.

Repositories auf mehreren Ebenen

In manchen Fällen mit komplexeren Geschäftsanforderungen muss ein Repository möglicherweise von anderen Repositories abhängen. Dies kann daran liegen, dass die beteiligten Daten eine Aggregation aus mehreren Datenquellen sind oder die Verantwortung in einer anderen Repository-Klasse gekapselt werden muss.

Beispielsweise könnte ein Repository, das Daten zur Nutzerauthentifizierung verarbeitet, UserRepository, für die Erfüllung seiner Anforderungen von anderen Repositories wie LoginRepository und RegistrationRepository abhängig sein.

Im Beispiel hängt UserRepository von zwei anderen Repository-Klassen ab: LoginRepository, das von anderen Datenquellen für die Anmeldung abhängt, und RegistrationRepository, das von anderen Registrierungsdatenquellen abhängt.
Abbildung 2. Abhängigkeitsdiagramm eines Repositorys, das von anderen Repositories abhängt.

Datenquelle

Es ist wichtig, dass jedes Repository eine einzige Datenquelle definiert. Die „Source of Truth“ enthält immer Daten, die einheitlich, richtig und aktuell sind. Tatsächlich sollten die aus dem Repository bereitgestellten Daten immer direkt von der „Source of Truth“ stammen.

Die „Source of Truth“ kann eine Datenquelle (z. B. die Datenbank) oder auch ein speicherinterner Cache sein, den das Repository enthalten könnte. Repositories kombinieren verschiedene Datenquellen und lösen potenzielle Konflikte zwischen den Datenquellen, um die Single Source of Truth regelmäßig oder aufgrund eines Nutzereingabeereignisses zu aktualisieren.

Verschiedene Repositories in Ihrer Anwendung können unterschiedliche „Source of Truth“ haben. Beispielsweise kann die LoginRepository-Klasse ihren Cache als zentrale Informationsquelle verwenden und die PaymentsRepository-Klasse die Netzwerkdatenquelle.

Für Offline-Support ist eine lokale Datenquelle (z. B. eine Datenbank) die empfohlene Datenquelle.

Mit Gewinde

Das Aufrufen von Datenquellen und Repositories sollte main-sicher sein, d. h., sie können sicher aus dem Hauptthread aufgerufen werden. Diese Klassen sind dafür verantwortlich, die Ausführung ihrer Logik in den entsprechenden Thread zu verschieben, wenn Blockiervorgänge mit langer Ausführungszeit ausgeführt werden. Es sollte beispielsweise besonders sicher sein, wenn eine Datenquelle aus einer Datei liest oder ein Repository teures Filtern bei einer großen Liste durchführt.

Die meisten Datenquellen bieten bereits bewährte APIs, z. B. die von Room, Retrofit oder Ktor bereitgestellten Aufrufe der Methode zum Anhalten. Ihr Repository kann diese APIs nutzen, wenn sie verfügbar sind.

Weitere Informationen zu Threading finden Sie im Leitfaden zur Hintergrundverarbeitung. Für Kotlin-Nutzer werden Koroutinen empfohlen. Empfohlene Optionen für die Programmiersprache Java finden Sie unter Android-Aufgaben in Hintergrundthreads ausführen.

Lebenszyklus

Instanzen von Klassen in der Datenschicht bleiben im Arbeitsspeicher, solange sie von einer Stammbereinigung aus erreichbar sind – in der Regel dadurch, dass von anderen Objekten in Ihrer Anwendung auf sie verwiesen wird.

Wenn eine Klasse speicherinterne Daten enthält, z. B. ein Cache, sollten Sie dieselbe Instanz dieser Klasse für einen bestimmten Zeitraum wiederverwenden. Dies wird auch als Lebenszyklus der Klasseninstanz bezeichnet.

Wenn die Verantwortung der Klasse für die gesamte Anwendung von entscheidender Bedeutung ist, können Sie eine Instanz dieser Klasse auf die Klasse Application beschränken. Die Instanz folgt also dem Lebenszyklus der Anwendung. Wenn Sie dieselbe Instanz nur in einem bestimmten Ablauf in Ihrer Anwendung wiederverwenden müssen, z. B. im Registrierungs- oder Anmeldevorgang, sollten Sie die Instanz der Klasse mit dem Lebenszyklus dieses Ablaufs zuweisen. Beispielsweise können Sie einen RegistrationRepository, der speicherinterne Daten enthält, auf den RegistrationActivity oder das Navigationsdiagramm des Registrierungsvorgangs beschränken.

Der Lebenszyklus jeder Instanz ist ein entscheidender Faktor bei der Entscheidung, wie Abhängigkeiten in Ihrer Anwendung bereitgestellt werden. Es empfiehlt sich, die Best Practices für die Abhängigkeitsinjektion einzuhalten, wenn die Abhängigkeiten verwaltet werden und auf Abhängigkeitscontainer beschränkt werden können. Weitere Informationen zum Umfang in Android finden Sie im Blogpost Bereich in Android und Hilt.

Geschäftsmodelle repräsentieren

Die Datenmodelle, die Sie aus der Datenschicht bereitstellen möchten, können eine Teilmenge der Informationen sein, die Sie aus den verschiedenen Datenquellen erhalten. Idealerweise sollten die verschiedenen Datenquellen – sowohl Netzwerk- als auch lokale – nur die Informationen zurückgeben, die Ihre Anwendung benötigt. Dies ist jedoch nicht oft der Fall.

Stellen Sie sich zum Beispiel einen News API-Server vor, der nicht nur die Artikelinformationen, sondern auch den Änderungsverlauf, Nutzerkommentare und einige Metadaten zurückgibt:

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
)

Die App benötigt nicht allzu viele Informationen über den Artikel, da nur der Inhalt des Artikels und grundlegende Informationen zum Autor auf dem Bildschirm angezeigt werden. Es empfiehlt sich, Modellklassen zu trennen und Ihre Repositories nur die Daten zur Verfügung zu stellen, die die anderen Hierarchieebenen benötigen. So können Sie beispielsweise die ArticleApiModel aus dem Netzwerk reduzieren, um eine Article-Modellklasse für die Domain- und UI-Ebenen verfügbar zu machen:

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

Das Trennen von Modellklassen ist auf folgende Weise von Vorteil:

  • Es spart App-Arbeitsspeicher, da die Daten nur auf das Nötigste reduziert werden.
  • Sie passt externe Datentypen an die von Ihrer Anwendung verwendeten Datentypen an. Beispielsweise kann Ihre Anwendung einen anderen Datentyp zur Darstellung von Datumsangaben verwenden.
  • Sie ermöglicht eine bessere Trennung von Belangen. Beispielsweise können Mitglieder eines großen Teams individuell an den Netzwerk- und UI-Ebenen eines Features arbeiten, wenn die Modellklasse zuvor definiert wurde.

Sie können diese Vorgehensweise erweitern und separate Modellklassen auch in anderen Teilen Ihrer App-Architektur definieren, z. B. in Datenquellenklassen und ViewModels. Dazu müssen Sie jedoch zusätzliche Klassen und Logik definieren, die Sie ordnungsgemäß dokumentieren und testen sollten. Es empfiehlt sich mindestens, wenn eine Datenquelle Daten empfängt, die nicht mit den Erwartungen Ihrer Anwendung übereinstimmen, neue Modelle zu erstellen.

Arten von Datenvorgängen

Die Datenschicht kann mit verschiedenen Arten von Vorgängen umgehen, die je nachdem, wie kritisch sie sind, variieren: UI-orientierte, anwendungsorientierte und geschäftsorientierte Vorgänge.

UI-orientierte Vorgänge

UI-orientierte Vorgänge sind nur relevant, wenn sich der Nutzer auf einem bestimmten Bildschirm befindet. Sie werden abgebrochen, wenn der Nutzer diesen Bildschirm verlässt. Ein Beispiel ist die Anzeige einiger Daten aus der Datenbank.

UI-orientierte Vorgänge werden in der Regel von der UI-Ebene ausgelöst und folgen dem Lebenszyklus des Aufrufs, z. B. dem Lebenszyklus von ViewModel. Im Abschnitt Netzwerkanfrage stellen finden Sie ein Beispiel für einen UI-orientierten Vorgang.

App-orientierte Vorgänge

App-orientierte Vorgänge sind relevant, solange die App geöffnet ist. Wenn die Anwendung geschlossen oder der Prozess beendet wird, werden diese Vorgänge abgebrochen. Ein Beispiel ist das Speichern des Ergebnisses einer Netzwerkanfrage im Cache, damit es bei Bedarf später verwendet werden kann. Weitere Informationen finden Sie im Abschnitt In-Memory-Daten-Caching implementieren.

Diese Vorgänge folgen in der Regel dem Lebenszyklus der Klasse Application oder der Datenschicht. Ein Beispiel finden Sie im Abschnitt Einen Vorgang länger laufen lassen als der Bildschirm.

Geschäftsorientiertes Arbeiten

Geschäftsorientierte Vorgänge können nicht abgebrochen werden. Sie sollten den Tod überleben. Ein Beispiel ist der Abschluss des Uploads eines Fotos, das der Nutzer in seinem Profil posten möchte.

Für geschäftsorientierte Vorgänge wird empfohlen, WorkManager zu verwenden. Weitere Informationen finden Sie im Abschnitt Aufgaben mit WorkManager planen.

Fehler anzeigen

Interaktionen mit Repositories und Datenquellen können entweder erfolgreich sein oder eine Ausnahme auslösen, wenn ein Fehler auftritt. Für Koroutinen und Abläufe sollten Sie den integrierten Fehlerbehandlungsmechanismus von Kotlin verwenden. Verwenden Sie für Fehler, die durch Aussetzenfunktionen ausgelöst werden können, gegebenenfalls try/catch-Blöcke und in Abläufen den Operator catch. Bei diesem Ansatz wird erwartet, dass die UI-Ebene Ausnahmen beim Aufrufen der Datenschicht verarbeitet.

Die Datenschicht kann verschiedene Arten von Fehlern erkennen und verarbeiten und sie mit benutzerdefinierten Ausnahmen wie einem UserNotAuthenticatedException ermitteln.

Weitere Informationen zu Fehlern in Koroutinen finden Sie im Blogpost Ausnahmen in Koroutinen.

Allgemeine Aufgaben

In den folgenden Abschnitten finden Sie Beispiele dafür, wie Sie die Datenebene verwenden und entwerfen, um bestimmte Aufgaben auszuführen, die in Android-Apps üblich sind. Die Beispiele basieren auf der typischen News-App, die weiter oben im Leitfaden erwähnt wurde.

Netzwerkanfrage stellen

Das Senden von Netzwerkanfragen ist eine der häufigsten Aufgaben, die Android-Apps ausführen können. Die News-App muss dem Nutzer die neuesten Nachrichten präsentieren, die aus dem Netzwerk abgerufen werden. Daher benötigt die Anwendung eine Datenquellenklasse, um Netzwerkvorgänge zu verwalten: NewsRemoteDataSource. Damit die Informationen für den Rest der Anwendung verfügbar sind, wird ein neues Repository erstellt, das Vorgänge für Nachrichtendaten verarbeitet: NewsRepository.

Voraussetzung ist, dass die aktuellen Nachrichten immer aktualisiert werden müssen, wenn der Nutzer den Bildschirm öffnet. Daher ist dies ein UI-orientierter Vorgang.

Datenquelle erstellen

Die Datenquelle muss eine Funktion bereitstellen, die die neuesten Nachrichten zurückgibt: eine Liste von ArticleHeadline-Instanzen. Die Datenquelle muss eine sichere Möglichkeit bieten, die neuesten Nachrichten aus dem Netzwerk zu beziehen. Dazu muss sie vom CoroutineDispatcher oder Executor abhängen, auf dem die Aufgabe ausgeführt wird.

Eine Netzwerkanfrage ist ein einmaliger Aufruf, der von einer neuen fetchLatestNews()-Methode verarbeitet wird:

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

Die NewsApi-Schnittstelle blendet die Implementierung des Netzwerk-API-Clients aus. Es macht keinen Unterschied, ob die Schnittstelle von Retrofit oder HttpURLConnection unterstützt wird. Durch die Verwendung von Schnittstellen sind API-Implementierungen in Ihrer Anwendung austauschbar.

Repository erstellen

Da für diese Aufgabe keine zusätzliche Logik in der Repository-Klasse erforderlich ist, fungiert NewsRepository als Proxy für die Netzwerkdatenquelle. Die Vorteile dieser zusätzlichen Abstraktionsebene werden im Abschnitt zum In-Memory-Caching erläutert.

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

In der Anleitung UI-Ebene erfahren Sie, wie Sie die Repository-Klasse direkt über die UI-Ebene verwenden können.

In-Memory-Daten-Caching implementieren

Angenommen, es gibt eine neue Anforderung für die News-App: Wenn der Nutzer den Bildschirm öffnet, müssen ihm im Cache gespeicherte Nachrichten angezeigt werden, wenn bereits eine Anfrage gestellt wurde. Andernfalls sollte die App eine Netzwerkanfrage zum Abrufen der neuesten Nachrichten senden.

Aufgrund der neuen Anforderung muss die App die neuesten Nachrichten speichern, während der Nutzer sie geöffnet hat. Daher ist der Vorgang anwendungsorientiert.

Caches

Durch Hinzufügen von speicherinternem Daten-Caching können Sie Daten beibehalten, während sich der Nutzer in Ihrer Anwendung befindet. Caches sollen bestimmte Informationen für einen bestimmten Zeitraum im Arbeitsspeicher speichern – in diesem Fall, solange sich der Nutzer in der App befindet. Cache-Implementierungen können unterschiedliche Formen annehmen. Sie kann von einer einfachen änderbaren Variablen zu einer komplexeren Klasse wechseln, die vor Lese-/Schreibvorgängen in mehreren Threads schützt. Je nach Anwendungsfall kann das Caching im Repository oder in Datenquellenklassen implementiert werden.

Ergebnis der Netzwerkanfrage im Cache speichern

Der Einfachheit halber verwendet NewsRepository eine änderbare Variable, um die neuesten Nachrichten im Cache zu speichern. Zum Schutz von Lese- und Schreibvorgängen aus verschiedenen Threads wird ein Mutex verwendet. Weitere Informationen zum freigegebenen änderbaren Status und zur Nebenläufigkeit finden Sie in der Kotlin-Dokumentation.

Mit der folgenden Implementierung werden die neuesten Nachrichteninformationen in einer Variablen im Repository im Cache gespeichert, die mit einem Mutex schreibgeschützt ist. Wenn das Ergebnis der Netzwerkanfrage erfolgreich ist, werden die Daten der Variablen latestNews zugewiesen.

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

Einen Vorgang länger laufen lassen als der Bildschirm

Wenn der Nutzer den Bildschirm verlässt, während die Netzwerkanfrage ausgeführt wird, wird die Anfrage abgebrochen und das Ergebnis nicht im Cache gespeichert. NewsRepository sollte nicht die CoroutineScope des Aufrufers verwenden, um diese Logik auszuführen. Stattdessen sollte NewsRepository ein CoroutineScope verwenden, das an seinen Lebenszyklus angehängt ist. Der Abruf aktueller Nachrichten muss appbezogen sein.

Gemäß den Best Practices für die Abhängigkeitsinjektion sollte NewsRepository einen Bereich als Parameter im Konstruktor erhalten, anstatt einen eigenen CoroutineScope zu erstellen. Da Repositories den größten Teil ihrer Arbeit in Hintergrundthreads erledigen sollten, sollten Sie CoroutineScope entweder mit Dispatchers.Default oder mit Ihrem eigenen Threadpool konfigurieren.

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

Da NewsRepository bereit ist, anwendungsorientierte Vorgänge mit dem externen CoroutineScope auszuführen, muss er den Aufruf der Datenquelle ausführen und das Ergebnis in einer neuen Koroutine speichern, die von diesem Bereich gestartet wurde:

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

Mit async wird die Koroutine im externen Bereich gestartet. await wird in der neuen Koroutine aufgerufen, um anzuhalten, bis die Netzwerkanfrage zurückkehrt und das Ergebnis im Cache gespeichert wird. Wenn der Nutzer zu diesem Zeitpunkt noch auf dem Bildschirm ist, sieht er die neuesten Nachrichten. Verlässt er sich vom Bildschirm, wird await abgebrochen, aber die Logik innerhalb von async wird weiterhin ausgeführt.

In diesem Blogpost erfahren Sie mehr über Muster für CoroutineScope.

Daten auf dem Laufwerk speichern und abrufen

Angenommen, Sie möchten Daten wie als Lesezeichen gespeicherte Nachrichten und Nutzereinstellungen speichern. Diese Art von Daten muss auch dann zugänglich sein, wenn der Nutzer nicht mit dem Netzwerk verbunden ist.

Wenn die Daten, mit denen Sie arbeiten, den Tod überleben müssen, müssen Sie sie auf eine der folgenden Arten auf der Festplatte speichern:

  • Speichern Sie die Daten für große Datasets, die abgefragt werden müssen, referenzielle Integrität oder Teilaktualisierungen benötigen, in einer Raumdatenbank. Im Beispiel der News-App könnten die Nachrichtenartikel oder Autoren in der Datenbank gespeichert werden.
  • Verwenden Sie DataStore für kleine Datasets, die nur abgerufen und festgelegt werden müssen (keine Abfragen oder teilweise aktualisiert). Im Beispiel der News App können das bevorzugte Datumsformat des Nutzers oder andere Anzeigeeinstellungen im Datenspeicher gespeichert werden.
  • Verwenden Sie für Datenblöcke wie ein JSON-Objekt eine Datei.

Wie im Abschnitt Source of Truth erwähnt, funktioniert jede Datenquelle nur mit einer Quelle und entspricht einem bestimmten Datentyp (z. B. News, Authors, NewsAndAuthors oder UserPreferences). Klassen, die die Datenquelle verwenden, sollten nicht wissen, wie die Daten gespeichert werden, z. B. in einer Datenbank oder in einer Datei.

Chatroom als Datenquelle

Da jede Datenquelle für einen bestimmten Datentyp nur mit einer Quelle arbeiten sollte, erhält eine Raumdatenquelle entweder ein Datenzugriffsobjekt (DAO) oder die Datenbank selbst als Parameter. NewsLocalDataSource kann z. B. eine Instanz von NewsDao als Parameter und AuthorsLocalDataSource eine Instanz von AuthorsDao annehmen.

Wenn keine zusätzliche Logik erforderlich ist, können Sie die DAO in einigen Fällen direkt in das Repository einschleusen, da sie eine Schnittstelle ist, die Sie in Tests einfach ersetzen können.

Weitere Informationen zur Verwendung der Room APIs finden Sie unter Raumanleitungen.

DataStore als Datenquelle

DataStore eignet sich perfekt zum Speichern von Schlüssel/Wert-Paaren wie Nutzereinstellungen. Beispiele hierfür sind das Zeitformat, Benachrichtigungseinstellungen und die Frage, ob Nachrichtenelemente ein- oder ausgeblendet werden sollen, nachdem der Nutzer sie gelesen hat. DataStore kann auch typisierte Objekte mit Protokollzwischenspeichern speichern.

Wie bei jedem anderen Objekt sollte auch eine Datenquelle, die von DataStore unterstützt wird, Daten enthalten, die einem bestimmten Typ oder einem bestimmten Teil der Anwendung entsprechen. Dies gilt bei DataStore sogar noch vor allem, da DataStore-Lesevorgänge als ein Ablauf dargestellt werden, der bei jeder Aktualisierung eines Werts ausgegeben wird. Aus diesem Grund sollten Sie verwandte Einstellungen im selben Datenspeicher speichern.

Beispielsweise könnten Sie eine NotificationsDataStore haben, die nur benachrichtigungsbezogene Einstellungen verarbeitet, und ein NewsPreferencesDataStore, das nur Einstellungen für den Nachrichtenbildschirm verarbeitet. Auf diese Weise können Sie die Updates besser eingrenzen, da der newsScreenPreferencesDataStore.data-Ablauf nur dann ausgelöst wird, wenn eine Einstellung im Zusammenhang mit diesem Bildschirm geändert wird. Es bedeutet auch, dass der Lebenszyklus des Objekts kürzer sein kann, da es nur so lange existieren kann, wie der Nachrichtenbildschirm angezeigt wird.

Weitere Informationen zur Arbeit mit den DataStore APIs finden Sie in den Anleitungen für Datenspeicher.

Eine Datei als Datenquelle

Wenn Sie mit großen Objekten wie einem JSON-Objekt oder einer Bitmap arbeiten, müssen Sie mit einem File-Objekt arbeiten und Wechselthreads verarbeiten.

Weitere Informationen zum Arbeiten mit Dateispeichern finden Sie auf der Seite Speicherübersicht.

Aufgaben mit WorkManager planen

Angenommen, für die News-App wird eine weitere neue Anforderung eingeführt: Die App muss dem Nutzer die Möglichkeit bieten, die aktuellen Nachrichten regelmäßig und automatisch abzurufen, solange das Gerät geladen wird und mit einem kostenlosen Netzwerk verbunden ist. Das macht dies zu einem geschäftsorientierten Vorgang. Diese Anforderung sorgt dafür, dass Nutzer auch dann aktuelle Nachrichten sehen können, wenn das Gerät nicht verbunden ist, wenn der Nutzer die App öffnet.

WorkManager erleichtert die Planung asynchroner und zuverlässiger Arbeiten und kann sich um die Verwaltung von Einschränkungen kümmern. Es ist die empfohlene Bibliothek für dauerhafte Arbeit. Zum Ausführen der oben definierten Aufgabe wird die Klasse Worker erstellt: RefreshLatestNewsWorker. Diese Klasse verwendet NewsRepository als Abhängigkeit, um die neuesten Nachrichten abzurufen und im Cache zu speichern.

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

Die Geschäftslogik für diesen Aufgabentyp sollte in einer eigenen Klasse gekapselt und als separate Datenquelle behandelt werden. WorkManager ist dann nur dafür verantwortlich, dass die Arbeit in einem Hintergrundthread ausgeführt wird, wenn alle Einschränkungen erfüllt sind. Wenn Sie sich an dieses Muster halten, können Sie Implementierungen in anderen Umgebungen bei Bedarf schnell austauschen.

In diesem Beispiel muss die nachrichtenbezogene Aufgabe aus NewsRepository aufgerufen werden. Dabei wird eine neue Datenquelle als Abhängigkeit verwendet: NewsTasksDataSource. Die Implementierung erfolgt so:

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

Diese Klassentypen werden nach den Daten benannt, für die sie verantwortlich sind, z. B. NewsTasksDataSource oder PaymentsTasksDataSource. Alle Aufgaben, die sich auf einen bestimmten Datentyp beziehen, sollten in derselben Klasse gekapselt werden.

Wenn die Aufgabe beim Start der App ausgelöst werden muss, wird empfohlen, die WorkManager-Anfrage über die Bibliothek App Startup auszulösen, die das Repository über eine Initializer aufruft.

Weitere Informationen zur Arbeit mit WorkManager APIs finden Sie in den WorkManager-Leitfäden.

Testen

Die Best Practices für Abhängigkeitsinjektion helfen beim Testen Ihrer Anwendung. Es ist auch hilfreich, sich auf Schnittstellen für Klassen zu verlassen, die mit externen Ressourcen kommunizieren. Wenn Sie eine Einheit testen, können Sie gefälschte Versionen ihrer Abhängigkeiten einschleusen, um den Test deterministisch und zuverlässig zu machen.

Unit tests

Beim Testen der Datenebene gelten allgemeine Testrichtlinien. Verwenden Sie für Unittests bei Bedarf echte Objekte und fälschen Sie alle Abhängigkeiten, die auf externe Quellen zugreifen, z. B. das Lesen aus einer Datei oder das Lesen aus dem Netzwerk.

Integrationstests

Integrationstests, die auf externe Quellen zugreifen, sind in der Regel weniger deterministisch, da sie auf einem echten Gerät ausgeführt werden müssen. Es empfiehlt sich, diese Tests in einer kontrollierten Umgebung auszuführen, um die Integrationstests zuverlässiger zu machen.

Für Datenbanken ermöglicht Room das Erstellen einer speicherinternen Datenbank, die Sie in Ihren Tests vollständig steuern können. Weitere Informationen finden Sie auf der Seite Datenbank testen und debuggen.

Für Netzwerke gibt es gängige Bibliotheken wie WireMock oder MockWebServer, mit denen Sie HTTP- und HTTPS-Aufrufe fälschen und prüfen können, ob die Anfragen wie erwartet ausgeführt wurden.

Produktproben

In den folgenden Google-Beispielen wird die Verwendung der Datenschicht veranschaulicht. Sehen Sie sich diese Tipps in der Praxis an: