Вступ: чому саме SQLite для мобільних додатків
Практично кожен мобільний додаток рано чи пізно потребує локального зберігання даних. Налаштування користувача, кешовані відповіді API, офлайн-режим — усе це вимагає надійної бази даних прямо на пристрої.
І тут SQLite — безумовний лідер. Він не потребує окремого серверного процесу, працює на всіх платформах і чудово інтегрується з .NET MAUI 10. Чесно кажучи, складно уявити мобільну розробку без нього.
Але є одна проблема. Більшість розробників зупиняються на базових прикладах із документації: один клас, один файл, мінімум структури. Спочатку все ніби працює. А потім додаток зростає до десятків таблиць і сотень запитів — і цей «простий» підхід перетворюється на технічний борг, який боляче рефакторити.
У цьому посібнику ми пройдемо повний шлях — від встановлення NuGet-пакетів до побудови масштабованого Repository Pattern із Dependency Injection, оптимізації продуктивності та шифрування даних. Кожен крок супроводжується робочим кодом для .NET MAUI 10, готовим до копіювання у ваш проєкт.
Налаштування SQLite у проєкті .NET MAUI 10
Встановлення NuGet-пакетів
Для роботи з SQLite у .NET MAUI потрібен пакет sqlite-net-pcl. Не звертайте уваги на «pcl» у назві — це актуальний і активно підтримуваний пакет:
dotnet add package sqlite-net-pcl
dotnet add package SQLitePCLRaw.bundle_green
Пакет SQLitePCLRaw.bundle_green забезпечує нативну реалізацію SQLite на кожній платформі — Android, iOS, macOS та Windows. Без нього ви отримаєте runtime-помилку при спробі створити з'єднання. Повірте, зловити цю помилку на етапі тестування краще, ніж у продакшені.
Визначення шляху до бази даних
SQLite зберігає всі дані у єдиному файлі. На мобільних платформах цей файл живе у приватній пісочниці (sandbox) додатка, недоступній для інших програм:
public static class DbConstants
{
public const string DatabaseFilename = "app_data.db3";
public static string DatabasePath =>
Path.Combine(FileSystem.AppDataDirectory, DatabaseFilename);
}
FileSystem.AppDataDirectory — це API .NET MAUI, що повертає платформо-специфічний шлях. На Android це /data/data/{package}/files, на iOS — каталог Library пісочниці додатка. Приємна частина — вам взагалі не потрібно думати про різницю між платформами, MAUI абстрагує все автоматично.
Створення моделі даних
SQLite.NET використовує атрибути для маппінгу C#-класів на таблиці бази даних. Кожна публічна властивість стає стовпцем таблиці (якщо її не позначено атрибутом [Ignore]):
using SQLite;
[Table("contacts")]
public class Contact
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
[MaxLength(100), NotNull]
public string FirstName { get; set; } = string.Empty;
[MaxLength(100), NotNull]
public string LastName { get; set; } = string.Empty;
[MaxLength(200), Unique, Indexed]
public string Email { get; set; } = string.Empty;
[MaxLength(20)]
public string? Phone { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public bool IsFavorite { get; set; }
[Ignore]
public string FullName => $"{FirstName} {LastName}";
}
Давайте розберемо ключові атрибути, які тут використовуються:
- [PrimaryKey, AutoIncrement] — автоматично генерує унікальний цілочисельний ідентифікатор для кожного запису
- [MaxLength(n)] — обмежує довжину рядкового поля до n символів
- [NotNull] — забороняє NULL-значення на рівні бази даних
- [Unique] — гарантує унікальність значення в межах таблиці
- [Indexed] — створює індекс для прискорення пошуку за цим полем (про це ще поговоримо в розділі оптимізації)
- [Ignore] — виключає властивість із маппінгу на стовпець бази
- [Table("name")] — явно задає назву таблиці замість автоматичної генерації з імені класу
Сервіс для роботи з базою даних
Ось центральний компонент нашої архітектури — сервіс, що управляє підключенням до бази. Тут ми використовуємо ледачу асинхронну ініціалізацію, щоб база створювалась лише при першому зверненні:
using SQLite;
public class DatabaseService
{
private SQLiteAsyncConnection? _database;
private async Task<SQLiteAsyncConnection> GetConnectionAsync()
{
if (_database is not null)
return _database;
_database = new SQLiteAsyncConnection(
DbConstants.DatabasePath,
SQLiteOpenFlags.ReadWrite |
SQLiteOpenFlags.Create |
SQLiteOpenFlags.SharedCache);
// Увімкнення WAL для кращої продуктивності
await _database.EnableWriteAheadLoggingAsync();
// Створення таблиць
await _database.CreateTableAsync<Contact>();
return _database;
}
}
Прапорці SQLiteOpenFlags визначають поведінку з'єднання. ReadWrite дозволяє читання та запис, Create автоматично створить файл бази, якщо його ще не існує, а SharedCache дозволяє кільком підключенням спільно використовувати кеш. Такий набір прапорців підходить для більшості сценаріїв.
CRUD-операції: повний приклад
Тепер додамо до DatabaseService методи для повного циклу роботи з даними. Нічого надприродного — створення, читання, оновлення та видалення.
Створення (Create)
public async Task<int> AddContactAsync(Contact contact)
{
var db = await GetConnectionAsync();
return await db.InsertAsync(contact);
}
public async Task<int> AddContactsBatchAsync(IEnumerable<Contact> contacts)
{
var db = await GetConnectionAsync();
return await db.InsertAllAsync(contacts);
}
Метод InsertAsync повертає кількість вставлених рядків (зазвичай 1). Зручна деталь: після вставки властивість Id об'єкта автоматично заповнюється згенерованим значенням, тож вам не потрібно робити окремий запит, щоб його отримати.
Читання (Read)
public async Task<List<Contact>> GetAllContactsAsync()
{
var db = await GetConnectionAsync();
return await db.Table<Contact>()
.OrderBy(c => c.LastName)
.ToListAsync();
}
public async Task<Contact?> GetContactByIdAsync(int id)
{
var db = await GetConnectionAsync();
return await db.Table<Contact>()
.FirstOrDefaultAsync(c => c.Id == id);
}
public async Task<List<Contact>> SearchContactsAsync(string query)
{
var db = await GetConnectionAsync();
return await db.Table<Contact>()
.Where(c => c.FirstName.Contains(query) ||
c.LastName.Contains(query) ||
c.Email.Contains(query))
.ToListAsync();
}
public async Task<List<Contact>> GetFavoritesAsync()
{
var db = await GetConnectionAsync();
return await db.Table<Contact>()
.Where(c => c.IsFavorite)
.OrderBy(c => c.FirstName)
.ToListAsync();
}
Оновлення (Update)
public async Task<int> UpdateContactAsync(Contact contact)
{
var db = await GetConnectionAsync();
return await db.UpdateAsync(contact);
}
UpdateAsync знаходить запис за Primary Key і оновлює всі інші поля. Один нюанс, який варто тримати в голові: якщо запису з таким Id не існує, метод мовчки повертає 0 — жодного виключення не буде.
Видалення (Delete)
public async Task<int> DeleteContactAsync(Contact contact)
{
var db = await GetConnectionAsync();
return await db.DeleteAsync(contact);
}
public async Task<int> DeleteAllContactsAsync()
{
var db = await GetConnectionAsync();
return await db.DeleteAllAsync<Contact>();
}
Repository Pattern для масштабування
Окей, CRUD працює. Але що робити, коли у додатку з'являються десятки моделей? Дублювати ті самі методи для кожної — прямий шлях до хаосу. Узагальнений (generic) Repository Pattern вирішує цю проблему елегантно.
Інтерфейс репозиторію
public interface IRepository<T> where T : class, new()
{
Task<List<T>> GetAllAsync();
Task<T?> GetByIdAsync(int id);
Task<int> AddAsync(T entity);
Task<int> AddRangeAsync(IEnumerable<T> entities);
Task<int> UpdateAsync(T entity);
Task<int> DeleteAsync(T entity);
Task<int> CountAsync();
}
Реалізація на базі SQLite
public class SqliteRepository<T> : IRepository<T> where T : class, new()
{
private readonly SQLiteAsyncConnection _db;
public SqliteRepository(SQLiteAsyncConnection db)
{
_db = db;
}
public Task<List<T>> GetAllAsync()
=> _db.Table<T>().ToListAsync();
public Task<T?> GetByIdAsync(int id)
=> _db.FindAsync<T>(id);
public Task<int> AddAsync(T entity)
=> _db.InsertAsync(entity);
public Task<int> AddRangeAsync(IEnumerable<T> entities)
=> _db.InsertAllAsync(entities);
public Task<int> UpdateAsync(T entity)
=> _db.UpdateAsync(entity);
public Task<int> DeleteAsync(T entity)
=> _db.DeleteAsync(entity);
public Task<int> CountAsync()
=> _db.Table<T>().CountAsync();
}
Тепер для кожної нової моделі — будь то Order, Product чи Setting — вам потрібно лише зареєструвати новий IRepository<T> у DI-контейнері. Жодного додаткового коду для базових CRUD-операцій. На мій досвід, це одне з найкращих архітектурних рішень, яке можна прийняти на ранній стадії проєкту.
Інтеграція з Dependency Injection
Реєстрація сервісів у MauiProgram.cs — ключовий крок. Без нього весь наш Repository Pattern залишається красивою теорією:
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>();
// Реєстрація з'єднання з БД як Singleton
builder.Services.AddSingleton<SQLiteAsyncConnection>(provider =>
{
var connection = new SQLiteAsyncConnection(
DbConstants.DatabasePath,
SQLiteOpenFlags.ReadWrite |
SQLiteOpenFlags.Create |
SQLiteOpenFlags.SharedCache);
connection.EnableWriteAheadLoggingAsync().Wait();
connection.CreateTableAsync<Contact>().Wait();
return connection;
});
// Реєстрація репозиторіїв
builder.Services.AddSingleton<IRepository<Contact>,
SqliteRepository<Contact>>();
// Реєстрація ViewModel та сторінок
builder.Services.AddTransient<ContactsViewModel>();
builder.Services.AddTransient<ContactsPage>();
return builder.Build();
}
}
Зверніть увагу: з'єднання з базою реєструється як Singleton, тому що SQLiteAsyncConnection внутрішньо управляє пулом підключень і безпечний для багатопотокового використання. А ViewModel та сторінки — як Transient, щоб кожен перехід створював свіжий екземпляр. Це важливий момент, який часто плутають.
Використання у ViewModel
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;
public partial class ContactsViewModel : ObservableObject
{
private readonly IRepository<Contact> _repository;
public ContactsViewModel(IRepository<Contact> repository)
{
_repository = repository;
}
[ObservableProperty]
private ObservableCollection<Contact> _contacts = new();
[ObservableProperty]
private bool _isLoading;
[RelayCommand]
private async Task LoadContactsAsync()
{
IsLoading = true;
try
{
var items = await _repository.GetAllAsync();
Contacts = new ObservableCollection<Contact>(items);
}
finally
{
IsLoading = false;
}
}
[RelayCommand]
private async Task DeleteContactAsync(Contact contact)
{
await _repository.DeleteAsync(contact);
Contacts.Remove(contact);
}
}
Завдяки інтерфейсу IRepository<T> цю ViewModel легко покрити модульними тестами — достатньо підмінити реальний репозиторій на mock-об'єкт. І це не теоретична перевага — коли ваш додаток виросте, ви будете вдячні собі за таку архітектуру.
Оптимізація продуктивності SQLite
Write-Ahead Logging (WAL)
WAL — це режим журналювання SQLite, який дозволяє одночасне читання та запис без блокувань. Як це працює? Замість блокування всієї бази при записі, зміни спочатку потрапляють у окремий WAL-файл, а читачі продовжують працювати з основним файлом.
Ми вже увімкнули WAL у нашому сервісі вище — і це одна з найважливіших оптимізацій. Особливо відчутна різниця, коли UI-потік читає дані паралельно із фоновим записом.
Пакетні операції через транзакції
Для масового вставлення даних використовуйте RunInTransactionAsync замість багаторазового виклику InsertAsync у циклі:
public async Task ImportContactsAsync(List<Contact> contacts)
{
var db = await GetConnectionAsync();
await db.RunInTransactionAsync(conn =>
{
foreach (var contact in contacts)
{
conn.Insert(contact);
}
});
}
Різниця в продуктивності тут просто вражає. Транзакція об'єднує всі операції в один дисковий запис. Вставка 1000 записів без транзакції може зайняти 10–15 секунд, а в транзакції — менше секунди. Це не перебільшення, а реальні цифри.
Пагінація для великих таблиць
Коли таблиця містить тисячі записів, завантажувати все в пам'ять — погана ідея. Використовуйте пагінацію:
public async Task<List<Contact>> GetContactsPagedAsync(
int page, int pageSize = 20)
{
var db = await GetConnectionAsync();
return await db.Table<Contact>()
.OrderBy(c => c.LastName)
.Skip(page * pageSize)
.Take(pageSize)
.ToListAsync();
}
Шифрування бази даних
Якщо ваш додаток зберігає персональні або фінансові дані, шифрування — не опція, а необхідність. SQLite можна зашифрувати за допомогою SQLCipher. Спочатку замініть стандартний пакет:
dotnet remove package sqlite-net-pcl
dotnet add package sqlite-net-sqlcipher
Після заміни пакета створіть підключення із ключем шифрування:
var options = new SQLiteConnectionString(
DbConstants.DatabasePath,
storeDateTimeAsTicks: true,
key: encryptionKey);
var db = new SQLiteAsyncConnection(options);
А ключ шифрування зберігайте у безпечному сховищі платформи через SecureStorage API:
// Збереження ключа при першому запуску
var key = await SecureStorage.Default.GetAsync("db_key");
if (key is null)
{
key = Guid.NewGuid().ToString();
await SecureStorage.Default.SetAsync("db_key", key);
}
SecureStorage використовує Keychain на iOS та Android KeyStore — це найбезпечніший спосіб зберігання секретів на мобільних платформах. І головне правило: ніколи (взагалі ніколи!) не записуйте ключ шифрування безпосередньо у вихідний код.
SQLite.NET чи EF Core: що обрати для .NET MAUI
У .NET MAUI 10 є два основних шляхи роботи з SQLite. Вибір залежить від складності вашого додатка:
- sqlite-net-pcl — легковагий ORM з мінімальним впливом на розмір додатка. Ідеальний для проєктів із простою структурою даних (до 10–15 таблиць без складних зв'язків). Працює швидше завдяки простоті.
- EF Core + Microsoft.EntityFrameworkCore.Sqlite — повнофункціональний ORM з автоматичними міграціями, складними LINQ-запитами та підтримкою lazy/eager loading. Варто обирати, коли у вас складна реляційна модель або ви перевикористовуєте код із веб-проєкту на ASP.NET Core.
На мою думку, для більшості мобільних додатків sqlite-net-pcl — оптимальний вибір. Він додає мінімум до розміру APK/IPA, а його API інтуїтивно зрозумілий навіть для тих, хто тільки починає працювати з .NET MAUI.
Поширені помилки та як їх уникнути
За роки роботи з SQLite у мобільних проєктах я бачив одні й ті самі помилки знову і знову. Ось найтиповіші:
- Синхронне підключення у UI-потоці — використання
SQLiteConnectionзамістьSQLiteAsyncConnectionзаблокує головний потік і спричинить «зависання» інтерфейсу. Завжди використовуйте асинхронну версію. - Множинні екземпляри з'єднання — створення нового
SQLiteAsyncConnectionу кожному методі призводить до витоків пам'яті. Реєструйте з'єднання як Singleton через DI. - Вставка у циклі без транзакції — кожен окремий
InsertAsyncвиконує дисковий запис. Для масових операцій завжди обгортайте вставки уRunInTransactionAsync. - Ігнорування індексів — без атрибута
[Indexed]на полях, що часто використовуються уWhere-запитах, пошук по великій таблиці буде болісно повільним. - Зберігання DateTime як рядка — за замовчуванням SQLite.NET зберігає
DateTimeяк ticks (числове значення), що забезпечує коректне сортування. Не змінюйте цю поведінку без дуже вагомої причини.
Часті запитання (FAQ)
Де зберігається файл бази даних SQLite у .NET MAUI?
Файл бази зберігається у приватному каталозі додатка, доступному через FileSystem.AppDataDirectory. На Android це /data/data/{package_name}/files/, на iOS — каталог Library пісочниці додатка. Інші програми не мають прямого доступу до цього файлу. При видаленні додатка файл бази також видаляється.
Чи можна використовувати SQLite для синхронізації з хмарним сервером?
Так, і це досить популярний підхід. SQLite ідеально працює як локальний кеш для офлайн-синхронізації. Серед популярних рішень — Azure Mobile Apps із IOfflineTable<T>, бібліотека NubeSync (open-source) або власний REST API з логікою конфліктного злиття. Принцип простий: SQLite зберігає дані локально та відстежує зміни, а синхронізація відбувається при відновленні мережі.
Як захистити дані у SQLite від несанкціонованого доступу?
Встановіть пакет sqlite-net-sqlcipher і створіть підключення із ключем шифрування. Ключ зберігайте через SecureStorage API .NET MAUI, який використовує Keychain на iOS та Android KeyStore. Файл бази стає повністю нечитабельним без правильного ключа — навіть при фізичному доступі до пристрою.
Як уникнути блокування UI при роботі з SQLite?
Завжди використовуйте SQLiteAsyncConnection замість синхронного SQLiteConnection. Усі операції з базою виконуються у фоновому потоці через async/await, залишаючи UI-потік вільним. Додатково увімкніть WAL-режим — він дозволяє одночасне читання та запис без взаємного блокування.
Скільки даних можна зберігати у SQLite на мобільному пристрої?
Технічно SQLite підтримує бази до 281 ТБ, але на практиці обмеження визначається вільним місцем на пристрої. Для мобільних додатків рекомендується тримати розмір бази в межах 50–100 МБ для оптимальної продуктивності. Якщо дані значно перевищують цей обсяг, розгляньте пагінацію, архівування старих записів або зберігання великих об'єктів (зображення, файли) окремо від бази.