文章展示原始碼只關注 realm 部分,為了清晰的表達核心主旨也做了相應修改,所有完整原始碼都可以在 https://github.com/CrossPaste/crosspaste-desktop 找到。
Realm 資料庫簡介
Realm 是一個現代化的移動資料庫引擎,專為移動和跨平臺應用設計。不同於傳統的 SQLite,它採用了物件導向的資料模型,提供了更簡單直觀的 API。Realm 最初由 Y Combinator 孵化,後被 MongoDB 收購,目前作為 MongoDB 產品線的重要組成部分。
Realm 的核心優勢
跨平臺支援
- 支援 Android、iOS、Windows、macOS 和 Linux
- 提供統一的 API,降低多平臺開發成本
- 使用 Kotlin Multiplatform 可實現程式碼共享
高效能
- 採用零複製架構,直接在記憶體對映檔案上操作
- 支援懶載入,按需獲取資料
- 相比 SQLite,在大多數場景下有更好的效能表現
實時同步
- 支援資料實時監聽和自動更新
- 提供細粒度的變更通知
- 支援跨執行緒資料同步
易用性
- 物件導向的資料模型,無需編寫 SQL
- 自動資料持久化
- 簡單直觀的 CRUD API
基於這些優點 CrossPaste 選擇了使用 Realm 作為客戶端的儲存方案。接下來讓我們看看如何在 Compose Multiplatform 專案中整合 Realm 資料庫。
環境配置與初始化
- 新增 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)
}
}
}
- 初始化資料庫
在初始化資料庫前,我們需要提供資料庫的初始化配置 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 Type | Required | Optional |
---|---|---|
String | var stringReq: String = "" | var stringOpt: String? = null |
Byte | var byteReq: Byte = 0 | var byteOpt: Byte? = null |
Short | var shortReq: Short = 0 | var shortOpt: Short? = null |
Int | var intReq: Int = 0 | var intOpt: Int? = null |
Long | var longReq: Long = 0L | var longOpt: Long? = null |
Float | var floatReq: Float = 0.0f | var floatOpt: Float? = null |
Double | var doubleReq: Double = 0.0 | var doubleOpt: Double? = null |
Boolean | var boolReq: Boolean = false | var boolOpt: Boolean? = null |
Char | var charReq: Char = 'a' | var charOpt: Char? = null |
支援的 MongoDB BSON 資料型別
- ObjectId:MongoDB 特有的 BSON 型別,是一個 12 位元組的全域性唯一值,可用作物件識別符號。它可以為空、可索引,並可用作主鍵。
MongoDB BSON Type | Required | Optional |
---|---|---|
ObjectId | var objectIdReq: ObjectId = ObjectId() | var objectIdOpt: ObjectId? = null |
Decimal128 | var decimal128Req: Decimal128 = Decimal128.ZERO | var 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 Type | Required | Optional |
---|---|---|
RealmUUID | var uuidReq: RealmUUID = RealmUUID.random() | var uuidOpt: RealmUUID? = null |
RealmInstant | var realmInstantReq: RealmInstant = RealmInstant.now() | var realmInstantOpt: RealmInstant? = null |
RealmAny | N/A | var realmAnyOpt: RealmAny? = RealmAny.nullValue() |
MutableRealmInt | var mutableRealmIntReq: MutableRealmInt = MutableRealmInt.create(0) | var mutableRealmIntOpt: MutableRealmInt? = null |
RealmList | var listReq: RealmList<CustomObject> = realmListOf() | N/A |
RealmSet | var setReq: RealmSet<String> = realmSetOf() | N/A |
RealmDictionary | var dictionaryReq: RealmDictionary<String> = realmDictionaryOf() | N/A |
RealmObject | N/A | var realmObjectPropertyOpt: CustomObject? = null |
EmbeddedRealmObject | N/A | var 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 與傳統關聯式資料庫的區別:
- 直觀的物件引用
var pasteCollection: PasteCollection? = null
- Realm: 直接透過物件引用方式建立關係,就像普通的 Kotlin 物件引用一樣
- 關聯式資料庫: 需要透過外來鍵(foreign key)來建立關係,比如 collection_id: Long?
- 多型儲存
var pasteAppearItem: RealmAny? = null
- Realm: 使用 RealmAny 可以儲存不同型別的資料,支援執行時多型
關聯式資料庫: 通常需要額外的型別欄位(type column)和多個表來實現多型,比如:
type: String -- 儲存具體型別 reference_id: Long -- 引用ID
巢狀資料結構
class PasteCollection { var pasteItems: RealmList<RealmAny?> = realmListOf() }
- Realm: 支援複雜的巢狀資料結構,集合型別可以直接作為屬性
- 關聯式資料庫: 需要建立額外的關聯表(junction table)來儲存一對多關係
總的來說,Realm 更接近物件導向的思維方式,而傳統關聯式資料庫更偏向關係模型的思維方式。Realm 讓資料建模更自然,程式碼更簡潔,但可能在某些複雜查詢場景下不如關聯式資料庫靈活。
基礎操作
下面介紹 Realm 資料庫的常用操作:
- 查詢資料
// 基於主鍵查詢指定物件
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()
}
插入資料
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 }
刪除資料
suspend fun deletePasteData(id: ObjectId) { realm.write { mutableRealm -> // 查詢並刪除指定 id 的貼上板資料 query(PasteData::class, "id == $0", id).first().find()?.let { mutableRealm.delete(it) } } }
更新資料
fun updateFavorite( id: ObjectId, favorite: Boolean, ) { realm.writeBlocking { // 查詢並更新指定 id 的貼上板收藏狀態 query(PasteData::class, "id == $0", id).first().find()?.let { it.favorite = favorite } } }
監聽資料變化
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 資料庫的使用方法,為實際專案的開發提供參考。