SQLite + EF Core в .NET MAUI: Локална база данни за офлайн приложения (2026)

Практическо ръководство за локална база данни в .NET MAUI 9 със SQLite и Entity Framework Core 9: DI, миграции, SQLCipher, офлайн-първо синхронизация и производителност.

SQLite + EF Core в .NET MAUI (2026)

Актуализирано: 3 юли 2026 г.

Локална база данни в .NET MAUI най-надеждно се реализира със SQLite и Entity Framework Core 9. Двойката ви дава типизиран DbContext, автоматични миграции, LINQ заявки и работеща офлайн синхронизация върху всички четири платформи (Android, iOS, macOS, Windows). В този материал ще покажа целия път: от инсталация и конфигурация през шифроване със SQLCipher до реален pattern за офлайн-първо приложение, което подкарахме за екипи с милиони инсталации на Play Store и App Store. Всички примери са тествани върху .NET 9 и MAUI 9.0.60.

  • SQLite + EF Core 9 е официално препоръчаният stack за структурирани локални данни в .NET MAUI 9; SQLite-net-pcl остава валидна алтернатива за прости KV сценарии.
  • Файлът с базата трябва да седи в FileSystem.AppDataDirectory. Там е защитен от app sandbox и оцелява при рестарт, но не и при десинсталиране.
  • За production-приложения ползвайте SQLCipher (Microsoft.Data.Sqlite + SQLitePCLRawBundle_e_sqlcipher). Keychain и Keystore пазят ключа, не самата база.
  • Използвайте пул от DbContext-и през AddDbContextFactory, не singleton. DbContext не е thread-safe и екипите най-често катастрофират точно тук.
  • Offline-first sync с last-write-wins и RowVersion колона се справя с 90% от случаите; CRDT-та ги пазим за колаборативни редактори.
  • Индексирайте всяка колона, по която филтрирате в LINQ. SQLite не прави автоматичен index selection като PostgreSQL.

Защо SQLite + EF Core за .NET MAUI

Отговорът е кратък. SQLite е вграден във всяка мобилна ОС, а EF Core дава единствения ORM, който официално поддържа AOT сценарии, native trimming и compiled models, от които зависи startup performance-ът на едно MAUI приложение. От гледна точка на екип-лийд, това означава един и същ DbContext за прототипа на бекенда и за мобилния клиент. По-малко ментален превключвател при code review.

В моята практика виждам три сценария, в които локалната база не подлежи на договаряне: приложения с интермитентна свързаност (полева работа, delivery), приложения с чувствителни данни, които не искате да напускат устройството (health, финанси), и приложения с offline-first UX, при които потребителят пише първо, а синхронизацията се случва във фонов режим. И в трите SQLite + EF Core печели пред plain files, Preferences или custom binary formats, заради типизацията, миграциите и LINQ.

Междувременно екосистемата се стабилизира. .NET 9 донесе UseSqlite с native AOT поддръжка, SQLitePCLRaw 2.1.10 фиксира дългогодишни iOS bitcode issues, а Microsoft.EntityFrameworkCore.Sqlite достигна версия 9.0.6 през юни 2026 г. Ако сте започнали проекта си върху sqlite-net-pcl преди 2 години, разумно е да преоцените. Миграцията не е тежка и получавате миграции без ръчен SQL.

Инсталация и конфигурация на пакетите

Започваме от чист MAUI 9 template. Ето точния списък с NuGet пакети, които препоръчвам, и защо всеки един е там:

dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 9.0.6
dotnet add package Microsoft.EntityFrameworkCore.Design --version 9.0.6
dotnet add package SQLitePCLRawBundle_e_sqlcipher --version 2.1.10
dotnet add package CommunityToolkit.Mvvm --version 8.4.0

Microsoft.EntityFrameworkCore.Design ви трябва само за времето на разработка (миграции през CLI), но добавете го в основния проект. Visual Studio 17.12 и dotnet ef търсят точно там. Второто важно решение е bundle-ът: за шифровани бази ползвате SQLitePCLRawBundle_e_sqlcipher; ако не искате шифроване, минете на SQLitePCLRawBundle_green (по-малък app size, около 300 KB на платформа).

Дефиниране на моделите

Ето минимален работещ пример с два entity-та, покриващ типични sync полета:

public class Note
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Body { get; set; } = string.Empty;
    public DateTime UpdatedAt { get; set; }
    public bool IsSynced { get; set; }
    // За optimistic concurrency при синхронизация
    public byte[]? RowVersion { get; set; }
}

public class NotesDbContext : DbContext
{
    public DbSet<Note> Notes => Set<Note>();

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

    protected override void OnModelCreating(ModelBuilder mb)
    {
        mb.Entity<Note>()
            .HasIndex(n => n.UpdatedAt)
            .HasDatabaseName("IX_Notes_UpdatedAt");

        mb.Entity<Note>()
            .Property(n => n.RowVersion)
            .IsRowVersion();
    }
}

Регистрация на DbContext през DI

Тук е грешката №1, която срещам при code review на MAUI екипи: регистрират NotesDbContext като singleton, за да си "спестят обекти". Проблемът е, че DbContext не е thread-safe. Паралелен await към DbContext води до "A second operation was started on this context" изключение, което на iOS понякога crash-ва без proper stack trace. Правилният подход е AddDbContextFactory плюс scoped resolution в page level.

public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();
    builder.UseMauiApp<App>();

    var dbPath = Path.Combine(FileSystem.AppDataDirectory, "notes.db3");

    builder.Services.AddDbContextFactory<NotesDbContext>(options =>
    {
        options.UseSqlite($"Data Source={dbPath}");
        #if DEBUG
        options.EnableSensitiveDataLogging();
        options.LogTo(msg => System.Diagnostics.Debug.WriteLine(msg));
        #endif
    });

    builder.Services.AddSingleton<INotesRepository, NotesRepository>();
    return builder.Build();
}

Забележете FileSystem.AppDataDirectory. Това е директорията, която ОС гарантира, че се пази между рестарти, но е скрита от файловия мениджър на потребителя. За временни кешове ползвайте FileSystem.CacheDirectory; за файлове, споделени с други приложения, ги мапвайте през MediaStore (Android) или NSDocumentDirectory (iOS). Ако вече ползвате HttpClient и Refit за REST API заявки, същият IServiceCollection ще ги регистрирате един до друг.

Как работят миграциите в MAUI приложение

Тук MAUI разработчиците често се спъват: dotnet ef migrations add изисква да resolve-не DbContext в design time, но MAUI проектът по подразбиране не може да се стартира от CLI. Решението е IDesignTimeDbContextFactory:

public class NotesDbContextFactory : IDesignTimeDbContextFactory<NotesDbContext>
{
    public NotesDbContext CreateDbContext(string[] args)
    {
        var options = new DbContextOptionsBuilder<NotesDbContext>()
            .UseSqlite("Data Source=design.db3")
            .Options;
        return new NotesDbContext(options);
    }
}

След това от корена на проекта:

dotnet ef migrations add InitialCreate --project MyApp.csproj
dotnet ef migrations add AddNoteTags --project MyApp.csproj

На runtime трябва да приложите миграциите преди първата заявка. Аз препоръчвам да го правите в App.xaml.cs с progress индикатор, а не lazy при първата LINQ заявка. Иначе първият tap на потребителя ще увисне за 2-3 секунди на устройства от нисък клас. Честно казано, ударих се в този проблем при последна доставка към Play Store и оценките в първите 24 часа паднаха с половин звезда.

public partial class App : Application
{
    public App(IDbContextFactory<NotesDbContext> factory)
    {
        InitializeComponent();
        MainPage = new AppShell();

        Task.Run(async () =>
        {
            await using var db = await factory.CreateDbContextAsync();
            await db.Database.MigrateAsync();
        });
    }
}

CRUD и repository pattern на практика

Repository pattern-ът в MAUI не е самоцел. Той ни дава два конкретни бонуса: 1) можем да mock-ваме за unit tests без in-memory database, и 2) капсулираме DbContext factory логиката, така че ViewModel-ите не знаят за EF. Ето минимален, production-ready repository:

public interface INotesRepository
{
    Task<List<Note>> GetAllAsync(CancellationToken ct = default);
    Task<Note?> GetByIdAsync(int id, CancellationToken ct = default);
    Task<int> UpsertAsync(Note note, CancellationToken ct = default);
    Task DeleteAsync(int id, CancellationToken ct = default);
}

public class NotesRepository : INotesRepository
{
    private readonly IDbContextFactory<NotesDbContext> _factory;

    public NotesRepository(IDbContextFactory<NotesDbContext> factory)
        => _factory = factory;

    public async Task<List<Note>> GetAllAsync(CancellationToken ct = default)
    {
        await using var db = await _factory.CreateDbContextAsync(ct);
        return await db.Notes
            .AsNoTracking()
            .OrderByDescending(n => n.UpdatedAt)
            .ToListAsync(ct);
    }

    public async Task<int> UpsertAsync(Note note, CancellationToken ct = default)
    {
        await using var db = await _factory.CreateDbContextAsync(ct);
        note.UpdatedAt = DateTime.UtcNow;
        note.IsSynced = false;

        if (note.Id == 0) db.Notes.Add(note);
        else db.Notes.Update(note);

        await db.SaveChangesAsync(ct);
        return note.Id;
    }
    // GetByIdAsync и DeleteAsync – аналогично
}

AsNoTracking() при read-only заявки удвоява производителността в моите бенчмаркове. EF Core иначе поддържа change tracker за всеки върнат entity, което е излишно за списъци, показвани в CollectionView. Ако използвате CommunityToolkit.Mvvm със source generators за MVVM, repository-то се инжектира директно във вашите [ObservableObject] ViewModel-и.

Шифроване на базата със SQLCipher

Ако приложението ви пази PII, здравни данни или финансова информация, шифроването на SQLite файла не е бонус, а изискване. SQLCipher е де факто стандартът и се интегрира с EF Core през Microsoft.Data.Sqlite-provider-а. Ключът трябва да седи в Keychain (iOS) или Keystore (Android). Никога в кода, никога в Preferences.

SQLitePCL.Batteries_V2.Init();
SQLitePCL.raw.SetProvider(new SQLitePCL.SQLite3Provider_e_sqlcipher());

var key = await SecureStorage.Default.GetAsync("db_key");
if (key is null)
{
    key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
    await SecureStorage.Default.SetAsync("db_key", key);
}

var connectionString = new SqliteConnectionStringBuilder
{
    DataSource = Path.Combine(FileSystem.AppDataDirectory, "notes.db3"),
    Mode = SqliteOpenMode.ReadWriteCreate,
    Password = key
}.ToString();

builder.Services.AddDbContextFactory<NotesDbContext>(o => o.UseSqlite(connectionString));

SecureStorage на MAUI обвива Keychain/Keystore transparently. Операционната система пази ключа зад биометрия, ако сте я активирали. Прочетете официалната документация на SecureStorage, преди да го ползвате в production. Има известни ограничения на емулатори без Google Play Services.

Офлайн-първо синхронизация с REST backend

Практическият pattern, който ползваме в почти всеки проект, е last-write-wins с RowVersion. Схемата:

  1. Всеки локален запис получава IsSynced = false при UPSERT.
  2. Background service извлича всички IsSynced = false редове и ги push-ва към REST endpoint-а.
  3. Сървърът връща актуална RowVersion и UpdatedAt; клиентът marks-ва като synced.
  4. При pull, клиентът праща последната си UpdatedAt и получава само записите, по-нови от нея.

Кодът в MAUI, който реално работи през фоново изпълнение с connectivity detection:

public class SyncService
{
    private readonly IDbContextFactory<NotesDbContext> _factory;
    private readonly INotesApi _api; // Refit клиент

    public async Task SyncAsync(CancellationToken ct)
    {
        if (Connectivity.NetworkAccess != NetworkAccess.Internet) return;

        await using var db = await _factory.CreateDbContextAsync(ct);

        // 1) Push local changes
        var dirty = await db.Notes.Where(n => !n.IsSynced).ToListAsync(ct);
        foreach (var n in dirty)
        {
            var remote = await _api.UpsertAsync(n, ct);
            n.RowVersion = remote.RowVersion;
            n.IsSynced = true;
        }
        await db.SaveChangesAsync(ct);

        // 2) Pull remote changes
        var since = await db.Notes.MaxAsync(n => (DateTime?)n.UpdatedAt, ct)
                    ?? DateTime.MinValue;
        var incoming = await _api.GetChangesSinceAsync(since, ct);
        foreach (var r in incoming)
        {
            db.Notes.Update(r);
            r.IsSynced = true;
        }
        await db.SaveChangesAsync(ct);
    }
}

За архитектурата на офлайн синхронизацията препоръчвам да прочетете и материала за устойчиви REST заявки с Polly и Refit. При флаки мрежи sync loop-ът ви трябва exponential backoff, иначе battery drain-ът излиза от контрол. В последния ми проект точно това ни струваше две седмици разследване с Firebase Performance преди да намерим виновника.

Производителност и индекси

SQLite е бърз, но не умен. За разлика от PostgreSQL, той не прави автоматичен index scan; ако филтрирате по колона без индекс, планира table scan. В MAUI приложение с 50 000+ записа (не толкова рядко за offline apps) разликата е между 30 ms и 900 ms на всяка заявка. Правилата, които спазваме:

  • Индексирайте всяка колона в Where, OrderBy и Join.
  • За composite queries ползвайте composite indices; редът на колоните трябва да съвпада с ORDER BY.
  • Батчирайте insert-и в BeginTransactionAsync: 10 000 отделни SaveChanges → ~40 сек; един transaction → <2 сек.
  • Ползвайте EF Core Compiled Models (dotnet ef dbcontext optimize), което намалява startup time с ~200 ms на entry-level Android устройства.
  • За списъци ползвайте Take/Skip pagination, а не ToListAsync() върху цялата таблица. Паметта на UI thread не се възстановява лесно.

Профилирайте с options.LogTo(...) в DEBUG режим и потърсете N+1 pattern-и в logs. Те са убиецът на MAUI приложения с master-detail списъци.

SQLite-net-pcl срещу Entity Framework Core

Въпросът "кой ORM да ползвам" продължава да идва на всеки .NET MAUI meetup. Ето кратко сравнение, което давам на екипите:

КритерийSQLite-net-pclEntity Framework Core 9
Крива на ученеПлитка, CRUD за 15 минутиСредна, DbContext, миграции, DI
МиграцииРъчни (SQL или CreateTable)Автоматични през CLI
LINQ поддръжкаОграничена (Table<T>)Пълна LINQ-to-SQL
Change trackingНямаВградено, опционално
App size overhead~200 KB~1.5 MB
AOT / trimmingРаботи веднагаРаботи от .NET 9 нататък
Complex queriesРъчен SQLLINQ + Include
ПрепоръкаKV storage, прототипиProduction apps с релации

Ако разработвате settings-heavy приложение с 3-4 таблици без релации, sqlite-net-pcl остава легитимен избор. За всичко останало (real-world apps с релации, миграции и множество разработчици по проекта), EF Core печели, дори с 1.5 MB overhead.

Често задавани въпроси

Работи ли Entity Framework Core с .NET MAUI?

Да, EF Core 9 официално се поддържа в .NET MAUI 9 върху всички платформи: Android, iOS, macOS и Windows. Microsoft добави native AOT съвместимост през 2025 г., така че миграциите и compiled models работят и в trimmed release build-ове.

Къде се съхранява SQLite базата данни в .NET MAUI приложение?

В FileSystem.AppDataDirectory. На Android това е /data/data/<package>/files/, на iOS съответно <bundle>/Documents/. Файлът оцелява при рестарт и OS ъпдейти, но се изтрива при десинсталиране на приложението.

Как се шифрова SQLite база в .NET MAUI?

Ползвайте SQLitePCLRawBundle_e_sqlcipher плюс Microsoft.Data.Sqlite с Password в connection string-а. Ключът трябва да седи в SecureStorage, който вътрешно ползва Keychain на iOS и Keystore на Android.

Кое е по-добро: SQLite-net-pcl или Entity Framework Core?

За production приложения с релации и множество разработчици, EF Core, заради автоматичните миграции и пълна LINQ поддръжка. За прототипи и прости KV сценарии, sqlite-net-pcl остава по-лек (~1.3 MB по-малък app size).

Как да направя офлайн синхронизация на данни в .NET MAUI?

Използвайте last-write-wins pattern с IsSynced флаг и RowVersion колона. Push всички dirty записи при поява на мрежа, после pull промените от сървъра, филтрирани по последната синхронизирана UpdatedAt. За флаки мрежи добавете exponential backoff.

Priya Sharma
За Автора Priya Sharma

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.