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

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

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

כדי להגדיר את Room בפרויקט KMP, מוסיפים את יחסי התלות של פריטי המידע שנוצרו בתהליך הפיתוח (Artifact) בקובץ build.gradle.kts של מודול ה-KMP.

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

[versions]
room3 = "3.1.0-alpha03"
sqlite = "2.7.1"
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 כדי להגדיר את הסכימות של Room ואת הפלאגין KSP.

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 לבלוק dependencies ברמה הבסיסית (root). צריך להוסיף את כל היעדים שהאפליקציה משתמשת בהם. מידע נוסף זמין במאמר 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. מידע נוסף זמין במאמר הגדרת מיקום הסכימה באמצעות פלאגין Room Gradle.

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

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

צריך ליצור מחלקת מסד נתונים עם הערה @Database, יחד עם אובייקטים של DAO וישויות בתוך קבוצת המקורות המשותפת של מודול ה-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, בדרך כלל מקבלים את מיקום מסד הנתונים באמצעות ה-API של Context.getDatabasePath. כדי ליצור את המכונה של מסד הנתונים, מציינים את 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,
    )
}

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

JVM desktop

כדי ליצור את המכונה של מסד הנתונים, צריך לציין נתיב למסד הנתונים באמצעות ממשקי 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

קטע הקוד הקודם קורא לפונקציית ה-builder‏ setDriver כדי להגדיר באיזה מנהל התקן של 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 כדי לבצע פעולות במסד הנתונים.

מכיוון שמבצעי משימות לא תואמים ל-KMP, ה-API של setQueryExecutor ב-Room לא זמין ב-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 צריך לעבור refactoring באמצעות ממשקי SQLite Driver API, כי ממשקי ה-API ב-androidx.sqlite.db מיועדים ל-Android בלבד (שימו לב שהחבילה שונה מחבילת KMP).

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

שימוש ב-Room SQLite wrapper

רכיב ה-artifact של 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 suspend fun migrate(connection: SQLiteConnection) {
        // ...
    }
}

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

class AutoMigrationSpec_1_2 : AutoMigrationSpec {
    override suspend 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) של מסד נתונים

צריך להעביר את פונקציות ה-callback של מסד הנתונים לפונקציות המקבילות של מנהל ההתקן של SQLite:

Kotlin Multiplatform

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

    override suspend fun onDestructiveMigration(connection: SQLiteConnection) {
        // ...
    }

    override suspend 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 getDao().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 חוסמות

Room נהנית מהספרייה האסינכרונית 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 עם סוגי החזרה ריאקטיביים לתהליכי coroutine.

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 = getRoomDatabase(builder)
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. בחיבורים לקורא כרטיסים אפשר לבצע רק עסקאות מושהות. ניסיון להתחיל טרנזקציה מיידית או בלעדית בחיבור לקריאה בלבד יגרום להחרגה, כי הפעולות האלה נחשבות לפעולות "כתיבה".

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

לא זמין ב-Kotlin Multiplatform

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

קריאה חוזרת (callback) של שאילתה

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

  • RoomDatabase.Builder.setQueryCallback
  • RoomDatabase.QueryCallback

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

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

מסד נתונים מוכן מראש

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

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

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

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