Bazy danych i ORM-y w .NET MAUI — Realm, Entity Framework, SQLite i Dapper w porównaniu
Praktyczne porównanie czterech popularnych podejść do persystencji danych w aplikacjach .NET MAUI. Wydajność, developer experience i rekomendacje dla różnych przypadków użycia.
- .NET MAUI
- SQLite
- Entity Framework
- Realm
- Dapper
Wstęp
Każda aplikacja mobilna musi przechowywać dane lokalnie — czy to ustawienia użytkownika, zcache'owane odpowiedzi API, czy pełne zestawy danych offline-first. W ekosystemie .NET MAUI mamy do dyspozycji kilka dojrzałych opcji, z których każda ma swoje kompromisy.
Po latach budowania produkcyjnych aplikacji MAUI miałem okazję przetestować w boju wszystkie cztery podejścia opisane w tym artykule. Przeanalizujmy je.
SQLite — klasyczny fundament
SQLite to de facto standard lokalnego przechowywania danych na urządzeniach mobilnych. Z biblioteką sqlite-net-pcl dostajemy lekką, bezkonfiguracyjną bazę danych:
Minimalny footprint — cały silnik to jeden plik
Cross-platform — działa identycznie na Androidzie, iOS, Windows i macOS
Oparty na SQL — znajome zapytania dla każdego, kto zna relacyjne bazy danych
Dojrzały i sprawdzony — używany na miliardach urządzeń na całym świecie
public class ArticleRepository
{
private readonly SQLiteAsyncConnection _db;
public ArticleRepository(string dbPath)
{
_db = new SQLiteAsyncConnection(dbPath);
_db.CreateTableAsync<Article>().Wait();
}
public Task<List<Article>> GetAllAsync()
=> _db.Table<Article>().ToListAsync();
public Task<Article> GetByIdAsync(int id)
=> _db.Table<Article>()
.Where(a => a.Id == id)
.FirstOrDefaultAsync();
public Task SaveAsync(Article article)
=> article.Id == 0
? _db.InsertAsync(article)
: _db.UpdateAsync(article);
}Kiedy wybrać SQLite?
SQLite jest idealny dla prostych do średnio złożonych modeli danych, gdzie chcemy pełnej kontroli nad zapytaniami, minimalne zależności i maksymalną przenośność. To wybór numer jeden, gdy schemat danych jest prosty i nie potrzebujemy zaawansowanych funkcji ORM.
Entity Framework Core — pełnoprawny ORM
EF Core przynosi pełną moc ORM do .NET MAUI dzięki providerowi SQLite:
Podejście code-first — definiuj modele w C#, a EF wygeneruje schemat
Zapytania LINQ — typowane, sprawdzane w czasie kompilacji
Change tracking — automatyczne wykrywanie zmian i batch updates
Migracje — wersjonowanie schematu bazy razem z kodem
public class AppDbContext : DbContext
{
public DbSet<Article> Articles => Set<Article>();
public DbSet<Tag> Tags => Set<Tag>();
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options) { }
protected override void OnModelCreating(ModelBuilder builder)
{
builder.Entity<Article>()
.HasMany(a => a.Tags)
.WithMany(t => t.Articles);
}
}
var recentArticles = await _db.Articles
.Include(a => a.Tags)
.Where(a => a.PublishedAt > DateTime.UtcNow.AddDays(-30))
.OrderByDescending(a => a.PublishedAt)
.Take(10)
.ToListAsync();Kiedy wybrać Entity Framework Core?
EF Core sprawdza się najlepiej przy złożonych relacyjnych modelach danych z relacjami many-to-many, gdy potrzebujemy change trackingu lub chcemy współdzielić definicje modeli między klientem MAUI a backendem ASP.NET.
Realm — obiektowa baza danych
Realm reprezentuje fundamentalnie inne podejście — to baza obiektowa, nie relacyjna:
Architektura zero-copy — obiekty są odczytywane bezpośrednio z pliku bazy, bez deserializacji
Reaktywność — wbudowane notyfikacje o zmianach przez INotifyPropertyChanged
Thread-safe — automatyczne ograniczenie wątkowe z wzorcami cross-thread
Gotowy do synchronizacji — Atlas Device Sync do synchronizacji z chmurą (opcjonalnie)
public partial class Article : IRealmObject
{
[PrimaryKey]
[MapTo("_id")]
public ObjectId Id { get; set; } = ObjectId.GenerateNewId();
public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public DateTimeOffset PublishedAt { get; set; }
public IList<Tag> Tags { get; } = null!;
}
var recentArticles = _realm.All<Article>()
.Where(a => a.PublishedAt > DateTimeOffset.UtcNow.AddDays(-30))
.OrderByDescending(a => a.PublishedAt);
_realm.Write(() =>
{
_realm.Add(new Article
{
Title = "Nowy wpis",
Content = "Witaj, świecie!",
PublishedAt = DateTimeOffset.UtcNow
});
});Kiedy wybrać Realm?
Realm sprawdza się znakomicie w aplikacjach wymagających reaktywnych aktualizacji UI w czasie rzeczywistym, synchronizacji offline-first z backendem chmurowym lub przy głęboko zagnieżdżonych grafach obiektów, które byłyby uciążliwe w modelu relacyjnym.
Dapper — micro-ORM
Dapper plasuje się między surowym SQLite a pełnym ORM-em. Mapuje wyniki SQL na obiekty z minimalnym narzutem:
Surowy SQL z mapowaniem — piszesz zapytania, Dapper mapuje wyniki
Błyskawicznie szybki — praktycznie zero narzutu w porównaniu z surowym ADO.NET
Lekki — jeden pakiet NuGet, brak złożonej konfiguracji
Elastyczny — procedury składowane, multi-mapping, dynamiczne parametry
public class ArticleRepository
{
private readonly IDbConnection _connection;
public ArticleRepository(string dbPath)
{
_connection = new SqliteConnection($"Data Source={dbPath}");
}
public async Task<IEnumerable<Article>> GetRecentAsync()
{
const string sql = @"
SELECT a.*, t.Name as TagName
FROM Articles a
LEFT JOIN ArticleTags at ON a.Id = at.ArticleId
LEFT JOIN Tags t ON at.TagId = t.Id
WHERE a.PublishedAt > @Since
ORDER BY a.PublishedAt DESC";
return await _connection.QueryAsync<Article>(
sql,
new { Since = DateTime.UtcNow.AddDays(-30) });
}
public async Task<int> InsertAsync(Article article)
{
const string sql = @"
INSERT INTO Articles (Title, Content, PublishedAt)
VALUES (@Title, @Content, @PublishedAt);
SELECT last_insert_rowid();";
return await _connection.ExecuteScalarAsync<int>(sql, article);
}
}Kiedy wybrać Dappera?
Dapper jest idealny, gdy chcesz mieć pełną kontrolę nad SQL z wygodą automatycznego mapowania obiektów. Świetnie sprawdza się na ścieżkach krytycznych wydajnościowo, przy złożonych zapytaniach, z którymi ORM-y sobie nie radzą, lub w zespołach preferujących SQL nad LINQ.
Porównanie
| Cecha | SQLite (surowy) | Entity Framework | Realm | Dapper |
|---|---|---|---|---|
| Typ | Wbudowana DB | Pełny ORM | Obiektowa DB | Micro-ORM |
| Język zapytań | SQL | LINQ | LINQ | SQL |
| Wydajność | Bardzo wysoka | Średnia | Bardzo wysoka | Bardzo wysoka |
| Krzywa uczenia | Niska | Średnia | Średnia | Niska |
| Change tracking | Nie | Tak | Tak | Nie |
| Reaktywne aktualizacje | Nie | Nie | Tak | Nie |
| Synchronizacja z chmurą | Nie | Nie | Tak (Atlas) | Nie |
| Rozmiar pakietu | Mały | Duży | Średni | Mały |
Benchmarki wydajności
W moich testach na 10 000 rekordach na średniopółkowym urządzeniu z Androidem:
| Operacja | SQLite | EF Core | Realm | Dapper |
|---|---|---|---|---|
| Insert (batch) | 120ms | 450ms | 95ms | 130ms |
| Odczyt wszystkich | 45ms | 180ms | 30ms | 50ms |
| Zapytanie (filtrowane) | 12ms | 85ms | 8ms | 15ms |
| Update (pojedynczy) | 3ms | 25ms | 2ms | 4ms |
Uwaga: to wartości przybliżone — rzeczywista wydajność zależy od złożoności danych, urządzenia i projektu schematu.
Podsumowanie
Moje rekomendacje w zależności od typu projektu:
Proste aplikacje z podstawowym storage → SQLite z sqlite-net — minimalna konfiguracja, spełnia swoją rolę
Złożone dane relacyjne + współdzielone modele z backendem → Entity Framework Core — moc LINQ, migracje, znajome API
Aplikacje offline-first z synchronizacją w czasie rzeczywistym → Realm — reaktywne aktualizacje, cloud sync, doskonała wydajność mobilna
Aplikacje krytyczne wydajnościowo z kontrolą nad SQL → Dapper — praktycznie zerowy narzut, pełna elastyczność SQL
W praktyce często łączyłem podejścia: SQLite/Dapper do lekkiego cachowania obok EF Core dla głównego modelu danych w tej samej aplikacji. Nie bójcie się mieszać rozwiązań w zależności od potrzeb poszczególnych funkcjonalności.
