Úvod: Proč vlastně offline-first?
Ruku na srdce — kolikrát se vám stalo, že jste otevřeli appku v metru a ona se jen točila a čekala na internet? Mobilní aplikace se dnes běžně používají v prostředích, kde je připojení nestabilní nebo úplně chybí. Ať už je to v podzemí, na cestách, nebo prostě při přechodu mezi Wi-Fi a mobilními daty. Uživatelé zkrátka očekávají, že aplikace bude fungovat plynule bez ohledu na stav sítě.
A přesně tady přichází přístup offline-first. Místo toho, aby offline režim byl jen „záchrannou sítí", stává se primárním způsobem práce s daty. Je to vlastně obrácení celé filozofie vývoje.
V tomto článku si ukážeme, jak navrhnout a implementovat kompletní offline-first architekturu v .NET MAUI pomocí SQLite. Projdeme si konfiguraci databáze, asynchronní CRUD operace, detekci připojení, synchronizační strategii, řešení konfliktů i testování. Vše s praktickými příklady, které můžete rovnou použít.
Architektura offline-first řešení
Než se pustíme do kódu, pojďme si nastínit základní principy, na kterých celé řešení stojí:
- Lokální databáze jako zdroj pravdy: Aplikace vždy čte a zapisuje do lokální SQLite databáze. UI nikdy nečeká na síťový požadavek pro zobrazení dat.
- Fronta synchronizačních operací: Změny provedené offline se ukládají do fronty a automaticky se synchronizují, jakmile je připojení dostupné.
- Detekce konektivity: Aplikace průběžně monitoruje stav sítě a reaguje na změny.
- Řešení konfliktů: Jasně definovaná strategie pro případy, kdy stejná data byla změněna lokálně i na serveru.
- Oddělení odpovědností: Repozitáře, služby a synchronizační vrstva jsou oddělené a testovatelné.
Celková architektura funguje tak, že UI vrstva komunikuje s ViewModely (vzor MVVM), které volají repozitáře. Repozitáře pracují výhradně s lokální SQLite databází. Na pozadí pak běží synchronizační služba, která sleduje stav sítě a při dostupnosti připojení odesílá změny na server a stahuje aktualizace.
Konfigurace projektu a NuGet balíčky
Pro naši implementaci budeme potřebovat tyto NuGet balíčky:
<ItemGroup>
<PackageReference Include="sqlite-net-pcl" Version="1.9.172" />
<PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.10" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="System.Text.Json" Version="9.0.1" />
</ItemGroup>
Balíček sqlite-net-pcl poskytuje jednoduchý ORM pro SQLite s podporou asynchronních operací. SQLitePCLRaw.bundle_green zajišťuje nativní SQLite knihovnu pro všechny platformy. CommunityToolkit.Mvvm nám usnadní implementaci MVVM vzoru a System.Text.Json využijeme pro serializaci synchronizačních dat.
Definice datových modelů
Začneme definicí entit pro lokální databázi. Klíčový prvek offline-first architektury? Přidání synchronizačních metadat ke každé entitě:
using SQLite;
using System;
namespace MauiOfflineApp.Models
{
public abstract class BaseEntity
{
[PrimaryKey]
public string Id { get; set; } = Guid.NewGuid().ToString();
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// Synchronizační metadata
public bool IsSynced { get; set; } = false;
public DateTime? LastSyncedAt { get; set; }
public string SyncStatus { get; set; } = "pending";
// pending, synced, conflict, error
public bool IsDeleted { get; set; } = false;
// Soft delete pro správnou synchronizaci
}
public class TodoItem : BaseEntity
{
[MaxLength(200)]
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public bool IsCompleted { get; set; }
public DateTime? DueDate { get; set; }
[MaxLength(50)]
public string Priority { get; set; } = "normal";
}
}
Všimněte si, že jako primární klíč používáme string s generováním GUID. Tohle je pro offline-first architekturu naprosto zásadní — umožňuje to vytvářet záznamy na různých zařízeních bez rizika kolize ID. Vlastnost IsDeleted pak implementuje vzor „soft delete", díky kterému lze smazání správně propagovat na server při synchronizaci.
Inicializace SQLite databáze
Teď vytvoříme databázovou službu, která se postará o inicializaci připojení, vytvoření tabulek a konfiguraci optimalizací:
using SQLite;
using System;
using System.IO;
using System.Threading.Tasks;
namespace MauiOfflineApp.Data
{
public class DatabaseService : IAsyncDisposable
{
private SQLiteAsyncConnection _database;
private readonly string _dbPath;
public DatabaseService()
{
_dbPath = Path.Combine(
FileSystem.AppDataDirectory, "mauioffline.db3");
}
public SQLiteAsyncConnection Database
{
get
{
if (_database == null)
throw new InvalidOperationException(
"Databáze nebyla inicializována. Zavolejte InitializeAsync().");
return _database;
}
}
public async Task InitializeAsync()
{
if (_database != null)
return;
_database = new SQLiteAsyncConnection(
_dbPath,
SQLiteOpenFlags.ReadWrite |
SQLiteOpenFlags.Create |
SQLiteOpenFlags.SharedCache);
// Povolení Write-Ahead Logging pro lepší výkon
await _database.ExecuteAsync("PRAGMA journal_mode=WAL;");
// Vytvoření tabulek
await _database.CreateTableAsync<TodoItem>();
await _database.CreateTableAsync<SyncQueueItem>();
}
public async ValueTask DisposeAsync()
{
if (_database != null)
{
await _database.CloseAsync();
_database = null;
}
}
}
}
Důležitý detail — aktivace Write-Ahead Logging (WAL) režimu pomocí PRAGMA journal_mode=WAL. WAL výrazně zlepšuje výkon při souběžném čtení a zápisu — čtenáři neblokují zapisovatele a naopak. To je zvláště důležité v mobilních aplikacích, kde UI vlákno čte data, zatímco synchronizační služba na pozadí zapisuje. Z vlastní zkušenosti můžu říct, že bez WAL režimu se dají pozorovat nepříjemné záseky v UI.
Synchronizační fronta
Jednou z klíčových komponent celé architektury je fronta pro sledování změn, které je třeba synchronizovat:
using SQLite;
using System;
namespace MauiOfflineApp.Models
{
public class SyncQueueItem
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
[MaxLength(100)]
public string EntityType { get; set; } = string.Empty;
[MaxLength(100)]
public string EntityId { get; set; } = string.Empty;
[MaxLength(20)]
public string Operation { get; set; } = string.Empty;
// create, update, delete
public string SerializedData { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public int RetryCount { get; set; } = 0;
public int MaxRetries { get; set; } = 3;
[MaxLength(500)]
public string LastError { get; set; }
}
}
Princip je jednoduchý. Každá změna v lokální databázi se zaznamenává jako položka fronty — s informací o typu operace (vytvoření, úprava, smazání) a serializovanými daty entity. Fronta se zpracovává v pořadí FIFO, čímž se zachová správná posloupnost operací.
Generický repozitář s offline podporou
Teď přichází ta zajímavější část. Vytvoříme generický repozitář, který zapouzdřuje veškerou logiku pro CRUD operace a automaticky zařazuje změny do synchronizační fronty:
using MauiOfflineApp.Models;
using SQLite;
using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Text.Json;
using System.Threading.Tasks;
namespace MauiOfflineApp.Data
{
public class OfflineRepository<T> where T : BaseEntity, new()
{
private readonly DatabaseService _dbService;
public OfflineRepository(DatabaseService dbService)
{
_dbService = dbService;
}
private SQLiteAsyncConnection Db => _dbService.Database;
public async Task<List<T>> GetAllAsync()
{
return await Db.Table<T>()
.Where(e => !e.IsDeleted)
.ToListAsync();
}
public async Task<T> GetByIdAsync(string id)
{
return await Db.Table<T>()
.Where(e => e.Id == id && !e.IsDeleted)
.FirstOrDefaultAsync();
}
public async Task<List<T>> QueryAsync(
Expression<Func<T, bool>> predicate)
{
return await Db.Table<T>()
.Where(e => !e.IsDeleted)
.Where(predicate)
.ToListAsync();
}
public async Task<T> CreateAsync(T entity)
{
entity.CreatedAt = DateTime.UtcNow;
entity.UpdatedAt = DateTime.UtcNow;
entity.IsSynced = false;
entity.SyncStatus = "pending";
await Db.InsertAsync(entity);
await EnqueueSyncOperationAsync(entity, "create");
return entity;
}
public async Task<T> UpdateAsync(T entity)
{
entity.UpdatedAt = DateTime.UtcNow;
entity.IsSynced = false;
entity.SyncStatus = "pending";
await Db.UpdateAsync(entity);
await EnqueueSyncOperationAsync(entity, "update");
return entity;
}
public async Task DeleteAsync(string id)
{
var entity = await GetByIdAsync(id);
if (entity == null) return;
entity.IsDeleted = true;
entity.UpdatedAt = DateTime.UtcNow;
entity.IsSynced = false;
entity.SyncStatus = "pending";
await Db.UpdateAsync(entity);
await EnqueueSyncOperationAsync(entity, "delete");
}
private async Task EnqueueSyncOperationAsync(
T entity, string operation)
{
var queueItem = new SyncQueueItem
{
EntityType = typeof(T).Name,
EntityId = entity.Id,
Operation = operation,
SerializedData = JsonSerializer.Serialize(entity),
CreatedAt = DateTime.UtcNow
};
await Db.InsertAsync(queueItem);
}
}
}
Tenhle repozitář za vás automaticky:
- Filtruje smazané záznamy (soft delete) při čtení
- Nastavuje synchronizační metadata při každé změně
- Zařazuje každou operaci do synchronizační fronty
- Serializuje data entity pro pozdější odeslání na server
Jednoduše řečeno — vy voláte běžné CRUD metody a o zbytek se postará repozitář.
Detekce stavu připojení
.NET MAUI nabízí vestavěné API pro monitorování sítě přes rozhraní IConnectivity. Pojďme si vytvořit službu, která ho zapouzdří a bude posílat reaktivní notifikace o změnách:
using Microsoft.Maui.Networking;
using System;
namespace MauiOfflineApp.Services
{
public class ConnectivityService : IDisposable
{
private readonly IConnectivity _connectivity;
public event EventHandler<bool> ConnectivityChanged;
public bool IsConnected =>
_connectivity.NetworkAccess == NetworkAccess.Internet;
public ConnectivityService(IConnectivity connectivity)
{
_connectivity = connectivity;
_connectivity.ConnectivityChanged += OnConnectivityChanged;
}
private void OnConnectivityChanged(
object sender, ConnectivityChangedEventArgs e)
{
bool isConnected =
e.NetworkAccess == NetworkAccess.Internet;
ConnectivityChanged?.Invoke(this, isConnected);
}
public ConnectionProfile GetConnectionType()
{
var profiles = _connectivity.ConnectionProfiles;
if (profiles.Contains(ConnectionProfile.WiFi))
return ConnectionProfile.WiFi;
if (profiles.Contains(ConnectionProfile.Cellular))
return ConnectionProfile.Cellular;
return ConnectionProfile.Unknown;
}
public void Dispose()
{
_connectivity.ConnectivityChanged -= OnConnectivityChanged;
}
}
}
ConnectivityService nejenže detekuje, jestli je zařízení připojeno k internetu, ale rozlišuje i typ připojení (Wi-Fi vs. mobilní data). To se hodí pro inteligentní synchronizaci — třeba můžete omezit synchronizaci velkých souborů jen na Wi-Fi.
Synchronizační služba
A teď to hlavní — jádro celé offline-first architektury. Synchronizační služba zpracovává frontu operací a komunikuje se serverem:
using MauiOfflineApp.Data;
using MauiOfflineApp.Models;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace MauiOfflineApp.Services
{
public class SyncService
{
private readonly DatabaseService _dbService;
private readonly ConnectivityService _connectivityService;
private readonly HttpClient _httpClient;
private readonly SemaphoreSlim _syncLock = new(1, 1);
private bool _isSyncing;
public event EventHandler<SyncProgressEventArgs> SyncProgress;
public SyncService(
DatabaseService dbService,
ConnectivityService connectivityService,
HttpClient httpClient)
{
_dbService = dbService;
_connectivityService = connectivityService;
_httpClient = httpClient;
// Automatická synchronizace při obnovení připojení
_connectivityService.ConnectivityChanged +=
async (s, isConnected) =>
{
if (isConnected)
await SyncAsync();
};
}
public async Task SyncAsync(
CancellationToken ct = default)
{
if (_isSyncing || !_connectivityService.IsConnected)
return;
await _syncLock.WaitAsync(ct);
try
{
_isSyncing = true;
// 1. Odeslat lokální změny na server (push)
await PushChangesAsync(ct);
// 2. Stáhnout změny ze serveru (pull)
await PullChangesAsync(ct);
}
finally
{
_isSyncing = false;
_syncLock.Release();
}
}
private async Task PushChangesAsync(
CancellationToken ct)
{
var queue = await _dbService.Database
.Table<SyncQueueItem>()
.OrderBy(q => q.CreatedAt)
.ToListAsync();
int total = queue.Count;
int processed = 0;
foreach (var item in queue)
{
if (ct.IsCancellationRequested) break;
try
{
bool success = await ProcessQueueItemAsync(item);
if (success)
{
await _dbService.Database.DeleteAsync(item);
}
else
{
item.RetryCount++;
if (item.RetryCount >= item.MaxRetries)
{
item.LastError =
"Překročen maximální počet pokusů";
}
await _dbService.Database.UpdateAsync(item);
}
}
catch (Exception ex)
{
item.RetryCount++;
item.LastError = ex.Message;
await _dbService.Database.UpdateAsync(item);
}
processed++;
SyncProgress?.Invoke(this,
new SyncProgressEventArgs(processed, total));
}
}
private async Task<bool> ProcessQueueItemAsync(
SyncQueueItem item)
{
var endpoint = $"/api/{item.EntityType.ToLower()}";
HttpResponseMessage response = item.Operation switch
{
"create" => await _httpClient.PostAsJsonAsync(
endpoint,
JsonSerializer.Deserialize<JsonElement>(
item.SerializedData)),
"update" => await _httpClient.PutAsJsonAsync(
$"{endpoint}/{item.EntityId}",
JsonSerializer.Deserialize<JsonElement>(
item.SerializedData)),
"delete" => await _httpClient.DeleteAsync(
$"{endpoint}/{item.EntityId}"),
_ => throw new InvalidOperationException(
$"Neznámá operace: {item.Operation}")
};
return response.IsSuccessStatusCode;
}
private async Task PullChangesAsync(
CancellationToken ct)
{
var lastSync = Preferences.Get(
"last_sync_timestamp", DateTime.MinValue.ToString("O"));
var response = await _httpClient.GetAsync(
$"/api/todoitem/changes?since={lastSync}", ct);
if (!response.IsSuccessStatusCode) return;
var serverItems =
await response.Content
.ReadFromJsonAsync<List<TodoItem>>(
cancellationToken: ct);
if (serverItems == null) return;
foreach (var serverItem in serverItems)
{
var localItem = await _dbService.Database
.Table<TodoItem>()
.Where(e => e.Id == serverItem.Id)
.FirstOrDefaultAsync();
if (localItem == null)
{
// Nový záznam ze serveru
serverItem.IsSynced = true;
serverItem.SyncStatus = "synced";
serverItem.LastSyncedAt = DateTime.UtcNow;
await _dbService.Database.InsertAsync(serverItem);
}
else if (localItem.IsSynced)
{
// Lokální data nebyla změněna — aktualizovat
serverItem.IsSynced = true;
serverItem.SyncStatus = "synced";
serverItem.LastSyncedAt = DateTime.UtcNow;
await _dbService.Database.UpdateAsync(serverItem);
}
else
{
// Konflikt! Lokální i serverová data byla změněna
await ResolveConflictAsync(localItem, serverItem);
}
}
Preferences.Set("last_sync_timestamp",
DateTime.UtcNow.ToString("O"));
}
private async Task ResolveConflictAsync(
TodoItem localItem, TodoItem serverItem)
{
// Strategie: Last Write Wins (LWW)
if (serverItem.UpdatedAt > localItem.UpdatedAt)
{
serverItem.IsSynced = true;
serverItem.SyncStatus = "synced";
serverItem.LastSyncedAt = DateTime.UtcNow;
await _dbService.Database.UpdateAsync(serverItem);
}
else
{
localItem.SyncStatus = "pending";
await _dbService.Database.UpdateAsync(localItem);
}
}
}
public class SyncProgressEventArgs : EventArgs
{
public int Processed { get; }
public int Total { get; }
public double Progress =>
Total == 0 ? 1.0 : (double)Processed / Total;
public SyncProgressEventArgs(int processed, int total)
{
Processed = processed;
Total = total;
}
}
}
Synchronizační služba implementuje několik důležitých vzorů:
- Automatická synchronizace: Naslouchá změnám konektivity a automaticky spustí synchronizaci při obnovení připojení.
- Vzájemné vyloučení:
SemaphoreSlimzajišťuje, že současně probíhá pouze jedna synchronizace. - Opakování pokusů: Neúspěšné operace se opakují (s omezeným počtem pokusů, samozřejmě).
- Inkrementální synchronizace: Při stahování dat ze serveru se používá časové razítko posledního syncu, čímž se minimalizuje objem přenášených dat.
Řešení konfliktů
Upřímně, řešení konfliktů je asi nejtěžší aspekt offline-first architektury. V našem příkladu jsme použili strategii Last Write Wins (LWW), která je jednoduchá a pro většinu případů dostatečná. Ale existují i pokročilejší přístupy.
1. Last Write Wins (LWW)
Jednoduchá strategie — vyhrává novější úprava na základě časového razítka UpdatedAt. Výhoda? Snadná implementace. Nevýhoda? Možná ztráta dat.
2. Sloučení na úrovni polí (Field-Level Merge)
Porovnají se jednotlivá pole entity a sloučí se nezávisle. Pokud uživatel A změnil titulek a uživatel B popis, obě změny se zachovají. To je mnohem elegantnější řešení:
private async Task FieldLevelMergeAsync(
TodoItem localItem, TodoItem serverItem, TodoItem baseItem)
{
var merged = new TodoItem { Id = localItem.Id };
// Pro každé pole: pokud se změnilo lokálně, použít lokální verzi
// Pokud se změnilo na serveru, použít serverovou verzi
// Pokud se změnilo obojí — konflikt na úrovni pole
merged.Title = localItem.Title != baseItem.Title
? localItem.Title
: serverItem.Title;
merged.Description = localItem.Description != baseItem.Description
? localItem.Description
: serverItem.Description;
merged.IsCompleted = localItem.IsCompleted != baseItem.IsCompleted
? localItem.IsCompleted
: serverItem.IsCompleted;
merged.IsSynced = true;
merged.SyncStatus = "synced";
merged.UpdatedAt = DateTime.UtcNow;
merged.LastSyncedAt = DateTime.UtcNow;
await _dbService.Database.UpdateAsync(merged);
}
3. Uživatelské rozhodnutí
Pro kritická data můžete konflikt zobrazit přímo uživateli a nechat ho rozhodnout:
private async Task UserResolvedConflictAsync(
TodoItem localItem, TodoItem serverItem)
{
// Uložit obě verze jako konflikt
localItem.SyncStatus = "conflict";
await _dbService.Database.UpdateAsync(localItem);
// Notifikovat UI o konfliktu
WeakReferenceMessenger.Default.Send(
new ConflictDetectedMessage(localItem.Id, serverItem));
}
Registrace služeb v MauiProgram.cs
Všechny služby je potřeba zaregistrovat v DI kontejneru. Tady je kompletní nastavení:
using MauiOfflineApp.Data;
using MauiOfflineApp.Services;
using MauiOfflineApp.ViewModels;
using MauiOfflineApp.Views;
using Microsoft.Maui.Networking;
namespace MauiOfflineApp;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
// Databázová služba — singleton
builder.Services.AddSingleton<DatabaseService>();
// Repozitáře
builder.Services.AddSingleton<OfflineRepository<TodoItem>>();
// Platformní služby
builder.Services.AddSingleton<IConnectivity>(
Connectivity.Current);
builder.Services.AddSingleton<ConnectivityService>();
// HTTP klient
builder.Services.AddHttpClient("API", client =>
{
client.BaseAddress = new Uri("https://api.example.com");
client.Timeout = TimeSpan.FromSeconds(30);
});
builder.Services.AddSingleton(sp =>
{
var factory = sp.GetRequiredService<IHttpClientFactory>();
return factory.CreateClient("API");
});
// Synchronizační služba
builder.Services.AddSingleton<SyncService>();
// ViewModely a stránky
builder.Services.AddTransient<TodoListViewModel>();
builder.Services.AddTransient<TodoListPage>();
return builder.Build();
}
}
DatabaseService a SyncService jsou registrovány jako singletony — potřebujeme jednu sdílenou instanci v celé aplikaci. ViewModely a stránky jsou naproti tomu transientní, protože každé otevření stránky by mělo vytvořit čerstvou instanci.
ViewModel s offline podporou
Pojďme se podívat na ViewModel, který propojuje offline repozitář s UI a zobrazuje stav synchronizace:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MauiOfflineApp.Data;
using MauiOfflineApp.Models;
using MauiOfflineApp.Services;
using System.Collections.ObjectModel;
namespace MauiOfflineApp.ViewModels;
public partial class TodoListViewModel : ObservableObject
{
private readonly OfflineRepository<TodoItem> _repository;
private readonly SyncService _syncService;
private readonly ConnectivityService _connectivityService;
[ObservableProperty]
private ObservableCollection<TodoItem> _items = new();
[ObservableProperty]
private bool _isOnline;
[ObservableProperty]
private bool _isSyncing;
[ObservableProperty]
private double _syncProgress;
[ObservableProperty]
private string _syncStatusText = "Připraveno";
public TodoListViewModel(
OfflineRepository<TodoItem> repository,
SyncService syncService,
ConnectivityService connectivityService)
{
_repository = repository;
_syncService = syncService;
_connectivityService = connectivityService;
IsOnline = _connectivityService.IsConnected;
_connectivityService.ConnectivityChanged += (s, connected) =>
{
IsOnline = connected;
SyncStatusText = connected ? "Online" : "Offline";
};
_syncService.SyncProgress += (s, e) =>
{
SyncProgress = e.Progress;
SyncStatusText =
$"Synchronizace... {e.Processed}/{e.Total}";
};
}
[RelayCommand]
private async Task LoadItemsAsync()
{
var items = await _repository.GetAllAsync();
Items = new ObservableCollection<TodoItem>(items);
}
[RelayCommand]
private async Task AddItemAsync(string title)
{
if (string.IsNullOrWhiteSpace(title)) return;
var item = new TodoItem { Title = title };
await _repository.CreateAsync(item);
Items.Add(item);
}
[RelayCommand]
private async Task ToggleCompleteAsync(TodoItem item)
{
item.IsCompleted = !item.IsCompleted;
await _repository.UpdateAsync(item);
}
[RelayCommand]
private async Task DeleteItemAsync(TodoItem item)
{
await _repository.DeleteAsync(item.Id);
Items.Remove(item);
}
[RelayCommand]
private async Task SyncAsync()
{
if (!IsOnline) return;
IsSyncing = true;
SyncStatusText = "Zahajuji synchronizaci...";
try
{
await _syncService.SyncAsync();
SyncStatusText = "Synchronizace dokončena";
await LoadItemsAsync();
}
catch (Exception ex)
{
SyncStatusText = $"Chyba: {ex.Message}";
}
finally
{
IsSyncing = false;
}
}
}
ViewModel využívá CommunityToolkit.Mvvm a jeho zdrojové generátory ([ObservableProperty], [RelayCommand]). Sleduje stav konektivity a zobrazuje průběh synchronizace v reálném čase — uživatel tak vždy ví, co se děje.
Inicializace databáze při startu aplikace
Databázi je potřeba inicializovat co nejdříve při spuštění. Nejlepší místo je v App.xaml.cs:
namespace MauiOfflineApp;
public partial class App : Application
{
private readonly DatabaseService _dbService;
public App(DatabaseService dbService)
{
InitializeComponent();
_dbService = dbService;
}
protected override Window CreateWindow(
IActivationState activationState)
{
return new Window(new AppShell());
}
protected override async void OnStart()
{
base.OnStart();
await _dbService.InitializeAsync();
}
}
Optimalizace výkonu SQLite
Pro plynulý chod aplikace je důležité dbát na výkon databáze. Tady je pár osvědčených triků.
Dávkové operace
Při vkládání většího množství záznamů vždy používejte transakce. Rozdíl ve výkonu je dramatický:
public async Task BulkInsertAsync(IEnumerable<T> entities)
{
await Db.RunInTransactionAsync(conn =>
{
foreach (var entity in entities)
{
entity.CreatedAt = DateTime.UtcNow;
entity.UpdatedAt = DateTime.UtcNow;
entity.IsSynced = false;
entity.SyncStatus = "pending";
conn.Insert(entity);
}
});
}
Indexy
Přidejte indexy na sloupce, které často filtrujete:
[Table("TodoItems")]
public class TodoItem : BaseEntity
{
[Indexed]
public bool IsCompleted { get; set; }
[Indexed]
public string Priority { get; set; } = "normal";
[Indexed]
public bool IsSynced { get; set; }
}
Stránkování
Pro velké datové sady implementujte stránkování — nikdy nenačítejte všechny záznamy najednou:
public async Task<List<T>> GetPagedAsync(
int pageIndex, int pageSize = 20)
{
return await Db.Table<T>()
.Where(e => !e.IsDeleted)
.OrderByDescending(e => e.CreatedAt)
.Skip(pageIndex * pageSize)
.Take(pageSize)
.ToListAsync();
}
Synchronizace na pozadí
Pro pravidelnou synchronizaci (i když aplikace běží na pozadí) můžeme na Androidu využít WorkManager a na iOS BGTaskScheduler. Tady je ukázka abstrakce, která to celé hezky zapouzdří:
public interface IBackgroundSyncScheduler
{
void SchedulePeriodicSync(TimeSpan interval);
void CancelScheduledSync();
}
// Registrace specifické implementace pro Android
#if ANDROID
public class AndroidBackgroundSyncScheduler : IBackgroundSyncScheduler
{
public void SchedulePeriodicSync(TimeSpan interval)
{
var workRequest = PeriodicWorkRequest.Builder
.From<SyncWorker>(interval)
.SetConstraints(
new Constraints.Builder()
.SetRequiredNetworkType(NetworkType.Connected)
.Build())
.Build();
WorkManager.GetInstance(Platform.AppContext)
.EnqueueUniquePeriodicWork(
"data_sync",
ExistingPeriodicWorkPolicy.Keep,
workRequest);
}
public void CancelScheduledSync()
{
WorkManager.GetInstance(Platform.AppContext)
.CancelUniqueWork("data_sync");
}
}
#endif
Testování offline-first architektury
Testování je u offline-first aplikace naprosto klíčové. Nestačí otestovat jen běžné CRUD operace — musíte ověřit i chování při přepínání mezi online a offline režimem:
using MauiOfflineApp.Data;
using MauiOfflineApp.Models;
using SQLite;
using Xunit;
namespace MauiOfflineApp.Tests;
public class OfflineRepositoryTests : IAsyncLifetime
{
private DatabaseService _dbService;
private OfflineRepository<TodoItem> _repository;
public async Task InitializeAsync()
{
_dbService = new DatabaseService(":memory:");
await _dbService.InitializeAsync();
_repository = new OfflineRepository<TodoItem>(_dbService);
}
public async Task DisposeAsync()
{
await _dbService.DisposeAsync();
}
[Fact]
public async Task CreateAsync_SetsCorrectSyncStatus()
{
var item = new TodoItem { Title = "Testovací úkol" };
var result = await _repository.CreateAsync(item);
Assert.False(result.IsSynced);
Assert.Equal("pending", result.SyncStatus);
Assert.Null(result.LastSyncedAt);
}
[Fact]
public async Task DeleteAsync_PerformsSoftDelete()
{
var item = new TodoItem { Title = "Ke smazání" };
await _repository.CreateAsync(item);
await _repository.DeleteAsync(item.Id);
// Záznam by neměl být viditelný přes GetAllAsync
var items = await _repository.GetAllAsync();
Assert.Empty(items);
// Ale měl by stále existovat v databázi
var raw = await _dbService.Database.Table<TodoItem>()
.Where(e => e.Id == item.Id)
.FirstOrDefaultAsync();
Assert.NotNull(raw);
Assert.True(raw.IsDeleted);
}
[Fact]
public async Task CreateAsync_EnqueuesSyncOperation()
{
var item = new TodoItem { Title = "Sync test" };
await _repository.CreateAsync(item);
var queue = await _dbService.Database
.Table<SyncQueueItem>()
.Where(q => q.EntityId == item.Id)
.ToListAsync();
Assert.Single(queue);
Assert.Equal("create", queue[0].Operation);
Assert.Equal("TodoItem", queue[0].EntityType);
}
}
Monitorování a diagnostika
Pro offline-first architekturu v produkci je důležité monitorovat několik věcí:
- Velikost synchronizační fronty: Kolik operací čeká na synchronizaci?
- Velikost databáze: Roste lokální databáze do neúnosných rozměrů? Implementujte čištění starých dat.
- Latence synchronizace: Jak dlouho trvá zpracování celé fronty?
- Počet konfliktů: Jsou konflikty časté? Možná je čas přehodnotit synchronizační strategii.
public class SyncDiagnostics
{
private readonly DatabaseService _dbService;
public SyncDiagnostics(DatabaseService dbService)
{
_dbService = dbService;
}
public async Task<SyncHealthReport> GetHealthReportAsync()
{
var pendingCount = await _dbService.Database
.Table<SyncQueueItem>()
.CountAsync();
var errorCount = await _dbService.Database
.Table<SyncQueueItem>()
.Where(q => q.RetryCount >= q.MaxRetries)
.CountAsync();
var dbFileInfo = new FileInfo(
Path.Combine(
FileSystem.AppDataDirectory, "mauioffline.db3"));
return new SyncHealthReport
{
PendingOperations = pendingCount,
FailedOperations = errorCount,
DatabaseSizeBytes = dbFileInfo.Length,
LastSyncTime = DateTime.TryParse(
Preferences.Get("last_sync_timestamp", ""),
out var dt) ? dt : null
};
}
}
public class SyncHealthReport
{
public int PendingOperations { get; set; }
public int FailedOperations { get; set; }
public long DatabaseSizeBytes { get; set; }
public DateTime? LastSyncTime { get; set; }
public string DatabaseSizeFormatted =>
$"{DatabaseSizeBytes / 1024.0:F1} KB";
}
Bezpečnostní aspekty
Na bezpečnost lokálně uložených dat se nesmí zapomínat. Tady jsou nejdůležitější body:
- Šifrování databáze: Zvažte
SQLCipherpro šifrování celé SQLite databáze. Balíčeksqlite-net-sqlciphernabízí snadnou integraci. - Zabezpečené úložiště pro tokeny: Používejte
SecureStorageAPI pro autentizační tokeny a citlivé konfigurační údaje. - Validace dat: Vždy validujte data přijatá ze serveru před uložením do lokální databáze.
- Automatické mazání: Implementujte automatické mazání citlivých dat po delší nečinnosti nebo po odhlášení.
public async Task SecureCleanupAsync()
{
// Smazat všechna lokální data při odhlášení
await _dbService.Database.DeleteAllAsync<TodoItem>();
await _dbService.Database.DeleteAllAsync<SyncQueueItem>();
Preferences.Remove("last_sync_timestamp");
SecureStorage.RemoveAll();
}
Doporučené postupy a shrnutí
Na závěr si pojďme shrnout to nejdůležitější, co byste si z tohoto článku měli odnést:
- Používejte GUID jako primární klíče: Umožňují vytvářet záznamy na libovolném zařízení bez kolizí.
- Implementujte soft delete: Nikdy fyzicky nemažte záznamy — pouze je označte. Fyzické smazání provádějte až po úspěšné synchronizaci.
- Aktivujte WAL režim: Výrazně zlepší souběžný přístup k databázi.
- Používejte transakce: Pro dávkové operace je rozdíl ve výkonu obrovský.
- Implementujte inkrementální synchronizaci: Stahujte ze serveru jen změny od posledního syncu.
- Zvolte vhodnou strategii řešení konfliktů: LWW stačí pro většinu případů, ale pro kritická data zvažte field-level merge nebo uživatelské rozhodnutí.
- Monitorujte synchronizaci: Sledujte velikost fronty, chyby a latenci.
- Testujte offline scénáře: Simulujte výpadky sítě, konflikty a velké objemy dat.
- Šifrujte citlivá data: SQLCipher a SecureStorage jsou vaši přátelé.
- Informujte uživatele: Vždy jasně zobrazujte, jestli aplikace pracuje online nebo offline.
Offline-first architektura přináší uživatelům aplikace, které jsou rychlé, spolehlivé a fungují kdekoli. S .NET MAUI a SQLite máte k dispozici všechny potřebné nástroje. Klíčem je pečlivý návrh synchronizace, promyšlená strategie řešení konfliktů a důsledné testování. Stojí to za tu extra práci — vaši uživatelé to ocení.