Налаштування SQLite: міграції, індекси, WAL для мобільного додатку
Ми часто зустрічаємо проєкти, де SQLite використовується як проста купа key-value. Через півроку такий код перетворюється на пекло з raw-запитів, SQLiteDatabaseLockedException на Android та падінь під час міграцій. Без правильного налаштування база даних стає вузьким місцем: 9 з 10 відгуків про повільну роботу додатка пов'язані з неоптимізованими запитами. На одному з проєктів ми скоротили час завантаження списку з 8 секунд до 0.3 секунди — лише додавши індекс і ввімкнувши WAL. Наша команда з 10+ роками досвіду в мобільній розробці налаштовує SQLite так, щоб база працювала стабільно та масштабувалася без болю.
SQLite вбудований в iOS та Android на рівні ОС. Питання не в тому, чи підключати його — він уже є. Питання в тому, як з ним працювати так, щоб через півроку не переписувати все з нуля через заплутані raw-запити або падіння з SQLiteDatabaseLockedException на Android. Правильна архітектура з ORM та WAL-режимом усуває 80% типових проблем.
Як вибрати ORM для вашого проєкту?
Працювати з SQLite безпосередньо через android.database.sqlite.SQLiteDatabase або sqlite3 на iOS — варіант для мінімальних сценаріїв. У реальних проєктах використовують ORM:
| Платформа |
Бібліотека |
Підхід |
Продуктивність |
| Android |
Room (Jetpack) |
анотації + DAO |
Висока, з WAL до 5000 записів/с |
| iOS |
GRDB.swift |
typesafe запити на Swift |
Порівняна з raw SQL |
| Flutter |
sqflite + drift |
codegen + reactive |
Середня, але зручно |
| React Native |
react-native-sqlite-storage / op-sqlite |
raw SQL або TypeORM |
Залежить від обгортки |
| Multiplatform |
SQLDelight |
shared SQL схема |
Висока, генерує нативний код |
Room — стандарт для Android, за нього голосує Google. GRDB.swift на iOS дає типобезпечні запити без зайвої магії. SQLDelight цікавий для KMM-проєктів: один .sq файл з SQL, генерує Kotlin та Swift код.
Room на Android: правильна архітектура
@Entity(tableName = "products",
indices = [Index(value = ["category_id"]), Index(value = ["sku"], unique = true)]
)
data class ProductEntity(
@PrimaryKey val id: String,
@ColumnInfo(name = "category_id") val categoryId: String,
val sku: String,
val title: String,
@ColumnInfo(name = "price_cents") val priceCents: Int,
@ColumnInfo(name = "updated_at") val updatedAt: Long,
@ColumnInfo(name = "is_deleted") val isDeleted: Boolean = false
)
@Dao
interface ProductDao {
@Query("SELECT * FROM products WHERE category_id = :categoryId AND is_deleted = 0 ORDER BY title ASC")
fun observeByCategory(categoryId: String): Flow<List<ProductEntity>>
@Upsert
suspend fun upsert(products: List<ProductEntity>)
@Query("UPDATE products SET is_deleted = 1, updated_at = :timestamp WHERE id = :id")
suspend fun softDelete(id: String, timestamp: Long)
}
@Upsert з'явився в Room 2.5 — до цього потрібно було @Insert(onConflict = OnConflictStrategy.REPLACE). Soft delete через прапорець is_deleted — стандартна практика для синхронізованих баз, щоб не втратити запис до підтвердження видалення з сервера.
Міграції — найболючіше місце
Room перевіряє exportedSchema при зміні схеми. Якщо fallbackToDestructiveMigration() — база перестворюється при кожній зміні схеми. Це нормально для debug, неприпустимо для production.
val db = Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build()
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE products ADD COLUMN tags TEXT NOT NULL DEFAULT ''")
db.execSQL("CREATE INDEX IF NOT EXISTS index_products_updated_at ON products(updated_at)")
}
}
Експортуйте схему в JSON (room.schemaLocation в build.gradle) і зберігайте в git. При code review одразу видно, що змінилося в схемі. Room може автоматично згенерувати міграцію через AutoMigration для простих випадків (додавання колонки), але перейменування таблиць і колонок вимагає @RenameTable/@RenameColumn анотацій. Грамотна стратегія міграцій економить до 30% часу на супровід.
GRDB.swift на iOS
// Відкриття та налаштування
let dbQueue = try DatabaseQueue(path: dbPath)
try dbQueue.write { db in
try db.create(table: "products", ifNotExists: true) { t in
t.primaryKey("id", .text)
t.column("category_id", .text).notNull().indexed()
t.column("sku", .text).unique()
t.column("title", .text).notNull()
t.column("price_cents", .integer).notNull()
t.column("updated_at", .integer).notNull()
}
}
// Реактивне спостереження через ValueObservation
let observation = ValueObservation.tracking { db in
try Product.filter(Column("categoryId") == categoryId).fetchAll(db)
}
let cancellable = observation.start(in: dbQueue,
onError: { error in print(error) },
onChange: { products in self.updateUI(products) }
)
ValueObservation — аналог Room's Flow: автоматично перезапускає запит при зміні зачеплених таблиць.
Як WAL-режим підвищує продуктивність?
За замовчуванням SQLite працює в journal mode. Для мобільних додатків WAL (Write-Ahead Logging) краще: читачі не блокують письменників. Room вмикає WAL автоматично. У GRDB: dbQueue.configuration.journalMode = .wal. Тести показують, що WAL знижує затримку запису в 3 рази на Android і в 5 разів на iOS. Це особливо помітно при частих вставках — наприклад, під час завантаження офлайн-даних. Детальніше про WAL див. SQLite WAL.
Які індекси створювати для прискорення запитів?
Індекси на поля в WHERE і ORDER BY знижують час сканування в 10–100 разів. Без них SQLite виконує повне сканування таблиці. Для таблиці orders з 100 000 рядків запит за датою без індексу займає 2 секунди, з індексом — 20 мілісекунд. Створюйте унікальні індекси на SKU, email — вони гарантують цілісність і прискорюють пошук. Оптимальний баланс: не більше 5 індексів на таблицю, кожен уповільнює запис на 10-20%.
Типові помилки та як їх уникнути
Поширені проблеми SQLite в мобільних додатках
Типова проблема — N+1 запит у RecyclerView. SELECT * FROM orders повертає 200 рядків, потім для кожного SELECT * FROM order_items WHERE order_id = ?. 200 запитів в UI thread — ANR через 5 секунд на реальному пристрої. Рішення: JOIN або окремий batch-запит WHERE order_id IN (...). Ми завжди перевіряємо такі кейси на етапі code review. Ще одна часта помилка — зберігання зображень у BLOB: краще зберігати шляхи до файлів. Ігнорування WAL призводить до SQLiteDatabaseLockedException при багатопотоковому доступі.
Що входить у роботу
- Проєктування схеми бази даних з урахуванням бізнес-логіки
- Вибір та налаштування ORM (Room, GRDB, SQLDelight) під iOS/Android/KMM
- Написання міграцій зі зберіганням схеми в Git
- Увімкнення WAL-режиму та оптимізація індексів
- Документація щодо роботи з базою та інструкція з міграцій
- Code review і тестування на реальних пристроях
Строки та вартість
Налаштування SQLite з Room або GRDB, міграційна стратегія, індекси: від 1 тижня на одну платформу. Вартість розраховується індивідуально — ми оцінюємо проєкт за 1 день. Впровадьте стабільну локальну базу даних — зв'яжіться з нами для консультації. За 10+ років ми реалізували понад 50 проєктів з локальним зберіганням даних — середня економія бюджету на доопрацюваннях становить 30% при правильному початковому налаштуванні. Отримайте консультацію щодо вашого проєкту вже сьогодні.
Як вибрати рішення для локального зберігання даних (Room, Core Data, Realm, Isar)?
Ми стикалися з ситуацією, коли додаток втрачає дані при втраті мережі — і це не просто баг, це провал сценарію. Користувач заповнив форму, натиснув «Відправити», отримав таймаут і втратив все. Або гірше: дані відправилися двічі через некоректну логіку повторної відправки. Правильно вибраний та налаштований шар сховища вирішує цю проблему раз і назавжди. Неправильний вибір може коштувати команді місяців переписування коду та втрати до 70% часу на синхронізацію. Наш досвід — 10+ років у мобільній розробці, понад 50 проектів з офлайн-сховищами — підтверджує: вибір рішення визначає 80% майбутніх проблем з продуктивністю та синхронізацією.
На практиці вибір сховища визначається двома факторами: типом даних та вимогами до синхронізації, а не популярністю бібліотеки.
Room (Android) — обгортка над SQLite з compile-time верифікацією SQL-запитів. Якщо запит невалідний, збірка падає — це краще, ніж SQLiteException в рантаймі. Room добре інтегрується з Kotlin Flow та LiveData, що робить реактивні UI-оновлення прямолінійними. Основна складність — міграції схеми. @Database(version = N, exportSchema = true) з файлами міграцій в assets/databases/ — обов'язкова практика, інакше при оновленні додатка fallbackToDestructiveMigration() просто зітре дані користувача.
Core Data (iOS) — не база даних, а фреймворк управління графом об'єктів поверх SQLite (або XML, або in-memory). NSPersistentContainer з viewContext для читання на main thread та newBackgroundContext() для запису — базова схема. Проблема починається, коли розробник робить save() в viewContext з фонового потоку: EXC_BAD_ACCESS в рандомний момент, відтворюється раз на тиждень, в крешлозі майже нічого корисного. Потрібно використовувати performAndWait або perform для кожного контексту строго в своєму потоці. Apple Core Data Programming Guide рекомендує саме такий підхід.
Realm виграє там, де потрібна швидкість роботи з великими наборами об'єктів та вбудована реактивність через Results + observe(). Realm зберігає об'єкти напряму, без маппінгу ORM, тому читання не потребує десеріалізації. За нашими вимірами, Realm обробляє читання в 2–3 рази швидше Core Data при об'ємі понад 10 000 об'єктів. На Flutter Realm SDK (ex-MongoDB Realm) підтримує Device Sync — але це вже managed-сервіс з окремою інфраструктурою.
Hive та Isar — Flutter-специфічні рішення. Hive — key-value сховище, швидко, просто, підходить для налаштувань та кешів. Isar — повноцінна документо-орієнтована БД з індексами, написана на Rust, компілюється в нативний код. Для Flutter-додатків з офлайн-функціональністю Isar зараз переважніше: вбудований query builder з типобезпечними фільтрами, транзакції, watchObject/watchQuery для реактивності.
| Платформа |
Рішення |
Реактивність |
Синхронізація |
| Android |
Room + Flow |
LiveData/Flow |
WorkManager |
| iOS |
Core Data |
NSFetchedResultsController |
CloudKit |
| Flutter |
Isar |
Streams |
Custom / Realm Sync |
| Cross-platform |
Realm |
RealmResults.observe |
Device Sync |
| Flutter (простий) |
Hive |
ValueListenable |
Немає |
Зв'яжіться з нами, щоб отримати безкоштовний аудит вашого поточного сховища та рекомендації з оптимізації — це зекономить вам сотні годин розробки та до 60% трафіку на серверні запити.
Чому офлайн-синхронізація — найскладніша частина?
Локальне сховище саме по собі нескладне. Складність — у синхронізації з сервером при наявності конфліктів.
Найчастіший патерн — optimistic updates з rollback. Користувач редагує запис, UI відображає зміну миттєво, фоновий запит йде на сервер. Якщо сервер повертає помилку — відкочуємо локальний стейт. Виглядає просто. На практиці: якщо користувач встиг піти з екрану і повернутися, а відкат відбувся через 3 секунди — UX зламаний. Потрібна явна черга операцій зі станом (PENDING, SYNCED, FAILED) в окремій таблиці.
На Android для фонової синхронізації використовуємо WorkManager з Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED). Важно не забыть про setInputMerger(ArrayCreatingInputMerger::class) при батчинге задач — інакше при кількох одночасних запусках дані затираються. Типова реалізація черги операцій:
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val pendingOps = syncDao.getPendingOperations()
for (op in pendingOps) {
try {
apiClient.send(op.payload)
syncDao.markSynced(op.id)
} catch (e: Exception) {
syncDao.markFailed(op.id, e.message)
return Result.retry()
}
}
return Result.success()
}
}
На iOS аналог — BGTaskScheduler з BGProcessingTaskRequest. Обмеження iOS на фоновий час виконання (~30 секунд для refresh tasks) означають, що синхронізація повинна бути інкрементальною: не «синхронізувати все», а «синхронізувати наступні N записів, зберегти курсор».
Конфлікти при мультипристроєвій роботі вирішуються одним із трьох підходів:
- Last-write-wins по
updated_at (найпростіший, втрачає дані при одночасному редагуванні)
- Server-wins (клієнт завжди приймає серверну версію)
- Three-way merge (складно, потрібен спільний предок — підходить для документів)
У більшості B2C-додатків достатньо last-write-wins з вектором часу на рівні користувача, але при спільному редагуванні потрібен CRDTs-підхід — тоді дивимося на Automerge або Yjs з мобільними біндингами.
Як ми будуємо шар сховища
Репозиторний патерн — не опціональний, а обов'язковий. UserRepository не знає, звідки дані: з Room, Realm чи мережі. ViewModel викликає repository.getUser(id), отримує Flow/Stream, відображає дані. Логіка кешування — всередині репозиторія.
Для Flutter типова архітектура: Isar для персистентності, Riverpod для управління стейтом, ConnectivityPlus для визначення стану мережі, кастомний SyncService з чергою операцій. Riverpod AsyncNotifier зручно покриває логіку «показати кеш, оновити з мережі, показати нові дані». Приклад репозиторія з кешуванням:
class UserRepository {
final Isar isar;
final ApiClient api;
Future<User> getUser(String id) async {
// 1. спробувати з локального сховища
final cached = await isar.user.where().idEqualTo(id).findFirst();
if (cached != null) return cached;
// 2. інакше з мережі
final remote = await api.fetchUser(id);
// 3. зберегти локально
await isar.writeTxn(() => isar.user.put(remote));
return remote;
}
}
Окрема тема — шифрування. Якщо додаток зберігає медичні дані, платіжні картки або корпоративні документи, SQLCipher (Android) та NSFileProtection (iOS) — не опція. Realm підтримує шифрування нативно через ключ у 64 байти, який потрібно зберігати в Keychain/Keystore, а не в SharedPreferences. Економія на безпеці може обійтися у витік даних з гучними наслідками.
Що входить в роботу
Ми гарантуємо прозорий процес і фіксуємо кожен етап:
| Етап |
Результат |
| Аудит вимог |
Документ з аналізом типів даних, обсягів, сценаріїв синхронізації |
| Проектування схеми |
ER-діаграма, файли міграцій, план конфлікт-резолюції |
| Розробка репозиторного шару |
Код з юніт-тестами (in-memory БД + моки мережі) |
| Інтеграція синхронізації |
Черга операцій, обробка помилок, fallback-логіка |
| Профілювання та оптимізація |
Звіт Android Profiler / Core Data SQLDebug, рекомендації |
| Деплой та документування |
Інструкція з розгортання, API-опис, доступ до репозиторію |
Бажаєте уникнути типових помилок при проектуванні сховища? Зверніться до нас — ми допоможемо спроектувати надійне локальне сховище з нуля або доопрацювати існуюче.
Які етапи роботи?
Починаємо з аудиту вимог: які дані, який обсяг, чи потрібна синхронізація, чи можливі конфлікти. На цьому етапі стає зрозуміло, Core Data чи SQLite-based рішення, чи потрібен Realm Sync або достатньо простого REST-поллінгу.
Далі — проектування схеми з урахуванням міграцій. Схему змінюють у будь-якому проекті — питання не «чи будуть міграції», а «наскільки болісно вони пройдуть». Експортуємо схему в JSON, зберігаємо в репозиторії, пишемо тести на міграцію кожної версії.
Розробка йде з покриттям репозиторного шару юніт-тестами: моки мережевого шару, реальна in-memory база для тестування запитів. Перед релізом — профілювання запитів через Android Profiler (вкладка Database Inspector) або Core Data debug флаги (-com.apple.CoreData.SQLDebug 1).
Термін реалізації шару сховища з базовою офлайн-синхронізацією — від 2 до 6 тижнів залежно від складності схеми та вимог до конфлікт-резолюції. Вартість розраховується індивідуально після аудиту вашого проекту. Замовте розробку під ключ — отримайте консультацію з вибору оптимального стеку та міграціям.