Offline-orientierte App erstellen

Eine Offline-First-Anwendung ist eine Anwendung, die in der Lage ist, alle oder einen kritischen Teil ihrer Hauptfunktionen ohne Zugriff auf das Internet auszuführen. Das heißt, es kann einen Teil oder die gesamte Geschäftslogik offline ausführen.

Zum Erstellen einer Offline-First-Anwendung sollten Sie in der Datenschicht berücksichtigen, die Zugriff auf Anwendungsdaten und Geschäftslogik bietet. Die App muss diese Daten möglicherweise von Zeit zu Zeit aus Quellen außerhalb des Geräts aktualisieren. Dazu müssen möglicherweise Netzwerkressourcen abgerufen werden, um auf dem neuesten Stand zu bleiben.

Die Netzwerkverfügbarkeit wird nicht immer garantiert. Die Netzwerkverbindung ist oft schlecht oder langsam. Nutzer können Folgendes sehen:

  • Begrenzte Internetbandbreite
  • vorübergehende Verbindungsunterbrechungen, z. B. in einem Aufzug oder Tunnel.
  • Gelegentlicher Datenzugriff. Beispiel: Nur-WLAN-Tablets.

Unabhängig vom Grund ist es oft möglich, dass eine Anwendung unter diesen Umständen angemessen funktioniert. Damit Ihre Anwendung offline ordnungsgemäß funktioniert, sollte sie folgende Voraussetzungen erfüllen:

  • Sie können auch ohne eine zuverlässige Netzwerkverbindung nutzbar bleiben.
  • Sie können Nutzern lokale Daten sofort zur Verfügung stellen, anstatt darauf zu warten, dass der erste Netzwerkaufruf abgeschlossen ist oder fehlschlägt.
  • Rufen Sie Daten unter Berücksichtigung des Akku- und Datenstatus ab. Beispielsweise können Daten nur unter optimalen Bedingungen abgerufen werden, z. B. beim Aufladen oder bei WLAN.

Eine App, die die oben genannten Kriterien erfüllt, wird häufig als Offline-First-App bezeichnet.

Eine Offline-First-App entwerfen

Beim Entwerfen einer Offline-First-App sollten Sie mit der Datenschicht und den beiden Hauptvorgängen beginnen, die Sie mit App-Daten ausführen können:

  • Lesevorgänge: Abrufen von Daten zur Verwendung durch andere Teile der App, z. B. das Anzeigen von Informationen für den Nutzer
  • Schreibvorgänge: Nutzereingaben werden beibehalten, damit sie später abgerufen werden können.

Repositories in der Datenschicht sind dafür verantwortlich, Datenquellen zu kombinieren, um App-Daten bereitzustellen. In einer Offline-First-Anwendung muss es mindestens eine Datenquelle geben, die keinen Netzwerkzugriff benötigt, um ihre wichtigsten Aufgaben auszuführen. Eine dieser kritischen Aufgaben ist das Lesen von Daten.

Modelldaten in einer Offline-First-App

Eine Offline-First-Anwendung hat mindestens 2 Datenquellen für jedes Repository, das Netzwerkressourcen nutzt:

  • Lokale Datenquelle
  • Datenquelle des Netzwerks
Eine Offline-First-Datenschicht besteht sowohl aus lokalen als auch aus Netzwerkdatenquellen.
Abbildung 1: Ein Offline-First-Repository

Lokale Datenquelle

Die lokale Datenquelle ist die kanonische Quelle der Wahrheit für die Anwendung. Sie sollte die ausschließliche Quelle aller Daten sein, die von höheren Ebenen der Anwendung gelesen werden. Dadurch wird die Datenkonsistenz zwischen den Verbindungsstatus sichergestellt. Die lokale Datenquelle wird oft durch Speicher gesichert, der auf einem Laufwerk gespeichert ist. Hier sind einige gängige Methoden zum Speichern von Daten auf der Festplatte:

  • Strukturierte Datenquellen, z. B. relationale Datenbanken wie Room
  • Unstrukturierte Datenquellen. Beispielsweise werden Protokollpuffer mit Datastore verwendet.
  • Einfache Dateien

Datenquelle des Netzwerks

Die Netzwerkdatenquelle entspricht dem tatsächlichen Status der Anwendung. Die lokale Datenquelle wird im besten Fall mit der Netzwerkdatenquelle synchronisiert. Es kann auch etwas länger dauern. In diesem Fall muss die Anwendung aktualisiert werden, sobald sie wieder online ist. Umgekehrt kann die Netzwerkdatenquelle hinter der lokalen Datenquelle zurückbleiben, bis die Anwendung sie aktualisieren kann, sobald die Verbindung wiederhergestellt ist. Die Domain- und UI-Ebenen der App sollten nie direkt mit der Netzwerkschicht in Verbindung stehen. Es liegt in der Verantwortung des Hosts repository, mit ihm zu kommunizieren und ihn zum Aktualisieren der lokalen Datenquelle zu verwenden.

Ressourcen verfügbar machen

Die lokalen Datenquellen und die Netzwerkdatenquellen können sich grundlegend in der Art und Weise unterscheiden, wie Ihre Anwendung sie lesen und schreiben kann. Das Abfragen einer lokalen Datenquelle kann schnell und flexibel sein, z. B. bei SQL-Abfragen. Umgekehrt können Netzwerkdatenquellen langsam und beschränkt sein, z. B. beim inkrementellen Zugriff auf RESTful-Ressourcen nach ID. Aus diesem Grund benötigt jede Datenquelle oft eine eigene Darstellung der bereitgestellten Daten. Die lokale Datenquelle und die Netzwerkdatenquelle können daher eigene Modelle haben.

Die folgende Verzeichnisstruktur veranschaulicht dieses Konzept. Die AuthorEntity ist eine Darstellung eines Autors, der aus der lokalen Datenbank der App gelesen wurde, und die NetworkAuthor eine Darstellung einer autorisierten, die über das Netzwerk serialisiert ist:

data/
├─ local/
│ ├─ entities/
│ │ ├─ AuthorEntity
│ ├─ dao/
│ ├─ NiADatabase
├─ network/
│ ├─ NiANetwork
│ ├─ models/
│ │ ├─ NetworkAuthor
├─ model/
│ ├─ Author
├─ repository/

Im Folgenden finden Sie die Details zu AuthorEntity und NetworkAuthor:

/**
 * Network representation of [Author]
 */
@Serializable
data class NetworkAuthor(
    val id: String,
    val name: String,
    val imageUrl: String,
    val twitter: String,
    val mediumPage: String,
    val bio: String,
)

/**
 * Defines an author for either an [EpisodeEntity] or [NewsResourceEntity].
 * It has a many-to-many relationship with both entities
 */
@Entity(tableName = "authors")
data class AuthorEntity(
    @PrimaryKey
    val id: String,
    val name: String,
    @ColumnInfo(name = "image_url")
    val imageUrl: String,
    @ColumnInfo(defaultValue = "")
    val twitter: String,
    @ColumnInfo(name = "medium_page", defaultValue = "")
    val mediumPage: String,
    @ColumnInfo(defaultValue = "")
    val bio: String,
)

Es empfiehlt sich, sowohl das AuthorEntity als auch das NetworkAuthor-Objekt intern in der Datenschicht beizubehalten und einen dritten Typ für externe Ebenen zur Verfügung zu stellen. Dadurch werden externe Ebenen vor geringfügigen Änderungen in den lokalen und Netzwerkdatenquellen geschützt, die das Verhalten der Anwendung nicht grundlegend ändern. Dies wird im folgenden Snippet veranschaulicht:

/**
 * External data layer representation of a "Now in Android" Author
 */
data class Author(
    val id: String,
    val name: String,
    val imageUrl: String,
    val twitter: String,
    val mediumPage: String,
    val bio: String,
)

Das Netzwerkmodell kann dann eine Erweiterungsmethode definieren, um es in das lokale Modell zu konvertieren. Das lokale Modell hat dann auf ähnliche Weise eine Methode, um sie in die externe Darstellung zu konvertieren, wie unten dargestellt:

/**
 * Converts the network model to the local model for persisting
 * by the local data source
 */
fun NetworkAuthor.asEntity() = AuthorEntity(
    id = id,
    name = name,
    imageUrl = imageUrl,
    twitter = twitter,
    mediumPage = mediumPage,
    bio = bio,
)

/**
 * Converts the local model to the external model for use
 * by layers external to the data layer
 */
fun AuthorEntity.asExternalModel() = Author(
    id = id,
    name = name,
    imageUrl = imageUrl,
    twitter = twitter,
    mediumPage = mediumPage,
    bio = bio,
)

Lesevorgänge

Lesevorgänge sind der grundlegende Vorgang für die Anwendungsdaten in einer Anwendung, die offline verwendet wird. Sie müssen daher dafür sorgen, dass Ihre Anwendung die Daten lesen kann und dass sie, sobald neue Daten verfügbar sind, sie anzeigen können. Eine solche Anwendung ist eine reaktive Anwendung, da sie Lese-APIs mit beobachtbaren Typen zur Verfügung stellt.

Im folgenden Snippet gibt OfflineFirstTopicRepository für alle Lese-APIs Flows zurück. Dadurch kann er seine Lesegeräte aktualisieren, wenn er Aktualisierungen von der Netzwerkdatenquelle erhält. Mit anderen Worten: Die Übertragung von OfflineFirstTopicRepository kann geändert werden, wenn die lokale Datenquelle entwertet wird. Daher muss jeder Reader des OfflineFirstTopicRepository auf Datenänderungen vorbereitet sein, die ausgelöst werden können, wenn die Netzwerkverbindung zur Anwendung wiederhergestellt wird. Außerdem liest OfflineFirstTopicRepository Daten direkt aus der lokalen Datenquelle. Sie kann ihre Leser nur über Datenänderungen informieren, indem sie zuerst ihre lokale Datenquelle aktualisiert.

class OfflineFirstTopicsRepository(
    private val topicDao: TopicDao,
    private val network: NiaNetworkDataSource,
) : TopicsRepository {

    override fun getTopicsStream(): Flow<List<Topic>> =
        topicDao.getTopicEntitiesStream()
            .map { it.map(TopicEntity::asExternalModel) }
}

Strategien zur Fehlerbehandlung

Es gibt unterschiedliche Möglichkeiten, Fehler in Offline-Apps zu beheben, je nachdem, in welchen Datenquellen sie auftreten. In den folgenden Unterabschnitten werden diese Strategien beschrieben.

Lokale Datenquelle

Fehler beim Lesen aus der lokalen Datenquelle sollten selten auftreten. Verwende den Operator catch für die Flows, über die die Leser Daten erfassen, um Leser vor Fehlern zu schützen.

Die Verwendung des Operators catch in einer ViewModel sieht so aus:

class AuthorViewModel(
    authorsRepository: AuthorsRepository,
    ...
) : ViewModel() {
   private val authorId: String = ...

   // Observe author information
    private val authorStream: Flow<Author> =
        authorsRepository.getAuthorStream(
            id = authorId
        )
        .catch { emit(Author.empty()) }
}

Datenquelle des Netzwerks

Wenn beim Lesen von Daten aus einer Netzwerkdatenquelle Fehler auftreten, muss die Anwendung eine Heuristik verwenden, um das Abrufen von Daten zu wiederholen. Gängige Heuristiken:

Exponentieller Backoff

Beim exponentiellen Backoff versucht die Anwendung weiter, mit zunehmenden Zeitintervallen aus der Netzwerkdatenquelle zu lesen, bis der Vorgang erfolgreich ist oder andere Bedingungen vorschreiben, dass die Anwendung beendet werden sollte.

Daten mit exponentiellem Backoff lesen
Abbildung 2: Daten mit exponentiellem Backoff lesen

Folgende Kriterien werden geprüft, ob die App immer wieder deaktiviert werden sollte:

  • Die Art des Fehlers, der von der Netzwerkdatenquelle angezeigt wurde. Versuchen Sie beispielsweise, Netzwerkaufrufe zu wiederholen, die einen Fehler zurückgeben, der auf eine mangelnde Verbindung hinweist. Umgekehrt sollten Sie HTTP-Anfragen, die nicht autorisiert sind, erst wiederholen, wenn die richtigen Anmeldedaten vorliegen.
  • Maximal zulässige Wiederholungsversuche.
Monitoring der Netzwerkkonnektivität

Bei diesem Ansatz werden Leseanfragen so lange in die Warteschlange gestellt, bis die Anwendung sicher ist, dass sie eine Verbindung zur Netzwerkdatenquelle herstellen kann. Sobald eine Verbindung hergestellt wurde, wird die Leseanfrage aus der Warteschlange entfernt und die gelesenen Daten sowie die lokale Datenquelle werden aktualisiert. Unter Android kann diese Warteschlange mit einer Room-Datenbank verwaltet und als persistente Arbeit mit WorkManager per Drain beendet werden.

Daten mit Netzwerkmonitoren und -Warteschlangen lesen
Abbildung 3: Lesewarteschlangen mit Netzwerk-Monitoring

Schreibvorgänge

Während zum Lesen von Daten in einer Offline-First-Anwendung beobachtbare Typen empfohlen werden, sind das Äquivalent für Schreib-APIs asynchrone APIs wie Haltefunktionen. Dadurch wird das Blockieren des UI-Threads vermieden und die Fehlerbehandlung erleichtert, da Schreibvorgänge in Offline-First-Anwendungen möglicherweise beim Überschreiten einer Netzwerkgrenze fehlschlagen.

interface UserDataRepository {
    /**
     * Updates the bookmarked status for a news resource
     */
    suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean)
}

Im Snippet oben ist die ausgewählte asynchrone API Coroutines, da die obige Methode angehalten wird.

Schreibstrategien

Beim Schreiben von Daten in Offline-Apps gibt es drei Strategien, die zu berücksichtigen sind. Ihre Wahl hängt vom Typ der geschriebenen Daten und den Anforderungen der Anwendung ab:

Nur Onlineschreibvorgänge

Versuchen Sie, die Daten über die Netzwerkgrenze hinaus zu schreiben. Wenn der Vorgang erfolgreich ist, wird die lokale Datenquelle aktualisiert. Andernfalls wird eine Ausnahme ausgelöst und die Antwort dem Aufrufer überlassen.

Nur Onlineschreibvorgänge
Abbildung 4: Nur Onlineschreibvorgänge

Diese Strategie wird häufig für Schreibtransaktionen verwendet, die online nahezu in Echtzeit erfolgen müssen. Zum Beispiel eine Überweisung. Da Schreibvorgänge fehlschlagen können, ist es oft erforderlich, dem Nutzer mitzuteilen, dass der Schreibvorgang fehlgeschlagen ist, oder zu verhindern, dass der Nutzer überhaupt versucht, Daten zu schreiben. Strategien, die Sie in diesen Szenarien anwenden können, sind:

  • Wenn eine Anwendung zum Schreiben von Daten Internetzugriff benötigt, kann sie entscheiden, dem Nutzer keine UI anzuzeigen, über die er Daten schreiben kann, oder zumindest deaktiviert diese UI.
  • Du kannst eine Pop-up-Meldung, die der Nutzer nicht schließen kann, oder eine vorübergehende Aufforderung verwenden, um den Nutzer darüber zu informieren, dass er offline ist.

Schreibvorgänge in der Warteschlange

Wenn Sie ein Objekt schreiben möchten, fügen Sie es in eine Warteschlange ein. Fahren Sie mit dem Leeren der Warteschlange mit exponentiellem Backoff fort, wenn die Anwendung wieder online ist. Unter Android ist das Leeren einer Offline-Warteschlange persistente Arbeit, die häufig an WorkManager delegiert wird.

Schreibwarteschlangen mit Wiederholungsversuchen
Abbildung 5: Schreibwarteschlangen mit Wiederholungsversuchen

Dieser Ansatz ist in folgenden Fällen sinnvoll:

  • Es ist nicht unbedingt erforderlich, dass die Daten jemals in das Netzwerk geschrieben werden.
  • Die Transaktion ist nicht zeitsensitiv.
  • Der Nutzer muss nicht unbedingt informiert werden, wenn der Vorgang fehlschlägt.

Anwendungsfälle für diesen Ansatz sind beispielsweise Analyseereignisse und Logging.

Verzögerte Schreibvorgänge

Schreiben Sie zuerst in die lokale Datenquelle und stellen Sie den Schreibvorgang dann in die Warteschlange, um das Netzwerk so bald wie möglich zu benachrichtigen. Dies ist nicht einfach, da es zu Konflikten zwischen dem Netzwerk und den lokalen Datenquellen kommen kann, wenn die Anwendung wieder online ist. Der nächste Abschnitt zur Konfliktlösung enthält weitere Einzelheiten.

Verzögerte Schreibvorgänge mit Netzwerkmonitoring
Abbildung 6: Verzögerte Schreibvorgänge

Dieser Ansatz ist die richtige Wahl, wenn die Daten für die Anwendung von entscheidender Bedeutung sind. Bei einer Offline-First-To-do-Listen-Anwendung ist es beispielsweise wichtig, dass alle Aufgaben, die der Nutzer offline hinzufügt, lokal gespeichert werden, um das Risiko eines Datenverlusts zu vermeiden.

Synchronisierung und Konfliktlösung

Wenn eine Offline-First-Anwendung ihre Verbindung wiederherstellt, muss sie die Daten in ihrer lokalen Datenquelle mit denen in der Netzwerkdatenquelle abgleichen. Dieser Vorgang wird als Synchronisierung bezeichnet. Es gibt zwei Möglichkeiten, wie eine Anwendung mit ihrer Netzwerkdatenquelle synchronisiert werden kann:

  • Pull-basierte Synchronisierung
  • Push-basierte Synchronisierung

Pull-basierte Synchronisierung

Bei der Pull-basierten Synchronisierung kontaktiert die App das Netzwerk, um bei Bedarf die neuesten Anwendungsdaten zu lesen. Eine gängige Heuristik für diesen Ansatz ist navigationsbasiert, wobei die Anwendung Daten nur kurz vor der Präsentation für den Nutzer abruft.

Dieser Ansatz funktioniert am besten, wenn die App von kurzen bis mittleren Zeiträumen ohne Netzwerkverbindung ausgeht. Dies liegt daran, dass die Datenaktualisierung opportunistisch ist und lange Zeiträume ohne Verbindung die Wahrscheinlichkeit erhöhen, dass der Nutzer versucht, Anwendungsziele mit einem veralteten oder leeren Cache aufzurufen.

Pull-basierte Synchronisierung
Abbildung 7: Pull-basierte Synchronisierung: Gerät A greift nur auf Ressourcen für die Bildschirme A und B zu, während Gerät B nur auf Ressourcen für die Bildschirme B, C und D zugreift.

Nehmen wir eine App, in der Seitentokens verwendet werden, um Elemente in einer endlosen Scrollliste für einen bestimmten Bildschirm abzurufen. Die Implementierung kann sich verzögert an das Netzwerk wenden, die Daten in der lokalen Datenquelle dauerhaft speichern und dann aus der lokalen Datenquelle lesen, um dem Nutzer Informationen anzuzeigen. Wenn keine Netzwerkverbindung besteht, kann das Repository Daten nur von der lokalen Datenquelle anfordern. Dies ist das Muster, das von der Jetpack Paging Library mit ihrer RemoteMediator API verwendet wird.

class FeedRepository(...) {

    fun feedPagingSource(): PagingSource<FeedItem> { ... }
}

class FeedViewModel(
    private val repository: FeedRepository
) : ViewModel() {
    private val pager = Pager(
        config = PagingConfig(
            pageSize = NETWORK_PAGE_SIZE,
            enablePlaceholders = false
        ),
        remoteMediator = FeedRemoteMediator(...),
        pagingSourceFactory = feedRepository::feedPagingSource
    )

    val feedPagingData = pager.flow
}

Die Vor- und Nachteile der Pull-basierten Synchronisierung sind in der folgenden Tabelle zusammengefasst:

Vorteile Nachteile
Relativ einfach zu implementieren. Sie sind anfällig für eine intensive Datennutzung. Dies liegt daran, dass wiederholte Besuche an einem Navigationsziel ein unnötiges erneutes Abrufen unveränderter Informationen auslösen. Sie können dies durch ordnungsgemäßes Caching umgehen. Das kann auf der UI-Ebene mit dem Operator cachedIn oder auf der Netzwerkebene mit einem HTTP-Cache erfolgen.
Daten, die nicht benötigt werden, werden nie abgerufen. Skaliert nicht gut mit relationalen Daten, da das abgerufene Modell selbst ausreichend sein muss. Wenn das zu synchronisierende Modell davon abhängt, dass andere Modelle abgerufen werden müssen, um sich selbst zu befüllen, wird das bereits erwähnte Problem mit der hohen Datennutzung noch größer werden. Außerdem kann es zu Abhängigkeiten zwischen Repositories des übergeordneten Modells und Repositories des verschachtelten Modells kommen.

Push-basierte Synchronisierung

Bei der Push-basierten Synchronisierung versucht die lokale Datenquelle, ein Replikatset der Netzwerkdatenquelle so gut wie möglich zu imitieren. Beim ersten Start ruft er proaktiv eine geeignete Datenmenge ab, um eine Referenz festzulegen. Ist dies der Fall, werden Benachrichtigungen vom Server benötigt, um benachrichtigt zu werden.

Push-basierte Synchronisierung
Abbildung 8: Push-basierte Synchronisierung: Das Netzwerk benachrichtigt die App, wenn sich Daten ändern, und die Anwendung antwortet durch Abrufen der geänderten Daten.

Nach Erhalt der Benachrichtigung über die veraltete Nachricht kontaktiert die Anwendung das Netzwerk, um nur die Daten zu aktualisieren, die als veraltet markiert wurden. Diese Arbeit wird an Repository delegiert, der auf die Netzwerkdatenquelle zugreift und die an die lokale Datenquelle abgerufenen Daten speichert. Da die Daten im Repository mit beobachtbaren Typen verfügbar gemacht werden, werden Leser über Änderungen informiert.

class UserDataRepository(...) {

    suspend fun synchronize() {
        val userData = networkDataSource.fetchUserData()
        localDataSource.saveUserData(userData)
    }
}

Bei diesem Ansatz ist die Anwendung weit weniger von der Netzwerkdatenquelle abhängig und kann längere Zeit ohne sie arbeiten. Es bietet im Offline-Modus sowohl Lese- als auch Schreibzugriff, da davon ausgegangen wird, dass die neuesten Informationen aus der lokalen Netzwerkdatenquelle vorliegen.

Die Vor- und Nachteile der Push-basierten Synchronisierung sind in der folgenden Tabelle zusammengefasst:

Vorteile Nachteile
Die App kann für unbegrenzte Zeit offline bleiben. Versionsdaten zur Konfliktlösung sind nicht einfach.
Minimale Datennutzung. Die App ruft nur geänderte Daten ab. Sie müssen die Schreibbedenken während der Synchronisierung berücksichtigen.
Eignet sich gut für relationale Daten. Jedes Repository ist nur für das Abrufen von Daten für das von ihm unterstützte Modell verantwortlich. Die Netzwerkdatenquelle muss die Synchronisierung unterstützen.

Hybridsynchronisierung

Einige Anwendungen verwenden einen hybriden Ansatz, bei dem je nach Daten Pull oder Push verwendet werden. Beispielsweise kann eine Social-Media-App die Pull-basierte Synchronisierung verwenden, um den folgenden Feed des Nutzers bei Bedarf aufgrund der hohen Häufigkeit von Feedaktualisierungen abzurufen. Dieselbe Anwendung kann eine Push-basierte Synchronisierung für Daten über den angemeldeten Nutzer verwenden, einschließlich seines Nutzernamens, seines Profilbilds usw.

Letztendlich hängt die Wahl der Offline-Synchronisierung von den Produktanforderungen und der verfügbaren technischen Infrastruktur ab.

Konfliktlösung

Wenn die Anwendung offline Daten lokal schreibt, die nicht mit der Netzwerkdatenquelle ausgerichtet sind, ist ein Konflikt aufgetreten, den Sie vor der Synchronisierung beheben müssen.

Konfliktlösung erfordert oft eine Versionsverwaltung. Die App muss einige Buchhaltungsvorgänge durchführen, um nachzuverfolgen, wann Änderungen vorgenommen wurden. Dadurch können die Metadaten an die Netzwerkdatenquelle übergeben werden. Die Netzwerkdatenquelle ist dann für die Bereitstellung der absoluten Datenquelle zuständig. Es gibt je nach den Anforderungen der Anwendung eine Vielzahl von Strategien, die bei der Konfliktlösung berücksichtigt werden können. Bei mobilen Apps ist ein gängiger Ansatz „Letzter Schreibvorgang gewinnt“.

Letzter Schreibvorgang gewinnt

Bei diesem Ansatz hängen Geräte Zeitstempel-Metadaten an die Daten an, die sie in das Netzwerk schreiben. Wenn die Netzwerkdatenquelle solche Daten empfängt, verwirft sie alle Daten, die älter als der aktuelle Zustand sind. Die Daten, die neuer sind als der aktuelle Zustand, werden akzeptiert.

Letzter Schreibvorgang gewinnt Konfliktlösung
Abbildung 9: „Letzter Schreibvorgang hat Vorrang“ Die „Source of Truth“ für Daten wird durch die letzte Entität bestimmt, die Daten schreibt

Im obigen Beispiel sind beide Geräte offline und anfänglich mit der Netzwerkdatenquelle synchronisiert. Während sie offline sind, schreiben sie Daten lokal und verfolgen den Zeitpunkt, zu dem sie ihre Daten geschrieben haben. Wenn beide wieder online sind und mit der Netzwerkdatenquelle synchronisiert werden, löst das Netzwerk den Konflikt, indem die Daten von Gerät B dauerhaft gespeichert werden, da es seine Daten später geschrieben hat.

WorkManager in Offline-Apps

Bei den oben beschriebenen Lese- und Schreibstrategien gab es zwei gängige Dienstprogramme:

  • Warteschlangen
    • Lesevorgänge: Wird verwendet, um Lesevorgänge zu verschieben, bis eine Netzwerkverbindung verfügbar ist.
    • Schreibvorgänge: Wird verwendet, um Schreibvorgänge zu verzögern, bis die Netzwerkverbindung verfügbar ist, und um Schreibvorgänge für Wiederholungsversuche wieder in die Warteschlange zu stellen.
  • Monitore für Netzwerkkonnektivität
    • Lesevorgänge: Wird als Signal zum Draining der Lesewarteschlange verwendet, wenn die Anwendung verbunden ist, und zur Synchronisierung.
    • Schreibvorgänge: Wird als Signal zum Draining der Schreibwarteschlange verwendet, wenn die Anwendung verbunden ist, und zur Synchronisierung

Beide Fälle sind Beispiele für beständige Arbeit, die sich mit WorkManager besonders gut eignet. In der Beispiel-App Now in Android wird WorkManager beispielsweise bei der Synchronisierung der lokalen Datenquelle sowohl als Lesewarteschlange als auch als Netzwerkmonitor verwendet. Beim Start führt die App die folgenden Aktionen aus:

  1. Lesesynchronisierung in eine Warteschlange, um für Parität zwischen der lokalen Datenquelle und der Netzwerkdatenquelle zu sorgen.
  2. Leeren Sie die Lesesynchronisierungswarteschlange und starten Sie die Synchronisierung, wenn die Anwendung online ist.
  3. Einen Lesevorgang aus der Netzwerkdatenquelle mit exponentiellem Backoff durchführen.
  4. Speichern Sie die Ergebnisse des Lesevorgangs in der lokalen Datenquelle, um mögliche Konflikte zu beheben.
  5. Stellen Sie die Daten aus der lokalen Datenquelle anderen Ebenen der Anwendung zur Verfügung.

Das obige Diagramm ist in folgendem Diagramm dargestellt:

Datensynchronisierung in der Now in Android-App
Abbildung 10: Datensynchronisierung in der Now in Android-App

Im Anschluss wird die Synchronisierung mit WorkManager in die Warteschlange gestellt, indem sie mit der KEEP ExistingWorkPolicy als Unique Work angegeben wird:

class SyncInitializer : Initializer<Sync> {
   override fun create(context: Context): Sync {
       WorkManager.getInstance(context).apply {
           // Queue sync on app startup and ensure only one
           // sync worker runs at any time
           enqueueUniqueWork(
               SyncWorkName,
               ExistingWorkPolicy.KEEP,
               SyncWorker.startUpSyncWork()
           )
       }
       return Sync
   }
}

Dabei ist SyncWorker.startupSyncWork() so definiert:


/**
 Create a WorkRequest to call the SyncWorker using a DelegatingWorker.
 This allows for dependency injection into the SyncWorker in a different
 module than the app module without having to create a custom WorkManager
 configuration.
*/
fun startUpSyncWork() = OneTimeWorkRequestBuilder<DelegatingWorker>()
    // Run sync as expedited work if the app is able to.
    // If not, it runs as regular work.
   .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
   .setConstraints(SyncConstraints)
    // Delegate to the SyncWorker.
   .setInputData(SyncWorker::class.delegatedData())
   .build()

val SyncConstraints
   get() = Constraints.Builder()
       .setRequiredNetworkType(NetworkType.CONNECTED)
       .build()

Insbesondere erfordern die von SyncConstraints definierten Constraints, dass der NetworkType NetworkType.CONNECTED sein muss. Das heißt, es wird mit der Ausführung gewartet, bis das Netzwerk verfügbar ist.

Sobald das Netzwerk verfügbar ist, entleert der Worker die durch SyncWorkName angegebene eindeutige Arbeitswarteschlange, indem er an die entsprechenden Repository-Instanzen delegiert. Wenn die Synchronisierung fehlschlägt, gibt die Methode doWork() die Meldung Result.retry() zurück. WorkManager wiederholt die Synchronisierung automatisch mit exponentiellem Backoff. Andernfalls wird Result.success() zurückgegeben, wenn die Synchronisierung abgeschlossen ist.

class SyncWorker(...) : CoroutineWorker(appContext, workerParams), Synchronizer {

    override suspend fun doWork(): Result = withContext(ioDispatcher) {
        // First sync the repositories in parallel
        val syncedSuccessfully = awaitAll(
            async { topicRepository.sync() },
            async { authorsRepository.sync() },
            async { newsRepository.sync() },
        ).all { it }

        if (syncedSuccessfully) Result.success()
        else Result.retry()
    }
}

Produktproben

Die folgenden Google-Beispiele zeigen Offline-orientierte Apps. Sehen Sie sich diese Tipps in der Praxis an: