העברת חדר ל-Kotlin Multiplatform

במסמך הזה מוסבר איך להעביר הטמעה של חדר קיים למכשיר אחד שמשתמש ב-Kotlin Multiplatform (KMP).

העברת שימושים בחדרים ב-codebase קיים של Android ל-KMP משותף רמת הקושי של המודול יכולה להיות שונה בהתאם לממשקי ה-API של החדר שבהם משתמשים ה-codebase כבר משתמש ב-Coroutines. בקטע הזה אנחנו מציעים כמה הנחיות וטיפים כשמנסים להעביר את השימושים ב'חדר' למודול משותף.

חשוב להכיר קודם את ההבדלים והחסרונות בין גרסת Android של Room לבין גרסת KMP, בתהליך ההגדרה. בעיקרון, העברה מוצלחת כוללת ארגון מחדש שימושים בממשקי ה-API של SupportSQLite* והחלפתם בממשקי ה-API של SQLite Driver API וגם הצהרות לגבי החדר (@Database כיתה עם הערות, DAOs, וכן הלאה) לקוד משותף.

לפני שממשיכים, חשוב לבדוק את המידע הבא:

בחלקים הבאים מתוארים השלבים השונים שנדרשים כדי להצליח מיגרציה.

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

ממשקי ה-API ב-androidx.sqlite.db מיועדים ל-Android בלבד, וכל השימושים צריכים להיות ארגון מחדש באמצעות ממשקי SQLite Driver APIs. לתאימות לאחור, וכל עוד RoomDatabase מוגדר באמצעות SupportSQLiteOpenHelper.Factory (כלומר לא הוגדר SQLiteDriver), אבל החדר פועל ב'מצב תאימות' איפה התמיכה ב-SQLite וגם בממשקי ה-API של SQLite פועלים כצפוי. הפעולה הזאת מאפשרת בהעברות הדרגתיות, כך שלא תצטרכו להמיר את כל רמות התמיכה ב-SQLite לשימושים ב-SQLite Driver בשילוב אחד.

הדוגמאות הבאות הן שימושים נפוצים ב-Support SQLite וב-SQLite שלהם. מקבילים של נהגים:

תמיכה ב-SQLite (מ-)

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

val database: SupportSQLiteDatabase = ...
database.execSQL("ALTER TABLE ...")

הרצת שאילתה עם תוצאה אבל ללא ארגומנטים

val database: SupportSQLiteDatabase = ...
database.query("SELECT * FROM Pet").use { cursor ->
  while (cusor.moveToNext()) {
    // read columns
    cursor.getInt(0)
    cursor.getString(1)
  }
}

הרצת שאילתה עם תוצאה וארגומנטים

database.query("SELECT * FROM Pet WHERE id = ?", id).use { cursor ->
  if (cursor.moveToNext()) {
    // row found, read columns
  } else {
    // row not found
  }
}

מנהל התקן SQLite (אל)

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

val connection: SQLiteConnection = ...
connection.execSQL("ALTER TABLE ...")

הרצת שאילתה עם תוצאה אבל ללא ארגומנטים

val connection: SQLiteConnection = ...
connection.prepare("SELECT * FROM Pet").use { statement ->
  while (statement.step()) {
    // read columns
    statement.getInt(0)
    statement.getText(1)
  }
}

הרצת שאילתה עם תוצאה וארגומנטים

connection.prepare("SELECT * FROM Pet WHERE id = ?").use { statement ->
  statement.bindInt(1, id)
  if (statement.step()) {
    // row found, read columns
  } else {
    // row not found
  }
}

ממשקי API לעסקאות של מסדי נתונים זמינים ישירות ב-SupportSQLiteDatabase עם beginTransaction(), setTransactionSuccessful() וגם endTransaction(). הן זמינות גם דרך 'חדר' באמצעות runInTransaction(). העברת האפשרויות האלה שימושים בממשקי ה-API של מנהל התקן SQLite.

תמיכה ב-SQLite (מ-)

ביצוע עסקה (באמצעות RoomDatabase)

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

ביצוע עסקה (באמצעות SupportSQLiteDatabase)

val database: SupportSQLiteDatabase = ...
database.beginTransaction()
try {
  // perform database operations in transaction
  database.setTransactionSuccessful()
} finally {
  database.endTransaction()
}

מנהל התקן SQLite (אל)

ביצוע עסקה (באמצעות RoomDatabase)

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

ביצוע עסקה (באמצעות SQLiteConnection)

val connection: SQLiteConnection = ...
connection.execSQL("BEGIN IMMEDIATE TRANSACTION")
try {
  // perform database operations in transaction
  connection.execSQL("END TRANSACTION")
} catch(t: Throwable) {
  connection.execSQL("ROLLBACK TRANSACTION")
}

בנוסף, צריך להעביר לנהגים המקבילים שלהם הגדרות שונות של קריאה חוזרת:

תמיכה ב-SQLite (מ-)

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

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

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

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

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

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

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

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

מנהל התקן SQLite (אל)

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

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

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

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

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

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

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

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

לסיכום, צריך להחליף את השימושים של SQLiteDatabase ב-SQLiteConnection כאשר הפונקציה RoomDatabase לא זמינה, למשל במסגרת ביטולי קריאה חוזרת (onMigrate, onCreate, וכו'). אם RoomDatabase זמין, אפשר לגשת אל חיבור למסד הנתונים באמצעות RoomDatabase.useReaderConnection RoomDatabase.useWriterConnection במקום RoomDatabase.openHelper.writtableDatabase

המרת פונקציות DAO החוסמות כדי להשעות פונקציות

גרסת KMP של Room מסתמכת על קורוטינים לביצוע קלט/פלט (I/O) פעולות ב-CoroutineContext שהוגדר. המשמעות היא צריך להעביר את כל פונקציות DAO החוסמות כדי להשעות פונקציות.

חסימת פונקציית DAO (מ-)

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

השעיית פונקציית DAO (אל)

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

ניתן להעביר פונקציות קיימות של חסימת DAO לפונקציות להשעות משהו מורכב יותר אם ב-codebase הקיים לא משולבים קורוטינים. כדי להתחיל להשתמש בקורוטינים, מומלץ לעיין בקורוטינים ב-Android ב-codebase.

המרת סוגי החזרה תגובתית לזרימה

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

סוג KMP לא תואם (מאת)

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

סוג KMP תואם (אל)

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

כדי להתחיל להשתמש ב-Flow ב-Android, קראו את המאמר זרימה ב-Android ב-codebase.

הגדרת הקשר קורטיני (אופציונלי)

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

הגדרת מנהל התקן SQLite

ברגע שמשתמשי התמיכה ב-SQLite יועברו לממשקי ה-API של SQLite Driver, יש להגדיר את מנהל ההתקן באמצעות RoomDatabase.Builder.setDriver. הנהג המומלץ הוא BundledSQLiteDriver. למידע נוסף, אפשר לעיין בהטמעות מנהלי ההתקנים. תיאורים של ההטמעות הזמינות של מנהלי התקנים.

SupportSQLiteOpenHelper.Factory בהתאמה אישית הוגדר באמצעות ב-RoomDatabase.Builder.openHelperFactory() אין תמיכה ב-KMP, תכונות שמסופקות על ידי העוזר הדיגיטלי הפתוח בהתאמה אישית יזדקקו להטמעה מחדש עם ממשקי SQLite Driver.

הצהרות לגבי חדרי העברה

אחרי שרוב שלבי ההעברה יושלמו, אפשר יהיה להעביר את החדר להגדרות קבוצת מקור משותפת. לתשומת ליבך: expect מתוך actual שיטות יכול לשמש להעברה מצטברת של הגדרות שקשורות לחדר. לדוגמה, אם לא כולן אפשר להעביר פונקציות DAO כדי להשעות פונקציות, להצהיר על ממשק expect עם הערות @Dao שהוא ריק בקוד משותף, אבל מכילה פונקציות חסימה ב-Android.

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

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

@Dao
interface TodoDao {
    @Query("SELECT count(*) FROM TodoEntity")
    suspend fun count(): Int
}

@Dao
expect interface BlockingTodoDao
// shared/src/androidMain/kotlin/BlockingTodoDao.kt

@Dao
actual interface BlockingTodoDao {
    @Query("SELECT count(*) FROM TodoEntity")
    fun count(): Int
}