Skip to content
Compose Multiplatform

Cross-Platform Database Integration Practice with Realm

The source code shown in this article focuses only on the Realm portion and has been modified for clarity. All complete source code can be found at https://github.com/CrossPaste/crosspaste-desktop.

Introduction to Realm Database

Realm is a modern mobile database engine designed for mobile and cross-platform applications. Unlike traditional SQLite, it adopts an object-oriented data model and provides simpler, more intuitive APIs. Realm was initially incubated by Y Combinator and later acquired by MongoDB, now serving as an important part of MongoDB's product line.

Core Advantages of Realm

  1. Cross-Platform Support

    • Supports Android, iOS, Windows, macOS, and Linux
    • Provides unified APIs, reducing multi-platform development costs
    • Enables code sharing through Kotlin Multiplatform
  2. High Performance

    • Uses zero-copy architecture, operating directly on memory-mapped files
    • Supports lazy loading, fetching data on demand
    • Better performance than SQLite in most scenarios
  3. Real-time Synchronization

    • Supports real-time data monitoring and automatic updates
    • Provides fine-grained change notifications
    • Supports cross-thread data synchronization
  4. Ease of Use

    • Object-oriented data model, no SQL required
    • Automatic data persistence
    • Simple and intuitive CRUD APIs

Based on these advantages, CrossPaste chose Realm as its client-side storage solution. Let's see how to integrate Realm database in a Compose Multiplatform project.

Environment Configuration and Initialization

  1. Add Realm Gradle Plugin and Dependencies

gradle/versions.toml

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

kotlin
plugins {
    alias(libs.plugins.realmKotlin)
}

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(libs.realm.kotlin.base)
        }
    }
}
  1. Database Initialization

Before initializing the database, we need to provide the database initialization configuration RealmConfiguration

kotlin
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()
}

Here, DTO_TYPES + SIGNAL_TYPES + PASTE_TYPES + TASK_TYPES is our collection of database models (comparable to table structures in relational databases), NAME is the database storage filename, and SCHEMA_VALUE is the database version number (we'll discuss when we need to upgrade database schema version later).

kotlin
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 is our database management class, creating a Realm instance through Realm.open(config) and closing the database through realm.close() when closing the application or no longer using the database.

Data Model Design

Data Types

Realm supports the following Kotlin data types, which can be defined as required or optional (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

Supported MongoDB BSON data types:

  • ObjectId: MongoDB-specific BSON type, a 12-byte globally unique value used as object identifier. It can be null, indexable, and used as a primary key.
MongoDB BSON TypeRequiredOptional
ObjectIdvar objectIdReq: ObjectId = ObjectId()var objectIdOpt: ObjectId? = null
Decimal128var decimal128Req: Decimal128 = Decimal128.ZEROvar decimal128Opt: Decimal128? = null

The following table lists supported Realm-specific data types:

  • RealmUUID: Stores UUID (Universal Unique Identifier), equivalent to unique ID
  • RealmInstant: Stores timestamps, similar to Java's Instant but optimized by Realm
  • RealmAny: Can store any type of data, similar to Java's Object type
  • MutableRealmInt: Integer type that can be modified outside transactions, mainly used for counter scenarios
  • RealmList: Realm's list type, used to store one-to-many relationships, like a user having multiple orders
  • RealmSet: Set type, ensures element uniqueness, like a user's tag collection
  • RealmDictionary: Key-value collection, similar to Map, used to store property-value mappings
  • RealmObject: Realm object type, used to represent an entity, like User, Order, etc.
  • EmbeddedRealmObject: Embedded object, bound with the main object, ensuring creation and deletion together, like Address embedded in 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

For more detailed documentation, visit https://www.mongodb.com/docs/atlas/device-sdks/sdk/kotlin/realm-database/schemas/supported-types/

PasteData Example

Let's look at how to define a Realm data model using CrossPaste's core clipboard data as an example:

kotlin
@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 = realmSetOf()
}

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

    @Serializable(with = RealmAnyRealmListSerializer::class)
    var pasteItems: RealmList = realmListOf()
}

In this example, we use multiple annotations to define data characteristics:

  • @PrimaryKey marks primary keys
  • @Index marks indexed fields
  • @FullText marks full-text indexed fields
  • @Transient marks fields to ignore during serialization (these fields will still be persisted)

Note that Realm models must provide an empty constructor. The Realm SDK will create objects based on this constructor and implement property lazy loading and change tracking through its proxy mechanism.

Let's look at two important field designs:

kotlin
// Stores clipboard display data (it could be text, image, file, HTML, etc.)
var pasteAppearItem: RealmAny? = null
// Clipboard data collection (e.g., a clipboard contains multiple clipboard items, like copying text from Word, you need to save format information: color, font, etc., and plain text information)
var pasteCollection: PasteCollection? = null

This design well demonstrates the differences between Realm and traditional relational databases:

  1. Intuitive Object References

var pasteCollection: PasteCollection? = null

  • Realm: Establishes relationships directly through object references, just like regular Kotlin object references
  • Relational Database: Needs to establish relationships through foreign keys, like collection_id: Long?
  1. Polymorphic Storage

var pasteAppearItem: RealmAny? = null

  • Realm: Uses RealmAny to store different types of data, supporting runtime polymorphism
  • Relational Database: Usually requires additional type fields and multiple tables to implement polymorphism, like:
sql
type: String  -- stores concrete type
reference_id: Long  -- reference ID
  1. Nested Data Structures
kotlin
class PasteCollection {
   var pasteItems: RealmList = realmListOf()
}
  • Realm: Supports complex nested data structures, collection types can be used directly as properties
  • Relational Database: Needs to create additional junction tables to store one-to-many relationships

Overall, Realm is closer to object-oriented thinking, while traditional relational databases lean more towards relational model thinking. Realm makes data modeling more natural and code more concise but might be less flexible than relational databases in some complex query scenarios.

Basic Operations

Here are the common operations in Realm database:

  1. Query Data
kotlin
// Query specific object based on primary key
fun getPasteData(id: ObjectId): PasteData? {
   return realm.query(
      PasteData::class,
      "id == $0 AND pasteState != $1",
      id,
      PasteState.DELETED,
   ).first().find()
}

// Get maximum value based on index
fun getMaxPasteId(): Long {
   return realm.query(PasteData::class).sort("pasteId", Sort.DESCENDING).first().find()?.pasteId ?: 0L
}

// Aggregate query, calculate clipboard storage size
fun getSize(): Long {
   return realm.query(PasteData::class, "pasteState != $0", PasteState.DELETED).sum("size").find()
}
  1. Insert Data
kotlin
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
       }
   // Start write transaction
   return realm.write {
      copyToRealm(pasteData)
   }.id
}
  1. Delete Data
kotlin
suspend fun deletePasteData(id: ObjectId) {
   realm.write { mutableRealm ->
      // Find and delete clipboard data with specified id
      query(PasteData::class, "id == $0", id).first().find()?.let {
          mutableRealm.delete(it)
      }
   }
}
  1. Update Data
kotlin
fun updateFavorite(
   id: ObjectId,
   favorite: Boolean,
) {
   realm.writeBlocking {
      // Find and update favorite status of clipboard with specified id
      query(PasteData::class, "id == $0", id).first().find()?.let {
          it.favorite = favorite
      }
   }
}
  1. Monitor Data Changes
kotlin
suspend fun listenSyncRuntimeInfo() {
   realm.query(SyncRuntimeInfo::class)
      .sort("createTime", Sort.DESCENDING)
      .find()
      .flow()
      .collect { changes: ResultsChange ->
         when (changes) {
             is UpdatedResults -> {
                 // Handle deleted devices
                 for (deletion in changes.deletions) {
                     handleDeviceDeletion(deletion)
                 }
      
                 // Handle added devices
                 for (insertion in changes.insertions) {
                     handleDeviceInsertion(insertion)
                 }
                 
                 // Handle updated devices
                 for (change in changes.changes) {
                     handleDeviceChange(change)
                 }
                 
                 // changes.list contains the latest device list
                 // For simple scenarios, you can directly update data using this list
             }
             is InitialResults -> {
                 // Initialize device list
                 initializeDeviceList(changes.list)
             }
         }
   }
}

Version Management and Data Migration

Realm manages data model versions through schemaVersion. For simple field additions/deletions (new fields with default values), just increment schemaVersion and republish the application. When users update the application, Realm SDK will automatically complete the database schema upgrade.

For complex data migration scenarios (such as deleting fields, modifying field types, etc.), we need to write migration code:

kotlin
val config = RealmConfiguration.Builder(schema = setOf(Person::class))
    .schemaVersion(2) // Set current schema version
    .migration(AutomaticSchemaMigration { context ->
        context.enumerate(className = "Person") { oldObject: DynamicRealmObject, newObject: DynamicMutableRealmObject? ->
            newObject?.run {
                // Modify field type
                set(
                    "_id",
                    oldObject.getValue<ObjectId>(fieldName = "_id").toString()
                )
                // Merge fields
                set(
                    "fullName",
                    "${oldObject.getValue<String>(fieldName = "firstName")} ${oldObject.getValue<String>(fieldName = "lastName")}"
                )
                // Rename field
                set(
                    "yearsSinceBirth",
                    oldObject.getValue<String>(fieldName = "age")
                )
            }
        }
    })
    .build()
val realm = Realm.open(config)

When users might upgrade across versions, we can get the old version number through val oldVersion = context.oldRealm.version() and perform corresponding data migration operations.

JSON Serialization

Realm provides functionality to serialize objects to JSON strings, which is very useful in network transmission and local storage scenarios. Realm SDK has built-in serializers for various Realm-specific data types, we just need to register them in JSON configuration. For custom data types, we can register them as subclasses of specified types to achieve JSON polymorphic serialization:

kotlin
override val JSON: Json =
   Json {
      encodeDefaults = true
      ignoreUnknownKeys = true
      serializersModule =
          SerializersModule {
              // Register clipboard data-related serializers
              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)
              }
          }
   }

When we need complete control over serialization logic, we can implement it through custom serializers:

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

Debugging and Operations Tools

Realm Studio is a powerful database management tool that provides the following core features:

  • View and edit data
  • Import and export data
  • Execute data queries
  • Create indexes
  • View database structure

Through Realm Studio, we can view database status in real-time, which greatly facilitates development debugging and operations work. You can download the corresponding version of Realm Studio at https://studio-releases.realm.io/.

Conclusion

Realm is a powerful cross-platform database engine that offers advantages in high performance, real-time synchronization, and ease of use. Integrating Realm database in Compose Multiplatform projects allows us to handle data storage and management more efficiently. Through this article's introduction, we hope to help developers better understand how to use Realm database and provide reference for actual project development.