基于 Realm 的跨平台数据库集成实践
文章展示源码只关注 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/realm-database/schemas/supported-types/
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 数据库的使用方法,为实际项目的开发提供参考。