Room (Kotlin Multiplatform)

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

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

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

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

[versions]
room = "2.7.2"
sqlite = "2.5.2"
ksp = "<kotlinCompatibleKspVersion>"

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

# Optional SQLite Wrapper available in version 2.8.0 and higher
androidx-room-sqlite-wrapper = { module = "androidx.room:room-sqlite-wrapper", version.ref = "room" }

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

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

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

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

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

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

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

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

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

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 שחייב להיות בקבוצות של מקורות ספציפיים לפלטפורמה, בגלל ההבדלים ב-APIs של מערכת הקבצים.

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

קטע הקוד הקודם קורא לפונקציית ה-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() כדי לבצע פעולות במסד הנתונים.

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

הקטנה וערפול קוד (obfuscation)

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

-keep class * extends androidx.room.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 בשינוי אחד.

המרת מחלקות משנה של מיגרציות

צריך להעביר מחלקות משנה של העברות למקבילות של מנהל ההתקן 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: במצב Write-Ahead Logging (WAL) (ברירת מחדל), סוג העסקה הזה מקבל נעילה כשהוא מתחיל, אבל קוראים יכולים להמשיך לקרוא. זו האפשרות המועדפת ברוב המקרים.

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

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

קריאת עסקאות

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

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

לא זמין ב-Kotlin Multiplatform

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

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

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

  • RoomDatabase.Builder.setQueryCallback
  • RoomDatabase.QueryCallback

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

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

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

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

Pre-package Database

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

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

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

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

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