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
Cross-Platform Support
- Supports Android, iOS, Windows, macOS, and Linux
- Provides unified APIs, reducing multi-platform development costs
- Enables code sharing through Kotlin Multiplatform
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
Real-time Synchronization
- Supports real-time data monitoring and automatic updates
- Provides fine-grained change notifications
- Supports cross-thread data synchronization
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
- Add Realm Gradle Plugin and Dependencies
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)
}
}
}
- Database Initialization
Before initializing the database, we need to provide the database initialization configuration 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()
}
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).
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 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 |
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 Type | Required | Optional |
---|---|---|
ObjectId | var objectIdReq: ObjectId = ObjectId() | var objectIdOpt: ObjectId? = null |
Decimal128 | var decimal128Req: Decimal128 = Decimal128.ZERO | var 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 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 |
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:
@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:
// 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:
- 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?
- 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:
type: String -- stores concrete type
reference_id: Long -- reference ID
- Nested Data Structures
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:
- Query Data
// 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()
}
- Insert Data
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
}
- Delete Data
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)
}
}
}
- Update Data
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
}
}
}
- Monitor Data Changes
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:
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:
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:
@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.