SQLite în .NET MAUI 2026: Ghid Offline-First cu Sincronizare
Configurează SQLite în .NET MAUI 9 (2026) cu arhitectură offline-first: sqlite-net-pcl, DotMim.Sync, SQLCipher, WAL pe iOS, ULID și op-log, cu cod complet.
SQLite este soluția implicită pentru persistență locală în .NET MAUI 9, iar recomandarea actuală (2026) este pachetul sqlite-net-pcl peste SQLitePCLRaw.bundle_green, cu SQLiteAsyncConnection pentru toate operațiile de I/O. Într-o arhitectură offline-first, aplicația scrie mai întâi local și sincronizează asincron cu backend-ul folosind o coloană updated_at plus soft-delete, iar pentru date sensibile trecem pe SQLCipher. Am ajuns la exact această combinație după ce am spart un release pe TestFlight din cauza unui singur detaliu de configurare (mai jos, la iOS AOT, apare exact vinovatul). În ghidul de față configurăm complet stratul, tratăm migrațiile, indexăm cum trebuie și ne conectăm la DotMim.Sync pentru replicare bidirecțională.
sqlite-net-pcl 1.11.x rămâne standardul pentru MAUI 9 în 2026. EF Core Sqlite se justifică doar când chiar ai nevoie de LINQ complex și migrații generate automat.
Fișierul bazei de date trebuie să stea în FileSystem.Current.AppDataDirectory, cu WAL activ și, pe iOS, cu flag-ul NSURLIsExcludedFromBackupKey pe fișierele -wal și -shm.
Criptarea se face cu SQLCipher for .NET de la Zetetic, iar parola se ține în SecureStorage (Keychain sau Android Keystore), niciodată hardcodată.
Pentru sincronizare offline-first, combinăm o coloană UpdatedAt per rând, ID-uri generate pe client (ULID) și un op-log care se golește când Connectivity.NetworkAccess == Internet.
DotMim.Sync este cea mai matură bibliotecă .NET pentru sincronizare bidirecțională SQLite către SQL Server sau PostgreSQL, ideală când nu vrem să scriem noi resolverul de conflicte.
Trimming și NativeAOT sunt încă un câmp minat. sqlite-net folosește reflection, deci modelele au nevoie de [DynamicallyAccessedMembers] sau de includere explicită în TrimmerRootAssembly.
Ce pachete NuGet folosim în 2026
Începem cu peisajul de biblioteci, pentru că este locul unde majoritatea echipelor pierd o zi înainte să scrie o singură linie de cod. În .NET MAUI 9, ai efectiv patru opțiuni și fiecare are un rost clar. Am ajutat trei echipe să migreze de la Xamarin în ultimele luni și, sincer, de fiecare dată am ajuns la aceeași combinație pentru cazul de bază.
sqlite-net-pcl 1.11.285, micro-ORM-ul de la Frank A. Krueger. Este ce recomandă Microsoft Learn în mod oficial pentru MAUI și e opțiunea implicită pentru 90% din aplicațiile pe care le vedem în producție. Peste el rulează SQLitePCLRaw.bundle_green (sau bundle_e_sqlite3), care ambalează binarul SQLite pentru fiecare platformă.
Microsoft.Data.Sqlite, provider ADO.NET, potrivit când ai nevoie de SQL scris de mână cu parametri și de DbDataReader. Îl mixăm des cu Dapper pentru rapoarte grele.
Microsoft.EntityFrameworkCore.Sqlite, full ORM cu migrații generate prin dotnet ef migrations add. Adaugă ~2 MB la APK/IPA și consumă mai multă memorie la startup, deci îl folosim doar când chiar avem nevoie de LINQ complex și de un tooling formal de migrare.
sqlite-net-sqlcipher, clonă a lui sqlite-net-pcl cu SQLCipher inclus. Opțiune gratuită dar nesuportată oficial. Alternativa recomandată în producție este SQLCipher for .NET de la Zetetic (comercial, dar suportat).
Comanda de instalare pentru stack-ul de bază arată așa:
Pe iOS, dacă folosim Full AOT sau NativeAOT, trebuie să adăugăm și SQLitePCLRaw.provider.dynamic_cdecl ca fallback pentru anumite dispozitive. Nu-l uita. E cauza cea mai frecventă pentru crash-ul cu DllNotFoundException: e_sqlite3 pe TestFlight, iar debugger-ul local nu îl reproduce niciodată. Am pierdut o zi întreagă pe asta.
Unde stochez fișierul SQLite într-o aplicație .NET MAUI?
Fișierul .db3 trebuie să stea în FileSystem.Current.AppDataDirectory. Acesta se mapează la Library/ pe iOS și la stocarea internă a aplicației pe Android, ambele fiind zone la care sistemul face backup implicit (iCloud, respectiv Auto Backup pe Google Drive). Nu îl pune în CacheDirectory, pentru că sistemul îl poate șterge oricând, iar utilizatorii tăi îți vor mulțumi cu recenzii de o stea.
public static class DatabaseConstants
{
public const string FileName = "app.db3";
public static SQLiteOpenFlags Flags =
SQLiteOpenFlags.ReadWrite |
SQLiteOpenFlags.Create |
SQLiteOpenFlags.SharedCache;
public static string DatabasePath =>
Path.Combine(FileSystem.Current.AppDataDirectory, FileName);
}
Pe iOS, activarea modului Write-Ahead Logging (WAL) crește performanța la scrieri concurente cu ~30% în benchmark-urile noastre pe iPhone 13, dar creează fișiere secundare app.db3-wal și app.db3-shm. Ca să evităm ca acestea să ajungă pe iCloud (unde ocupă spațiu inutil și pot fi restaurate corupt pe alt dispozitiv), setăm flag-ul NSURLIsExcludedFromBackupKey:
#if IOS
using Foundation;
void ExcludeFromBackup(string path)
{
var url = NSUrl.FromFilename(path);
url.SetResource(NSUrl.IsExcludedFromBackupKey, NSNumber.FromBoolean(true));
}
#endif
Pe Android 14/15, storage-ul aplicației e deja izolat prin scoped storage, deci nu ai nevoie de WRITE_EXTERNAL_STORAGE. Dacă aplicația ta este o migrare de la o versiune veche care ținea DB-ul pe SD card, planifică un pas de migrare la prima pornire. Ghidul nostru de migrare de la Xamarin.Forms la .NET MAUI tratează acest scenariu în detaliu.
Înregistrarea SQLiteAsyncConnection în MauiAppBuilder
Preferăm o singură instanță SQLiteAsyncConnection pe durata aplicației. Este thread-safe intern, folosește un pool de conexiuni și evită overhead-ul de Open()/Close() per operațiune. O înregistrăm ca singleton în container-ul de DI:
// MauiProgram.cs
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>();
builder.Services.AddSingleton(sp =>
{
var conn = new SQLiteAsyncConnection(
DatabaseConstants.DatabasePath,
DatabaseConstants.Flags);
conn.EnableWriteAheadLoggingAsync().GetAwaiter().GetResult();
return conn;
});
builder.Services.AddSingleton<IConnectivity>(Connectivity.Current);
builder.Services.AddSingleton<INoteRepository, NoteRepository>();
builder.Services.AddSingleton<ISyncService, SyncService>();
return builder.Build();
}
De ce singleton și nu scoped? .NET MAUI nu are, în mod nativ, conceptul de "scope" per request ca ASP.NET Core. Fiecare pagină este creată prin DI, dar nu vrem să deschidem un fișier nou de fiecare dată. Dacă folosești pattern-ul MVVM cu ViewModel-uri tranzitorii, injectează repository-ul care, la rândul lui, primește conexiunea singleton. Vezi ghidul nostru MVVM cu CommunityToolkit.Mvvm pentru integrarea corectă cu ObservableObject și RelayCommand.
Repository pattern peste sqlite-net-pcl
Repository pattern nu este un accesoriu ceremonial. Ne dă un singur loc unde tratăm migrațiile, sincronizarea și testarea. Iată un exemplu complet pentru o entitate Note:
public class Note
{
[PrimaryKey]
public string Id { get; set; } = Ulid.NewUlid().ToString();
[Indexed]
public string UserId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
[Indexed]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public bool IsDeleted { get; set; }
public bool NeedsSync { get; set; } = true;
}
public interface INoteRepository
{
Task InitAsync();
Task<List<Note>> GetAllAsync(string userId);
Task SaveAsync(Note note);
Task SoftDeleteAsync(string id);
Task<List<Note>> GetPendingSyncAsync();
}
public class NoteRepository : INoteRepository
{
private readonly SQLiteAsyncConnection _db;
private bool _initialized;
public NoteRepository(SQLiteAsyncConnection db) => _db = db;
public async Task InitAsync()
{
if (_initialized) return;
await _db.CreateTableAsync<Note>();
_initialized = true;
}
public async Task<List<Note>> GetAllAsync(string userId)
{
await InitAsync();
return await _db.Table<Note>()
.Where(n => n.UserId == userId && !n.IsDeleted)
.OrderByDescending(n => n.UpdatedAt)
.ToListAsync();
}
public async Task SaveAsync(Note note)
{
await InitAsync();
note.UpdatedAt = DateTime.UtcNow;
note.NeedsSync = true;
var rows = await _db.UpdateAsync(note);
if (rows == 0) await _db.InsertAsync(note);
}
public async Task SoftDeleteAsync(string id)
{
await InitAsync();
await _db.ExecuteAsync(
"UPDATE Note SET IsDeleted = 1, NeedsSync = 1, UpdatedAt = ? WHERE Id = ?",
DateTime.UtcNow, id);
}
public Task<List<Note>> GetPendingSyncAsync() =>
_db.Table<Note>().Where(n => n.NeedsSync).ToListAsync();
}
Trei detalii de subliniat. Primul: folosim ULID-uri, nu int auto-increment. Într-o arhitectură offline-first, doi utilizatori pe două dispozitive nu se pot coordona pe un contor central, deci ID-urile trebuie generate pe client și trebuie să fie globale. ULID-urile păstrează ordinea temporală, spre deosebire de UUID v4. Al doilea: SoftDeleteAsync marchează în loc să șteargă efectiv, altfel nu poți sincroniza ștergerea cu serverul. Al treilea: NeedsSync este flag-ul care alimentează op-log-ul; îl vezi în acțiune în secțiunea despre DotMim.Sync.
Migrații, indexuri și tranzacții
sqlite-net-pcl nu are un framework de migrații ca EF Core. Recomandarea noastră este să folosim PRAGMA user_version și un switch cu pași idempotenți. E primitiv, dar controlabil, și pentru majoritatea aplicațiilor e suficient.
public async Task MigrateAsync()
{
var current = await _db.ExecuteScalarAsync<int>("PRAGMA user_version;");
if (current < 1)
{
await _db.CreateTableAsync<Note>();
await _db.ExecuteAsync("PRAGMA user_version = 1;");
}
if (current < 2)
{
await _db.ExecuteAsync(
"ALTER TABLE Note ADD COLUMN Color TEXT DEFAULT 'white';");
await _db.ExecuteAsync(
"CREATE INDEX IF NOT EXISTS idx_note_user_updated ON Note(UserId, UpdatedAt);");
await _db.ExecuteAsync("PRAGMA user_version = 2;");
}
}
Indexurile compuse contează. Pe un tabel de 50 000 de rânduri într-un stress-test pe Pixel 6, query-ul WHERE UserId = ? ORDER BY UpdatedAt DESC a scăzut de la 340 ms la 8 ms după ce am adăugat indexul (UserId, UpdatedAt). Verifică întotdeauna cu EXPLAIN QUERY PLAN înainte să dai release.
Pentru inserturi bulk (ex: seed inițial de la un API), împachetează într-o tranzacție. E diferența dintre 12 secunde și 400 ms pentru 5 000 de rânduri:
Pentru date sensibile (token-uri, PII, note medicale) trecem pe SQLCipher, care aplică AES-256 la nivel de pagină. Recomandarea noastră pentru echipe cu contract de suport este SQLCipher for .NET de la Zetetic. Este oficial, cu suport plătit, și funcționează cu Microsoft.Data.Sqlite, EF Core și sqlite-net. Alternativa gratuită este sqlite-net-sqlcipher, comunitară și fără garanții.
Parola nu se hardcodează niciodată. O generăm la prima pornire și o salvăm în SecureStorage, care mapează la Keychain pe iOS și la Android Keystore. Am tratat pe larg pattern-urile de protecție a datelor în ghidul nostru de securitate .NET MAUI.
public async Task<SQLiteAsyncConnection> OpenEncryptedAsync()
{
var password = await SecureStorage.Default.GetAsync("db_key");
if (string.IsNullOrEmpty(password))
{
password = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
await SecureStorage.Default.SetAsync("db_key", password);
}
var options = new SQLiteConnectionString(
DatabaseConstants.DatabasePath,
storeDateTimeAsTicks: true,
key: password);
return new SQLiteAsyncConnection(options);
}
Overhead-ul de criptare este de ~5–15% pentru operații mixte read/write, măsurat pe iPhone 13 și Pixel 7. E acceptabil pentru majoritatea cazurilor. Dacă totuși ai loop-uri de scriere de zeci de mii de rânduri pe secundă, reconsideră arhitectura, nu criptarea.
Arhitectură offline-first cu op-log și ULID
Offline-first înseamnă că aplicația scrie întotdeauna local mai întâi și tratează rețeaua ca un opțional. Ne asigurăm asta prin trei mecanisme concrete: ID-uri generate pe client (ULID), o coloană UpdatedAt per rând și un flag NeedsSync care ne dă efectiv un op-log virtual. Când conectivitatea revine, un serviciu de sincronizare golește această coadă.
Pentru rezoluția de conflicte există trei pattern-uri principale în producție:
Last-Write-Wins (LWW) pe baza UpdatedAt. Simplu, dar poate pierde silențios date. Potrivit pentru câmpuri idempotente (setări de utilizator, contoare de vizualizare).
Version vectors / vector clocks, unde fiecare replică menține un vector cu numărul de operații per nod. Mai corect matematic, dar complexitatea implementării este considerabilă.
CRDT-uri (Automerge, Yjs), colaborare în timp real fără conflicte. Nu există încă un port .NET first-class matur; dacă ai nevoie de asta, ia în calcul un microserviciu Node.js dedicat.
Pentru 80% din aplicații, LWW cu ULID și soft-delete e răspunsul corect. Cheia este să nu ștergem niciodată efectiv, ci doar să marcăm; altfel serverul primește o inserție nouă pentru un rând pe care clientul l-a șters.
Sincronizare bidirecțională cu DotMim.Sync
DotMim.Sync este cea mai matură bibliotecă .NET pentru sincronizare bidirecțională SQLite cu SQL Server, PostgreSQL sau MySQL. Configurarea într-o aplicație .NET MAUI arată așa:
// Client (MAUI)
var clientProvider = new SqliteSyncProvider(DatabaseConstants.DatabasePath);
var proxyClientProvider = new WebRemoteOrchestrator(
"https://api.example.com/sync",
new HttpClient());
var agent = new SyncAgent(clientProvider, proxyClientProvider);
var options = new SyncOptions
{
BatchSize = 500,
ConflictResolutionPolicy = ConflictResolutionPolicy.ServerWins
};
var result = await agent.SynchronizeAsync(options);
// result.TotalChangesUploadedToServer
// result.TotalChangesDownloadedFromServer
// result.TotalConflictsApplied
Pe server, un controller ASP.NET Core expune /sync prin WebServerAgent. Nu intru în detalii server aici, repo-ul oficial are exemple end-to-end.
Când DotMim.Sync e prea greu (backend necustomizabil, protocol proprietar), scriem sincronizarea la mână pornind de la GetPendingSyncAsync() din repository. Un ciclu tipic: colectăm entitățile cu NeedsSync=true, le trimitem PATCH la server cu If-Unmodified-Since, la răspuns 200 setăm NeedsSync=false, la 409 preluăm versiunea serverului și rezolvăm.
Monitorizarea conectivității cu IConnectivity
Pentru a ști când să sincronizăm, folosim API-ul Microsoft.Maui.Networking.Connectivity. Este cross-platform și emite un eveniment ConnectivityChanged pe care putem asculta pentru sync automat.
Verifică și ConnectionProfiles înainte să sincronizezi peste rețea celulară. Utilizatorii cu date mobile limitate nu apreciază 15 MB de sincronizare fără avertisment. Pentru sync în background pe Android folosește WorkManager (via un handler MAUI custom), iar pe iOS BGTaskScheduler.
Testare, NativeAOT și capcane comune
Testarea repository-ului este directă: folosește ":memory:" ca path de fișier și obții o bază de date pe RAM, izolată per test. Fără mock-uri, fără fișiere temporare, fără flakiness. Vezi ghidul complet de testare în .NET MAUI 2026 pentru harness-ul xUnit pe care îl folosim intern.
public class NoteRepositoryTests
{
private static NoteRepository CreateSut()
{
var conn = new SQLiteAsyncConnection(":memory:");
return new NoteRepository(conn);
}
[Fact]
public async Task SaveAsync_SetsNeedsSync()
{
var repo = CreateSut();
var note = new Note { UserId = "u1", Title = "test" };
await repo.SaveAsync(note);
var pending = await repo.GetPendingSyncAsync();
Assert.Single(pending);
}
}
Câteva capcane pe care le-am întâlnit în review-uri de PR:
Trimming / NativeAOT:sqlite-net face maparea coloană către proprietate prin reflection. Setează <TrimMode>partial</TrimMode> sau adnotează modelele cu [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]. Trimming complet fără astea îți lasă tabele cu coloane goale, cel mai insidios bug.
DateTime.Now vs UtcNow: stochează întotdeauna UTC. Utilizatorii care traversează fusuri orare (călători, aviație) altfel văd note "din viitor" sau "din trecut".
Async void: nu marca handler-ele de eveniment ca async void fără try/catch, o excepție de la SQLiteAsyncConnection îți doboară procesul.
Multithreading:SQLiteAsyncConnection serializează operațiile intern, dar nu partaja o instanță SQLiteConnection sincronă între thread-uri.
Deschideri concurente: mai multe procese care deschid același fișier (ex: Widget iOS + app) au nevoie de SharedCache și de reguli clare cine scrie.
Întrebări frecvente
Care este cea mai bună bază de date locală pentru .NET MAUI?
Pentru marea majoritate a aplicațiilor .NET MAUI, SQLite prin sqlite-net-pcl este alegerea implicită și cea recomandată de Microsoft Learn. Este ușor (~500 KB), rapid, are suport oficial pe toate platformele MAUI și un ecosistem matur. Realm și LiteDB sunt alternative viabile pentru scenarii specifice (grafuri complexe, sincronizare Realm Cloud), dar SQLite rămâne default-ul cel mai sigur în 2026.
SQLite vs Realm în .NET MAUI: care este mai rapid?
Pe scrieri single-thread, Realm este cu ~20–40% mai rapid datorită formatului său binar; pe query-uri LINQ complexe, diferența dispare. SQLite câștigă însă pe compatibilitate cu toolinguri (DB Browser, Litecli), pe suport pe termen lung și pe portabilitate. Realm .NET rămâne susținut, dar viitorul lui după achiziția MongoDB este mai puțin previzibil decât cel al SQLite.
Funcționează sqlite-net-pcl cu NativeAOT și trimming?
Da, dar doar cu configurări atente. Trimming complet elimină setterii proprietăților de care depinde sqlite-net pentru maparea coloanelor. Fie folosești TrimMode=partial, fie adaugi [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] pe modele, fie declari asamblarea ca TrimmerRootAssembly în csproj.
Există o alternativă gratuită la SQLCipher pentru MAUI?
Da: sqlite-net-sqlcipher, o clonă a lui sqlite-net-pcl cu suport SQLCipher încorporat, este gratuită dar comunitară și fără garanții de suport. Pentru aplicații de producție cu obligații de compliance (GDPR, HIPAA), recomandăm SQLCipher for .NET de la Zetetic, care este comercial și oferă contract de suport.
Cum verific conexiunea la internet în .NET MAUI înainte de sincronizare?
Folosește Connectivity.Current.NetworkAccess pentru verificare punctuală și evenimentul ConnectivityChanged pentru reacție automată. Verifică valoarea NetworkAccess.Internet, nu doar Local (Local înseamnă router accesibil dar fără ieșire la internet). Pe Android, adaugă permisiunea ACCESS_NETWORK_STATE în manifest.
Cross-platform engineering lead who's shipped apps to millions on both Play Store and App Store. Believes shared codebases shouldn't mean shared mediocrity.
Ghid practic 2026 pentru accesibilitatea .NET MAUI: SemanticProperties, VoiceOver, TalkBack, WCAG 2.2, Dynamic Type și target-uri tactile, cu exemple XAML gata de folosit pentru iOS și Android.
Ghid complet pentru custom handlers în .NET MAUI 2026: învață cum funcționează arhitectura pe două straturi, cum modifici mappers, cum construiești un handler nou pe iOS și Android și cum eviți memory leaks în producție.