Warum Offline-First keine Option, sondern Pflicht ist
Stellen Sie sich vor: Ihr Nutzer steht in einer Tiefgarage, öffnet Ihre App — und nichts geht. Kein Empfang, kein Laden, kein Arbeiten. Die App ist praktisch nutzlos. Genau das passiert, wenn Offline-Fähigkeit als nachträglicher Gedanke behandelt wird.
Und ehrlich gesagt passiert das häufiger, als die meisten Entwickler annehmen.
Studien zeigen, dass mobile Nutzer durchschnittlich 28 % ihrer Zeit ohne stabile Internetverbindung verbringen — sei es in der U-Bahn, auf Reisen oder in ländlichen Regionen. Ich hab das selbst erlebt, als ich vor ein paar Monaten eine Field-Service-App für einen Kunden entwickelt habe: Die Techniker waren ständig in Kellern und Fabrikhallen unterwegs, wo einfach kein Empfang war. Eine App, die unter solchen Bedingungen versagt, verliert nicht nur Nutzer, sondern auch Vertrauen. In der Praxis bedeutet das: Offline-First ist kein nettes Feature, sondern ein architektonisches Grundprinzip.
.NET MAUI bietet mit seiner Cross-Platform-Architektur, dem integrierten IConnectivity-Interface und der nahtlosen SQLite-Integration alle Werkzeuge, die Sie für eine robuste Offline-First-Strategie brauchen. In diesem Leitfaden gehen wir Schritt für Schritt durch die komplette Implementierung — von der Datenbankschicht über die Synchronisierung bis zur Konfliktlösung. Mit lauffähigen C#-Codebeispielen, die Sie direkt in Ihre Projekte übernehmen können.
Architekturüberblick: Schichten einer Offline-First-App
Bevor wir in den Code einsteigen, brauchen wir ein klares Bild der Architektur. Also, schauen wir uns das mal an. Eine gut strukturierte Offline-First-App in .NET MAUI folgt dem Schichtenprinzip:
- Präsentationsschicht (Views + ViewModels): Die UI kommuniziert ausschließlich mit den ViewModels und kennt weder die Datenbank noch die API direkt.
- Service-Schicht: Orchestriert die Geschäftslogik und entscheidet, ob Daten lokal oder remote geladen werden.
- Repository-Schicht: Abstrahiert den Datenzugriff. Ein lokales Repository für SQLite, ein Remote-Repository für die API.
- Sync-Engine: Der Kern der Offline-First-Architektur. Überwacht die Konnektivität, verwaltet die Warteschlange ausstehender Änderungen und löst Konflikte.
- Datenschicht: SQLite als lokale Datenbank, EF Core als ORM.
┌─────────────────────────────────────┐
│ Views + ViewModels │
├─────────────────────────────────────┤
│ Service-Schicht │
├──────────────┬──────────────────────┤
│ Lokales Repo │ Remote Repository │
├──────────────┤──────────────────────┤
│ SQLite │ Sync-Engine │
│ (EF Core) │ + Request-Queue │
└──────────────┴──────────────────────┘
Das Schöne an dieser Architektur: Die ViewModels wissen nicht, woher die Daten kommen. Ob aus der lokalen Datenbank oder frisch vom Server — das entscheidet die Service-Schicht transparent. Das macht den Code testbar, wartbar und vor allem flexibel.
SQLite mit EF Core in .NET MAUI einrichten
SQLite ist die erste Wahl für lokale mobile Datenbanken — leichtgewichtig, dateibasiert und auf allen Plattformen verfügbar. In Kombination mit Entity Framework Core bekommen Sie ein vollwertiges ORM mit LINQ-Abfragen, Migrationen und Change Tracking. Klingt nach viel Overhead? Ist es nicht — EF Core ist mittlerweile erstaunlich schlank geworden.
NuGet-Pakete installieren
Installieren Sie zunächst die benötigten Pakete:
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package SQLitePCLRaw.bundle_e_sqlite3
Das Datenmodell mit Sync-Metadaten
Ein häufiger Fehler bei Offline-First-Apps (und ich hab ihn selbst schon gemacht): Die Entitäten enthalten keine Metadaten für die Synchronisierung. Jede Entität braucht mindestens einen Zeitstempel für die letzte Änderung und ein Flag, ob sie mit dem Server synchronisiert ist:
public abstract class SyncableEntity
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public bool IsSynced { get; set; }
public bool IsDeleted { get; set; }
public long Version { get; set; }
}
public class Aufgabe : SyncableEntity
{
public string Titel { get; set; } = string.Empty;
public string Beschreibung { get; set; } = string.Empty;
public bool IstErledigt { get; set; }
public DateTime? FaelligAm { get; set; }
}
Die Basisklasse SyncableEntity enthält alles, was die Sync-Engine braucht: eine global eindeutige ID (GUID statt Auto-Increment — super wichtig, damit es bei der Synchronisierung keine ID-Konflikte gibt), einen Zeitstempel, ein Sync-Flag, ein Soft-Delete-Flag und eine Versionsnummer für die optimistische Nebenläufigkeitskontrolle.
Der DbContext für Offline-First
public class AppDbContext : DbContext
{
public DbSet<Aufgabe> Aufgaben => Set<Aufgabe>();
public DbSet<SyncQueueItem> SyncQueue => Set<SyncQueueItem>();
private readonly string _dbPfad;
public AppDbContext()
{
_dbPfad = Path.Combine(
FileSystem.AppDataDirectory, "meineapp.db");
}
protected override void OnConfiguring(
DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlite($"Data Source={_dbPfad}");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Aufgabe>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.IsSynced);
entity.HasIndex(e => e.UpdatedAt);
entity.HasQueryFilter(e => !e.IsDeleted);
});
modelBuilder.Entity<SyncQueueItem>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.CreatedAt);
});
}
}
Zwei Details, die leicht übersehen werden: Der Query-Filter HasQueryFilter(e => !e.IsDeleted) sorgt dafür, dass gelöschte Einträge automatisch aus normalen Abfragen ausgeschlossen werden — die Sync-Engine kann sie aber trotzdem lesen, indem sie IgnoreQueryFilters() aufruft. Und die Indizes auf IsSynced und UpdatedAt? Die beschleunigen die häufigsten Sync-Abfragen erheblich. Ohne die wird es bei wachsenden Datenmengen schnell zäh.
Write-Ahead Logging für bessere Performance
Für Offline-First-Apps mit häufigen Schreibvorgängen empfiehlt Microsoft den WAL-Modus (Write-Ahead Logging). WAL schreibt Änderungen zunächst in eine separate Datei, bevor sie in die Hauptdatenbank übernommen werden. Das ermöglicht gleichzeitiges Lesen und Schreiben — ein echter Gamechanger für die gefühlte Performance:
public async Task InitialisiereDatenbankAsync()
{
using var db = new AppDbContext();
await db.Database.EnsureCreatedAsync();
// WAL-Modus aktivieren
await db.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;");
// Synchronous auf NORMAL setzen für bessere Performance
await db.Database.ExecuteSqlRawAsync("PRAGMA synchronous=NORMAL;");
}
Konnektivität überwachen mit IConnectivity
Das Herzstück jeder Offline-First-App ist die Konnektivitätsüberwachung. .NET MAUI stellt dafür das IConnectivity-Interface bereit, das in Echtzeit über Netzwerkänderungen informiert. Schauen wir uns an, wie man das sauber implementiert.
Den Konnektivitätsdienst implementieren
public interface IKonnektivitaetsDienst
{
bool IstOnline { get; }
event EventHandler<bool> KonnektivitaetGeaendert;
}
public class KonnektivitaetsDienst : IKonnektivitaetsDienst, IDisposable
{
private readonly IConnectivity _connectivity;
public bool IstOnline =>
_connectivity.NetworkAccess == NetworkAccess.Internet;
public event EventHandler<bool>? KonnektivitaetGeaendert;
public KonnektivitaetsDienst(IConnectivity connectivity)
{
_connectivity = connectivity;
_connectivity.ConnectivityChanged += OnKonnektivitaetGeaendert;
}
private void OnKonnektivitaetGeaendert(
object? sender, ConnectivityChangedEventArgs e)
{
var istOnline = e.NetworkAccess == NetworkAccess.Internet;
KonnektivitaetGeaendert?.Invoke(this, istOnline);
}
public void Dispose()
{
_connectivity.ConnectivityChanged -= OnKonnektivitaetGeaendert;
}
}
Registrieren Sie den Dienst in MauiProgram.cs:
builder.Services.AddSingleton<IConnectivity>(Connectivity.Current);
builder.Services.AddSingleton<IKonnektivitaetsDienst, KonnektivitaetsDienst>();
Ein wichtiger Punkt, der gerne vergessen wird: NetworkAccess.Internet bedeutet nicht automatisch, dass Ihre API erreichbar ist. Ein Gerät kann mit einem WLAN verbunden sein, das keinen Internetzugang hat (klassisches Captive-Portal-Problem), oder Ihre spezifische API könnte schlicht down sein. Für kritische Operationen empfehle ich zusätzlich einen Health-Check-Endpoint.
Die Offline-Request-Queue: Änderungen puffern
Wenn der Nutzer offline ist und Daten ändert, müssen diese Änderungen zuverlässig zwischengespeichert und später synchronisiert werden. Dafür verwenden wir eine persistente Warteschlange in SQLite. Das Prinzip ist simpel, aber mächtig.
Das Queue-Modell
public class SyncQueueItem
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string EntityTyp { get; set; } = string.Empty;
public string EntityId { get; set; } = string.Empty;
public string Operation { get; set; } = string.Empty; // Create, Update, Delete
public string JsonPayload { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public int RetryCount { get; set; }
public string? FehlerNachricht { get; set; }
}
Der Queue-Service
public class SyncQueueService
{
public async Task InQueueEinreihenAsync<T>(
T entity, string operation) where T : SyncableEntity
{
using var db = new AppDbContext();
var item = new SyncQueueItem
{
EntityTyp = typeof(T).Name,
EntityId = entity.Id,
Operation = operation,
JsonPayload = JsonSerializer.Serialize(entity)
};
db.SyncQueue.Add(item);
await db.SaveChangesAsync();
}
public async Task<List<SyncQueueItem>>
HoleAusstehendAsync(int maxAnzahl = 50)
{
using var db = new AppDbContext();
return await db.SyncQueue
.Where(q => q.RetryCount < 5)
.OrderBy(q => q.CreatedAt)
.Take(maxAnzahl)
.ToListAsync();
}
public async Task AlsVerarbeitetMarkierenAsync(string id)
{
using var db = new AppDbContext();
var item = await db.SyncQueue.FindAsync(id);
if (item != null)
{
db.SyncQueue.Remove(item);
await db.SaveChangesAsync();
}
}
public async Task RetryCountErhoehenAsync(
string id, string fehler)
{
using var db = new AppDbContext();
var item = await db.SyncQueue.FindAsync(id);
if (item != null)
{
item.RetryCount++;
item.FehlerNachricht = fehler;
await db.SaveChangesAsync();
}
}
}
Die Queue arbeitet nach dem FIFO-Prinzip (First In, First Out) und begrenzt die Wiederholungsversuche auf maximal fünf. Einträge, die fünfmal fehlgeschlagen sind, bleiben in der Datenbank, werden aber nicht mehr automatisch verarbeitet. Das ist bewusst so — im Support-Fall können Sie diese Einträge analysieren und manuell nacharbeiten.
Die Sync-Engine: Alles zusammenführen
So, jetzt wird es spannend. Die Sync-Engine ist das Herzstück der ganzen Offline-First-Architektur. Sie orchestriert den Datenabgleich zwischen lokaler Datenbank und Server.
Implementierung der Sync-Engine
public class SyncEngine
{
private readonly IKonnektivitaetsDienst _konnektivitaet;
private readonly SyncQueueService _queue;
private readonly IApiClient _apiClient;
private readonly SemaphoreSlim _syncLock = new(1, 1);
private bool _syncLaeuft;
public event EventHandler<SyncStatusEventArgs>? SyncStatusGeaendert;
public SyncEngine(
IKonnektivitaetsDienst konnektivitaet,
SyncQueueService queue,
IApiClient apiClient)
{
_konnektivitaet = konnektivitaet;
_queue = queue;
_apiClient = apiClient;
_konnektivitaet.KonnektivitaetGeaendert += async (s, online) =>
{
if (online) await SynchronisierenAsync();
};
}
public async Task SynchronisierenAsync()
{
if (!_konnektivitaet.IstOnline || _syncLaeuft) return;
if (!await _syncLock.WaitAsync(0)) return;
try
{
_syncLaeuft = true;
SyncStatusGeaendert?.Invoke(this,
new SyncStatusEventArgs("Synchronisiere..."));
// Phase 1: Lokale Änderungen hochladen (Push)
await PushAenderungenAsync();
// Phase 2: Serveränderungen herunterladen (Pull)
await PullAenderungenAsync();
SyncStatusGeaendert?.Invoke(this,
new SyncStatusEventArgs("Synchronisiert"));
}
catch (Exception ex)
{
SyncStatusGeaendert?.Invoke(this,
new SyncStatusEventArgs($"Fehler: {ex.Message}"));
}
finally
{
_syncLaeuft = false;
_syncLock.Release();
}
}
private async Task PushAenderungenAsync()
{
var ausstehend = await _queue.HoleAusstehendAsync();
foreach (var item in ausstehend)
{
try
{
await _apiClient.SendeAenderungAsync(
item.EntityTyp,
item.Operation,
item.JsonPayload);
await _queue.AlsVerarbeitetMarkierenAsync(item.Id);
}
catch (ApiConflictException ex)
{
await KonfliktLoesenAsync(item, ex.ServerVersion);
}
catch (HttpRequestException)
{
await _queue.RetryCountErhoehenAsync(
item.Id, "Netzwerkfehler");
break; // Abbrechen bei Netzwerkfehler
}
}
}
private async Task PullAenderungenAsync()
{
using var db = new AppDbContext();
var letzterSync = await db.Aufgaben
.IgnoreQueryFilters()
.MaxAsync(a => (DateTime?)a.UpdatedAt)
?? DateTime.MinValue;
var serverAenderungen = await _apiClient
.HoleAenderungenSeitAsync<Aufgabe>(letzterSync);
foreach (var serverEntity in serverAenderungen)
{
var lokaleEntity = await db.Aufgaben
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.Id == serverEntity.Id);
if (lokaleEntity == null)
{
serverEntity.IsSynced = true;
db.Aufgaben.Add(serverEntity);
}
else if (lokaleEntity.IsSynced)
{
// Lokale Version ist synced — Serverversion übernehmen
db.Entry(lokaleEntity)
.CurrentValues.SetValues(serverEntity);
lokaleEntity.IsSynced = true;
}
// Wenn !IsSynced: Lokale Änderung hat Vorrang,
// wird beim nächsten Push hochgeladen
}
await db.SaveChangesAsync();
}
}
Der Sync-Algorithmus arbeitet in zwei Phasen: Zuerst werden lokale Änderungen hochgeladen (Push), dann Serveränderungen heruntergeladen (Pull). Diese Reihenfolge ist wichtig — wenn Sie zuerst pullen, riskieren Sie, lokale Änderungen zu überschreiben. Das klingt offensichtlich, aber ich hab schon Projekte gesehen, die genau das falsch gemacht haben.
Der SemaphoreSlim stellt sicher, dass niemals zwei Synchronisierungen gleichzeitig laufen. Klingt wie ein Detail, aber ohne das riskieren Sie Datenbankkorruption und Race Conditions.
Konfliktlösung: Wenn zwei Seiten dasselbe ändern
Konflikte sind — meiner Erfahrung nach — der schwierigste Teil jeder Offline-First-Architektur. Sie entstehen, wenn ein Datensatz sowohl lokal als auch auf dem Server geändert wurde, bevor eine Synchronisierung stattfinden konnte.
Es gibt drei gängige Strategien, und jede hat ihre Berechtigung.
Strategie 1: Last Write Wins (LWW)
Die einfachste Strategie: Die neueste Änderung gewinnt, basierend auf dem Zeitstempel. Einfach zu implementieren, aber potenziell gehen Daten verloren. Für viele Anwendungsfälle ist das trotzdem völlig okay.
private async Task KonfliktLoesenLwwAsync(
SyncQueueItem lokal, SyncableEntity serverVersion)
{
using var db = new AppDbContext();
var lokaleEntity = await db.Aufgaben
.IgnoreQueryFilters()
.FirstAsync(a => a.Id == lokal.EntityId);
if (lokaleEntity.UpdatedAt > serverVersion.UpdatedAt)
{
// Lokale Version ist neuer — erneut pushen
lokal.RetryCount = 0;
await _apiClient.SendeAenderungAsync(
lokal.EntityTyp,
lokal.Operation,
lokal.JsonPayload,
forceOverwrite: true);
await _queue.AlsVerarbeitetMarkierenAsync(lokal.Id);
}
else
{
// Serverversion ist neuer — lokale Änderung verwerfen
db.Entry(lokaleEntity)
.CurrentValues.SetValues(serverVersion);
lokaleEntity.IsSynced = true;
await db.SaveChangesAsync();
await _queue.AlsVerarbeitetMarkierenAsync(lokal.Id);
}
}
Strategie 2: Feldbasiertes Merging
Intelligenter, aber auch komplexer: Statt den ganzen Datensatz zu überschreiben, werden einzelne Felder verglichen und zusammengeführt. Wenn Nutzer A den Titel ändert und Nutzer B die Beschreibung, können beide Änderungen erhalten bleiben. Das ist meistens die Strategie, die Nutzer intuitiv erwarten würden:
private Aufgabe FeldbasiertesMerging(
Aufgabe lokal, Aufgabe server, Aufgabe basis)
{
var ergebnis = new Aufgabe { Id = lokal.Id };
// Für jedes Feld: Hat sich lokal oder auf dem Server geändert?
ergebnis.Titel = lokal.Titel != basis.Titel
? lokal.Titel
: server.Titel;
ergebnis.Beschreibung = lokal.Beschreibung != basis.Beschreibung
? lokal.Beschreibung
: server.Beschreibung;
ergebnis.IstErledigt = lokal.IstErledigt != basis.IstErledigt
? lokal.IstErledigt
: server.IstErledigt;
ergebnis.UpdatedAt = DateTime.UtcNow;
ergebnis.Version = Math.Max(lokal.Version, server.Version) + 1;
return ergebnis;
}
Für feldbasiertes Merging brauchen Sie eine Referenzkopie des Datensatzes zum Zeitpunkt der letzten Synchronisierung (die sogenannte „Basis"). Das erfordert zusätzlichen Speicher, liefert aber die deutlich bessere Nutzererfahrung.
Strategie 3: Nutzerentscheidung
Bei kritischen Daten lassen Sie am besten den Nutzer entscheiden. Die App zeigt beide Versionen an und der Nutzer wählt, welche gelten soll — oder kann manuell zusammenführen. Ein bisschen wie Git-Merge-Konflikte, nur mit UI:
public class KonfliktViewModel : ObservableObject
{
[ObservableProperty]
private Aufgabe _lokaleVersion = null!;
[ObservableProperty]
private Aufgabe _serverVersion = null!;
[RelayCommand]
private async Task LokaleVersionWaehlenAsync()
{
await _syncEngine.KonfliktMitLokaleVersionLoesenAsync(
LokaleVersion);
}
[RelayCommand]
private async Task ServerVersionWaehlenAsync()
{
await _syncEngine.KonfliktMitServerVersionLoesenAsync(
ServerVersion);
}
}
Retry-Strategie mit Polly
Netzwerkaufrufe schlagen regelmäßig fehl — und das ist völlig normal. Eine robuste Retry-Strategie mit exponentieller Wartezeit sorgt dafür, dass vorübergehende Fehler automatisch behoben werden, ohne den Server zu überlasten. Polly macht das Ganze überraschend elegant:
// NuGet: Microsoft.Extensions.Http.Polly
builder.Services.AddHttpClient("SyncApi", client =>
{
client.BaseAddress = new Uri("https://api.meineapp.de");
})
.AddTransientHttpErrorPolicy(policy =>
policy.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: versuch =>
TimeSpan.FromSeconds(Math.Pow(2, versuch)),
onRetry: (ergebnis, wartezeit, versuch, kontext) =>
{
Debug.WriteLine(
$"Retry {versuch} nach {wartezeit.TotalSeconds}s " +
$"wegen: {ergebnis.Exception?.Message}");
}))
.AddTransientHttpErrorPolicy(policy =>
policy.CircuitBreakerAsync(
handledEventsAllowedBeforeBreaking: 5,
durationOfBreak: TimeSpan.FromMinutes(1)));
Die Kombination aus Retry und Circuit Breaker ist hier entscheidend: Der Retry versucht es bei vorübergehenden Fehlern erneut (mit 2, 4, 8 Sekunden Wartezeit). Der Circuit Breaker verhindert, dass die App bei einem dauerhaften Serverausfall endlos Anfragen sendet — nach fünf aufeinanderfolgenden Fehlern wird der Circuit für eine Minute geöffnet und alle Anfragen sofort abgelehnt. Ohne dieses Zusammenspiel würden Sie entweder den Akku des Nutzers leer saugen oder den Server zusätzlich belasten.
Hintergrundsynchronisierung auf Android und iOS
Echte Offline-First-Apps synchronisieren nicht nur, wenn der Nutzer die App aktiv verwendet. Sie nutzen plattformspezifische Background-APIs, um Daten im Hintergrund abzugleichen. Und hier wird es leider plattformspezifisch — daran führt kein Weg vorbei.
Android: WorkManager
Android bietet mit dem WorkManager eine robuste API für Hintergrundaufgaben, die sogar App-Neustarts überlebt:
// In Platforms/Android
[BroadcastReceiver(Exported = false)]
public class SyncWorker : Worker
{
public SyncWorker(Context context, WorkerParameters workerParams)
: base(context, workerParams) { }
public override Result DoWork()
{
try
{
var syncEngine = MauiApplication.Current
.Services.GetRequiredService<SyncEngine>();
syncEngine.SynchronisierenAsync()
.GetAwaiter().GetResult();
return Result.InvokeSuccess();
}
catch
{
return Result.InvokeRetry();
}
}
}
// Registrierung in MainActivity.cs
var constraints = new Constraints.Builder()
.SetRequiredNetworkType(NetworkType.Connected)
.Build();
var syncRequest = new PeriodicWorkRequest.Builder(
typeof(SyncWorker),
TimeSpan.FromMinutes(15))
.SetConstraints(constraints)
.Build();
WorkManager.GetInstance(this)
.EnqueueUniquePeriodicWork(
"sync",
ExistingPeriodicWorkPolicy.Keep,
syncRequest);
iOS: BGTaskScheduler
Auf iOS sieht die Sache leider anders aus. Apple begrenzt Hintergrundaktivitäten deutlich strenger, um die Akkulaufzeit zu schonen:
// In Platforms/iOS/AppDelegate.cs
public override bool FinishedLaunching(
UIApplication application,
NSDictionary launchOptions)
{
BGTaskScheduler.Shared.Register(
"com.meineapp.sync",
null,
task =>
{
HandleSyncTask(task as BGProcessingTask);
});
return base.FinishedLaunching(application, launchOptions);
}
private void HandleSyncTask(BGProcessingTask? task)
{
if (task == null) return;
var syncEngine = MauiApplication.Current
.Services.GetRequiredService<SyncEngine>();
var syncTask = Task.Run(async () =>
{
await syncEngine.SynchronisierenAsync();
});
task.ExpirationHandler = () =>
{
// iOS kann die Aufgabe jederzeit abbrechen
task.SetTaskCompleted(false);
};
syncTask.ContinueWith(t =>
{
task.SetTaskCompleted(!t.IsFaulted);
ScheduleNextSync();
});
}
private void ScheduleNextSync()
{
var request = new BGProcessingTaskRequest("com.meineapp.sync")
{
RequiresNetworkConnectivity = true,
EarliestBeginDate = NSDate.FromTimeIntervalSinceNow(
15 * 60) // Frühestens in 15 Minuten
};
BGTaskScheduler.Shared.Submit(request, out _);
}
Beachten Sie: iOS gibt Ihnen keinerlei Garantie, wann oder ob Ihre Hintergrundaufgabe ausgeführt wird. Das System berücksichtigt Akkuladung, Netzwerkverfügbarkeit und Nutzungsmuster. Das frustriert am Anfang, aber planen Sie Ihre App einfach so, dass sie auch ohne Hintergrundsynchronisierung funktioniert — dann ist alles andere ein Bonus.
Dependency Injection: Alles verdrahten
Hier kommt alles zusammen. Die komplette Registrierung aller Services in MauiProgram.cs:
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>();
// Konnektivität
builder.Services.AddSingleton<IConnectivity>(
Connectivity.Current);
builder.Services.AddSingleton<IKonnektivitaetsDienst,
KonnektivitaetsDienst>();
// Datenbank
builder.Services.AddTransient<AppDbContext>();
// Sync-Dienste
builder.Services.AddSingleton<SyncQueueService>();
builder.Services.AddSingleton<SyncEngine>();
// HTTP-Client mit Polly
builder.Services.AddHttpClient("SyncApi", client =>
{
client.BaseAddress = new Uri("https://api.meineapp.de");
})
.AddTransientHttpErrorPolicy(p =>
p.WaitAndRetryAsync(3,
v => TimeSpan.FromSeconds(Math.Pow(2, v))));
builder.Services.AddSingleton<IApiClient, ApiClient>();
// ViewModels
builder.Services.AddTransient<AufgabenViewModel>();
return builder.Build();
}
Das ViewModel: Offline-transparent für die UI
Das ViewModel kennt den Online-/Offline-Status, arbeitet aber in beiden Fällen identisch. Der Nutzer merkt im Idealfall gar nicht, ob er gerade online oder offline ist — und genau das ist das Ziel:
public partial class AufgabenViewModel : ObservableObject
{
private readonly SyncEngine _syncEngine;
private readonly IKonnektivitaetsDienst _konnektivitaet;
[ObservableProperty]
private ObservableCollection<Aufgabe> _aufgaben = new();
[ObservableProperty]
private bool _istOnline;
[ObservableProperty]
private string _syncStatus = string.Empty;
public AufgabenViewModel(
SyncEngine syncEngine,
IKonnektivitaetsDienst konnektivitaet)
{
_syncEngine = syncEngine;
_konnektivitaet = konnektivitaet;
IstOnline = _konnektivitaet.IstOnline;
_konnektivitaet.KonnektivitaetGeaendert += (s, online) =>
{
IstOnline = online;
};
_syncEngine.SyncStatusGeaendert += (s, status) =>
{
SyncStatus = status.Nachricht;
};
}
[RelayCommand]
private async Task LadenAsync()
{
using var db = new AppDbContext();
var daten = await db.Aufgaben
.OrderByDescending(a => a.UpdatedAt)
.ToListAsync();
Aufgaben = new ObservableCollection<Aufgabe>(daten);
if (_konnektivitaet.IstOnline)
{
await _syncEngine.SynchronisierenAsync();
// Nach Sync erneut laden
daten = await db.Aufgaben
.OrderByDescending(a => a.UpdatedAt)
.ToListAsync();
Aufgaben = new ObservableCollection<Aufgabe>(daten);
}
}
[RelayCommand]
private async Task AufgabeHinzufuegenAsync(Aufgabe aufgabe)
{
using var db = new AppDbContext();
aufgabe.IsSynced = false;
db.Aufgaben.Add(aufgabe);
await db.SaveChangesAsync();
await _syncEngine.InQueueEinreihenUndSyncenAsync(
aufgabe, "Create");
Aufgaben.Insert(0, aufgabe);
}
}
Häufig gestellte Fragen
Wie viel Speicher braucht eine Offline-First-App mit SQLite?
Weniger als Sie denken. SQLite-Datenbanken sind äußerst kompakt. Eine typische App mit 10.000 Datensätzen und moderaten Textfeldern benötigt nur wenige Megabyte. Die Sync-Queue und Metadaten kommen mit wenigen hundert Kilobyte aus. Beachten Sie aber, dass der WAL-Modus temporär zusätzlichen Speicher für die WAL-Datei belegt — planen Sie etwa das Doppelte der Datenbankgröße ein.
Funktioniert die Hintergrundsynchronisierung zuverlässig auf iOS?
Ehrlich gesagt: nicht wirklich zuverlässig, zumindest nicht im Vergleich zu Android. iOS schränkt Hintergrundaktivitäten deutlich stärker ein. Der BGTaskScheduler bietet keine Garantie für die Ausführung — iOS entscheidet basierend auf Akku, Nutzungsmustern und Netzwerk. Für zeitkritische Daten sollten Sie zusätzlich Push-Benachrichtigungen nutzen, um eine Synchronisierung beim nächsten App-Start auszulösen.
Welche Konfliktlösungsstrategie sollte ich wählen?
Für die meisten Business-Apps ist Last Write Wins (LWW) ein pragmatischer Einstieg — schnell implementiert und gut genug für viele Szenarien. Wenn mehrere Nutzer häufig dieselben Datensätze bearbeiten, lohnt sich feldbasiertes Merging. Nutzerentscheidung eignet sich für Fälle, in denen Datenverlust inakzeptabel ist — etwa bei medizinischen Dokumentationen oder juristischen Texten.
Kann ich EF Core Migrationen in .NET MAUI verwenden?
Ja, aber mit Einschränkungen. Da MAUI-Projekte Multi-Target-Projekte sind, funktioniert der Add-Migration-Befehl nicht direkt. Die empfohlene Lösung: Lagern Sie den DbContext in ein separates Class-Library-Projekt aus und verwenden Sie ein Konsolen-Projekt als Startprojekt für die Migrationsgenerierung. Außerdem unterstützt SQLite nicht alle Schema-Operationen — das Löschen von Spalten erfordert beispielsweise einen Table-Rebuild.
Wie teste ich eine Offline-First-App effektiv?
Erstellen Sie eine Mock-Implementierung des IKonnektivitaetsDienst, mit dem Sie den Online-/Offline-Status programmatisch umschalten können. Testen Sie dann gezielt Szenarien wie: Daten offline erstellen, dann online synchronisieren; Konflikte erzeugen und auflösen; Netzwerk während einer laufenden Synchronisierung unterbrechen. Das klingt nach viel Aufwand, aber glauben Sie mir — diese Tests ersparen Ihnen später stundenlange Debugging-Sessions.