跨平臺資料庫 Realm 整合實踐

GeekCat發表於2024-11-04

文章展示原始碼只關注 realm 部分,為了清晰的表達核心主旨也做了相應修改,所有完整原始碼都可以在 https://github.com/CrossPaste/crosspaste-desktop 找到。

Realm 資料庫簡介

Realm 是一個現代化的移動資料庫引擎,專為移動和跨平臺應用設計。不同於傳統的 SQLite,它採用了物件導向的資料模型,提供了更簡單直觀的 API。Realm 最初由 Y Combinator 孵化,後被 MongoDB 收購,目前作為 MongoDB 產品線的重要組成部分。

Realm 的核心優勢

  1. 跨平臺支援

    • 支援 Android、iOS、Windows、macOS 和 Linux
    • 提供統一的 API,降低多平臺開發成本
    • 使用 Kotlin Multiplatform 可實現程式碼共享
  2. 高效能

    • 採用零複製架構,直接在記憶體對映檔案上操作
    • 支援懶載入,按需獲取資料
    • 相比 SQLite,在大多數場景下有更好的效能表現
  3. 實時同步

    • 支援資料實時監聽和自動更新
    • 提供細粒度的變更通知
    • 支援跨執行緒資料同步
  4. 易用性

    • 物件導向的資料模型,無需編寫 SQL
    • 自動資料持久化
    • 簡單直觀的 CRUD API

基於這些優點 CrossPaste 選擇了使用 Realm 作為客戶端的儲存方案。接下來讓我們看看如何在 Compose Multiplatform 專案中整合 Realm 資料庫。

環境配置與初始化

  1. 新增 Realm Gradle 外掛和依賴庫

gradle/versions.toml

[versions]
realm = "3.0.0"

[libraries]
realm-kotlin-base = { module = "io.realm.kotlin:library-base", version.ref = "realm" }

[plugins]
realmKotlin = { id = "io.realm.kotlin", version.ref = "realm" }

composeApp/build.gradle.kts

plugins {
    alias(libs.plugins.realmKotlin)
}

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(libs.realm.kotlin.base)
        }
    }
}
  1. 初始化資料庫

在初始化資料庫前,我們需要提供資料庫的初始化配置 RealmConfiguration

fun createRealmConfig(path: Path): RealmConfiguration {
   return RealmConfiguration.Builder(DTO_TYPES + SIGNAL_TYPES + PASTE_TYPES + TASK_TYPES)
       .directory(path.toString())
       .name(NAME)
       .schemaVersion(SCHEMA_VALUE)
       .build()
}

其中,DTO_TYPES + SIGNAL_TYPES + PASTE_TYPES + TASK_TYPES 是我們定義的資料庫模型集合(相對與關聯式資料庫可以類比於定義的表結構),NAME 是資料庫的儲存檔名,SCHEMA_VALUE 是資料庫的版本號(後續我們會講解何時我們需要升級資料庫 schema 版本)。

class RealmManager private constructor(private val config: RealmConfiguration) {

   val realm: Realm by lazy {
      createRealm()
   }
   
   private fun createRealm(): Realm {
      try {
         return Realm.open(config)
      } finally {
         logger.info { "RealmManager createRealm - ${config.path}" }
      }
   }
   
   fun close() {
      realm.close()
   }
}

RealmManager 是我們的資料庫管理類,透過 Realm.open(config) 建立一個 Realm 例項,在關閉應用或不再使用資料庫時透過 realm.close() 關閉資料庫。

資料模型設計

資料型別

Realm 支援以下 Kotlin 資料型別,可以定義為必選或可選(nullable)

Kotlin Data TypeRequiredOptional
Stringvar stringReq: String = ""var stringOpt: String? = null
Bytevar byteReq: Byte = 0var byteOpt: Byte? = null
Shortvar shortReq: Short = 0var shortOpt: Short? = null
Intvar intReq: Int = 0var intOpt: Int? = null
Longvar longReq: Long = 0Lvar longOpt: Long? = null
Floatvar floatReq: Float = 0.0fvar floatOpt: Float? = null
Doublevar doubleReq: Double = 0.0var doubleOpt: Double? = null
Booleanvar boolReq: Boolean = falsevar boolOpt: Boolean? = null
Charvar charReq: Char = 'a'var charOpt: Char? = null

支援的 MongoDB BSON 資料型別

  • ObjectId:MongoDB 特有的 BSON 型別,是一個 12 位元組的全域性唯一值,可用作物件識別符號。它可以為空、可索引,並可用作主鍵。
MongoDB BSON TypeRequiredOptional
ObjectIdvar objectIdReq: ObjectId = ObjectId()var objectIdOpt: ObjectId? = null
Decimal128var decimal128Req: Decimal128 = Decimal128.ZEROvar decimal128Opt: Decimal128? = null

下表列出了支援的特定於 Realm 的資料型別

  • RealmUUID: 儲存 UUID(通用唯一識別符號),相當於唯一 ID
  • RealmInstant: 儲存時間戳,類似 Java 的 Instant,但經過 Realm 最佳化
  • RealmAny: 可儲存任意型別資料,類似 Java 的 Object 型別
  • MutableRealmInt: 可在事務外修改的整數型別,主要用於計數器場景
  • RealmList: Realm 的列表型別,用於儲存一對多關係,比如一個使用者有多個訂單
  • RealmSet: 集合型別,保證元素唯一性,比如使用者的標籤集合
  • RealmDictionary: 鍵值對集合,類似 Map,用於儲存屬性-值的對映關係
  • RealmObject: Realm 物件型別,用於表示一個實體,比如 User、Order 等
  • EmbeddedRealmObject: 嵌入式物件,和主物件繫結在一起,保證了一起建立,一起刪除,比如 Address 嵌入到 User 中
Realm-Specific TypeRequiredOptional
RealmUUIDvar uuidReq: RealmUUID = RealmUUID.random()var uuidOpt: RealmUUID? = null
RealmInstantvar realmInstantReq: RealmInstant = RealmInstant.now()var realmInstantOpt: RealmInstant? = null
RealmAnyN/Avar realmAnyOpt: RealmAny? = RealmAny.nullValue()
MutableRealmIntvar mutableRealmIntReq: MutableRealmInt = MutableRealmInt.create(0)var mutableRealmIntOpt: MutableRealmInt? = null
RealmListvar listReq: RealmList<CustomObject> = realmListOf()N/A
RealmSetvar setReq: RealmSet<String> = realmSetOf()N/A
RealmDictionaryvar dictionaryReq: RealmDictionary<String> = realmDictionaryOf()N/A
RealmObjectN/Avar realmObjectPropertyOpt: CustomObject? = null
EmbeddedRealmObjectN/Avar embeddedProperty: EmbeddedObject? = null

更詳細的文件可以檢視 https://www.mongodb.com/docs/atlas/device-sdks/sdk/kotlin/rea...

PasteData 示例

以 CrossPaste 中最核心的貼上板資料為例,讓我們看看如何定義一個 Realm 資料模型:

@Serializable(with = PasteDataSerializer::class)
class PasteData : RealmObject {
    @PrimaryKey
    var id: ObjectId = ObjectId()

    @Index
    var appInstanceId: String = ""

    @Index
    var pasteId: Long = 0
    var pasteAppearItem: RealmAny? = null
    var pasteCollection: PasteCollection? = null

    @Index
    var pasteType: Int = PasteType.INVALID

    var source: String? = null

    @FullText
    @Transient
    var pasteSearchContent: String? = null

    var size: Long = 0

    @Index
    var hash: String = ""

    @Index
    @Transient
    var createTime: RealmInstant = RealmInstant.now()

    @Index
    @Transient
    var pasteState: Int = PasteState.LOADING

    var remote: Boolean = false

    @Index
    var favorite: Boolean = false

    @Serializable(with = PasteLabelRealmSetSerializer::class)
    var labels: RealmSet<PasteLabel> = realmSetOf()
}

@Serializable
@SerialName("collection")
class PasteCollection : RealmObject {

    @Serializable(with = RealmAnyRealmListSerializer::class)
    var pasteItems: RealmList<RealmAny?> = realmListOf()
}

在這個示例中,我們使用了多個註解來定義資料的特性:

  • @PrimaryKey 標記主鍵
  • @Index 標記索引欄位
  • @FullText 標記全文索引
  • @Transient 標記需要忽略序列化的欄位(這些欄位仍會被持久化)

需要注意的是,Realm 模型必須提供一個空的建構函式。Realm SDK 會基於這個建構函式建立物件,然後透過其代理機制(proxy)實現屬性的懶載入和變更追蹤。

讓我們看看兩個重要的欄位設計:

// 儲存貼上板的展現資料(它可能是文字、圖片、檔案、Html 等等)
var pasteAppearItem: RealmAny? = null
// 貼上板的資料集合(比如一個貼上板包含多個貼上板項,就好比複製 word 中的一段文字,你需要儲存帶有格式資訊:顏色字型等等,也需要儲存純文字資訊)
var pasteCollection: PasteCollection? = null

這個設計很好地展示了 Realm 與傳統關聯式資料庫的區別:

  1. 直觀的物件引用

var pasteCollection: PasteCollection? = null

  • Realm: 直接透過物件引用方式建立關係,就像普通的 Kotlin 物件引用一樣
  • 關聯式資料庫: 需要透過外來鍵(foreign key)來建立關係,比如 collection_id: Long?
  1. 多型儲存

var pasteAppearItem: RealmAny? = null

  • Realm: 使用 RealmAny 可以儲存不同型別的資料,支援執行時多型
  • 關聯式資料庫: 通常需要額外的型別欄位(type column)和多個表來實現多型,比如:

    type: String  -- 儲存具體型別
    reference_id: Long  -- 引用ID
  1. 巢狀資料結構

    class PasteCollection {
    var pasteItems: RealmList<RealmAny?> = realmListOf()
    }
  2. Realm: 支援複雜的巢狀資料結構,集合型別可以直接作為屬性
  3. 關聯式資料庫: 需要建立額外的關聯表(junction table)來儲存一對多關係

總的來說,Realm 更接近物件導向的思維方式,而傳統關聯式資料庫更偏向關係模型的思維方式。Realm 讓資料建模更自然,程式碼更簡潔,但可能在某些複雜查詢場景下不如關聯式資料庫靈活。

基礎操作

下面介紹 Realm 資料庫的常用操作:

  1. 查詢資料
// 基於主鍵查詢指定物件
fun getPasteData(id: ObjectId): PasteData? {
   return realm.query(
      PasteData::class,
      "id == $0 AND pasteState != $1",
      id,
      PasteState.DELETED,
   ).first().find()
}

// 基於索引獲取最大值
fun getMaxPasteId(): Long {
   return realm.query(PasteData::class).sort("pasteId", Sort.DESCENDING).first().find()?.pasteId ?: 0L
}

// 聚合查詢,計算貼上板的儲存大小
fun getSize(): Long {
   return realm.query(PasteData::class, "pasteState != $0", PasteState.DELETED).sum<Long>("size").find()
}
  1. 插入資料

    suspend fun createPasteData(): ObjectId {
    val pasteData =
        PasteData().apply {
            this.pasteId = pasteId
            this.pasteCollection = pasteCollection
            this.pasteType = PasteType.INVALID
            this.source = source
            this.hash = ""
            this.appInstanceId = appInfo.appInstanceId
            this.createTime = RealmInstant.now()
            this.pasteState = PasteState.LOADING
            this.remote = remote
        }
    // 開啟寫事務
    return realm.write {
       copyToRealm(pasteData)
    }.id
    }
  2. 刪除資料

    suspend fun deletePasteData(id: ObjectId) {
    realm.write { mutableRealm ->
       // 查詢並刪除指定 id 的貼上板資料
       query(PasteData::class, "id == $0", id).first().find()?.let {
           mutableRealm.delete(it)
       }
    }
    }
  3. 更新資料

    fun updateFavorite(
    id: ObjectId,
    favorite: Boolean,
    ) {
    realm.writeBlocking {
       // 查詢並更新指定 id 的貼上板收藏狀態
       query(PasteData::class, "id == $0", id).first().find()?.let {
           it.favorite = favorite
       }
    }
    }
  4. 監聽資料變化

    suspend fun listenSyncRuntimeInfo() {
    realm.query(SyncRuntimeInfo::class)
       .sort("createTime", Sort.DESCENDING)
       .find()
       .flow()
       .collect { changes: ResultsChange<SyncRuntimeInfo> ->
          when (changes) {
              is UpdatedResults -> {
                  // 處理刪除的裝置
                  for (deletion in changes.deletions) {
                      handleDeviceDeletion(deletion)
                  }
       
                  // 處理新增的裝置
                  for (insertion in changes.insertions) {
                      handleDeviceInsertion(insertion)
                  }
                  
                  // 處理更新的裝置
                  for (change in changes.changes) {
                      handleDeviceChange(change)
                  }
                  
                  // changes.list 包含最新的裝置列表
                  // 簡單場景可直接用此列表更新資料
              }
              is InitialResults -> {
                  // 初始化裝置列表
                  initializeDeviceList(changes.list)
              }
          }
    }
    }

版本管理與資料遷移

Realm 透過 schemaVersion 管理資料模型版本。對於簡單的欄位增刪(新增欄位使用預設值),只需將 schemaVersion 加 1 並重新發布應用即可。使用者更新應用後,Realm SDK 會自動完成資料庫 schema 升級。

對於複雜的資料遷移場景(如刪除欄位、修改欄位型別等),我們需要編寫遷移程式碼:

// 
val config = RealmConfiguration.Builder(schema = setOf(Person::class))
    .schemaVersion(2) // 設定當前的 schema version
    .migration(AutomaticSchemaMigration { context ->
        context.enumerate(className = "Person") { oldObject: DynamicRealmObject, newObject: DynamicMutableRealmObject? ->
            newObject?.run {
                // 修改欄位型別
                set(
                    "_id",
                    oldObject.getValue<ObjectId>(fieldName = "_id").toString()
                )
                // 合併欄位
                set(
                    "fullName",
                    "${oldObject.getValue<String>(fieldName = "firstName")} ${oldObject.getValue<String>(fieldName = "lastName")}"
                )
                // 重新命名欄位
                set(
                    "yearsSinceBirth",
                    oldObject.getValue<String>(fieldName = "age")
                )
            }
        }
    })
    .build()
val realm = Realm.open(config)

當使用者可能跨版本升級時,我們可以透過 val oldVersion = context.oldRealm.version() 獲取舊版本號,進行相應的資料遷移操作。

JSON 序列化

Realm 提供了將物件序列化為 JSON 字串的功能,這在網路傳輸和本地儲存場景中非常實用。

Realm SDK 已內建了各種 Realm 特有資料型別的序列化器,我們只需在 JSON 配置中註冊即可。對於自定義資料型別,可以將其註冊為指定型別的子類,從而實現 JSON 多型序列化:

override val JSON: Json =
   Json {
      encodeDefaults = true
      ignoreUnknownKeys = true
      serializersModule =
          SerializersModule {
              // 註冊貼上板資料相關序列化器
              serializersModuleOf(MutableRealmIntKSerializer)
              serializersModuleOf(RealmAnyKSerializer)
              polymorphic(RealmObject::class) {
                  subclass(ColorPasteItem::class)
                  subclass(FilesPasteItem::class)
                  subclass(HtmlPasteItem::class)
                  subclass(ImagesPasteItem::class)
                  subclass(RtfPasteItem::class)
                  subclass(TextPasteItem::class)
                  subclass(UrlPasteItem::class)
                  subclass(PasteLabel::class)
                  subclass(PasteCollection::class)
              }
          }
   }

當需要完全控制序列化邏輯時,我們可以透過自定義序列化器實現:

@Serializable(with = PasteDataSerializer::class)
class PasteData : RealmObject { ...  }

除錯與運維工具

Realm Studio 是一個功能強大的資料庫管理工具,它提供了以下核心功能:

  • 檢視和編輯資料
  • 匯入匯出資料
  • 執行資料查詢
  • 建立索引
  • 檢視資料庫結構

透過 Realm Studio,我們可以實時檢視資料庫狀態,這大大方便了開發除錯和運維工作。
您可以在 https://studio-releases.realm.io/ 下載對應版本的 Realm Studio。

總結

Realm 是一個功能強大的跨平臺資料庫引擎,提供了高效能、實時同步、易用性等優勢。在 Compose Multiplatform 專案中整合 Realm 資料庫,可以讓我們更加高效地處理資料儲存和管理。透過本文的介紹,希望能幫助開發者更好地理解 Realm 資料庫的使用方法,為實際專案的開發提供參考。

相關文章