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

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

SQLite + EF Core в .NET MAUI (2026)
On this page
  1. Защо SQLite + EF Core за .NET MAUI
  2. Инсталация и конфигурация на пакетите
  3. Регистрация на DbContext през DI
  4. Как работят миграциите в MAUI приложение
  5. CRUD и repository pattern на практика
  6. Шифроване на базата със SQLCipher
  7. Офлайн-първо синхронизация с REST backend
  8. Производителност и индекси
  9. SQLite-net-pcl срещу Entity Framework Core
  10. Често задавани въпроси

Актуализирано: 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 за .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.