Eine Offline-First-App ist eine App, die alle oder einen wichtigen Teil ihrer Hauptfunktionen ohne Internetzugriff ausführen kann. Das bedeutet, dass sie einen Teil oder die gesamte Geschäftslogik offline ausführen kann.
Überlegungen zum Erstellen einer Offline-First-App beginnen auf der Datenebene, die Zugriff auf Anwendungsdaten und Geschäftslogik bietet. Gelegentlich muss die App diese Daten möglicherweise aus Quellen außerhalb des Geräts aktualisieren. Dabei muss es möglicherweise Netzwerkressourcen aufrufen, um auf dem neuesten Stand zu bleiben.
Die Netzwerkverfügbarkeit ist nicht immer garantiert. Geräte haben häufig Phasen mit lückenhafter oder langsamer Netzwerkverbindung. Folgendes kann auftreten:
- Eingeschränkte Internetbandbreite
- Vorübergehende Verbindungsunterbrechungen, z. B. in einem Aufzug oder Tunnel
- Gelegentlicher Datenzugriff, z. B. Tablets, die nur WLAN-fähig sind
Unabhängig vom Grund ist es oft möglich, dass eine App unter diesen Umständen angemessen funktioniert. Damit Ihre App offline ordnungsgemäß funktioniert, muss sie Folgendes können:
- Auch ohne zuverlässige Netzwerkverbindung nutzbar bleiben
- Nutzern sofort lokale Daten präsentieren, anstatt zu warten, bis der erste Netzwerkaufruf abgeschlossen ist oder fehlschlägt
- Daten so abrufen, dass der Akku- und Datenstatus berücksichtigt wird, z. B. indem Datenabrufe nur unter optimalen Bedingungen angefordert werden, z. B. beim Laden oder bei einer WLAN-Verbindung
Eine App, die diese Kriterien erfüllt, wird oft als Offline-First-App bezeichnet.
Offline-First-App entwerfen
Wenn Sie eine Offline-First-App entwerfen, beginnen Sie mit der Datenebene und den beiden Hauptvorgängen, die Sie für App-Daten ausführen können:
- Lesevorgänge: Abrufen von Daten zur Verwendung durch andere Teile der App, z. B. zum Anzeigen von Informationen für den Nutzer. In Compose erreichen Sie dies normalerweise durch Beobachten des Status. Wenn die UI die lokale Datenquelle als Status beobachtet, zeigt der Bildschirm automatisch die neuesten lokalen Daten an.
- Schreibvorgänge: Nutzereingaben werden für den späteren Abruf gespeichert. In Compose erreichen Sie dies normalerweise mithilfe von Ereignissen und Aktionen, die von der Benutzeroberfläche an das ViewModel gesendet werden.
Repositories in der Datenschicht sind für die Kombination von Datenquellen verantwortlich, um App-Daten bereitzustellen. In einer Offline-First-App muss es mindestens eine Datenquelle geben, die keinen Netzwerkzugriff benötigt, um die wichtigsten Aufgaben auszuführen. Eine dieser wichtigen Aufgaben ist das Lesen von Daten.
Daten in einer Offline-First-App modellieren
Eine Offline-First-App hat mindestens zwei Datenquellen für jedes Repository, das Netzwerkressourcen verwendet:
- Die lokale Datenquelle
- Die Netzwerkdatenquelle
Die lokale Datenquelle
Die lokale Datenquelle ist die kanonische Source of Truth für die App. Sie sollte die einzige Quelle für alle Daten sein, die von höheren Schichten der App gelesen werden. So wird die Datenkonsistenz zwischen Verbindungsstatus sichergestellt. Die lokale Datenquelle wird häufig durch Speicher unterstützt, der auf einem Laufwerk gespeichert wird. Hier sind einige gängige Methoden zum Speichern von Daten auf einem Laufwerk:
- Strukturierte Datenquellen, z. B. relationale Datenbanken wie Room
- Unstrukturierte Datenquellen, z. B. Protokollzwischenspeicher mit DataStore
- Einfache Dateien
Die Netzwerkdatenquelle
Die Netzwerkdatenquelle ist der tatsächliche Status der Anwendung. Im besten Fall wird die lokale Datenquelle mit der Netzwerkdatenquelle synchronisiert. Die lokale Datenquelle kann auch hinter der Netzwerkdatenquelle zurückbleiben. In diesem Fall muss die App aktualisiert werden, wenn sie wieder online ist. Umgekehrt kann die Netzwerkdatenquelle hinter der lokalen Datenquelle zurückbleiben, bis die App sie aktualisieren kann, wenn die Verbindung wiederhergestellt ist. Die Domain- und UI-Ebenen der App dürfen niemals direkt mit der Netzwerkschicht kommunizieren. Es liegt in der Verantwortung des Hostings repository, mit ihm zu kommunizieren und es zum Aktualisieren der lokalen Datenquelle zu verwenden.
Benutzerdefinierte und verwaltete Synchronisierung im Vergleich
Es gibt zwei Hauptansätze, um eine Offline-First-Datenebene zu erstellen:
- Benutzerdefinierter Synchronisierungsstack: Sie verwalten lokale Datenbanken mit Room oder Dateien im lokalen Speicher, erstellen benutzerdefinierte Netzwerkclients und planen Abgleich-Worker mit WorkManager. Dieser Ansatz bietet maximale Kontrolle über Schemas und benutzerdefinierte Algorithmen zur Konfliktauflösung.
- Verwaltete Cloud-Dienste: Lösungen wie Cloud Firestore können einen lokalen All-in-One-Cache und einen Echtzeit-Cloud-Stream bereitstellen. Das SDK übernimmt automatisch das Datenträger-Caching, die Warteschlange für Offline-Schreibvorgänge und die bidirektionale Synchronisierung. So werden Echtzeit-Datenstreams direkt an Ihr Repository gesendet. Für relationale PostgreSQL-Backends generiert Firebase SQL Connect typsichere Kotlin-Client-SDKs, die sich nahtlos in Ihre Repositories einbinden lassen. Für unstrukturierte Daten (z. B. Medien, große Dateien oder Back-ups) bietet Cloud Storage for Firebase einen in der Cloud gehosteten Objektspeicher mit Upload- und Downloadvorgängen, die bei Netzwerkunterbrechungen automatisch pausiert und fortgesetzt werden.
Ressourcen freigeben
Lokale und Netzwerkdatenquellen können sich grundlegend darin unterscheiden, wie deine App Daten daraus lesen und darin schreiben kann. Das Abfragen einer lokalen Datenquelle kann schnell und flexibel sein, z. B. bei der Verwendung von SQL-Abfragen. Netzwerkdatenquellen können dagegen langsam und eingeschränkt sein, z. B. wenn inkrementell auf RESTful-Ressourcen nach ID zugegriffen wird. Daher benötigt jede Datenquelle oft eine eigene Darstellung der von ihr bereitgestellten Daten. Die lokale Datenquelle und die Netzwerkdatenquelle haben daher möglicherweise eigene Modelle.
Die folgende Verzeichnisstruktur hilft, dieses Konzept zu veranschaulichen. AuthorEntity steht für einen Autor, der aus der lokalen Datenbank der App gelesen wird, und NetworkAuthor für einen Autor, der über das Netzwerk serialisiert wird:
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 AuthorEntity als auch NetworkAuthor intern in der Datenschicht zu belassen und einen dritten Typ für externe Schichten bereitzustellen. So werden externe Ebenen vor geringfügigen Änderungen in den lokalen und Netzwerkdatenquellen geschützt, die das Verhalten der App 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 ebenfalls eine Methode, um es in die externe Darstellung zu konvertieren, wie im folgenden Snippet gezeigt:
/**
* 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 App-Daten in einer Offline-First-App. Sie müssen daher dafür sorgen, dass Ihre App die Daten lesen und anzeigen kann, sobald neue Daten verfügbar sind. Eine App, die das kann, ist eine reaktive App, da sie Lese-APIs mit beobachtbaren Typen bereitstellt.
Im folgenden Snippet gibt OfflineFirstTopicRepository Flows für alle Lese-APIs zurück. So kann es seine Leser aktualisieren, wenn es Aktualisierungen von der Netzwerkdatenquelle erhält. Mit anderen Worten: Die OfflineFirstTopicRepository kann Änderungen übertragen, wenn die lokale Datenquelle ungültig wird. Daher muss jeder Leser von OfflineFirstTopicRepository darauf vorbereitet sein, Datenänderungen zu verarbeiten, die ausgelöst werden können, wenn die Netzwerkverbindung zur App wiederhergestellt wird. Außerdem liest OfflineFirstTopicRepository Daten direkt aus der lokalen Datenquelle. Sie kann ihre Leser nur über Datenänderungen benachrichtigen, indem sie zuerst ihre lokale Datenquelle aktualisiert.
class TopicsViewModel(
offlineFirstTopicsRepository: OfflineFirstTopicsRepository
) : ViewModel() {
val topics: StateFlow<List<Topic>> = offlineFirstTopicsRepository.getTopicsStream()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
}
In einer Jetpack Compose-Anwendung verwenden Sie ein ViewModel, um die Datenebene und die Benutzeroberfläche zu verbinden.
Konvertieren Sie im ViewModel die Flow mit dem Operator stateIn in eine StateFlow. Composables erfassen diese Zustände dann mit collectAsStateWithLifecycle() und verwalten Abos automatisch auf lebenszyklusbewusste Weise.
Weitere Informationen zu collectAsStateWithLifecycle() finden Sie unter Zustand und Jetpack Compose.
Strategien zur Fehlerbehandlung
Es gibt verschiedene Möglichkeiten, Fehler in Offline-First-Apps zu behandeln, je nachdem, in welchen Datenquellen sie auftreten können. In den folgenden Abschnitten werden diese Strategien beschrieben.
Lokale Datenquelle
Versuchen Sie, Fehler beim Lesen aus der lokalen Datenquelle zu minimieren. Verwenden Sie den Operator catch für die Flows, aus denen der Reader Daten erfasst, um Reader vor Fehlern zu schützen.
Sie können den Operator catch in einem ViewModel so verwenden:
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()) }
}
Für einen robusteren Ansatz sollten Sie eine LCE-Lösung (Loading Content Error) in Betracht ziehen. In LCE wird ein Fehlerstatus angezeigt, wenn beim Lesen ein Fehler auftritt. Normalerweise erreichen Sie LCE, indem Sie die UI-Zustände als versiegelte Kotlin-Klassen modellieren.
// Define the LCE UI state
sealed interface AuthorUiState {
data object Loading : AuthorUiState
data class Success(val author: Author) : AuthorUiState
data object Error : AuthorUiState
}
class AuthorViewModel(
authorsRepository: AuthorsRepository,
...
) : ViewModel() {
private val authorId: String = ...
// Observe author information and map to LCE state
val authorUiState: StateFlow<AuthorUiState> =
authorsRepository.getAuthorStream(id = authorId)
.map<Author, AuthorUiState> { author ->
AuthorUiState.Success(author)
}
.catch { emit(AuthorUiState.Error) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = AuthorUiState.Loading
)
}
Netzwerkdatenquelle
Wenn beim Lesen von Daten aus einer Netzwerkdatenquelle Fehler auftreten, muss die App eine Heuristik verwenden, um das Abrufen von Daten zu wiederholen. Zu den gängigen Heuristiken gehören:
Exponentielle Backoffs
Beim exponentiellen Backoff versucht die App immer wieder, Daten aus der Netzwerkdatenquelle zu lesen, wobei die Zeitintervalle zwischen den Versuchen immer länger werden, bis der Vorgang erfolgreich ist oder andere Bedingungen vorgeben, dass er beendet werden soll.
Zu den Kriterien, anhand derer Sie beurteilen können, ob die App weiterhin Back-offs durchführt, gehören:
- Die Art des Fehlers, der von der Netzwerkdatenquelle angegeben wurde. Wiederholen Sie beispielsweise Netzwerkaufrufe, die einen Fehler zurückgeben, der auf eine fehlende Verbindung hinweist. Wiederholen Sie HTTP-Anfragen, die nicht autorisiert sind, erst, wenn die richtigen Anmeldedaten verfügbar sind.
- Maximal zulässige Anzahl von Wiederholungsversuchen.
Monitoring der Netzwerkverbindung
Bei diesem Ansatz werden Leseanfragen in die Warteschlange gestellt, bis die App sicher ist, dass sie eine Verbindung zur Netzwerkdatenquelle herstellen kann. Sobald eine Verbindung hergestellt ist, wird die Leseanfrage aus der Warteschlange entfernt, die Daten werden gelesen und die lokale Datenquelle wird aktualisiert. Unter Android kann diese Warteschlange mit einer Room-Datenbank verwaltet und als persistente Arbeit mit WorkManager geleert werden.
Schreibvorgänge
Die empfohlene Methode zum Lesen von Daten in einer Offline-First-App ist die Verwendung von beobachtbaren Typen. Das Äquivalent für Schreib-APIs sind asynchrone APIs wie Sperrfunktionen. Dadurch wird verhindert, dass der UI-Thread blockiert wird, und die Fehlerbehandlung wird vereinfacht, da Schreibvorgänge in Offline-First-Apps fehlschlagen können, wenn sie eine Netzwerkgrenze überschreiten.
interface UserDataRepository {
/**
* Updates the bookmarked status for a news resource
*/
suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean)
}
Im vorherigen Snippet ist die asynchrone API der Wahl Coroutines, da die Methode angehalten wird.
Schreibstrategien
Beim Schreiben von Daten in Offline-First-Apps gibt es drei Strategien, die Sie berücksichtigen sollten. Welche Option Sie wählen, hängt von der Art der zu schreibenden Daten und den Anforderungen der Anwendung ab:
Nur-Online-Schreibvorgänge
Versuchen Sie, die Daten über die Netzwerkgrenze hinweg zu schreiben. Wenn der Vorgang erfolgreich ist, aktualisieren Sie die lokale Datenquelle. Andernfalls lösen Sie eine Ausnahme aus und überlassen es dem Aufrufer, entsprechend zu reagieren.
Diese Strategie wird häufig für Schreibtransaktionen verwendet, die online nahezu in Echtzeit erfolgen müssen, z. B. eine Banküberweisung. Da Schreibvorgänge fehlschlagen können, ist es oft notwendig, den Nutzer darüber zu informieren, dass der Schreibvorgang fehlgeschlagen ist, oder ihn von vornherein daran zu hindern, Daten zu schreiben. Hier sind einige Strategien, die Sie in diesen Szenarien anwenden können:
- Wenn eine App Internetzugriff benötigt, um Daten zu schreiben, kannst du darauf verzichten, dem Nutzer eine Benutzeroberfläche zum Schreiben von Daten zu präsentieren, oder sie zumindest deaktivieren.
- Du kannst eine
AlertDialog, die der Nutzer nicht schließen kann, oder eineSnackbarverwenden, 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. Wenn die App wieder online ist, leeren Sie die Warteschlange mit exponentiellem Backoff. Unter Android ist das Leeren einer Offlinewarteschlange eine persistente Aufgabe, die häufig an WorkManager delegiert wird.
Dieser Ansatz ist in den folgenden Szenarien eine gute Wahl:
- Es ist nicht erforderlich, dass die Daten jemals in das Netzwerk geschrieben werden.
- Die Transaktion ist nicht zeitkritisch.
- Es ist nicht unbedingt erforderlich, dass der Nutzer informiert wird, wenn der Vorgang fehlschlägt.
Anwendungsfälle für diesen Ansatz sind Analyseereignisse und Logging.
Verzögerte Schreibvorgänge
Schreiben Sie zuerst in die lokale Datenquelle und stellen Sie dann den Schreibvorgang in die Warteschlange, um das Netzwerk so schnell wie möglich zu benachrichtigen. Das ist nicht trivial, da es zu Konflikten zwischen dem Netzwerk und lokalen Datenquellen kommen kann, wenn die App wieder online ist. Weitere Informationen finden Sie im nächsten Abschnitt zur Konfliktlösung.
Dieser Ansatz ist die richtige Wahl, wenn die Daten für die App wichtig sind. In einer Offline-First-To-Do-Listen-App 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-App die 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 App mit ihrer Netzwerkdatenquelle synchronisiert werden kann:
- Pull-basierte Synchronisierung
- Push-basierte Synchronisierung
Pull-basierte Synchronisierung
Bei der Pull-basierten Synchronisierung greift die App auf das Netzwerk zu, um die neuesten Anwendungsdaten bei Bedarf zu lesen. Eine gängige Heuristik für diesen Ansatz ist die navigationsbasierte Heuristik, bei der die App Daten erst kurz vor der Präsentation für den Nutzer abruft.
Dieser Ansatz funktioniert am besten, wenn die App kurze bis mittlere Zeiträume ohne Netzwerkverbindung erwartet. Das liegt daran, dass die Datenaktualisierung opportunistisch ist und lange Zeiträume ohne Verbindung die Wahrscheinlichkeit erhöhen, dass der Nutzer versucht, App-Ziele mit einem veralteten oder leeren Cache aufzurufen.
Angenommen, Sie haben eine App, in der Seitentokens verwendet werden, um Elemente in einer endlos scrollbaren Liste für einen bestimmten Bildschirm abzurufen. Die Implementierung kann bei Bedarf auf das Netzwerk zugreifen, die Daten in der lokalen Datenquelle persistent speichern und dann aus der lokalen Datenquelle lesen, um dem Nutzer Informationen zu präsentieren. Wenn keine Netzwerkverbindung besteht, fordert das Repository möglicherweise nur Daten von der lokalen Datenquelle an. Dieses Muster wird von der Jetpack Paging Library mit der RemoteMediator API verwendet.
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. | Neigt zu hohem Datenverbrauch. Das liegt daran, dass wiederholte Besuche eines Navigationsziels unnötige Abrufe unveränderter Informationen auslösen. Sie können dieses Problem durch geeignetes Caching beheben. Dies kann in der UI-Ebene mit dem Operator cachedIn oder in der Netzwerkschicht mit einem HTTP-Cache erfolgen. |
| Nicht benötigte Daten werden nie abgerufen. | Lässt sich nicht gut mit relationalen Daten skalieren, da das abgerufene Modell eigenständig sein muss. Wenn das zu synchronisierende Modell von anderen Modellen abhängt, die abgerufen werden müssen, um es zu füllen, wird das oben erwähnte Problem der hohen Datennutzung noch gravierender. 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, einen Replikatsatz der Netzwerkdatenquelle so gut wie möglich nachzuahmen. Beim ersten Start wird proaktiv eine angemessene Datenmenge abgerufen, um eine Referenz festzulegen. Danach verlässt er sich auf Benachrichtigungen vom Server, die ihn darauf hinweisen, wenn diese Daten veraltet sind.
Nach Erhalt der Benachrichtigung, dass Daten veraltet sind, kontaktiert die App das Netzwerk, um nur die Daten zu aktualisieren, die als veraltet markiert wurden. Diese Aufgabe wird an die Repository delegiert, die die Netzwerkdatenquelle kontaktiert und die abgerufenen Daten in der lokalen Datenquelle speichert. Da das Repository seine Daten mit beobachtbaren Typen bereitstellt, werden Leser über alle Änderungen benachrichtigt.
class UserDataRepository(...) {
suspend fun synchronize() {
val userData = networkDataSource.fetchUserData()
localDataSource.saveUserData(userData)
}
}
Bei diesem Ansatz ist die App weitaus weniger von der Netzwerkdatenquelle abhängig und kann auch über längere Zeiträume ohne sie funktionieren. Sie bietet sowohl Lese- als auch Schreibzugriff im Offlinemodus, da davon ausgegangen wird, dass die neuesten Informationen aus der Netzwerkdatenquelle lokal vorhanden sind.
Die Vor- und Nachteile der Push-basierten Synchronisierung sind in der folgenden Tabelle zusammengefasst:
| Vorteile | Nachteile |
|---|---|
| Die App kann unbegrenzt offline bleiben. | Die Versionsverwaltung von Daten zur Konfliktlösung ist nicht trivial. |
| Minimale Datennutzung. Die App ruft nur Daten ab, die sich geändert haben. | Sie müssen die Schreibbestätigungen während der Synchronisierung berücksichtigen. |
| Gut für relationale Daten geeignet. 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. |
Hybride Synchronisierung
Einige Apps verwenden einen hybriden Ansatz, der je nach Daten pull- oder pushbasiert ist. Eine Social-Media-App könnte beispielsweise die Pull-basierte Synchronisierung verwenden, um den Feed der Nutzer, denen der Nutzer folgt, bei Bedarf abzurufen, da der Feed häufig aktualisiert wird. Dieselbe App kann sich für die Verwendung der Push-basierten Synchronisierung für Daten zum angemeldeten Nutzer entscheiden, einschließlich Nutzername, Profilbild usw.
Letztendlich hängt die Wahl der Offline-First-Synchronisierung von den Produktanforderungen und der verfügbaren technischen Infrastruktur ab.
Konfliktlösung
Wenn die App im Offlinemodus Daten lokal schreibt, die nicht mit der Netzwerkdatenquelle übereinstimmen, müssen Sie den Konflikt beheben, bevor die Synchronisierung erfolgen kann.
Für die Konfliktlösung ist oft eine Versionsverwaltung erforderlich. Die App muss einige Buchungen vornehmen, um zu verfolgen, wann Änderungen aufgetreten sind, damit sie die Metadaten an die Netzwerkdatenquelle weitergeben kann. Die Netzwerkdatenquelle ist dann dafür verantwortlich, die absolute Source of Truth bereitzustellen. Je nach den Anforderungen der Anwendung gibt es viele Strategien zur Konfliktlösung. 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 sie empfängt, werden alle Daten verworfen, die älter als der aktuelle Status sind. Daten, die neuer als der aktuelle Status sind, werden akzeptiert.
In Abbildung 9 sind beide Geräte offline und anfänglich mit der Netzwerkdatenquelle synchronisiert. Während sie offline sind, schreiben beide Daten lokal und erfassen die Zeit, zu der sie ihre Daten geschrieben haben. Wenn beide wieder online sind und mit der Netzwerkdatenquelle synchronisiert werden, löst das Netzwerk den Konflikt, indem es die Daten von Gerät B beibehält, da sie später geschrieben wurden.
WorkManager in Offline-First-Apps
Sowohl bei den zuvor beschriebenen Lese- als auch bei den Schreibstrategien gibt es zwei gängige Dienstprogramme:
- Warteschlangen
- Lesevorgänge: Werden verwendet, um Lesevorgänge zu verschieben, bis eine Netzwerkverbindung verfügbar ist.
- Schreibvorgänge: Werden verwendet, um Schreibvorgänge zu verschieben, bis eine Netzwerkverbindung verfügbar ist, und um Schreibvorgänge für Wiederholungsversuche in die Warteschlange zu stellen.
- Monitore für Netzwerkverbindungen
- Lesevorgänge: Wird als Signal zum Leeren der Lesewarteschlange verwendet, wenn die App verbunden ist, und für die Synchronisierung.
- Schreibvorgänge: Wird als Signal zum Leeren der Schreibwarteschlange verwendet, wenn die App verbunden ist, und für die Synchronisierung.
Beide Fälle sind Beispiele für dauerhafte Arbeit, für die WorkManager hervorragend geeignet ist.
In der Beispiel-App Now in Android wird WorkManager beispielsweise sowohl als Lesewarteschlange als auch als Netzwerkmonitor verwendet, wenn die lokale Datenquelle synchronisiert wird. Beim Start führt die App folgende Schritte aus:
- Stellt Lesesynchronisierungsaufgaben in die Warteschlange, um sicherzustellen, dass die lokale Datenquelle und die Netzwerkdatenquelle übereinstimmen.
- Leert die Lesesynchronisierungswarteschlange und startet die Synchronisierung, wenn die App online ist.
- Führt einen Lesevorgang aus der Netzwerkdatenquelle mit exponentiellem Backoff aus.
- Die Ergebnisse des Lesevorgangs werden in der lokalen Datenquelle gespeichert und alle auftretenden Konflikte werden gelöst.
- Stellt die Daten aus der lokalen Datenquelle für andere Ebenen der App bereit.
Diese Aktionen werden im folgenden Diagramm veranschaulicht:
Die Synchronisierungsaufgabe wird mit WorkManager in die Warteschlange gestellt. Dazu wird sie mit der KEEP ExistingWorkPolicy als eindeutige Aufgabe angegeben:
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
}
}
SyncWorker.startupSyncWork() ist 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 erfordert die von SyncConstraints definierte Constraints, dass die NetworkType NetworkType.CONNECTED ist. Das bedeutet, dass er wartet, bis das Netzwerk verfügbar ist, bevor er ausgeführt wird.
Sobald das Netzwerk verfügbar ist, leert der Worker die eindeutige Arbeitswarteschlange, die von der SyncWorkName angegeben wird, indem er die Aufgaben an die entsprechenden Repository-Instanzen delegiert. Wenn die Synchronisierung fehlschlägt, gibt die Methode doWork() den Wert Result.retry() zurück. WorkManager wiederholt die Synchronisierung automatisch mit exponentiellem Backoff. Andernfalls wird Result.success() zurückgegeben und die Synchronisierung ist abgeschlossen.
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()
}
}
Beispiele
Die folgenden Google-Beispiele zeigen Offline-First-Apps. Sehen Sie sich die folgenden Beispiele an, um diese Anleitung in der Praxis zu sehen:
Empfehlungen für Sie
- Hinweis: Der Linktext wird angezeigt, wenn JavaScript deaktiviert ist.
- Zustandsproduktion in der UI
- UI-Ebene
- Datenebene