Налаштування SQLite бази даних в мобільному додатку
SQLite вбудований у iOS та Android на рівні ОС. Питання не в тому, підключити чи — він вже є. Питання в тому, як з ним працювати так, щоб через пів року не переписувати всё з нуля через запутані raw-запити або падання з SQLiteDatabaseLockedException на Android.
Вибір ORM/абстракції
Працювати з SQLite напрямку через android.database.sqlite.SQLiteDatabase або sqlite3 на iOS — варіант для мінімальних сценаріїв. У реальних проектах використовуються ORM:
| Платформа | Бібліотека | Підхід |
|---|---|---|
| Android | Room (Jetpack) | аннотації + DAO |
| iOS | GRDB.swift | типобезпечні запити на Swift |
| Flutter | sqflite + drift | codegen + reactive |
| React Native | react-native-sqlite-storage / op-sqlite | raw SQL або TypeORM |
| Multiplatform | SQLDelight | спільна SQL схема для iOS+Android |
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 аннотацій.
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.
Типова проблема — 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 (...).
Налаштування SQLite з Room або GRDB, міграційна стратегія, індекси: 1 тиждень на одну платформу. Вартість розраховується індивідуально.







