Archive for August 27, 2021

Migrating Your iOS App’s Realm Schema in Production

Introduction

Murphy’s law dictates that as soon as your mobile app goes live, you’ll receive a request to add a new feature. Then another. Then another.

This is fine if these features don’t require any changes to your data schema. But, that isn’t always the case.

Fortunately, Realm has built-in functionality to make schema migration easier.

This tutorial will step you through updating an existing mobile app to add some new features that require changes to the schema. In particular, we’ll look at the Realm migration code that ensures that no existing data is lost when the new app versions are rolled out to your production users.

We’ll use the Scrumdinger app that I modified in a previous post to show how Apple’s sample Swift app could be ported to Realm. The starting point for the app can be found in this branch of our Scrumdinger repo and the final version is in this branch.

Note that the app we’re using for this post doesn’t use MongoDB Realm Sync. If it did, then the schema migration process would be very different—I’ll cover that in a future tutorial, but for now, you can check out the docs.

Prerequisites

This tutorial has a dependency on Realm-Cocoa 10.13.0+.

Baseline App/Realm Schema

As a reminder, the starting point for this tutorial is the “realm” branch of the Scrumdinger repo.

Animated gif of the original Scrumdinger app in action

There are two Realm model classes that we’ll extend to add new features to Scrumdinger. The first, DailyScrum, represents one scrum:

class DailyScrum: Object, ObjectKeyIdentifiable {
   @Persisted var title = ""
   @Persisted var attendeeList = RealmSwift.List<String>()
   @Persisted var lengthInMinutes = 0
   @Persisted var colorComponents: Components?
   @Persisted var historyList = RealmSwift.List<History>()


   var color: Color { Color(colorComponents ?? Components()) }
   var attendees: [String] { Array(attendeeList) }
   var history: [History] { Array(historyList) }
   ...
}

The second, History, represents the minutes of a meeting from one of the user’s scrums:

class History: EmbeddedObject, ObjectKeyIdentifiable {
   @Persisted var date: Date?
   @Persisted var attendeeList = List<String>()
   @Persisted var lengthInMinutes: Int = 0
   @Persisted var transcript: String?
   var attendees: [String] { Array(attendeeList) }
   ...
}

We can use Realm Studio to examine the contents of our Realm database after the DailyScrum and History objects have been created:

DailyScrum data shown in RealmStudio

History data shown in RealmStudio

Accessing Realm Data on iOS Using Realm Studio explains how to locate and open the Realm files from your iOS simulator.

Schema Change #1—Mark Scrums as Public/Private

The first new feature we’ve been asked to add is a flag to indicate whether each scrum is public or private:

Screen capture highlighting the new status - set to "Private"

Screen capture showing the user setting the meeting status to "Public"

This feature requires the addition of a new Bool named isPublic to DailyScrum:

class DailyScrum: Object, ObjectKeyIdentifiable {
   @Persisted var title = ""
   @Persisted var attendeeList = RealmSwift.List<String>()
   @Persisted var lengthInMinutes = 0
   @Persisted var isPublic = false
   @Persisted var colorComponents: Components?
   @Persisted var historyList = RealmSwift.List<History>()


   var color: Color { Color(colorComponents ?? Components()) }
   var attendees: [String] { Array(attendeeList) }
   var history: [History] { Array(historyList) }
   ...
}

Remember that our original version of Scrumdinger is already in production, and the embedded Realm database is storing instances of DailyScrum. We don’t want to lose that data, and so we must migrate those objects to the new schema when the app is upgraded.

Fortunately, Realm has built-in functionality to automatically handle the addition and deletion of fields. When adding a field, Realm will use a default value (e.g., 0 for an Int, and false for a Bool).

If we simply upgrade the installed app with the one using the new schema, then we’ll get a fatal error. That’s because we need to tell Realm that we’ve updated the schema. We do that by setting the schema version to 1 (the version defaulted to 0 for the original schema):

@main
struct ScrumdingerApp: SwiftUI.App {
   var body: some Scene {
       WindowGroup {
           NavigationView {
               ScrumsView()
                   .environment(\.realmConfiguration,
                       Realm.Configuration(schemaVersion: 1))
           }
       }
   }
}

After upgrading the app, we can use Realm Studio to confirm that our DailyScrum object has been updated to initialize isPublic to false:

RealmStudio showing that the new, isPublic field has been initialised to false

Schema Change #2—Store The Number of Attendees at Each Meeting

The second feature request is to show the number of attendees in the history from each meeting:

Screen capture showing that the number of attendees is now displayed in the meeting minutes

We could calculate the count every time that it’s needed, but we’ve decided to calculate it just once and then store it in our History object in a new field named numberOfAttendees:

class History: EmbeddedObject, ObjectKeyIdentifiable {
   @Persisted var date: Date?
   @Persisted var attendeeList = List<String>()
   @Persisted var numberOfAttendees = 0
   @Persisted var lengthInMinutes: Int = 0
   @Persisted var transcript: String?
   var attendees: [String] { Array(attendeeList) }
   ...
}

We increment the schema version to 2. Note that the schema version applies to all Realm objects, and so we have to set the version to 2 even though this is the first time that we’ve changed the schema for History.

If we leave it to Realm to initialize numberOfAttendees, then it will set it to 0—which is not what we want. Instead, we provide a migrationBlock which initializes new fields based on the old schema version:

@main
struct ScrumdingerApp: SwiftUI.App {
   var body: some Scene {
       WindowGroup {
           NavigationView {
               ScrumsView()
                   .environment(\.realmConfiguration, Realm.Configuration(
                       schemaVersion: 2,
                       migrationBlock: { migration, oldSchemaVersion in
                            if oldSchemaVersion < 1 {
                                // Could init the `DailyScrum.isPublic` field here, but the default behavior of setting
                                // it to `false` is what we want.
                            }
                            if oldSchemaVersion < 2 {
                                migration.enumerateObjects(ofType: History.className()) { oldObject, newObject in
                                    let attendees = oldObject!["attendeeList"] as? RealmSwift.List<DynamicObject>
                                    newObject!["numberOfAttendees"] = attendees?.count ?? 0
                                }
                            }
                            if oldSchemaVersion < 3 {
                                // TODO: This is where you'd add you're migration code to go from version
                                // to version 3 when you next modify the schema
                            }
                        }
                   ))
           }
       }
   }
}

Note that all other fields are migrated automatically.

It’s up to you how you use data from the previous schema to populate fields in the new schema. E.g., if you wanted to combine firstName and lastName from the previous schema to populate a fullName field in the new schema, then you could do so like this:

migration.enumerateObjects(ofType: Person.className()) { oldObject, newObject in
   let firstName = oldObject!["firstName"] as! String
   let lastName = oldObject!["lastName"] as! String
   newObject!["fullName"] = "\(firstName) \(lastName)"
}

We can’t know what “old version” of the schema will be already installed on a user’s device when it’s upgraded to the latest version (some users may skip some versions,) and so the migrationBlock must handle all previous versions. Best practice is to process the incremental schema changes sequentially:

  • oldSchemaVersion < 1 : Process the delta between v0 and v1
  • oldSchemaVersion < 2 : Process the delta between v1 and v2
  • oldSchemaVersion < 3 : Process the delta between v2 and v3

Realm Studio shows that our code has correctly initialized numberOfAttendees:

Realm Studio showing that the numberOfAttendees field has been set to 2 – matching the number of attendees in the meeting history

Conclusion

It’s almost inevitable that any successful mobile app will need some schema changes after it’s gone into production. Realm makes adapting to those changes simple, ensuring that users don’t lose any of their existing data when upgrading to new versions of the app.

For changes such as adding or removing fields, all you need to do as a developer is to increment the version with each new deployed schema. For more complex changes, you provide code that computes the values for fields in the new schema using data from the old schema.

This tutorial stepped you through adding two new features that both required schema changes. You can view the final app in the new-schema branch of the Scrumdinger repo.

Next Steps

This post focussed on schema migration for an iOS app. You can find some more complex examples in the repo.

If you’re working with an app for a different platform, then you can find instructions in the docs:

If you’ve any questions about schema migration, or anything else related to Realm, then please post them to our community forum.