Hvorfor lokale databaser er afgørende i mobilapps
Forestil dig: din bruger sidder i toget, signalet forsvinder, og din app holder bare op med at virke. Det er præcis den slags oplevelse, der ender med én-stjernet anmeldelser. Av.
Lokale databaser løser det problem. De giver din app mulighed for at fungere offline, reagere hurtigt og levere en oplevelse, der ikke falder fra hinanden, bare fordi WiFi'en driller. I .NET MAUI er SQLite den go-to løsning til lokal datalagring — en letvægts, filbaseret databasemotor, der kører direkte på enheden. Ingen server, ingen konfiguration, bare en enkelt fil.
Og med Entity Framework Core 10 oveni? Så får du en ORM, der lader dig arbejde med C#-objekter og LINQ i stedet for rå SQL. Ærligt talt gør det hele udvikleroplevelsen markant rarere.
I denne guide tager vi hele turen: fra NuGet-pakker til migrationer, asynkron adgang og ydeevneoptimering. Al kode er testet med .NET 10 og EF Core 10, så du kan følge med direkte i dit eget projekt.
Opsætning: NuGet-pakker og projektstruktur
Nødvendige NuGet-pakker
Før vi kommer i gang med koden, skal du installere tre pakker. Åbn din terminal og kør:
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 10.0.3
dotnet add package SQLitePCLRaw.bundle_e_sqlite3
dotnet add package Microsoft.EntityFrameworkCore.Design --version 10.0.3
Du kan også bruge sqlite-net-pcl som et lettere alternativ, hvis du ikke har brug for EF Cores fulde funktionalitet. Men til de fleste produktionsapps vil jeg anbefale EF Core — du får migrationer, LINQ, change tracking og en API, du sikkert allerede kender fra andre .NET-projekter.
Projektstruktur
En god mappestruktur gør livet lettere, når projektet vokser. Her er hvad jeg plejer at gå med:
MinApp/
├── Models/
│ └── TodoItem.cs
├── Data/
│ └── AppDbContext.cs
├── Services/
│ └── IDatabaseService.cs
│ └── DatabaseService.cs
├── ViewModels/
│ └── TodoViewModel.cs
├── Views/
│ └── TodoPage.xaml
└── MauiProgram.cs
Den holder tingene adskilt og testbare. Du behøver ikke følge den slavisk, men den giver en solid base.
Opret din datamodel
Lad os starte med det grundlæggende — din datamodel. EF Core bruger konventioner til at mappe C#-klasser til databasetabeller, så det kræver overraskende lidt kode:
using System.ComponentModel.DataAnnotations;
namespace MinApp.Models;
public class TodoItem
{
[Key]
public int Id { get; set; }
[Required]
[MaxLength(200)]
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public bool IsCompleted { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
}
Læg mærke til DateTime.UtcNow — det er ikke tilfældigt. I EF Core 10 med SQLite er det vigtigt at bruge UTC-tidsstempler konsekvent. Microsoft.Data.Sqlite 10.0 antager nu, at tidsstempler uden offset er UTC, og det matcher SQLites egen adfærd. Så hold dig til UTC, og spar dig selv for en del hovedpine.
Konfigurér DbContext
DbContext er i bund og grund din session med databasen. Den håndterer forbindelsen, forespørgsler og ændringssporing. Sådan ser den ud:
using Microsoft.EntityFrameworkCore;
using MinApp.Models;
namespace MinApp.Data;
public class AppDbContext : DbContext
{
public DbSet<TodoItem> TodoItems => Set<TodoItem>();
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<TodoItem>(entity =>
{
entity.HasIndex(e => e.IsCompleted);
entity.HasIndex(e => e.CreatedAt);
});
}
}
Vi bruger constructor injection med DbContextOptions i stedet for at override OnConfiguring. Det gør din DbContext testbar og fungerer sømløst med .NET MAUIs dependency injection. Win-win.
Registrér databasen med Dependency Injection
Nu skal det hele bindes sammen. I MauiProgram.cs registrerer du din DbContext, og det er her magien sker:
using Microsoft.EntityFrameworkCore;
using MinApp.Data;
using MinApp.Services;
namespace MinApp;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
// Databasesti
string dbPath = Path.Combine(
FileSystem.AppDataDirectory, "minapp.db");
// Registrér DbContext med SQLite
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite($"Data Source={dbPath}"));
// Registrér services og ViewModels
builder.Services.AddTransient<IDatabaseService, DatabaseService>();
builder.Services.AddTransient<TodoViewModel>();
builder.Services.AddTransient<TodoPage>();
return builder.Build();
}
}
Vigtigt: Databasefilen placeres i FileSystem.AppDataDirectory — appens sikre datalagringsmappe. Den er sandboxed på både iOS og Android, og du behøver ingen specielle tilladelser. Nemt.
CRUD-operationer: Opret, Læs, Opdatér og Slet
Okay, nu kommer den sjove del. Lad os bygge et komplet Data Access Layer, der håndterer alle databaseoperationer. Vi starter med interfacet og implementeringen:
using Microsoft.EntityFrameworkCore;
using MinApp.Data;
using MinApp.Models;
namespace MinApp.Services;
public interface IDatabaseService
{
Task<List<TodoItem>> GetAllItemsAsync();
Task<TodoItem?> GetItemByIdAsync(int id);
Task<TodoItem> CreateItemAsync(TodoItem item);
Task UpdateItemAsync(TodoItem item);
Task DeleteItemAsync(int id);
Task<List<TodoItem>> GetActiveItemsAsync();
}
public class DatabaseService : IDatabaseService
{
private readonly AppDbContext _context;
public DatabaseService(AppDbContext context)
{
_context = context;
_context.Database.EnsureCreated();
}
// CREATE
public async Task<TodoItem> CreateItemAsync(TodoItem item)
{
item.CreatedAt = DateTime.UtcNow;
_context.TodoItems.Add(item);
await _context.SaveChangesAsync();
return item;
}
// READ - alle elementer
public async Task<List<TodoItem>> GetAllItemsAsync()
{
return await _context.TodoItems
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
}
// READ - enkelt element
public async Task<TodoItem?> GetItemByIdAsync(int id)
{
return await _context.TodoItems.FindAsync(id);
}
// READ - kun aktive elementer
public async Task<List<TodoItem>> GetActiveItemsAsync()
{
return await _context.TodoItems
.Where(t => !t.IsCompleted)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
}
// UPDATE
public async Task UpdateItemAsync(TodoItem item)
{
var existing = await _context.TodoItems.FindAsync(item.Id);
if (existing is null)
throw new InvalidOperationException(
$"TodoItem med Id {item.Id} blev ikke fundet.");
existing.Title = item.Title;
existing.Description = item.Description;
existing.IsCompleted = item.IsCompleted;
existing.CompletedAt = item.IsCompleted
? DateTime.UtcNow
: null;
await _context.SaveChangesAsync();
}
// DELETE
public async Task DeleteItemAsync(int id)
{
var item = await _context.TodoItems.FindAsync(id);
if (item is not null)
{
_context.TodoItems.Remove(item);
await _context.SaveChangesAsync();
}
}
}
Ret standard CRUD, men bemærk et par ting: vi bruger FindAsync til enkelte opslag (det tjekker cache først), og vi sætter CompletedAt automatisk i update-metoden. Små detaljer, der gør dit datalag mere robust.
Integration med ViewModel
I en MVVM-arkitektur bruger din ViewModel servicen via constructor injection. Her bruger vi CommunityToolkit.Mvvm, som sparer en masse boilerplate:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MinApp.Models;
using MinApp.Services;
using System.Collections.ObjectModel;
namespace MinApp.ViewModels;
public partial class TodoViewModel : ObservableObject
{
private readonly IDatabaseService _databaseService;
[ObservableProperty]
private ObservableCollection<TodoItem> _items = new();
[ObservableProperty]
private string _newItemTitle = string.Empty;
[ObservableProperty]
private bool _isLoading;
public TodoViewModel(IDatabaseService databaseService)
{
_databaseService = databaseService;
}
[RelayCommand]
private async Task LoadItemsAsync()
{
IsLoading = true;
try
{
var items = await _databaseService.GetAllItemsAsync();
Items = new ObservableCollection<TodoItem>(items);
}
finally
{
IsLoading = false;
}
}
[RelayCommand]
private async Task AddItemAsync()
{
if (string.IsNullOrWhiteSpace(NewItemTitle))
return;
var newItem = new TodoItem { Title = NewItemTitle.Trim() };
await _databaseService.CreateItemAsync(newItem);
Items.Insert(0, newItem);
NewItemTitle = string.Empty;
}
[RelayCommand]
private async Task ToggleCompletedAsync(TodoItem item)
{
item.IsCompleted = !item.IsCompleted;
await _databaseService.UpdateItemAsync(item);
}
[RelayCommand]
private async Task DeleteItemAsync(TodoItem item)
{
await _databaseService.DeleteItemAsync(item.Id);
Items.Remove(item);
}
}
EF Core-migrationer i .NET MAUI
Migrationer er det, der lader dig udvikle dit databaseskema over tid, efterhånden som din app ændrer sig. Og det er den anbefalede tilgang til produktionsapps — i modsætning til EnsureCreated(), som simpelthen ikke håndterer skemaændringer efter den første oprettelse.
Udfordringen med MAUI og migrationer
Her er der en vigtig begrænsning, du bør kende til: EF Core-migreringsværktøjerne understøtter ikke Android og iOS som startup-projekter direkte. Løsningen? Opret et hjælpeprojekt. Det lyder bøvlet, men det tager kun et par minutter at sætte op.
Opsætning af migrationer
Flyt din AppDbContext til et separat class library-projekt (f.eks. MinApp.Data), og opret et konsol-hjælpeprojekt til migrationer:
# Installér EF Core-værktøjerne globalt
dotnet tool install --global dotnet-ef
# Opret den første migrering
dotnet ef migrations add InitialCreate \
--project MinApp.Data \
--startup-project MinApp.Migrations
# Anvend migrering (til lokal udvikling)
dotnet ef database update \
--project MinApp.Data \
--startup-project MinApp.Migrations
Anvend migrationer ved app-start
I produktionsapps skal migrationer anvendes automatisk, når appen starter. Heldigvis er det ret simpelt:
// I App.xaml.cs eller en startup-service
public partial class App : Application
{
public App(AppDbContext dbContext)
{
InitializeComponent();
// Anvend ventende migrationer
dbContext.Database.Migrate();
}
}
En vigtig skelnen: Database.Migrate() anvender alle ventende migrationer, mens EnsureCreated() kun opretter databasen fra bunden. Brug aldrig begge sammen — det er en opskrift på problemer. Vælg migrationer til produktion og EnsureCreated() til prototyping.
Ydeevneoptimering: Få det maksimale ud af SQLite
SQLite er hurtig som standard, men med et par justeringer kan du presse endnu mere ydeevne ud af den. Her er de tricks, der gør den største forskel.
Aktivér Write-Ahead Logging (WAL)
WAL er ærligt talt en af de bedste optimeringer, du kan lave. I stedet for at låse hele databasen under skrivning, skriver WAL ændringer til en separat logfil. Det betyder, at læsninger og skrivninger kan ske samtidigt:
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
options.UseSqlite($"Data Source={_dbPath}", sqliteOptions =>
{
sqliteOptions.CommandTimeout(30);
});
}
// Aktivér WAL efter forbindelsen er oprettet
public async Task EnableWalModeAsync()
{
await _context.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;");
await _context.Database.ExecuteSqlRawAsync("PRAGMA synchronous=NORMAL;");
}
Hvad får du ud af det?
- Læsere blokerer ikke skrivere (og omvendt)
- Mere sekventiel disk I/O
- Færre
fsync()-operationer - Mærkbart bedre ydeevne ved samtidige operationer
Brug indekser strategisk
Indekser kan gøre en kæmpe forskel for læsehastighed, men hvert indeks koster lidt ved skrivning. Så opret dem kun på kolonner, du faktisk filtrerer eller sorterer på:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<TodoItem>(entity =>
{
// Indeks for hyppige filtreringer
entity.HasIndex(e => e.IsCompleted)
.HasDatabaseName("IX_TodoItem_IsCompleted");
// Sammensat indeks for sorteret filtrering
entity.HasIndex(e => new { e.IsCompleted, e.CreatedAt })
.HasDatabaseName("IX_TodoItem_IsCompleted_CreatedAt");
});
}
Brug AsNoTracking til read-only forespørgsler
Når du kun læser data uden at ændre den, kan du springe EF Cores change tracking over. Det lyder måske som en lille ting, men det reducerer hukommelsesforbrug mærkbart — især når du henter mange rækker:
public async Task<List<TodoItem>> GetAllItemsReadOnlyAsync()
{
return await _context.TodoItems
.AsNoTracking()
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
}
Bulk-operationer med transaktioner
Hvis du skal indsætte eller opdatere mange rækker på én gang, bør du pakke det ind i en transaktion. Ellers laver EF Core en implicit commit per operation, og det er langsomt:
public async Task CreateManyItemsAsync(IEnumerable<TodoItem> items)
{
using var transaction = await _context.Database
.BeginTransactionAsync();
try
{
await _context.TodoItems.AddRangeAsync(items);
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
Asynkron adgang: Undgå at blokere UI-tråden
En af de hyppigste fejl i mobile apps er at køre databaseoperationer på hovedtråden. Resultatet? En app der fryser og hakker. Det er ikke en god brugeroplevelse.
Her er de tre gyldne regler:
- Brug altid async-metoder:
ToListAsync(),SaveChangesAsync(),FindAsync()osv. - Brug aldrig .Result eller .GetAwaiter().GetResult(): Det kan forårsage deadlocks, og det er et mareridt at debugge.
- Kald databaseoperationer fra ViewModels, ikke code-behind: Det holder arkitekturen ren og testbar.
En vigtig nuance: Microsoft.Data.Sqlite udfører faktisk async-operationer synkront internt, fordi SQLite ikke understøtter ægte asynkron I/O. Men brug stadig async-metoderne! EF Cores infrastruktur sørger for, at arbejdet scheduleres korrekt væk fra UI-tråden. Så selvom SQLite-laget er synkront, holder du stadig din UI responsiv.
Nyheder i EF Core 10 for SQLite-brugere
EF Core 10 (der fulgte med .NET 10) bringer en håndfuld forbedringer, som er værd at kende til:
- AUTOINCREMENT-kontrol: Du kan nu slå AUTOINCREMENT fra for SQLite-tabeller. Det giver bedre indsættelses-ydeevne, hvis du ikke har brug for garanteret stigende ID'er.
- DateTimeOffset UTC-håndtering: Microsoft.Data.Sqlite 10.0 konverterer nu automatisk DateTimeOffset-værdier til UTC ved skrivning til REAL-kolonner. Pas på — det er en breaking change, så tjek din eksisterende kode.
- LINQ LeftJoin-operator: Endelig! Førsteklasses
LeftJoini LINQ, der oversættes direkte til SQL LEFT JOIN. Farvel til de grimme GroupJoin-mønstre. - Forbedret lazy loading-ydeevne: Intern optimering med ThreadId i stedet for AsyncLocal giver hurtigere adgang.
- Decimal-understøttelse i aggregater: MAX, MIN og ORDER BY fungerer nu korrekt med decimal-typer i SQLite. Det var på tide.
Almindelige faldgruber og hvordan du undgår dem
1. Glem ikke SQLitePCL-initialisering på iOS
Denne har bidt mig mere end én gang. Hvis du bruger EF Core uden den korrekte SQLitePCL-bundle, crasher din app på iOS uden nogen brugbar fejlmeddelelse. Sørg for, at SQLitePCLRaw.bundle_e_sqlite3 er installeret, og kald init tidligt:
// Kald dette tidligt i appens livscyklus
SQLitePCL.Batteries_V2.Init();
2. Bland ikke EnsureCreated og Migrate
Det er en klassisk fejl at kalde begge dele. EnsureCreated() til prototyping, Migrate() til produktion. Vælg én. Aldrig begge.
3. Håndtér concurrent adgang korrekt
SQLite er single-writer, og det er noget, folk glemmer. Hvis flere tråde forsøger at skrive samtidigt, får du en SQLiteException: database is locked. WAL-mode hjælper en del, men i kritiske scenarier bør du serialisere skrivninger med en SemaphoreSlim:
private static readonly SemaphoreSlim _semaphore = new(1, 1);
public async Task SafeWriteAsync(Func<Task> operation)
{
await _semaphore.WaitAsync();
try
{
await operation();
}
finally
{
_semaphore.Release();
}
}
4. Hold din DbContext kortlivet
DbContext bør ikke leve for længe. Med dependency injection og AddDbContext registreres den som scoped som standard, hvilket passer fint i MAUI, hvor en ny scope typisk oprettes for hver side. Lad være med at lave den til en singleton — det fører til stale data og mærkelig adfærd.
Ofte stillede spørgsmål
Kan jeg bruge SQLite i .NET MAUI uden Entity Framework Core?
Ja, absolut. sqlite-net-pcl er et populært alternativ med en simpel API, attribut-baseret mapping og asynkrone operationer via SQLiteAsyncConnection. Men til komplekse datamodeller med relationer og migrationer er EF Core klart bedre egnet.
Hvor gemmes SQLite-databasen på enheden?
Den ligger i FileSystem.AppDataDirectory, som mapper til en platformsspecifik sikker mappe. På Android er det appens interne lager, på iOS er det Documents-mappen i appens sandbox, og på Windows er det LocalApplicationData. Du behøver ikke bekymre dig om stierne — MAUI håndterer det for dig.
Hvordan håndterer jeg databasemigrationer ved appopdateringer?
Kald Database.Migrate() ved app-start. Det anvender automatisk alle ventende migrationer. Til større skemaændringer kan du overveje en versioneringstabel, der tracker databaseversionen og udfører migreringer trinvist — men for de fleste apps er Migrate() tilstrækkeligt.
Er SQLite sikkert nok til følsomme data?
Som udgangspunkt gemmer SQLite data som en ren fil uden kryptering. Til følsomme data bør du kigge på SQLCipher (en krypteret SQLite-variant) eller bruge .NET MAUIs SecureStorage til individuelle nøgle-værdier. Databasefilen er dog beskyttet af platformens app-sandbox, så den er ikke direkte tilgængelig for andre apps.
Hvad er forskellen på AddDbContext og AddDbContextFactory?
AddDbContext registrerer DbContext med scoped levetid — én instans per scope. AddDbContextFactory giver dig en fabrik, der opretter nye instanser on-demand. Fabrikken er specielt nyttig, når du skal oprette kortlevede kontekster manuelt, f.eks. i baggrundstråde eller services med singleton-levetid. Til de fleste MAUI-apps er AddDbContext dog helt fint.