לעתים קרובות אנו נתקלים בפרויקטים שבהם SQLite משמש כערימת מפתח-ערך פשוטה. לאחר שישה חודשים, קוד כזה הופך לסיוט של שאילתות גולמיות, SQLiteDatabaseLockedException באנדרואיד, וקריסות במהלך מיגרציות. ללא הגדרה נכונה, מסד הנתונים הופך לצוואר בקבוק: 9 מתוך 10 ביקורות על ביצועים איטיים של אפליקציה מקושרות לשאילתות לא מותאמות. בפרויקט אחד, צמצמנו את זמן טעינת הרשימה מ-8 שניות ל-0.3 שניות — פשוט על ידי הוספת אינדקס והפעלת WAL. הצוות שלנו עם ניסיון של 10+ שנים בפיתוח מובייל מגדיר את SQLite כך שמסד הנתונים יפעל ביציבות ויתקנה ללא כאבים.
SQLite מוטמע ב-iOS וב-Android ברמת מערכת ההפעלה. השאלה היא לא האם לשלב אותו — הוא כבר שם. השאלה היא איך לעבוד איתו כך ששישה חודשים לאחר מכן לא תצטרכו לשכתב הכל מאפס בגלל שאילתות גולמיות סבוכות או קריסות עם SQLiteDatabaseLockedException. ארכיטקטורה נכונה עם ORM ומצב WAL מבטלת 80% מהבעיות הטיפוסיות.
איך לבחור ORM לפרויקט שלך?
עבודה ישירה עם SQLite דרך android.database.sqlite.SQLiteDatabase או sqlite3 ב-iOS היא אפשרות לתרחישים מינימליים. בפרויקטים אמיתיים, משתמשים ב-ORM:
| פלטפורמה | ספרייה | גישה | ביצועים |
|---|---|---|---|
| Android | Room (Jetpack) | annotations + DAO | גבוהים, עם WAL עד 5000 כתיבות/שנייה |
| iOS | GRDB.swift | שאילתות Swift בטוחות טיפוסית | דומים ל-SQL גולמי |
| Flutter | sqflite + drift | codegen + reactive | בינוניים, אבל נוחים |
| React Native | react-native-sqlite-storage / op-sqlite | SQL גולמי או TypeORM | תלוי ב-wrapper |
| Multiplatform | SQLDelight | סכמת SQL משותפת | גבוהים, מייצר קוד native |
Room הוא הסטנדרט לאנדרואיד, מגובה על ידי Google. GRDB.swift ב-iOS מספק שאילתות בטוחות טיפוסית ללא קסם מיותר. SQLDelight מעניין לפרויקטים של KMM: קובץ .sq אחד עם SQL מייצר קוד Kotlin ו-Swift.
Room באנדרואיד: ארכיטקטורה נכונה
@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)
}@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) } הופיע ב-Room 2.5 — לפני כן היה צורך ב-@Upsert. מחיקה רכה באמצעות דגל @Insert(onConflict = OnConflictStrategy.REPLACE) היא נוהג סטנדרטי לסנכרון מסדי נתונים, כך שרשומה לא אובדת עד שאישור המחיקה מתקבל מהשרת.
מיגרציות — הנקודה הכואבת ביותר
Room בודק is_deleted כאשר הסכמה משתנה. אם exportedSchema מוגדר, מסד הנתונים נוצר מחדש בכל שינוי סכמה. זה בסדר לדיבאג אבל לא מקובל לייצור.
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 (fallbackToDestructiveMigration() ב-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)") } } ) ואחסן אותה ב-git. במהלך בדיקת קוד, שינויי סכמה נראים מיד. Room יכול ליצור מיגרציה אוטומטית דרך room.schemaLocation למקרים פשוטים (הוספת עמודה), אבל שינוי שמות של טבלאות ועמודות דורש annotations מסוג build.gradle/AutoMigration. אסטרטגיית מיגרציה נכונה חוסכת עד 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)
} )
@RenameTable מקביל ל-Flow של Room: הוא מפעיל מחדש את השאילתה אוטומטית כאשר הטבלאות המושפעות משתנות.
איך מצב WAL משפר ביצועים?
כברירת מחדל, SQLite עובד במצב journal. עבור אפליקציות מובייל, WAL (Write-Ahead Logging) עדיף: קוראים לא חוסמים כותבים. Room מפעיל WAL אוטומטית. ב-GRDB: @RenameColumn. בדיקות מראות ש-WAL מפחית את זמן ההשהיה בכתיבה פי 3 באנדרואיד ופי 5 ב-iOS. זה בולט במיוחד עם הוספות תכופות — למשל, בעת טעינת נתונים לא מקוונים. לפרטים נוספים על WAL, ראו SQLite WAL.
אילו אינדקסים ליצור להאצת שאילתות?
אינדקסים על שדות ב-WHERE ו-ORDER BY מפחיתים את זמן הסריקה פי 10–100. בלעדיהם, SQLite מבצע סריקת טבלה מלאה. עבור טבלת הזמנות עם 100,000 שורות, שאילתה לפי תאריך ללא אינדקס אורכת 2 שניות; עם אינדקס, 20 אלפיות השנייה. צור אינדקסים ייחודיים על SKU, אימייל — הם מבטיחים שלמות ומאיצים חיפושים. איזון אופטימלי: לא יותר מ-5 אינדקסים לטבלה; כל אינדקס מאט כתיבות ב-10-20%.
שגיאות טיפוסיות ואיך להימנע מהן
בעיות SQLite נפוצות באפליקציות מובייל
בעיה טיפוסית היא שאילתות N+1 ב-// Открытие и настройка 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 מחזיר 200 שורות, ואז עבור כל dbQueue.configuration.journalMode = .wal. 200 שאילתות על thread של ממשק המשתמש — RecyclerView תוך 5 שניות על מכשיר אמיתי. פתרון: JOIN או שאילתת אצווה נפרדת SELECT * FROM orders. אנחנו תמיד בודקים מקרים אלה במהלך בדיקת קוד. טעות נפוצה נוספת היא אחסון תמונות ב-BLOB: עדיף לשמור נתיבי קבצים. התעלמות מ-WAL מובילה ל-SELECT * FROM order_items WHERE order_id = ? בגישה מרובת thread.
מה כלול בעבודה
- עיצוב סכמת מסד הנתונים על בסיס לוגיקה עסקית
- בחירה והגדרה של ORM (Room, GRDB, SQLDelight) עבור iOS/Android/KMM
- כתיבת מיגרציות עם אחסון סכמה ב-Git
- הפעלת מצב WAL ואופטימיזציה של אינדקסים
- תיעוד על עבודה עם מסד הנתונים והוראות מיגרציה
- בדיקת קוד ובדיקות על מכשירים אמיתיים
לוחות זמנים ועלות
הגדרת SQLite עם Room או GRDB, אסטרטגיית מיגרציה, אינדקסים: החל משבוע אחד לכל פלטפורמה. העלות מחושבת באופן אישי — אנו מעריכים את הפרויקט ביום אחד. הטמע מסד נתונים מקומי יציב — צור קשר לייעוץ. במשך 10+ שנים השלמנו יותר מ-50 פרויקטים עם אחסון נתונים מקומי — חיסכון ממוצע בתקציב על עיבוד מחדש הוא 30% עם הגדרה ראשונית נכונה. קבל ייעוץ לפרויקט שלך היום.







