Architektura offline-first w .NET MAUI — SQLite, EF Core i synchronizacja danych

Praktyczny przewodnik po architekturze offline-first w .NET MAUI. Konfiguracja SQLite z EF Core, wykrywanie sieci, trzy wzorce synchronizacji danych i zadania w tle z WorkManager i Shiny — z gotowymi przykładami kodu.

Dlaczego architektura offline-first jest kluczowa dla aplikacji mobilnych

Każdy deweloper mobilny prędzej czy później zmierzy się z tą samą brutalną rzeczywistością — użytkownicy nie zawsze mają stabilne połączenie z internetem. Metro, lotnisko, winda, tereny wiejskie — sytuacje bez zasięgu to po prostu codzienność. I jeśli Twoja aplikacja .NET MAUI nie potrafi działać bez sieci, tracisz użytkowników szybciej, niż jesteś w stanie ich pozyskać.

Według badań Google z 2025 roku, 68% użytkowników odinstalowuje aplikację, która nie działa offline w kluczowych scenariuszach. Sześćdziesiąt osiem procent. To nie jest niszowy problem — to fundamentalna kwestia architektoniczna, którą trzeba zaadresować od samego początku.

Czym więc jest architektura offline-first? W skrócie: projektujemy aplikację z założeniem, że brak internetu jest stanem domyślnym, a połączenie sieciowe to miły bonus. Dane przechowujemy lokalnie w SQLite, logikę biznesową wykonujemy na urządzeniu, a synchronizację z serwerem odpalamy, gdy sieć staje się dostępna.

Brzmi prosto? No cóż, w praktyce wymaga to starannego zaplanowania architektury.

W tym przewodniku przejdziemy krok po kroku przez budowanie kompletnej architektury offline-first w .NET MAUI. Zaczniemy od konfiguracji SQLite z Entity Framework Core, przejdziemy przez wykrywanie stanu sieci, wzorce synchronizacji i rozwiązywanie konfliktów, a skończymy na zadaniach w tle. Wszystko z działającymi przykładami kodu, które możesz wziąć i od razu zastosować w swoich projektach.

Konfiguracja SQLite z Entity Framework Core w .NET MAUI

SQLite to naturalny wybór dla lokalnej bazy danych w aplikacjach mobilnych — jest lekki, nie wymaga serwera, działa na wszystkich platformach obsługiwanych przez .NET MAUI i oferuje pełne wsparcie dla transakcji ACID. A w połączeniu z Entity Framework Core dostajemy wygodną warstwę ORM, która eliminuje konieczność ręcznego pisania zapytań SQL.

Instalacja wymaganych pakietów NuGet

Zacznijmy od instalacji niezbędnych pakietów w projekcie .NET MAUI 10:

# Główny pakiet EF Core z providerem SQLite
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 10.0.0

# Narzędzia do migracji (opcjonalnie, przydatne w fazie developmentu)
dotnet add package Microsoft.EntityFrameworkCore.Design --version 10.0.0

Pakiet Microsoft.EntityFrameworkCore.Sqlite zawiera zarówno sam EF Core, jak i provider SQLite. Nie potrzebujesz dodatkowego pakietu sqlite-net-pcl — EF Core ma własną implementację dostępu do SQLite opartą na Microsoft.Data.Sqlite.

Definicja modeli danych

Zanim stworzymy DbContext, zdefiniujmy modele danych. W architekturze offline-first każda encja powinna posiadać pola umożliwiające śledzenie zmian i synchronizację. To absolutna podstawa — bez tych pól cały mechanizm sync nie zadziała.

namespace MojaAplikacja.Models;

public abstract class BaseEntity
{
    public int Id { get; set; }

    // Znacznik czasu ostatniej modyfikacji — kluczowy dla synchronizacji
    public DateTime ModifiedAt { get; set; } = DateTime.UtcNow;

    // Flaga oznaczająca, czy rekord został zsynchronizowany z serwerem
    public bool IsSynced { get; set; } = false;

    // Unikalny identyfikator pozwalający na matching
    // między bazą lokalną a zdalną
    public string RemoteId { get; set; } = string.Empty;

    // Flaga soft-delete — nigdy nie usuwamy lokalnie,
    // tylko oznaczamy do usunięcia przy synchronizacji
    public bool IsDeleted { get; set; } = false;
}

public class Zadanie : BaseEntity
{
    public string Tytul { get; set; } = string.Empty;
    public string Opis { get; set; } = string.Empty;
    public bool CzyUkonczone { get; set; } = false;
    public DateTime? TerminWykonania { get; set; }
    public int Priorytet { get; set; } = 0;

    // Relacja — każde zadanie należy do kategorii
    public int KategoriaId { get; set; }
    public Kategoria? Kategoria { get; set; }
}

public class Kategoria : BaseEntity
{
    public string Nazwa { get; set; } = string.Empty;
    public string Kolor { get; set; } = "#3498db";

    // Nawigacja — kategoria zawiera kolekcję zadań
    public ICollection<Zadanie> Zadania { get; set; } = new List<Zadanie>();
}

Zwróć uwagę na klasę bazową BaseEntity. Pola ModifiedAt, IsSynced, RemoteId i IsDeleted to fundament mechanizmu synchronizacji, który zaimplementujemy dalej. Bez nich śledzenie zmian i rozwiązywanie konfliktów staje się — szczerze mówiąc — koszmarnie trudne.

Konfiguracja DbContext

Teraz czas na DbContext — serce naszej warstwy dostępu do danych:

using Microsoft.EntityFrameworkCore;

namespace MojaAplikacja.Data;

public class AppDbContext : DbContext
{
    public DbSet<Zadanie> Zadania => Set<Zadanie>();
    public DbSet<Kategoria> Kategorie => Set<Kategoria>();

    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // Konfiguracja indeksów — kluczowe dla wydajności offline
        modelBuilder.Entity<Zadanie>(entity =>
        {
            entity.HasIndex(z => z.IsSynced)
                  .HasDatabaseName("IX_Zadania_IsSynced");

            entity.HasIndex(z => z.ModifiedAt)
                  .HasDatabaseName("IX_Zadania_ModifiedAt");

            entity.HasIndex(z => z.RemoteId)
                  .HasDatabaseName("IX_Zadania_RemoteId");

            entity.HasOne(z => z.Kategoria)
                  .WithMany(k => k.Zadania)
                  .HasForeignKey(z => z.KategoriaId)
                  .OnDelete(DeleteBehavior.Restrict);
        });

        modelBuilder.Entity<Kategoria>(entity =>
        {
            entity.HasIndex(k => k.IsSynced)
                  .HasDatabaseName("IX_Kategorie_IsSynced");
        });
    }
}

Indeksy na IsSynced i ModifiedAt to nie fanaberia — to konieczność. Bez nich każde zapytanie o niezsynchronizowane rekordy będzie skanować całą tabelę. Przy tysiącach rekordów różnica w wydajności jest naprawdę dramatyczna.

Rejestracja w MauiProgram.cs

Ostatni krok konfiguracji — rejestracja DbContext w kontenerze DI:

using Microsoft.EntityFrameworkCore;
using MojaAplikacja.Data;

namespace MojaAplikacja;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            });

        // Konfiguracja ścieżki do bazy SQLite
        string dbPath = Path.Combine(
            FileSystem.AppDataDirectory, "mojaaplikacja.db3");

        // Rejestracja DbContext z SQLite
        builder.Services.AddDbContext<AppDbContext>(options =>
            options.UseSqlite($"Data Source={dbPath}"));

        // Rejestracja serwisów (omówimy dalej)
        ConfigureServices(builder.Services);

        return builder.Build();
    }

    private static void ConfigureServices(IServiceCollection services)
    {
        services.AddSingleton<IConnectivity>(Connectivity.Current);
        services.AddTransient<ISyncService, SyncService>();
        services.AddTransient<IZadanieRepository, ZadanieRepository>();
    }
}

Inicjalizacja bazy danych i WAL mode

Domyślnie SQLite korzysta z trybu journalowania, który jest wolniejszy na urządzeniach mobilnych. Tryb WAL (Write-Ahead Logging) drastycznie poprawia wydajność zapisu i — co ważniejsze — pozwala na równoczesne odczyty i zapisy. Oto jak go włączyć:

public class DatabaseInitializer
{
    private readonly AppDbContext _context;

    public DatabaseInitializer(AppDbContext context)
    {
        _context = context;
    }

    public async Task InitializeAsync()
    {
        // Utwórz bazę danych, jeśli nie istnieje
        await _context.Database.EnsureCreatedAsync();

        // Włącz WAL mode — znacząca poprawa wydajności
        // na urządzeniach mobilnych
        await _context.Database.ExecuteSqlRawAsync(
            "PRAGMA journal_mode=WAL;");

        // Włącz tryb synchroniczny NORMAL (kompromis
        // między bezpieczeństwem a wydajnością)
        await _context.Database.ExecuteSqlRawAsync(
            "PRAGMA synchronous=NORMAL;");

        // Seed danych początkowych, jeśli baza jest pusta
        if (!await _context.Kategorie.AnyAsync())
        {
            _context.Kategorie.AddRange(
                new Kategoria { Nazwa = "Praca", Kolor = "#e74c3c" },
                new Kategoria { Nazwa = "Dom", Kolor = "#2ecc71" },
                new Kategoria { Nazwa = "Zakupy", Kolor = "#f39c12" }
            );
            await _context.SaveChangesAsync();
        }
    }
}

Dlaczego WAL jest tak ważny? Tryb WAL zapisuje zmiany do osobnego pliku zamiast bezpośrednio do głównego pliku bazy. Dzięki temu operacje odczytu nie blokują zapisów i odwrotnie. To ogromna różnica w scenariuszach, gdzie UI odczytuje dane, podczas gdy synchronizacja w tle zapisuje nowe rekordy.

Warstwa repozytorium — abstrakcja dostępu do danych

W architekturze offline-first warstwa repozytorium pełni podwójną rolę. Nie tylko abstrahuje dostęp do bazy danych, ale również odpowiada za oznaczanie rekordów jako niezsynchronizowanych po każdej modyfikacji. Bez tego mechanizm synchronizacji po prostu nie wie, co się zmieniło.

using Microsoft.EntityFrameworkCore;

namespace MojaAplikacja.Data;

public interface IZadanieRepository
{
    Task<List<Zadanie>> PobierzWszystkieAsync();
    Task<Zadanie?> PobierzPoIdAsync(int id);
    Task<int> DodajAsync(Zadanie zadanie);
    Task AktualizujAsync(Zadanie zadanie);
    Task UsunAsync(int id);
    Task<List<Zadanie>> PobierzNiezsynchronizowaneAsync();
    Task OznaczJakoZsynchronizowaneAsync(IEnumerable<int> ids);
}

public class ZadanieRepository : IZadanieRepository
{
    private readonly AppDbContext _context;

    public ZadanieRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<List<Zadanie>> PobierzWszystkieAsync()
    {
        return await _context.Zadania
            .Where(z => !z.IsDeleted)
            .Include(z => z.Kategoria)
            .OrderByDescending(z => z.Priorytet)
            .ThenBy(z => z.TerminWykonania)
            .ToListAsync();
    }

    public async Task<Zadanie?> PobierzPoIdAsync(int id)
    {
        return await _context.Zadania
            .Include(z => z.Kategoria)
            .FirstOrDefaultAsync(z => z.Id == id && !z.IsDeleted);
    }

    public async Task<int> DodajAsync(Zadanie zadanie)
    {
        zadanie.ModifiedAt = DateTime.UtcNow;
        zadanie.IsSynced = false;

        _context.Zadania.Add(zadanie);
        await _context.SaveChangesAsync();
        return zadanie.Id;
    }

    public async Task AktualizujAsync(Zadanie zadanie)
    {
        zadanie.ModifiedAt = DateTime.UtcNow;
        zadanie.IsSynced = false;

        _context.Zadania.Update(zadanie);
        await _context.SaveChangesAsync();
    }

    public async Task UsunAsync(int id)
    {
        var zadanie = await _context.Zadania.FindAsync(id);
        if (zadanie is not null)
        {
            // Soft delete — nie usuwamy fizycznie,
            // synchronizacja musi wiedzieć o usunięciu
            zadanie.IsDeleted = true;
            zadanie.ModifiedAt = DateTime.UtcNow;
            zadanie.IsSynced = false;
            await _context.SaveChangesAsync();
        }
    }

    public async Task<List<Zadanie>> PobierzNiezsynchronizowaneAsync()
    {
        return await _context.Zadania
            .Where(z => !z.IsSynced)
            .OrderBy(z => z.ModifiedAt)
            .ToListAsync();
    }

    public async Task OznaczJakoZsynchronizowaneAsync(
        IEnumerable<int> ids)
    {
        var zadania = await _context.Zadania
            .Where(z => ids.Contains(z.Id))
            .ToListAsync();

        foreach (var zadanie in zadania)
        {
            zadanie.IsSynced = true;
        }

        await _context.SaveChangesAsync();
    }
}

Kilka kluczowych decyzji projektowych warto tu podkreślić. Po pierwsze, soft delete — zamiast fizycznego usunięcia ustawiamy flagę IsDeleted. Gdybyśmy usuwali rekord z bazy lokalnej, synchronizacja nie miałaby skąd wiedzieć, że coś zostało usunięte. Po drugie, każda operacja modyfikacji automatycznie ustawia IsSynced = false — to gwarantuje, że żadna zmiana nie umknie mechanizmowi synchronizacji.

Taki wzorzec sam w sobie jest prosty, ale jego konsekwentne stosowanie w całym projekcie wymaga dyscypliny.

Wykrywanie stanu sieci z IConnectivity

Żeby nasza aplikacja mogła inteligentnie przełączać się między trybem offline i online, potrzebujemy niezawodnego mechanizmu wykrywania stanu sieci. Na szczęście .NET MAUI dostarcza interfejs IConnectivity z przestrzeni nazw Microsoft.Maui.Networking, który na każdej platformie korzysta z natywnych API systemu operacyjnego.

Serwis monitorujący połączenie

using Microsoft.Maui.Networking;

namespace MojaAplikacja.Services;

public interface INetworkService
{
    bool IsOnline { get; }
    event EventHandler<bool> ConnectivityChanged;
}

public class NetworkService : INetworkService, IDisposable
{
    private readonly IConnectivity _connectivity;

    public bool IsOnline =>
        _connectivity.NetworkAccess == NetworkAccess.Internet;

    public event EventHandler<bool>? ConnectivityChanged;

    public NetworkService(IConnectivity connectivity)
    {
        _connectivity = connectivity;
        _connectivity.ConnectivityChanged += OnConnectivityChanged;
    }

    private void OnConnectivityChanged(
        object? sender,
        ConnectivityChangedEventArgs e)
    {
        bool isOnline = e.NetworkAccess == NetworkAccess.Internet;
        ConnectivityChanged?.Invoke(this, isOnline);
    }

    public void Dispose()
    {
        _connectivity.ConnectivityChanged -= OnConnectivityChanged;
    }
}

Reagowanie na zmiany stanu sieci w ViewModelu

Prawdziwa moc wykrywania sieci ujawnia się dopiero wtedy, gdy połączymy je z ViewModelem. Dzięki temu możemy automatycznie uruchomić synchronizację w momencie powrotu połączenia — a użytkownik nawet nie musi o tym myśleć:

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace MojaAplikacja.ViewModels;

public partial class ZadaniaViewModel : ObservableObject
{
    private readonly IZadanieRepository _repository;
    private readonly INetworkService _networkService;
    private readonly ISyncService _syncService;

    [ObservableProperty]
    private bool _isOffline;

    [ObservableProperty]
    private string _statusMessage = string.Empty;

    [ObservableProperty]
    private bool _isSyncing;

    public ZadaniaViewModel(
        IZadanieRepository repository,
        INetworkService networkService,
        ISyncService syncService)
    {
        _repository = repository;
        _networkService = networkService;
        _syncService = syncService;

        IsOffline = !_networkService.IsOnline;

        _networkService.ConnectivityChanged += async (_, isOnline) =>
        {
            IsOffline = !isOnline;
            StatusMessage = isOnline
                ? "Połączono — synchronizuję..."
                : "Tryb offline — zmiany zapisane lokalnie";

            if (isOnline)
            {
                await SynchronizujAsync();
            }
        };
    }

    [RelayCommand]
    private async Task SynchronizujAsync()
    {
        if (IsSyncing || !_networkService.IsOnline)
            return;

        try
        {
            IsSyncing = true;
            StatusMessage = "Synchronizacja w toku...";

            var result = await _syncService.SynchronizujAsync();

            StatusMessage = result.Success
                ? $"Zsynchronizowano {result.SyncedCount} elementów"
                : $"Błąd synchronizacji: {result.ErrorMessage}";
        }
        finally
        {
            IsSyncing = false;
        }
    }
}

Ważna uwaga: Na Windowsie IConnectivity potrafi czasem zwrócić NetworkAccess.Unknown zamiast prawidłowego statusu — to znany bug w .NET MAUI. W produkcji warto potraktować Unknown jak Internet i po prostu spróbować wykonać operację sieciową, łapiąc ewentualny wyjątek. Nie idealne, ale działa.

Wzorce synchronizacji danych — od prostych po zaawansowane

Synchronizacja to zdecydowanie najtrudniejsza część architektury offline-first. Nie chodzi tylko o wysłanie i odebranie danych — chodzi o poradzenie sobie z sytuacjami, gdy ten sam rekord został zmodyfikowany zarówno lokalnie, jak i na serwerze. Poniżej trzy podejścia, od najprostszego do najbardziej zaawansowanego.

Podejście 1: Push-only (jednokierunkowe wysyłanie)

Najprostszy wzorzec. Zmiany lokalne wysyłamy na serwer, a dane z serwera pobieramy tylko jako pełną synchronizację. Idealny do aplikacji typu formularz, ankieta czy raport serwisowy — wszędzie tam, gdzie dane płyną głównie w jednym kierunku.

public class PushOnlySyncService
{
    private readonly AppDbContext _context;
    private readonly HttpClient _httpClient;

    public PushOnlySyncService(
        AppDbContext context,
        IHttpClientFactory httpClientFactory)
    {
        _context = context;
        _httpClient = httpClientFactory.CreateClient("API");
    }

    public async Task<SyncResult> WyslijZmianyAsync()
    {
        var niezsynchronizowane = await _context.Zadania
            .Where(z => !z.IsSynced)
            .ToListAsync();

        if (!niezsynchronizowane.Any())
            return new SyncResult { Success = true, SyncedCount = 0 };

        int zsynchronizowano = 0;

        // Wysyłamy w partiach po 50 rekordów
        foreach (var partia in niezsynchronizowane.Chunk(50))
        {
            var payload = partia.Select(z => new ZadanieDto
            {
                RemoteId = z.RemoteId,
                Tytul = z.Tytul,
                Opis = z.Opis,
                CzyUkonczone = z.CzyUkonczone,
                IsDeleted = z.IsDeleted,
                ModifiedAt = z.ModifiedAt
            });

            var response = await _httpClient.PostAsJsonAsync(
                "/api/zadania/sync", payload);

            if (response.IsSuccessStatusCode)
            {
                foreach (var z in partia)
                {
                    z.IsSynced = true;
                }
                zsynchronizowano += partia.Length;
            }
        }

        await _context.SaveChangesAsync();
        return new SyncResult
        {
            Success = true,
            SyncedCount = zsynchronizowano
        };
    }
}

public record SyncResult
{
    public bool Success { get; init; }
    public int SyncedCount { get; init; }
    public string? ErrorMessage { get; init; }
}

Podejście 2: Synchronizacja dwukierunkowa z timestampami

Bardziej zaawansowany wzorzec — zarówno zmiany lokalne wysyłamy na serwer, jak i pobieramy zmiany z serwera. Do rozwiązywania konfliktów używamy timestampów — wygrywa nowsza modyfikacja. Z mojego doświadczenia, to podejście sprawdza się w zdecydowanej większości typowych aplikacji biznesowych:

public interface ISyncService
{
    Task<SyncResult> SynchronizujAsync();
}

public class BidirectionalSyncService : ISyncService
{
    private readonly AppDbContext _context;
    private readonly HttpClient _httpClient;
    private readonly SemaphoreSlim _syncLock = new(1, 1);

    public BidirectionalSyncService(
        AppDbContext context,
        IHttpClientFactory httpClientFactory)
    {
        _context = context;
        _httpClient = httpClientFactory.CreateClient("API");
    }

    public async Task<SyncResult> SynchronizujAsync()
    {
        // Semaphore zapobiega równoczesnym synchronizacjom
        if (!await _syncLock.WaitAsync(TimeSpan.Zero))
            return new SyncResult
            {
                Success = false,
                ErrorMessage = "Synchronizacja już w toku"
            };

        try
        {
            // KROK 1: Wyślij lokalne zmiany na serwer
            int wyslanychZmian = await WyslijLokalneZmianyAsync();

            // KROK 2: Pobierz zmiany z serwera
            int pobranychZmian = await PobierzZdalneZmianyAsync();

            return new SyncResult
            {
                Success = true,
                SyncedCount = wyslanychZmian + pobranychZmian
            };
        }
        catch (HttpRequestException ex)
        {
            return new SyncResult
            {
                Success = false,
                ErrorMessage = $"Błąd sieci: {ex.Message}"
            };
        }
        finally
        {
            _syncLock.Release();
        }
    }

    private async Task<int> WyslijLokalneZmianyAsync()
    {
        var lokalne = await _context.Zadania
            .Where(z => !z.IsSynced)
            .ToListAsync();

        if (!lokalne.Any()) return 0;

        var response = await _httpClient.PostAsJsonAsync(
            "/api/sync/push",
            lokalne.Select(MapToDto));

        if (!response.IsSuccessStatusCode) return 0;

        // Serwer zwraca potwierdzone RemoteId
        var potwierdzone = await response.Content
            .ReadFromJsonAsync<List<SyncConfirmation>>();

        foreach (var potwierdzenie in potwierdzone ?? [])
        {
            var lokalne_zadanie = lokalne
                .FirstOrDefault(z => z.Id == potwierdzenie.LocalId);

            if (lokalne_zadanie is not null)
            {
                lokalne_zadanie.RemoteId = potwierdzenie.RemoteId;
                lokalne_zadanie.IsSynced = true;
            }
        }

        await _context.SaveChangesAsync();
        return lokalne.Count;
    }

    private async Task<int> PobierzZdalneZmianyAsync()
    {
        // Pobierz timestamp ostatniej synchronizacji
        var ostatniSync = await _context.Zadania
            .Where(z => z.IsSynced && !string.IsNullOrEmpty(z.RemoteId))
            .MaxAsync(z => (DateTime?)z.ModifiedAt)
            ?? DateTime.MinValue;

        var response = await _httpClient.GetFromJsonAsync
            <List<ZadanieDto>>(
                $"/api/sync/pull?since={ostatniSync:O}");

        if (response is null || !response.Any()) return 0;

        foreach (var zdalne in response)
        {
            var lokalne = await _context.Zadania
                .FirstOrDefaultAsync(z => z.RemoteId == zdalne.RemoteId);

            if (lokalne is null)
            {
                // Nowy rekord z serwera — dodajemy lokalnie
                _context.Zadania.Add(MapFromDto(zdalne));
            }
            else if (zdalne.ModifiedAt > lokalne.ModifiedAt)
            {
                // Serwer ma nowszą wersję — aktualizujemy
                AktualizujZDto(lokalne, zdalne);
                lokalne.IsSynced = true;
            }
            // Jeśli lokalna wersja jest nowsza, zostanie
            // wysłana przy następnym pushu
        }

        await _context.SaveChangesAsync();
        return response.Count;
    }

    private static ZadanieDto MapToDto(Zadanie z) => new()
    {
        RemoteId = z.RemoteId,
        Tytul = z.Tytul,
        Opis = z.Opis,
        CzyUkonczone = z.CzyUkonczone,
        IsDeleted = z.IsDeleted,
        ModifiedAt = z.ModifiedAt
    };

    private static Zadanie MapFromDto(ZadanieDto dto) => new()
    {
        RemoteId = dto.RemoteId,
        Tytul = dto.Tytul,
        Opis = dto.Opis,
        CzyUkonczone = dto.CzyUkonczone,
        IsDeleted = dto.IsDeleted,
        ModifiedAt = dto.ModifiedAt,
        IsSynced = true
    };

    private static void AktualizujZDto(
        Zadanie lokalne, ZadanieDto dto)
    {
        lokalne.Tytul = dto.Tytul;
        lokalne.Opis = dto.Opis;
        lokalne.CzyUkonczone = dto.CzyUkonczone;
        lokalne.IsDeleted = dto.IsDeleted;
        lokalne.ModifiedAt = dto.ModifiedAt;
    }
}

Podejście 3: Kolejka operacji (Operation Queue)

Najbardziej niezawodny wzorzec ze wszystkich trzech. Zamiast synchronizować stan, zapisujemy każdą operację użytkownika (dodanie, edycja, usunięcie) do lokalnej kolejki i odtwarzamy je na serwerze w oryginalnej kolejności. To podejście jest idealne dla aplikacji wymagających pełnej audytowalności i deterministycznej synchronizacji:

public class SyncOperation
{
    public int Id { get; set; }
    public string EntityType { get; set; } = string.Empty;
    public int EntityId { get; set; }
    public string OperationType { get; set; } = string.Empty; // Create, Update, Delete
    public string Payload { get; set; } = string.Empty; // JSON
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public bool IsProcessed { get; set; } = false;
    public int RetryCount { get; set; } = 0;
}

public class OperationQueueSyncService
{
    private readonly AppDbContext _context;
    private readonly HttpClient _httpClient;
    private const int MaxRetries = 3;

    public OperationQueueSyncService(
        AppDbContext context,
        IHttpClientFactory httpClientFactory)
    {
        _context = context;
        _httpClient = httpClientFactory.CreateClient("API");
    }

    public async Task<SyncResult> ProcessQueueAsync()
    {
        var operacje = await _context.Set<SyncOperation>()
            .Where(o => !o.IsProcessed && o.RetryCount < MaxRetries)
            .OrderBy(o => o.CreatedAt)
            .Take(100)
            .ToListAsync();

        int przetworzone = 0;

        foreach (var operacja in operacje)
        {
            try
            {
                var response = await _httpClient.PostAsJsonAsync(
                    "/api/sync/operation", operacja);

                if (response.IsSuccessStatusCode)
                {
                    operacja.IsProcessed = true;
                    przetworzone++;
                }
                else
                {
                    operacja.RetryCount++;
                }
            }
            catch (HttpRequestException)
            {
                operacja.RetryCount++;
                break; // Przerywamy — sieć niedostępna
            }
        }

        await _context.SaveChangesAsync();

        // Wyczyść przetworzone operacje starsze niż 7 dni
        var stare = await _context.Set<SyncOperation>()
            .Where(o => o.IsProcessed
                && o.CreatedAt < DateTime.UtcNow.AddDays(-7))
            .ToListAsync();

        _context.RemoveRange(stare);
        await _context.SaveChangesAsync();

        return new SyncResult
        {
            Success = true,
            SyncedCount = przetworzone
        };
    }
}

Rozwiązywanie konfliktów — strategie i implementacja

Konflikty synchronizacji to nieunikniona rzeczywistość w architekturze offline-first. Dwie osoby edytują ten sam rekord offline. Albo użytkownik modyfikuje zadanie na telefonie, podczas gdy system automatycznie zaktualizował je na serwerze. Takie sytuacje się zdarzają — i trzeba wiedzieć, jak sobie z nimi poradzić.

Strategia „ostatni wygrywa" (Last Writer Wins)

Najprostsza strategia — porównujemy timestampy i nowsza modyfikacja wygrywa. Jest idealna, gdy konflikty są rzadkie i akceptowalne jest nadpisanie starszych zmian. W wielu przypadkach to zupełnie wystarczające rozwiązanie:

public static class ConflictResolver
{
    public static T ResolveLastWriterWins<T>(
        T localVersion, T remoteVersion)
        where T : BaseEntity
    {
        return localVersion.ModifiedAt >= remoteVersion.ModifiedAt
            ? localVersion
            : remoteVersion;
    }
}

Strategia scalania pól (Field-Level Merge)

Bardziej zaawansowana strategia — zamiast wybierać całą wersję rekordu, porównujemy i scalamy poszczególne pola. To pozwala zachować zmiany z obu stron, pod warunkiem że dotyczą różnych pól. Wymaga trochę więcej kodu, ale daje znacznie lepsze wyniki (szczególnie w aplikacjach kolaboracyjnych):

public class FieldLevelMergeResolver
{
    public Zadanie Resolve(
        Zadanie original,  // Wersja sprzed konfliktu
        Zadanie local,     // Wersja lokalna
        Zadanie remote)    // Wersja ze serwera
    {
        var merged = new Zadanie
        {
            Id = local.Id,
            RemoteId = remote.RemoteId,
            ModifiedAt = DateTime.UtcNow,
            IsSynced = false
        };

        // Dla każdego pola: jeśli zmienione lokalnie — bierz lokalne,
        // jeśli zmienione zdalnie — bierz zdalne,
        // jeśli zmienione w obu — bierz nowsze
        merged.Tytul = ResolveField(
            original.Tytul, local.Tytul, remote.Tytul,
            local.ModifiedAt, remote.ModifiedAt);

        merged.Opis = ResolveField(
            original.Opis, local.Opis, remote.Opis,
            local.ModifiedAt, remote.ModifiedAt);

        merged.CzyUkonczone = local.CzyUkonczone != original.CzyUkonczone
            ? local.CzyUkonczone
            : remote.CzyUkonczone;

        return merged;
    }

    private static string ResolveField(
        string original, string local, string remote,
        DateTime localMod, DateTime remoteMod)
    {
        bool localChanged = local != original;
        bool remoteChanged = remote != original;

        if (localChanged && !remoteChanged) return local;
        if (!localChanged && remoteChanged) return remote;
        if (localChanged && remoteChanged)
        {
            // Oba zmienione — wygrywa nowsza
            return localMod >= remoteMod ? local : remote;
        }
        return original; // Żadne nie zmienione
    }
}

Synchronizacja w tle — Android WorkManager i iOS BGTaskScheduler

.NET MAUI nie oferuje wbudowanego, wieloplatformowego API do zadań w tle — i szczerze, to jedna z największych luk w frameworku. Na każdej platformie musisz sięgnąć po natywne mechanizmy. Na Androidzie to WorkManager, na iOS to BGTaskScheduler.

Android — WorkManager

WorkManager to preferowane rozwiązanie do odroczonej pracy w tle na Androidzie. Automatycznie dobiera najlepszą implementację w zależności od wersji systemu i gwarantuje wykonanie zadania nawet po restarcie urządzenia:

#if ANDROID
using Android.Content;
using AndroidX.Work;

namespace MojaAplikacja.Platforms.Android;

public class SyncWorker : Worker
{
    public SyncWorker(Context context, WorkerParameters parameters)
        : base(context, parameters)
    {
    }

    public override Result DoWork()
    {
        try
        {
            // Uwaga: DoWork jest synchroniczny!
            // Dla operacji async używaj CoroutineWorker
            // lub ListenableWorker
            var syncService = MauiApplication.Current.Services
                .GetRequiredService<ISyncService>();

            var result = syncService.SynchronizujAsync()
                .GetAwaiter().GetResult();

            return result.Success
                ? Result.InvokeSuccess()
                : Result.InvokeRetry();
        }
        catch (Exception)
        {
            return Result.InvokeRetry();
        }
    }
}

public static class BackgroundSyncScheduler
{
    public static void SchedulePeriodicSync(Context context)
    {
        var constraints = new Constraints.Builder()
            .SetRequiredNetworkType(NetworkType.Connected)
            .SetRequiresBatteryNotLow(true)
            .Build();

        var syncRequest = PeriodicWorkRequest.Builder
            .From<SyncWorker>(TimeSpan.FromMinutes(30))
            .SetConstraints(constraints)
            .SetBackoffCriteria(
                BackoffPolicy.Exponential,
                TimeSpan.FromMinutes(5))
            .AddTag("periodic_sync")
            .Build();

        WorkManager.GetInstance(context)
            .EnqueueUniquePeriodicWork(
                "data_sync",
                ExistingPeriodicWorkPolicy.Keep,
                syncRequest);
    }
}
#endif

Ważne zastrzeżenie: Android w trybie Doze Mode (po ~30 minutach bezczynności) wydłuża interwały wykonania zadań okresowych. Z 30 minut może zrobić się godzina, potem dwie, potem cztery. Nie polegaj na dokładnym harmonogramie — WorkManager gwarantuje jedynie, że zadanie zostanie wykonane, ale nie kiedy dokładnie. To frustrujące, ale tak po prostu działa oszczędzanie baterii na Androidzie.

Alternatywa wieloplatformowa — biblioteka Shiny

Jeśli nie chcesz pisać kodu specyficznego dla każdej platformy, biblioteka Shiny oferuje abstrakcję nad natywnymi mechanizmami zadań w tle:

using Shiny.Jobs;

namespace MojaAplikacja.Jobs;

public class DataSyncJob : Job
{
    private readonly ISyncService _syncService;

    public DataSyncJob(ISyncService syncService)
    {
        _syncService = syncService;
    }

    protected override async Task Run(CancellationToken cancelToken)
    {
        await _syncService.SynchronizujAsync();
    }
}

// Rejestracja w MauiProgram.cs:
// builder.Services.AddJob(typeof(DataSyncJob));

Pamiętaj jednak, że Shiny też nie daje gwarancji co do dokładnego czasu wykonania — system operacyjny ostatecznie decyduje, kiedy uruchomić zadanie, biorąc pod uwagę stan baterii, połączenie sieciowe i ogólne obciążenie urządzenia.

Najlepsze praktyki i pułapki architektury offline-first

Po kilku projektach z architekturą offline-first zebrałem zestaw praktyk, które nieraz uratowały mnie przed poważnymi problemami w produkcji. Może Ci się przydadzą:

  • Projektuj od offline — nie dodawaj trybu offline do istniejącej aplikacji online. To się nie sprawdza. Architekturę offline-first trzeba zaplanować od samego początku. Przekształcanie aplikacji online w offline-first to refactoring na skalę całej architektury i bywa bolesne.
  • Zawsze używaj transakcji — każda operacja synchronizacji powinna odbywać się w transakcji. Przerywanie synchronizacji w połowie bez transakcji prowadzi do niespójnego stanu danych, a to potem bardzo trudno naprawić.
  • Ogranicz rozmiar bazy lokalnej — nie synchronizuj wszystkiego. Ustal reguły, które dane są niezbędne offline (np. ostatnie 30 dni, aktywne projekty). Reszta powinna być dostępna tylko online.
  • Informuj użytkownika o stanie synchronizacji — wyraźnie pokazuj, czy dane są aktualne, czy oczekują na synchronizację. Nic nie frustruje bardziej niż utrata danych z powodu nieoczywistego stanu synchronizacji.
  • Loguj operacje synchronizacji — każda próba synchronizacji (udana czy nie) powinna być zalogowana. W produkcji to bezcenne dane diagnostyczne, które nieraz oszczędzą Ci godzin debugowania.
  • Testuj w warunkach niestabilnej sieci — nie wystarczy testować online i offline. Prawdziwe problemy pojawiają się przy przerywanym połączeniu, gdy request został wysłany, ale odpowiedź nigdy nie dotarła.
  • Szyfruj dane lokalne — jeśli przechowujesz dane wrażliwe offline, użyj SQLCipher do szyfrowania bazy SQLite. Standardowy SQLite przechowuje dane w postaci jawnego tekstu — warto o tym pamiętać.

Kompletna struktura projektu

Na zakończenie — oto rekomendowana struktura projektu .NET MAUI z architekturą offline-first:

MojaAplikacja/
├── Data/
│   ├── AppDbContext.cs
│   ├── DatabaseInitializer.cs
│   └── Repositories/
│       ├── IZadanieRepository.cs
│       └── ZadanieRepository.cs
├── Models/
│   ├── BaseEntity.cs
│   ├── Zadanie.cs
│   ├── Kategoria.cs
│   └── Dto/
│       ├── ZadanieDto.cs
│       └── SyncConfirmation.cs
├── Services/
│   ├── INetworkService.cs
│   ├── NetworkService.cs
│   ├── ISyncService.cs
│   ├── BidirectionalSyncService.cs
│   └── SyncResult.cs
├── ViewModels/
│   └── ZadaniaViewModel.cs
├── Views/
│   └── ZadaniaPage.xaml
├── Platforms/
│   ├── Android/
│   │   ├── SyncWorker.cs
│   │   └── BackgroundSyncScheduler.cs
│   └── iOS/
│       └── BackgroundSyncScheduler.cs
├── MauiProgram.cs
└── App.xaml.cs

Ta struktura wyraźnie oddziela warstwy odpowiedzialności — dane, logikę biznesową, prezentację i kod platformowy. Każda warstwa może być testowana niezależnie, a wymiana implementacji synchronizacji (np. z push-only na dwukierunkową) wymaga zmiany jedynie w warstwie Services. Czysto i przejrzyście.

Najczęściej zadawane pytania (FAQ)

Czy powinienem używać EF Core czy sqlite-net-pcl w .NET MAUI?

To zależy od złożoności projektu. EF Core oferuje pełne ORM z migracjami, relacjami i LINQ, co sprawdza się w większych projektach z rozbudowanym modelem danych. Biblioteka sqlite-net-pcl jest lżejsza i szybsza, ale oferuje ograniczone wsparcie dla relacji i brak automatycznych migracji. Dla architektury offline-first z synchronizacją polecam EF Core — śledzenie zmian i migracje schematu znacząco ułatwiają pracę.

Jak obsłużyć migracje bazy SQLite w aplikacji mobilnej?

W środowisku mobilnym nie uruchomisz dotnet ef database update na urządzeniu użytkownika. Zamiast tego użyj context.Database.EnsureCreated() dla pierwszej wersji, a dla kolejnych wersji zastosuj migracje w kodzie za pomocą context.Database.MigrateAsync(). Alternatywnie, dla prostszych zmian, możesz użyć ExecuteSqlRawAsync z ręcznymi poleceniami ALTER TABLE opakowonymi w sprawdzanie wersji schematu.

Ile danych mogę bezpiecznie przechowywać w SQLite na urządzeniu mobilnym?

SQLite technicznie obsługuje bazy do 281 TB, ale na urządzeniu mobilnym praktyczne limity wyglądają zupełnie inaczej. Większość aplikacji bezproblemowo obsługuje bazy do 50–100 MB. Powyżej tego rozmiaru zaczynają się problemy z pamięcią RAM przy dużych zapytaniach i wydłuża się czas backupu. Stosuj paginację, usuwaj nieaktualne dane i ogranicz synchronizację do niezbędnego minimum.

Jak uniknąć utraty danych podczas synchronizacji?

Kluczowe zasady: zawsze używaj transakcji bazodanowych, implementuj idempotentne operacje API (ponowne wysłanie tego samego requestu nie duplikuje danych), dodaj retry logic z exponential backoff, stosuj wzorzec soft-delete zamiast fizycznego usuwania i regularnie twórz kopie zapasowe bazy lokalnej. W najgorszym scenariuszu użytkownik powinien móc odtworzyć swoje dane.

Czy Azure Mobile Apps to jedyna opcja do synchronizacji offline w .NET MAUI?

Absolutnie nie. Azure Mobile Apps (DataSync Framework) to jedno z gotowych rozwiązań, ale nie jedyne. Możesz zbudować własne API synchronizacji (tak jak w tym artykule), użyć bibliotek open-source takich jak NubeSync, albo skorzystać z Firebase Realtime Database czy Couchbase Lite. Wybór zależy od Twoich wymagań — Azure Mobile Apps jest najwygodniejszy dla ekosystemu Microsoft, ale jeśli potrzebujesz pełnej kontroli nad logiką synchronizacji, własna implementacja daje największą elastyczność.

O Autorze Editorial Team

Our team of expert writers and editors.