Offline-first i .NET MAUI med SQLite och synkronisering

Bygg en robust offline-first-app i .NET MAUI med SQLite, EF Core 10 och CommunityToolkit Datasync. Komplett guide med kodexempel för lokal datalagring, bakgrundssynkronisering, konflikthantering och prestandaoptimering.

Varför offline-first är avgörande för mobilappar

Låt oss vara ärliga — hur ofta befinner du dig i en perfekt nätverksmiljö med din telefon? Sällan. Dina användare åker tunnelbana, rör sig genom områden med uselt mottagning och kopplar in och ur flygplansläge. En app som slutar fungera bara för att nätverket försvinner i tre sekunder? Den appen får snabbt ett-stjärniga recensioner.

Offline-first är en designfilosofi där appen alltid arbetar mot lokal data först, och synkroniserar med en server när anslutningen finns tillgänglig. Resultatet? Appen känns snabb och pålitlig oavsett nätverksförhållanden. Användaren märker knappt att de var offline — och det är precis så det ska vara.

I den här guiden bygger vi upp en komplett offline-first-arkitektur i .NET MAUI med SQLite som lokal databas, Entity Framework Core som ORM, och CommunityToolkit Datasync för synkronisering mot en backend. Vi går igenom varje steg — från projektuppsättning och databasdesign till konflikthantering och produktionsoptimering.

Guiden riktar sig till dig som redan har grundläggande erfarenhet av C# och XAML. Vi använder .NET 10 och EF Core 10 genomgående.

Arkitektöversikt: Så fungerar offline-first

Innan vi kastar oss in i koden är det värt att förstå helheten. En offline-first-arkitektur i .NET MAUI består av flera samverkande lager:

  • Presentationslagret — XAML-vyer och ViewModels som alltid läser och skriver mot det lokala datalagret
  • Tjänstelagret — Affärslogik och dataåtkomst via repository-mönstret
  • Lokalt datalager — SQLite-databas hanterad av Entity Framework Core
  • Synkroniseringslagret — CommunityToolkit Datasync som hanterar delta-synkronisering och konflikthantering
  • Backend-server — ASP.NET Core Web API med Datasync-stöd

Principen är egentligen ganska enkel: alla läs- och skrivoperationer sker mot SQLite. Synkronisering körs i bakgrunden när nätverket finns tillgängligt, och konflikter hanteras automatiskt eller via anpassad logik.

Steg 1: Konfigurera projektet och installera beroenden

Skapa MAUI-projektet

Börja med att skapa ett nytt .NET MAUI-projekt:

dotnet new maui -n MinOfflineApp -f net10.0
cd MinOfflineApp

Installera NuGet-paket

Vi behöver flera paket för vår offline-first-stack:

# Entity Framework Core med SQLite
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Design

# CommunityToolkit Datasync för synkronisering
dotnet add package CommunityToolkit.Datasync.Client
dotnet add package CommunityToolkit.Datasync.Client.SQLite

# MVVM Community Toolkit
dotnet add package CommunityToolkit.Mvvm

Viktigt: Kontrollera alltid att paketversioner matchar din .NET SDK-version. Om du kör .NET 10, välj EF Core 10.x-paket. Jag har sett det här gå fel mer än en gång — versionsfel är en vanlig orsak till kryptiska runtime-undantag som kan ta timmar att felsöka.

Projektstruktur

Organisera projektet med en ren lagerstruktur:

MinOfflineApp/
├── Entities/          → Databasentiteter
├── Models/            → ViewModels och DTO:er
├── Data/              → DbContext och migrationer
├── Services/          → Affärslogik och repositories
├── Sync/              → Synkroniseringslogik
├── Views/             → XAML-sidor
└── MauiProgram.cs     → Appkonfiguration och DI

Steg 2: Designa datamodellen för offline-synkronisering

Entiteter med synkroniseringsmetadata

CommunityToolkit Datasync kräver att varje synkroniserbar entitet har specifika egenskaper för att delta-synkronisering och konflikthantering ska fungera. Det här kan kännas som onödig overhead i början, men det är helt avgörande:

public class TodoItem
{
    // Globalt unikt ID — krävs av Datasync
    public string Id { get; set; } = Guid.NewGuid().ToString();

    // Synkroniseringsmetadata
    public DateTimeOffset? UpdatedAt { get; set; }
    public string? Version { get; set; }
    public bool Deleted { get; set; }

    // Affärsdata
    public string Title { get; set; } = string.Empty;
    public string? Description { get; set; }
    public bool IsCompleted { get; set; }
    public DateTime CreatedDate { get; set; } = DateTime.UtcNow;
    public int Priority { get; set; }
}

Notera: Använd inte samma entitetstyp på både klient och server. Serverentiteter har automatiska uppdateringar konfigurerade för UpdatedAt och Version som inte ska finnas på klienten. Skapa separata modeller och mappa dem vid synkronisering — lita på mig, det sparar dig huvudvärk längre fram.

Bastyp för synkroniserbara entiteter

För att slippa upprepa synkroniseringsfälten i varje entitet skapar vi en abstrakt basklass:

public abstract class SyncableEntity
{
    public string Id { get; set; } = Guid.NewGuid().ToString();
    public DateTimeOffset? UpdatedAt { get; set; }
    public string? Version { get; set; }
    public bool Deleted { get; set; }
}

public class TodoItem : SyncableEntity
{
    public string Title { get; set; } = string.Empty;
    public string? Description { get; set; }
    public bool IsCompleted { get; set; }
    public DateTime CreatedDate { get; set; } = DateTime.UtcNow;
    public int Priority { get; set; }
}

public class Category : SyncableEntity
{
    public string Name { get; set; } = string.Empty;
    public string Color { get; set; } = "#000000";
    public ICollection<TodoItem> Items { get; set; } = new List<TodoItem>();
}

Steg 3: Konfigurera Entity Framework Core med SQLite

Skapa DbContext

Här börjar det bli intressant. Vår AppDbContext ärver från OfflineDbContext (inte vanliga DbContext) för att aktivera Datasync-stöd:

using CommunityToolkit.Datasync.Client.SQLite;
using Microsoft.EntityFrameworkCore;

public class AppDbContext : OfflineDbContext
{
    public DbSet<TodoItem> TodoItems => Set<TodoItem>();
    public DbSet<Category> Categories => Set<Category>();

    public string DbPath { get; }

    public AppDbContext()
    {
        DbPath = Path.Combine(
            FileSystem.AppDataDirectory,
            "minofflineapp.db");
    }

    protected override void OnConfiguring(
        DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlite($"Data Source={DbPath}");
    }

    protected override void OnDatasyncInitialization(
        DatasyncOfflineOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseHttpClientFactory(() =>
        {
            var client = new HttpClient()
            {
                BaseAddress = new Uri("https://min-api.example.com"),
                Timeout = TimeSpan.FromSeconds(30)
            };
            return client;
        });

        optionsBuilder.Entity<TodoItem>(cfg =>
        {
            cfg.ClientName = "todoitems";
        });

        optionsBuilder.Entity<Category>(cfg =>
        {
            cfg.ClientName = "categories";
        });
    }

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

        modelBuilder.Entity<TodoItem>(entity =>
        {
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Title)
                  .IsRequired()
                  .HasMaxLength(200);
            entity.HasIndex(e => e.IsCompleted);
            entity.HasIndex(e => e.Priority);
        });

        modelBuilder.Entity<Category>(entity =>
        {
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Name)
                  .IsRequired()
                  .HasMaxLength(100);
        });
    }
}

Initiera databasen i MauiProgram.cs

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();

        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            });

        // Registrera DbContext
        builder.Services.AddDbContext<AppDbContext>();

        // Registrera tjänster
        builder.Services.AddSingleton<ISyncService, SyncService>();
        builder.Services.AddTransient<ITodoRepository, TodoRepository>();
        builder.Services.AddTransient<MainViewModel>();
        builder.Services.AddTransient<MainPage>();

        var app = builder.Build();

        // Skapa databasen vid uppstart
        using var scope = app.Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        db.Database.EnsureCreated();

        return app;
    }
}

Tips: I produktion bör du använda EF Core-migrationer istället för EnsureCreated(). Skapa ett separat klassprojekt (utan MAUI-referens) för ditt datalager, så att dotnet ef-verktygen fungerar korrekt. Ett DAL-projekt som refererar MAUI kan bryta EF CLI-verktygen — det är en fälla som många ramlar i.

Steg 4: Implementera repository-mönstret

Repository-mönstret abstraherar dataåtkomsten och gör koden testbar. Det viktiga att komma ihåg är att hela appen alltid läser och skriver mot det lokala SQLite-lagret. Synkronisering sker helt separat.

Repository-interface

public interface ITodoRepository
{
    Task<List<TodoItem>> GetAllAsync();
    Task<List<TodoItem>> GetActiveAsync();
    Task<TodoItem?> GetByIdAsync(string id);
    Task AddAsync(TodoItem item);
    Task UpdateAsync(TodoItem item);
    Task DeleteAsync(string id);
    Task<int> GetCountAsync(bool includeCompleted = false);
}

Konkret implementering

public class TodoRepository : ITodoRepository
{
    private readonly AppDbContext _db;

    public TodoRepository(AppDbContext db)
    {
        _db = db;
    }

    public async Task<List<TodoItem>> GetAllAsync()
    {
        return await _db.TodoItems
            .Where(t => !t.Deleted)
            .OrderByDescending(t => t.Priority)
            .ThenByDescending(t => t.CreatedDate)
            .ToListAsync();
    }

    public async Task<List<TodoItem>> GetActiveAsync()
    {
        return await _db.TodoItems
            .Where(t => !t.Deleted && !t.IsCompleted)
            .OrderByDescending(t => t.Priority)
            .ToListAsync();
    }

    public async Task<TodoItem?> GetByIdAsync(string id)
    {
        return await _db.TodoItems
            .FirstOrDefaultAsync(t => t.Id == id && !t.Deleted);
    }

    public async Task AddAsync(TodoItem item)
    {
        item.UpdatedAt = DateTimeOffset.UtcNow;
        _db.TodoItems.Add(item);
        await _db.SaveChangesAsync();
    }

    public async Task UpdateAsync(TodoItem item)
    {
        item.UpdatedAt = DateTimeOffset.UtcNow;
        _db.TodoItems.Update(item);
        await _db.SaveChangesAsync();
    }

    public async Task DeleteAsync(string id)
    {
        var item = await GetByIdAsync(id);
        if (item is not null)
        {
            // Soft delete för synkronisering
            item.Deleted = true;
            item.UpdatedAt = DateTimeOffset.UtcNow;
            await _db.SaveChangesAsync();
        }
    }

    public async Task<int> GetCountAsync(bool includeCompleted = false)
    {
        var query = _db.TodoItems.Where(t => !t.Deleted);
        if (!includeCompleted)
            query = query.Where(t => !t.IsCompleted);
        return await query.CountAsync();
    }
}

Lägg märke till att DeleteAsync utför en soft delete — vi sätter Deleted = true istället för att faktiskt ta bort raden. Det här är helt avgörande. Utan soft delete kan synkroniseringen inte meddela servern att posten har tagits bort, och du hamnar i ett läge där borttagna poster plötsligt dyker upp igen efter nästa sync.

Steg 5: Synkronisering med CommunityToolkit Datasync

Bakgrund: Från Azure Mobile Apps till Datasync Toolkit

Om du har arbetat med Xamarin tidigare kanske du känner igen Azure Mobile Apps SDK. Det projektet pensionerades 2024. För .NET 8 och senare är CommunityToolkit Datasync den officiella efterföljaren, underhållen under .NET Foundation.

Toolkit:et implementerar inkrementell synkronisering via delta-tokens. Istället för att hämta hela datauppsättningen vid varje synkronisering hämtas bara poster som ändrats sedan den senaste pull-operationen. Det sparar både bandbredd och batteri — och på mobila enheter är det guld värt.

Implementera synkroniseringstjänsten

public interface ISyncService
{
    Task<SyncResult> SynchronizeAsync();
    bool IsConnected { get; }
}

public class SyncResult
{
    public bool Success { get; set; }
    public int PushedItems { get; set; }
    public int PulledItems { get; set; }
    public string? ErrorMessage { get; set; }
}

public class SyncService : ISyncService
{
    private readonly AppDbContext _db;
    private readonly IConnectivity _connectivity;
    private readonly SemaphoreSlim _syncLock = new(1, 1);

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

    public SyncService(
        AppDbContext db,
        IConnectivity connectivity)
    {
        _db = db;
        _connectivity = connectivity;
    }

    public async Task<SyncResult> SynchronizeAsync()
    {
        if (!IsConnected)
        {
            return new SyncResult
            {
                Success = false,
                ErrorMessage = "Ingen nätverksanslutning"
            };
        }

        // Förhindra parallella synkroniseringar
        if (!await _syncLock.WaitAsync(TimeSpan.FromSeconds(5)))
        {
            return new SyncResult
            {
                Success = false,
                ErrorMessage = "Synkronisering pågår redan"
            };
        }

        try
        {
            // Push lokala ändringar till servern först
            var pushResult = await _db.PushAsync();

            // Pull ändringar från servern
            var pullResult = await _db.PullAsync();

            return new SyncResult
            {
                Success = !pushResult.IsConflict,
                PushedItems = pushResult.CompletedOperations,
                PulledItems = pullResult.CompletedOperations
            };
        }
        catch (Exception ex)
        {
            return new SyncResult
            {
                Success = false,
                ErrorMessage = $"Synkroniseringsfel: {ex.Message}"
            };
        }
        finally
        {
            _syncLock.Release();
        }
    }
}

En viktig detalj här: vi använder en SemaphoreSlim för att förhindra att flera synkroniseringar körs samtidigt. Det kan låta paranoid, men utan det skyddet kan du hamna i riktigt konstiga race conditions — speciellt om bakgrundssynkronisering och manuell synk triggas samtidigt.

Registrera Connectivity-tjänsten

Glöm inte att registrera IConnectivity i DI-containern:

// I MauiProgram.cs
builder.Services.AddSingleton<IConnectivity>(
    Connectivity.Current);

Steg 6: Automatisk bakgrundssynkronisering

Manuell synkronisering fungerar, men en riktigt bra offline-first-app synkroniserar automatiskt i bakgrunden. Här implementerar vi en timer-baserad bakgrundssynk som aktiveras när nätverket blir tillgängligt:

public class BackgroundSyncManager : IDisposable
{
    private readonly ISyncService _syncService;
    private readonly IConnectivity _connectivity;
    private PeriodicTimer? _timer;
    private CancellationTokenSource? _cts;

    public event EventHandler<SyncResult>? SyncCompleted;

    public BackgroundSyncManager(
        ISyncService syncService,
        IConnectivity connectivity)
    {
        _syncService = syncService;
        _connectivity = connectivity;

        // Lyssna på nätverksändringar
        _connectivity.ConnectivityChanged += OnConnectivityChanged;
    }

    public void Start(TimeSpan interval)
    {
        _cts = new CancellationTokenSource();
        _timer = new PeriodicTimer(interval);

        Task.Run(async () =>
        {
            while (await _timer.WaitForNextTickAsync(_cts.Token))
            {
                if (_syncService.IsConnected)
                {
                    var result = await _syncService
                        .SynchronizeAsync();
                    SyncCompleted?.Invoke(this, result);
                }
            }
        }, _cts.Token);
    }

    private async void OnConnectivityChanged(
        object? sender, ConnectivityChangedEventArgs e)
    {
        if (e.NetworkAccess == NetworkAccess.Internet)
        {
            // Synka direkt när nätverket kommer tillbaka
            var result = await _syncService.SynchronizeAsync();
            SyncCompleted?.Invoke(this, result);
        }
    }

    public void Stop()
    {
        _cts?.Cancel();
        _timer?.Dispose();
    }

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

Registrera den som en singleton och starta den vid appstart. Vad gäller intervall — 30 sekunder till 5 minuter är rimligt beroende på hur kritisk realtidsdata är. I min erfarenhet är 60 sekunder en bra startpunkt för de flesta appar.

Steg 7: Konflikthantering

Okej, nu kommer vi till den del som ofta ger utvecklare huvudvärk. Konflikter uppstår när samma post ändras på både klienten och servern innan en synkronisering hunnit ske. Det är helt oundvikligt i offline-first-appar, och du behöver en strategi.

Vanliga konfliktstrategier

  • Senaste skrivningen vinner (Last Write Wins) — Den senaste ändringen baserat på tidsstämpel överskriver den andra. Enkelt men kan orsaka dataförlust.
  • Servern vinner — Serverns version behålls alltid. Klientens ändringar förkastas vid konflikt.
  • Klienten vinner — Klientens version behålls. Serverns ändringar skrivs över.
  • Manuell konfliktlösning — Presentera båda versionerna för användaren och låt dem välja. Bäst för kritisk data, men kräver mer UI-arbete.

Implementera en konflikthanterare

public class ConflictResolver
{
    public async Task<T> ResolveAsync<T>(
        T clientVersion,
        T serverVersion,
        ConflictStrategy strategy) where T : SyncableEntity
    {
        return strategy switch
        {
            ConflictStrategy.LastWriteWins =>
                ResolveByTimestamp(clientVersion, serverVersion),
            ConflictStrategy.ServerWins =>
                serverVersion,
            ConflictStrategy.ClientWins =>
                clientVersion,
            _ => throw new ArgumentException(
                $"Okänd strategi: {strategy}")
        };
    }

    private T ResolveByTimestamp<T>(
        T clientVersion,
        T serverVersion) where T : SyncableEntity
    {
        if (clientVersion.UpdatedAt >= serverVersion.UpdatedAt)
            return clientVersion;
        return serverVersion;
    }
}

public enum ConflictStrategy
{
    LastWriteWins,
    ServerWins,
    ClientWins,
    Manual
}

I de flesta mobilappar fungerar Last Write Wins som standardval. Men för särskilt viktig data — betalningsinformation, medicinska journaler, den typen av saker — vill du definitivt ha manuell konfliktlösning där användaren får se båda versionerna och välja.

Steg 8: Optimera SQLite-prestanda

SQLite-prestanda på mobila enheter kan vara förvånansvärt bra — om du konfigurerar det rätt. Här är de viktigaste optimeringarna som gör verklig skillnad.

Aktivera Write-Ahead Logging (WAL)

WAL-läge förbättrar skrivprestanda avsevärt. Istället för att låsa hela databasen vid varje skrivning, skrivs ändringar till en separat WAL-fil som sedan slås ihop med huvuddatabasen vid en checkpoint:

protected override void OnConfiguring(
    DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder.UseSqlite($"Data Source={DbPath}",
        sqliteOptions =>
        {
            sqliteOptions.CommandTimeout(30);
        });
}

// Aktivera WAL efter databasinitiering
public async Task EnableWalAsync()
{
    await Database.ExecuteSqlRawAsync(
        "PRAGMA journal_mode=WAL;");
    await Database.ExecuteSqlRawAsync(
        "PRAGMA synchronous=NORMAL;");
}

Varning: Med WAL-läge aktiverat skapas extra filer (.wal och .shm) vid sidan av din databasfil. Om du behöver flytta, kopiera eller säkerhetskopiera databasen — se till att inkludera dessa filer också. Annars riskerar du dataförlust, och det är inte kul att felsöka.

Indexering

Korrekt indexering är ärligt talat den viktigaste enskilda prestandaoptimeringen för SQLite. Det kan göra skillnaden mellan millisekunder och sekunder:

modelBuilder.Entity<TodoItem>(entity =>
{
    // Sammansatt index för vanliga filtreringar
    entity.HasIndex(e => new { e.IsCompleted, e.Priority })
          .HasDatabaseName("IX_Todo_Status_Priority");

    // Index för synkronisering
    entity.HasIndex(e => e.UpdatedAt)
          .HasDatabaseName("IX_Todo_UpdatedAt");
});

Batchade operationer

Undvik att anropa SaveChangesAsync() för varje enskild ändring. Samla ihop ändringar och spara dem i en batch — skillnaden i prestanda är enorm:

public async Task ImportItemsAsync(List<TodoItem> items)
{
    // Bra: en enda SaveChanges för alla poster
    _db.TodoItems.AddRange(items);
    await _db.SaveChangesAsync();

    // Dåligt: SaveChanges per post (mycket långsammare)
    // foreach (var item in items)
    // {
    //     _db.TodoItems.Add(item);
    //     await _db.SaveChangesAsync();
    // }
}

Steg 9: ViewModel och UI-integration

Dags att koppla ihop allt. Här är ett ViewModel som hanterar dataåtkomst och synkronisering:

public partial class MainViewModel : ObservableObject
{
    private readonly ITodoRepository _repository;
    private readonly ISyncService _syncService;

    [ObservableProperty]
    private ObservableCollection<TodoItem> _items = new();

    [ObservableProperty]
    private bool _isSyncing;

    [ObservableProperty]
    private bool _isOffline;

    [ObservableProperty]
    private string _syncStatusText = "Redo";

    public MainViewModel(
        ITodoRepository repository,
        ISyncService syncService,
        IConnectivity connectivity)
    {
        _repository = repository;
        _syncService = syncService;

        IsOffline = connectivity.NetworkAccess
            != NetworkAccess.Internet;

        connectivity.ConnectivityChanged += (s, e) =>
        {
            IsOffline = e.NetworkAccess != NetworkAccess.Internet;
        };
    }

    [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.AddAsync(item);
        Items.Insert(0, 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 (IsSyncing) return;
        IsSyncing = true;
        SyncStatusText = "Synkroniserar...";

        var result = await _syncService.SynchronizeAsync();

        if (result.Success)
        {
            SyncStatusText = $"Synkroniserat: {result.PushedItems} skickade, "
                           + $"{result.PulledItems} mottagna";
            await LoadItemsAsync(); // Uppdatera listan
        }
        else
        {
            SyncStatusText = result.ErrorMessage
                ?? "Synkronisering misslyckades";
        }

        IsSyncing = false;
    }
}

XAML-vy med synkroniseringsstatus

Och så behöver vi naturligtvis en vy som visar allt detta. Notera offline-indikatorn högst upp — den är liten men gör stor skillnad för användarupplevelsen:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:vm="clr-namespace:MinOfflineApp.Models"
             x:Class="MinOfflineApp.Views.MainPage"
             x:DataType="vm:MainViewModel">

    <Grid RowDefinitions="Auto,*,Auto">
        <!-- Offline-indikator -->
        <Frame BackgroundColor="{Binding IsOffline,
               Converter={StaticResource BoolToColorConverter}}"
               Padding="8" IsVisible="{Binding IsOffline}">
            <Label Text="Offline — ändringar sparas lokalt"
                   HorizontalOptions="Center"
                   TextColor="White" />
        </Frame>

        <!-- Uppgiftslista -->
        <CollectionView Grid.Row="1"
                        ItemsSource="{Binding Items}">
            <CollectionView.ItemTemplate>
                <DataTemplate x:DataType="vm:TodoItem">
                    <SwipeView>
                        <SwipeView.RightItems>
                            <SwipeItems>
                                <SwipeItem Text="Ta bort"
                                    BackgroundColor="Red"
                                    Command="{Binding
                                        Source={RelativeSource
                                        AncestorType={x:Type vm:MainViewModel}},
                                        Path=DeleteItemCommand}"
                                    CommandParameter="{Binding .}" />
                            </SwipeItems>
                        </SwipeView.RightItems>
                        <Grid Padding="16" ColumnDefinitions="Auto,*">
                            <CheckBox IsChecked="{Binding IsCompleted}" />
                            <Label Grid.Column="1"
                                   Text="{Binding Title}"
                                   VerticalOptions="Center" />
                        </Grid>
                    </SwipeView>
                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>

        <!-- Synkroniseringsfält -->
        <Grid Grid.Row="2" Padding="8"
              ColumnDefinitions="*,Auto">
            <Label Text="{Binding SyncStatusText}"
                   VerticalOptions="Center" />
            <Button Grid.Column="1" Text="Synka"
                    Command="{Binding SyncCommand}"
                    IsEnabled="{Binding IsSyncing,
                        Converter={StaticResource InvertBoolConverter}}" />
        </Grid>
    </Grid>
</ContentPage>

Steg 10: Plattformsspecifika överväganden

iOS och Mac Catalyst

På iOS och Mac Catalyst behöver SQLite initieras explicit. Lägg till följande i din AppDelegate eller MauiProgram.cs:

// Initiera SQLite-providern för Apple-plattformar
SQLitePCL.Batteries_V2.Init();

En liten gotcha: FileSystem.AppDataDirectory på macOS pekar mot ~/Library utan mapphierarki. I produktion kan det vara värt att skapa en dedikerad undermapp för att hålla ordning.

Android

På Android fungerar SQLite utan speciell konfiguration, vilket är skönt. Tänk dock på att Android-emulatorer kan ha annorlunda filsystemprestanda än fysiska enheter. Testa alltid på riktiga enheter för prestandabedömning — emulatorn kan ge en helt missvisande bild.

Databasfilhantering vid flytt eller backup

Om du behöver flytta, kopiera eller säkerhetskopiera databasen:

  1. Stäng alla aktiva anslutningar först
  2. Om WAL är aktiverat, inkludera även .wal- och .shm-filerna
  3. Överväg att köra en checkpoint innan flytten för att slå ihop WAL-filen med huvuddatabasen
// Kör checkpoint innan backup
await db.Database.ExecuteSqlRawAsync(
    "PRAGMA wal_checkpoint(TRUNCATE);");

Backend-servern: Datasync med ASP.NET Core

Klienten behöver så klart en server att synka mot. Här visar vi grunderna för att komma igång snabbt med CommunityToolkit Datasync-servern.

Skapa serverprojektet

# Installera Datasync-servermallen
dotnet new install CommunityToolkit.Datasync.Server.Template.CSharp

# Skapa ett nytt serverprojekt
dotnet new datasync-server -n MinOfflineApp.Server

Serverentiteter och kontroller

// Serverversion av TodoItem (skiljer sig från klienten)
public class ServerTodoItem : EntityTableData
{
    public string Title { get; set; } = string.Empty;
    public string? Description { get; set; }
    public bool IsCompleted { get; set; }
    public DateTime CreatedDate { get; set; }
    public int Priority { get; set; }
}

// Kontroller genereras automatiskt av mallen,
// men du kan anpassa dem:
[Route("tables/todoitems")]
public class TodoItemController : TableController<ServerTodoItem>
{
    public TodoItemController(AppDbContext context)
        : base(new EntityTableRepository<ServerTodoItem>(context))
    {
        // Kräv autentisering om du vill
        // Options.UnauthorizedStatusCode = 401;
    }
}

Databasen på serversidan bör inte vara SQLite — den saknar millisekundprecision i tidsstämplar, vilket krävs för korrekt inkrementell synkronisering. Använd Azure SQL, PostgreSQL, MySQL eller CosmosDB istället. Det här är ett misstag jag sett flera göra, och det ger väldigt subtila buggar som är svåra att spåra.

Testning av offline-first-funktionalitet

Att testa offline-scenarier är avgörande men blir ofta bortprioriterat. Gör inte det misstaget. Här är ett strukturerat tillvägagångssätt:

Enhetstester med mockad connectivity

[Fact]
public async Task SyncAsync_WhenOffline_ReturnsFailure()
{
    // Arrange
    var mockConnectivity = new Mock<IConnectivity>();
    mockConnectivity.Setup(c => c.NetworkAccess)
        .Returns(NetworkAccess.None);

    var syncService = new SyncService(
        CreateInMemoryDb(),
        mockConnectivity.Object);

    // Act
    var result = await syncService.SynchronizeAsync();

    // Assert
    Assert.False(result.Success);
    Assert.Equal("Ingen nätverksanslutning",
        result.ErrorMessage);
}

[Fact]
public async Task AddItem_WhenOffline_SavesLocally()
{
    // Arrange
    var db = CreateInMemoryDb();
    var repo = new TodoRepository(db);

    // Act
    var item = new TodoItem { Title = "Testartikel" };
    await repo.AddAsync(item);

    // Assert
    var saved = await repo.GetByIdAsync(item.Id);
    Assert.NotNull(saved);
    Assert.Equal("Testartikel", saved.Title);
}

Manuell testchecklista

  1. Skapa poster i offline-läge — verifiera att de sparas lokalt
  2. Slå på nätverket — verifiera att synkronisering startar automatiskt
  3. Skapa samma post på server och klient — verifiera konflikthantering
  4. Stäng appen mitt i en synkronisering — verifiera att data inte korrumperas
  5. Testa med långsam nätverksanslutning — verifiera timeout-hantering

Vanliga fallgropar och hur du undviker dem

Efter att ha jobbat med den här typen av arkitektur ett tag har jag samlat ihop de vanligaste misstagen:

  • Glömd soft delete — Om du gör en riktig DELETE istället för att sätta Deleted = true, kan synkroniseringen inte meddela servern att posten tagits bort. Poster dyker upp igen som zombier.
  • Saknade index — Synkronisering filtrerar ofta på UpdatedAt. Utan index blir det en full table scan vid varje pull, och det märks snabbt med mer data.
  • DbContext-livstid — Undvik singleton-registrering av DbContext. EF Core är inte trådsäkert och en delad instans kan orsaka de mest kryptiska felen du någonsin sett.
  • Versionsmatchning — Se till att EF Core-paketversionen matchar din .NET SDK-version exakt. Blandar du versioner väntar märkliga körtidsfel.
  • SQLite på servern — Använd aldrig SQLite som serverdatabas för synkronisering. Den saknar den tidsstämpelprecision som krävs för korrekt delta-synkronisering.

Vanliga frågor

Vilken SQLite-paketet ska jag använda i .NET MAUI — sqlite-net-pcl eller EF Core?

Det beror på projektets komplexitet. sqlite-net-pcl är lättare och enklare för grundläggande CRUD-operationer. Entity Framework Core (Microsoft.EntityFrameworkCore.Sqlite) passar bättre för mer komplexa scenarier med relationer, migrationer och LINQ-frågor. Om du planerar synkronisering med CommunityToolkit Datasync rekommenderas EF Core eftersom OfflineDbContext bygger på det.

Vad händer med offline-data när användaren uppdaterar appen?

SQLite-databasen bevaras vid appuppdateringar — den lagras i AppDataDirectory som inte rensas vid uppdatering. Däremot bör du använda EF Core-migrationer för att hantera schemaändringar mellan versioner. Testa alltid migrationsflödet innan du publicerar en uppdatering.

Hur mycket data kan jag lagra lokalt i SQLite på en mobil enhet?

SQLite stöder databaser upp till 281 terabyte, men i praktiken begränsas du av enhetens lediga lagringsutrymme. De flesta mobilappar fungerar utmärkt med databaser upp till flera hundra megabyte. Om du arbetar med stora datamängder, överväg att implementera datapartitionering och bara synkronisera den data som faktiskt är relevant.

Är CommunityToolkit Datasync kompatibelt med Native AOT i .NET MAUI?

I nuläget har CommunityToolkit Datasync begränsat stöd för Native AOT. Det fungerar men kräver extra konfiguration, särskilt på iOS och Mac Catalyst. Konsultera den officiella dokumentationen för specifika krav och begränsningar.

Kan jag använda en annan backend än ASP.NET Core med CommunityToolkit Datasync?

Tekniskt sett ja — Datasync-klienten kommunicerar via ett REST-baserat protokoll. Du kan implementera samma API-kontrakt i valfritt backend-ramverk. Dock är det enklast att använda de medföljande ASP.NET Core-paketen som hanterar delta-tokens, versionering och konfliktdetektering automatiskt. CommunityToolkit Datasync stöder dessutom flera databasmotorer på serversidan: Azure SQL, PostgreSQL, MySQL, CosmosDB, MongoDB och LiteDB.

Om Författaren Editorial Team

Our team of expert writers and editors.