头图

文章展示源码只关注 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 数据库的使用方法,为实际项目的开发提供参考。


GeekCat
819 声望15 粉丝