הגדרת מסד נתונים של Room ל-KMP

ספריית Room persistence מספקת שכבת הפשטה מעל SQLite כדי לאפשר גישה חזקה יותר למסד הנתונים, תוך ניצול מלוא העוצמה של SQLite. בדף הזה נסביר איך להשתמש ב-Room בפרויקטים של Kotlin Multiplatform‏ (KMP). מידע נוסף על השימוש ב-Room זמין במאמר שמירת נתונים במסד נתונים מקומי באמצעות Room או בדוגמאות הרשמיות שלנו.

הגדרת יחסי תלות

כדי להגדיר את Room בפרויקט KMP, מוסיפים את יחסי התלות של הארטיפקטים בקובץ build.gradle.kts של מודול KMP.

מגדירים את יחסי התלות בקובץ libs.versions.toml:

[versions]
room3 = "3.0.1"
sqlite = "2.7.0"
ksp = "<kotlinCompatibleKspVersion>"

[libraries]
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
androidx-room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" }
androidx-room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" }

# Optional SQLite Wrapper
androidx-room3-sqlite-wrapper = { module = "androidx.room3:room3-sqlite-wrapper", version.ref = "room3" }

[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
androidx-room3 = { id = "androidx.room3", version.ref = "room3" }

מוסיפים את Room Gradle Plugin כדי להגדיר סכימות של Room ואת KSP plugin.

plugins {
  alias(libs.plugins.ksp)
  alias(libs.plugins.androidx.room3)
}

מוסיפים את התלות של זמן הריצה של Room ואת ספריית SQLite בחבילה:

commonMain.dependencies {
  implementation(libs.androidx.room3.runtime)
  implementation(libs.androidx.sqlite.bundled)
}

// Optional when using Room SQLite Wrapper
androidMain.dependencies {
  implementation(libs.androidx.room3.sqlite.wrapper)
}

מוסיפים את יחסי התלות של KSP לבלוק root dependencies. צריך להוסיף את כל היעדים שהאפליקציה משתמשת בהם. מידע נוסף זמין במאמר בנושא KSP עם Kotlin Multiplatform.

dependencies {
    add("kspAndroid", libs.androidx.room3.compiler)
    add("kspIosSimulatorArm64", libs.androidx.room3.compiler)
    add("kspIosX64", libs.androidx.room3.compiler)
    add("kspIosArm64", libs.androidx.room3.compiler)
    // Add any other platform target you use in your project, for example kspDesktop
}

מגדירים את ספריית הסכימה של החדר. מידע נוסף זמין במאמר בנושא הגדרת מיקום הסכימה באמצעות הפלאגין Room Gradle.

room {
    schemaDirectory("$projectDir/schemas")
}

הגדרת סוגי מסדי נתונים

צריך ליצור מחלקת מסד נתונים עם ההערה @Database, יחד עם DAOs וישויות בתוך קבוצת מקורות משותפת של מודול KMP משותף. אם תמקמו את המחלקות האלה במקורות משותפים, תוכלו לשתף אותן בכל פלטפורמות היעד.

// shared/src/commonMain/kotlin/Database.kt

@Database(entities = [TodoEntity::class], version = 1)
@ConstructedBy(AppDatabaseConstructor::class)
abstract class AppDatabase : RoomDatabase() {
  abstract fun getDao(): TodoDao
}

// The Room compiler generates the `actual` implementations.
@Suppress("KotlinNoActualForExpect")
expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase> {
    override fun initialize(): AppDatabase
}

כשמצהירים על אובייקט expect באמצעות הממשק RoomDatabaseConstructor, קומפיילר Room יוצר את ההטמעות של actual. יכול להיות שתופיע האזהרה הבאה ב-Android Studio, שאפשר להשבית אותה באמצעות @Suppress("KotlinNoActualForExpect"):

Expected object 'AppDatabaseConstructor' has no actual declaration in module`

אחר כך, מגדירים ממשק DAO חדש או מעבירים ממשק קיים אל commonMain:

// shared/src/commonMain/kotlin/TodoDao.kt

@Dao
interface TodoDao {
  @Insert
  suspend fun insert(item: TodoEntity)

  @Query("SELECT count(*) FROM TodoEntity")
  suspend fun count(): Int

  @Query("SELECT * FROM TodoEntity")
  fun getAllAsFlow(): Flow<List<TodoEntity>>
}

מגדירים או מעבירים את הישויות אל commonMain:

// shared/src/commonMain/kotlin/TodoEntity.kt

@Entity
data class TodoEntity(
  @PrimaryKey(autoGenerate = true) val id: Long = 0,
  val title: String,
  val content: String
)
באמצעות שאילתות נוספות במערכי מקור ספציפיים לפלטפורמה.

יצירת כלי לבניית מסדי נתונים שספציפיים לפלטפורמה

צריך להגדיר כלי ליצירת מסד נתונים כדי ליצור מופע של Room בכל פלטפורמה. זה החלק היחיד ב-API שצריך להגדיר בערכות מקור ספציפיות לפלטפורמה, בגלל ההבדלים בממשקי API של מערכת הקבצים.

Android

ב-Android, בדרך כלל מקבלים את המיקום של מסד הנתונים באמצעות Context.getDatabasePath API. כדי ליצור את מופע מסד הנתונים, מציינים Context ואת הנתיב של מסד הנתונים.

// shared/src/androidMain/kotlin/Database.android.kt

fun getDatabaseBuilder(context: Context): RoomDatabase.Builder<AppDatabase> {
  val appContext = context.applicationContext
  val dbFile = appContext.getDatabasePath("my_room.db")
  return Room.databaseBuilder<AppDatabase>(
    context = appContext,
    name = dbFile.absolutePath
  )
}

iOS

כדי ליצור את מופע מסד הנתונים ב-iOS, צריך לספק נתיב למסד הנתונים באמצעות NSFileManager, שנמצא בדרך כלל ב-NSDocumentDirectory.

// shared/src/iosMain/kotlin/Database.ios.kt

fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
    val dbFilePath = documentDirectory() + "/my_room.db"
    return Room.databaseBuilder<AppDatabase>(
        name = dbFilePath,
    )
}

private fun documentDirectory(): String {
  val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
    directory = NSDocumentDirectory,
    inDomain = NSUserDomainMask,
    appropriateForURL = null,
    create = false,
    error = null,
  )
  return requireNotNull(documentDirectory?.path)
}

מחשב JVM

כדי ליצור את מופע מסד הנתונים, צריך לספק נתיב למסד הנתונים באמצעות ממשקי API של Java או Kotlin.

// shared/src/jvmMain/kotlin/Database.desktop.kt

fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
    val dbFile = File(System.getProperty("java.io.tmpdir"), "my_room.db")
    return Room.databaseBuilder<AppDatabase>(
        name = dbFile.absolutePath,
    )
}

יצירת מופע של מסד הנתונים

אחרי שמקבלים את RoomDatabase.Builder מאחד מהבנאים הספציפיים לפלטפורמה, אפשר להגדיר את שאר מסד הנתונים של Room בקוד משותף יחד עם יצירת המופע של מסד הנתונים בפועל.

// shared/src/commonMain/kotlin/Database.kt

fun getRoomDatabase(
    builder: RoomDatabase.Builder<AppDatabase>
): AppDatabase {
  return builder
      .setDriver(BundledSQLiteDriver())
      .setQueryCoroutineContext(Dispatchers.IO)
      .build()
}

בחירת דרייבר SQLite

בקטע הקוד הקודם, הפונקציה setDriver builder נקראת כדי להגדיר באיזה דרייבר של SQLite מסד הנתונים של Room צריך להשתמש. הדרייברים האלה שונים בהתאם לפלטפורמת היעד. בדוגמאות הקודמות השתמשנו ב-BundledSQLiteDriver. זהו מנהל ההתקן המומלץ שכולל SQLite שעבר קומפילציה מהמקור, ומספק את הגרסה העדכנית והעקבית ביותר של SQLite בכל הפלטפורמות.

אם רוצים להשתמש ב-SQLite שסופק על ידי מערכת ההפעלה, צריך להשתמש ב-API ‏setDriver במערכי המקור הספציפיים לפלטפורמה שמציינים מנהל התקן ספציפי לפלטפורמה. במאמר הטמעות של מנהלי התקנים מפורטים תיאורים של הטמעות זמינות של מנהלי התקנים. אפשר להשתמש באחת מהאפשרויות הבאות:

כדי להשתמש ב-NativeSQLiteDriver, צריך לספק אפשרות לקישור -lsqlite3 כדי שאפליקציית iOS תקושר באופן דינמי ל-SQLite של המערכת.

// shared/build.gradle.kts

kotlin {
    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64()
    ).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "TodoApp"
            isStatic = true
            // Required when using NativeSQLiteDriver
            linkerOpts.add("-lsqlite3")
        }
    }
}

הגדרת הקשר של קורוטינה

אפשר להגדיר אובייקט RoomDatabase ב-Android עם מנהלי ביצוע משותפים של אפליקציות באמצעות RoomDatabase.Builder.setQueryExecutor כדי לבצע פעולות במסד הנתונים.

מכיוון ש-Executors לא תואמים ל-KMP, ה-API של Room‏ setQueryExecutor לא זמין ב-commonMain. במקום זאת, צריך להגדיר את האובייקט RoomDatabase עם CoroutineContext, שאפשר להגדיר באמצעות RoomDatabase.Builder.setCoroutineContext. אם לא מגדירים הקשר, אובייקט RoomDatabase מוגדר כברירת מחדל ל-Dispatchers.IO.

הקטנה והסתרה

אם הפרויקט עבר מיניפיקציה או טשטוש, צריך לכלול את כלל ProGuard הבא כדי שספריית Room תוכל למצוא את ההטמעה שנוצרה של הגדרת מסד הנתונים:

-keep class * extends androidx.room3.RoomDatabase { <init>(); }

העברה ל-Kotlin Multiplatform

‫Room פותחה במקור כספריית Android, ולאחר מכן הועברה ל-KMP עם התמקדות בתאימות ל-API. גרסת KMP של Room שונה מעט בין הפלטפורמות ומהגרסה הספציפית ל-Android. ההבדלים האלה מפורטים ומתוארים בהמשך.

מעבר מ-Support SQLite ל-SQLite Driver

כל השימושים ב-SupportSQLiteDatabase ובממשקי API אחרים ב-androidx.sqlite.db צריכים לעבור שינוי קוד באמצעות ממשקי SQLite Driver API, כי ממשקי ה-API ב-androidx.sqlite.db מיועדים רק ל-Android (שימו לב לחבילה השונה מחבילת KMP).

כדי לשמור על תאימות לאחור, כל עוד RoomDatabase מוגדר עם SupportSQLiteOpenHelper.Factory (לדוגמה, לא מוגדר SQLiteDriver), ‏ Room פועל ב 'מצב תאימות' שבו ממשקי ה-API של SQLite ושל SQLite Driver פועלים כמצופה. כך אפשר לבצע העברות מצטברות, ולא צריך להמיר את כל השימושים ב-Support SQLite ל-SQLite Driver בשינוי אחד.

שימוש ב-Room SQLite wrapper

ארטיפקט androidx.room3:room3-sqlite-wrapper מספק ממשקי API לגישור בין SQLiteDriver לבין SupportSQLiteDatabase במהלך המיגרציה.

כדי לקבל SupportSQLiteDatabase מ-RoomDatabase שהוגדר עם SQLiteDriver, משתמשים בפונקציית התוסף החדשה RoomDatabase.getSupportWrapper. העטיפה הזו של תאימות עוזרת לשמור על שימושים קיימים ב-SupportSQLiteDatabase, שלרוב מתקבל מ-RoomDatabase.openHelper.writableDatabase, תוך אימוץ של SQLiteDriver, במיוחד עבור בסיסי קוד עם שימושים נרחבים ב-API של SupportSQLite שרוצים להשתמש ב-BundledSQLiteDriver.

המרת מחלקות משנה של העברה

צריך להעביר את מחלקות המשנה של ההעברות למקבילות של מנהל ההתקן של SQLite:

‫Kotlin Multiplatform

מחלקות משנה של העברה

object Migration_1_2 : Migration(1, 2) {
  override fun migrate(connection: SQLiteConnection) {
    // …
  }
}

מחלקות משנה של מפרט העברה אוטומטית

class AutoMigrationSpec_1_2 : AutoMigrationSpec {
  override fun onPostMigrate(connection: SQLiteConnection) {
    // …
  }
}

‫Android בלבד

מחלקות משנה של העברה

object Migration_1_2 : Migration(1, 2) {
  override fun migrate(db: SupportSQLiteDatabase) {
    // …
  }
}

מחלקות משנה של מפרט העברה אוטומטית

class AutoMigrationSpec_1_2 : AutoMigrationSpec {
  override fun onPostMigrate(db: SupportSQLiteDatabase) {
    // …
  }
}

המרת התקשרות חוזרת למסד נתונים

צריך להעביר את הקריאות החוזרות (callback) של מסד הנתונים למקבילות שלהן בדרייבר SQLite:

‫Kotlin Multiplatform

object MyRoomCallback : RoomDatabase.Callback() {
  override fun onCreate(connection: SQLiteConnection) {
    // …
  }

  override fun onDestructiveMigration(connection: SQLiteConnection) {
    // …
  }

  override fun onOpen(connection: SQLiteConnection) {
    // …
  }
}

‫Android בלבד

object MyRoomCallback : RoomDatabase.Callback() {
  override fun onCreate(db: SupportSQLiteDatabase) {
    // …
  }

  override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
    // …
  }

  override fun onOpen(db: SupportSQLiteDatabase) {
    // …
  }
}

המרת פונקציות של @RawQuery DAO

פונקציות עם ההערה @RawQuery שעוברות קומפילציה לפלטפורמות שאינן Android צריכות להגדיר פרמטר מסוג RoomRawQuery במקום SupportSQLiteQuery.

‫Kotlin Multiplatform

הגדרת השאילתה הגולמית

@Dao
interface TodoDao {
  @RawQuery
  suspend fun getTodos(query: RoomRawQuery): List<TodoEntity>
}

לאחר מכן אפשר להשתמש ב-RoomRawQuery כדי ליצור שאילתה בזמן הריצה:

suspend fun AppDatabase.getTodosWithLowercaseTitle(title: String): List<TodoEntity> {
    val query = RoomRawQuery(
        sql = "SELECT * FROM TodoEntity WHERE title = ?",
        onBindStatement = {
            it.bindText(1, title.lowercase())
        }
    )

    return todoDao().getTodos(query)
}

‫Android בלבד

הגדרת השאילתה הגולמית

@Dao
interface TodoDao {
  @RawQuery
  suspend fun getTodos(query: SupportSQLiteQuery): List<TodoEntity>
}

לאחר מכן אפשר להשתמש ב-SimpleSQLiteQuery כדי ליצור שאילתה בזמן הריצה:

suspend fun AndroidOnlyDao.getTodosWithLowercaseTitle(title: String): List<TodoEntity> {
  val query = SimpleSQLiteQuery(
      query = "SELECT * FROM TodoEntity WHERE title = ?",
      bindArgs = arrayOf(title.lowercase())
  )
  return getTodos(query)
}

המרת פונקציות DAO לחסימה

החדר נהנה מהספרייה האסינכרונית kotlinx.coroutines העשירה בתכונות ש-Kotlin מציעה למספר פלטפורמות. כדי להבטיח פונקציונליות אופטימלית, suspend הפונקציות נאכפות ב-DAO שעברו קומפילציה בפרויקט KMP, למעט DAO שהוטמעו ב-androidMain כדי לשמור על תאימות לאחור עם בסיס הקוד הקיים. כשמשתמשים ב-Room ל-KMP, כל הפונקציות של DAO שקומפלו עבור פלטפורמות שאינן Android צריכות להיות פונקציות suspend.

‫Kotlin Multiplatform

השעיה של שאילתות

@Query("SELECT * FROM Todo")
suspend fun getAllTodos(): List<Todo>

השעיית עסקאות

@Transaction
suspend fun transaction() {  }

‫Android בלבד

חסימת שאילתות

@Query("SELECT * FROM Todo")
fun getAllTodos(): List<Todo>

חסימת עסקאות

@Transaction
fun blockingTransaction() {  }

המרת סוגים ריאקטיביים ל-Flow

לא כל הפונקציות של DAO צריכות להיות פונקציות השעיה. אין להמיר פונקציות DAO שמחזירות סוגים ריאקטיביים כמו LiveData או Flowable של RxJava לפונקציות השהיה. עם זאת, חלק מהסוגים, כמו LiveData, לא תואמים ל-KMP. צריך להעביר פונקציות DAO עם סוגי החזרה ריאקטיביים לזרימות של שגרות המשך.

Kotlin Multiplatform

סוגים של תגובות Flows

@Query("SELECT * FROM Todo")
fun getTodosFlow(): Flow<List<Todo>>

‫Android בלבד

סוגים ריאקטיביים כמו LiveData או Flowable של RxJava

@Query("SELECT * FROM Todo")
fun getTodosLiveData(): LiveData<List<Todo>>

המרת ממשקי API של עסקאות

ממשקי API של טרנזקציות במסד נתונים עבור Room KMP יכולים להבחין בין טרנזקציות של כתיבה (useWriterConnection) לבין טרנזקציות של קריאה (useReaderConnection).

Kotlin Multiplatform

val database: RoomDatabase = 
database.useWriterConnection { transactor ->
  transactor.immediateTransaction {
    // perform database operations in transaction
  }
}

‫Android בלבד

val database: RoomDatabase = 
database.withTransaction {
  // perform database operations in transaction
}

כתיבת עסקאות

משתמשים בעסקאות כתיבה כדי לוודא שכמה שאילתות כותבות נתונים באופן אטומי, כדי שקוראים יוכלו לגשת לנתונים באופן עקבי. אפשר לעשות את זה באמצעות useWriterConnection עם כל אחד משלושת סוגי העסקאות:

  • immediateTransaction: במצב כתיבה מראש ביומן (WAL) (ברירת מחדל), סוג הטרנזקציה הזה מקבל נעילה כשהוא מתחיל, אבל פעולות קריאה יכולות להמשיך. זו האפשרות המועדפת ברוב המקרים.

  • deferredTransaction: העסקה לא תקבל נעילה עד להצהרת הכתיבה הראשונה. אפשר להשתמש בסוג הזה של טרנזקציה כאופטימיזציה כשלא בטוחים אם יהיה צורך בפעולת כתיבה בתוך הטרנזקציה. לדוגמה, אם מתחילים טרנזקציה למחיקת שירים מפלייליסט רק לפי שם הפלייליסט, והפלייליסט לא קיים, לא צריך לבצע פעולת כתיבה (מחיקה).

  • exclusiveTransaction: המצב הזה מתנהג באופן זהה ל-immediateTransaction במצב WAL. במצבי יומן אחרים, היא מונעת מחיבורים אחרים למסד הנתונים לקרוא את מסד הנתונים בזמן שהטרנזקציה מתבצעת.

קריאת עסקאות

משתמשים בעסקאות קריאה כדי לקרוא מהמסד נתונים כמה פעמים באופן עקבי. לדוגמה, כשמבצעים שתי שאילתות נפרדות או יותר ולא משתמשים בסעיף JOIN. אפשר להשתמש בחיבורים לקוראים רק לעסקאות שנדחות. ניסיון להתחיל טרנזקציה מיידית או בלעדית בחיבור של קורא יגרום להקפיץ הודעת שגיאה (throw), כי אלה נחשבות פעולות 'כתיבה'.

val database: RoomDatabase = 
database.useReaderConnection { transactor ->
  transactor.deferredTransaction {
      // perform database operations in transaction
  }
}

לא זמין ב-Kotlin Multiplatform

חלק מהממשקי ה-API שהיו זמינים ל-Android לא זמינים ב-Kotlin Multiplatform.

התקשרות חזרה לשאילתה

ממשקי ה-API הבאים להגדרת קריאות חוזרות לשאילתות לא זמינים ב-common ולכן לא זמינים בפלטפורמות אחרות מלבד Android.

  • RoomDatabase.Builder.setQueryCallback
  • RoomDatabase.QueryCallback

אנחנו מתכננים להוסיף תמיכה בהחזרת קריאה של שאילתה בגרסה עתידית של Room.

ממשק ה-API להגדרת RoomDatabase עם קריאה חוזרת (callback) לשאילתה RoomDatabase.Builder.setQueryCallback יחד עם ממשק הקריאה החוזרת RoomDatabase.QueryCallback לא זמינים בדרך כלל, ולכן הם לא זמינים בפלטפורמות אחרות מלבד Android.

סגירה אוטומטית של מסד נתונים

ה-API שמאפשר סגירה אוטומטית אחרי זמן קצוב לתפוגה,‏ RoomDatabase.Builder.setAutoCloseTimeout, זמין רק ב-Android ולא בפלטפורמות אחרות.

מסד נתונים ארוז מראש

ממשקי ה-API הבאים ליצירת RoomDatabase באמצעות מסד נתונים קיים (כלומר, מסד נתונים מוכן מראש) לא זמינים בדרך כלל, ולכן הם לא זמינים בפלטפורמות אחרות מלבד Android. ממשקי ה-API האלה הם:

  • RoomDatabase.Builder.createFromAsset
  • RoomDatabase.Builder.createFromFile
  • RoomDatabase.Builder.createFromInputStream
  • RoomDatabase.PrepackagedDatabaseCallback

אנחנו מתכוונים להוסיף תמיכה במסדי נתונים ארוזים מראש בגרסה עתידית של Room.

ביטול תוקף של כמה מופעים במקביל

ה-API להפעלת ביטול תוקף של כמה מופעים במקביל, RoomDatabase.Builder.enableMultiInstanceInvalidation, זמין רק ב-Android ולא בפלטפורמות נפוצות או אחרות.