EDIT: This post is kept here for archival purposes, please read the updated version in my personal blog.
Synchronizing data with CloudKit
Apple introduced CloudKit in 2014. Since then, it has received many improvements, like the ability to use it outside Apple's platforms and to use it on apps distributed outside the App Store on the Mac.
I believe CloudKit is still not being used much by developers, perhaps because of ignorance or fear. I am writing this article to try and help change that.
Update: If you’d like to listen to a conversation about CloudKit, check out iPhreaks episode #226
Can I use CloudKit?
Unfortunately, whenever talking about Apple and the cloud, the question "can I use this?" arrives. It's true that Apple doesn't have a good history when it comes to cloud services, but the good news is this doesn't seem to apply to CloudKit.
You don't have to take my word for it. Do you use Apple Notes? Photos? iCloud Drive? Activity sharing on Apple Watch? These are all powered by CloudKit and they've been working just fine for me. If they don't work for you, well, then I won’t be able to convince you that CloudKit is good. 😅
Should I use CloudKit?
Even if you're convinced CloudKit is good and works well, it doesn't mean it's the best solution for your particular problem, since there are some applications where CloudKit is the best solution and some where it's not.
Where to use CloudKit
These are the situations for which CloudKit is the most indicated:
Sync private user data between devices
This is perhaps the most obvious use for CloudKit: syncing your users' data across their devices.
Example: a note taking app where the user can create and read notes on any device associated with their iCloud account.
Alternatives: Realm Mobile Platform, Firebase, iCloud KVS, iCloud Documents, custom web app.
Store application-specific data
By using the public database on CloudKit, you can store data that's specific to your app (or a set of apps you own). Let's say for instance you have an e-commerce app and you want to change the colors of the app during Black Friday. You could store color codes in CloudKit's public database and load them every time the app is launched.
Alternatives: Realm Mobile Platform, Firebase, custom web app.
Sync user data between multiple apps from the same developer
If you have a suite of apps, they can all share the same CloudKit container so users can have access to their data for all of your apps on every device they have associated with their iCloud account.
Alternatives: Realm Mobile Platform, custom web app.
Use iCloud as an authentication mechanism
You can use CloudKit just to get the user's unique identifier and use it as an authentication method for another service.
Send notifications
You can use CloudKit to send notifications, eliminating the need to use a 3rd party service or a VPS.
Alternatives: Firebase, custom web app.
Where NOT to use CloudKit
Now we've seen some applications for which CloudKit is best suited for, let's see some examples where it's not.
Store and sync documents
If your app works primarily with documents, CloudKit is probably not the best tool for the job. In this case you'd be better of using Google Drive, DropBox, iCloud Drive or other similar services. You can store large files in CloudKit, but it might not be the best solution if you have a document-based app.
Examples: text editors like Pages, image editors like Pixelmator and Sketch.
Sync user preferences
To store simple user preferences or very small amounts of data, use iCloud KVS (NSUbiquitousKeyValueStore).
Example: your app has a simple boolean preference to show or hide a toolbar and you want this preference to sync across a user's devices. You can do this with CloudKit, but it'd be overkill.
Why not just use an alternate service like Firebase?
CloudKit is a 1st party technology which comes pre-installed on all devices, doesn't require a separate authentication besides the user's iCloud account, has powerful features (like you'll see in the rest of this article) and has a great chance of continuing to exist and be supported for the foreseeable future. These are the main reasons why I think CloudKit is a better option than most 3rd party services.
How much does it cost?
This is a very common question when talking about CloudKit and it is frequently misinterpreted by developers.
Like Craig said when introducing CloudKit:
CloudKit is free… with limits
But what does that mean?
Simply put: you're not going to be paying for CloudKit. Period.
What Apple has done is they have created a system which prevents abuse. Therefore, if you do a "regular" use of the service, it'll always be free.
From WWDC 2014, session 231:
We don’t want to prevent legitimate use.
We just don’t want anyone abusing CloudKit.
But wait, there's more! The private database consumes your user's iCloud quota, so if you're only using the private database on your app, you'll never even start to consume your app's quota.
Even if you're using the public database (which counts against your app's quota), you have very generous limits and they scale with the number of users of your app.
CloudKit in practice
With the introductory stuff out of the way, let's start coding. I'll be explaining various concepts about CloudKit with small code snippets showing how to use them in practice.
Enabling CloudKit on your project
Before we can use CloudKit, we have to enable it for our app in Xcode's capabilities panel.
When we do that, Xcode talks to Apple's servers to update our app's provisioning profile and create our app's default container.
Notice that when we enabled CloudKit, Xcode automatically enabled push notifications, that's because CloudKit's subscriptions are powered by the Apple Push Notification Service (APNS — more on this later).
Container
A container is nothing more than a little box where you put all of your users' data. The most common configuration is to have a single container per app, but you can have an app that uses multiple containers and you can also use the same container across multiple apps.
Containers are represented by instances of CKContainer.
Accessing the default container
To access your app's default container, you use the default method in CKContainer.
Creating and accessing a custom container
If you want your users to be able to access their data on more than one of your apps, perhaps an iOS and a macOS version of the same app, you must create a custom container that will be used by both apps.
The name of the container must use the reverse DNS notation. For this example I've created a container named iCloud.br.com.guilhermerambo.KitchenContainer.
To access this custom container, you just initialize a CKContainer with its identifier.
Remember that if you're using the default container, you can always just use the default method of CKContainer. I'll be using the default container in the rest of the snippets for brevity.
Database
A database is where you're going to be storing your users' data and they are represented by objects of the type CKDatabase.
Every CloudKit container has three databases:
Private Database
This is the database where you'll be storing your user's private data. Only the user can access this data through a device authenticated with their iCloud account. You as the developer can't access data in your users' private databases (although you can access your own private database for debugging).
To access the private database, you use the privateCloudDatabase instance method from CKContainer.
Public database
This is the database where you'll be storing global app data relevant to all users of the app, this data can be created by you using the CloudKit dashboard or a custom CMS or it can be data generated by your users that should be visible to other users.
Although the database is public, it's possible to restrict access to its records by using security roles, but I won't be talking about them in this article.
To access the public database, you use the publicCloudDatabase instance method from CKContainer.
Shared database
With the introduction of iOS 10 and macOS Sierra, Apple added sharing to CloudKit. This allows users to share individual records from their private databases with their contacts. The shared database is used to store these records, but we never interact with it directly.
Zone
A zone is like a directory where you store your records. All databases on CloudKit have a Default Zone. You can use the default zone to store your records but you can also create custom zones. Only the private database can have custom zones — they are not supported in the public database.
Some of CloudKit's features like saving related records in batches and sharing can only be used with custom zones. Zones are represented by objects of the type CKZone.
Record
Records are objects of the type CKRecord and are the main object you use to talk to CloudKit. A CKRecord is basically a dictionary where the keys become fields on the database's tables.
Keep in mind that even though CloudKit's databases are schemaless (you don't have to define your schema by hand), this is only true in the development environment. After you deploy your app, you can only change your schema by changing it on the development environment first and then publishing the changes to the production environment.
Supported data types
Although CKRecord is basically a dictionary, this doesn't mean you can store any type of data in CloudKit. These are the types you can use as values in CKRecord:
String: Apple recommends String for small amounts of text
NSNumber: Swift's numeric types are automatically bridged for you
Data: you could use Data to store custom objects serialized with NSCoding
Date: dates and times can be stored in CloudKit directly
CLLocation: very useful for location-based apps (more on this later)
CKAsset: used to store big files (photos and videos, for instance)
CKReference: a reference that points to another record
Besides all of the types above, any key in CKRecord can contain an array of any of the supported types, provided that it only contains elements of the same type.
Creating a Record
Lets say we're creating an app where users can register movies. We'd probably have a "Movie" record. In this case, "Movie" will be our recordType.
To create a movie record, we initialize a CKRecord:
With that object created, all we have to do is to set its properties. Inside the view controller where the user enters the movie's data, we would probably update our record when a text field text's is changed:
Now you might be asking: "what the heck is a CKRecordValue? 🤔".
CKRecordValue is a Swift protocol adopted by objects which CloudKit supports. The issue here is that when using Swift, the compiler doesn't seem to know that String conforms to CKRecordValue, so we have to do that ugly typecast.
Improving our code with a custom subscript
To improve this, when working with CloudKit I always create enums for the fields of my records and add an extension to CKRecord with a custom subscript which takes that enum. I know it sounds complicated, but it's actually really simple.
First, let's create that enum:
Now we can add a extension to CKRecord:
Now, to change the values in our records, our code will look a lot cleaner:
Remember that with this custom subscript we're still limited to the data types supported by CloudKit. If we try to set a key to an unsupported type, the field will be nil.
Another important detail: CKRecord is NOT a value type, so when you pass objects of the type CKRecord, you're passing them by reference. This means that if you have a property that is a CKRecord and you add a didSet observer to it, it will not be executed when one of its values is changed.
In "real life" you should probably be using your own models (preferably value types like structs) and converting them to/from CKRecord when dealing with CloudKit.
The entire code for this section can be found in SimpleRecordTableViewController.swift.
The CloudKit Dashboard
Now that we know how to create records on CloudKit, it'd be cool to have some means of knowing what's going on at the server when we save our records.
Apple created a tool for this, the CloudKit Dashboard. In the dashboard we have access to all of our containers, databases, record types and more.
First of all, using the sample app, let's create a movie record:
With our record created, let's go to the dashboard. The first step is to open the menu at the top-left and choose which container we want to see.
With the container selected, we see a list of record types. Every database on CloudKit comes with a record of type User by default. This record type is used to store the user records (more about them later).
To see the record stored on the public database, we selected the option "Default Zone" in "Public Data".
Now the dashboard is telling us that we need an index for the record ID. Just click "Add Record ID Query Index" and then the dashboard is going to show the record we created using the app.
Note that, apart from the data we have explicitly added to the record, CloudKit adds some metadata automatically:
Record Name
: this is the unique identifier for the record, used to locate records on the database. We can create our own ID or leave it to CloudKit to generate a random UUID.
Created
: the date/time of creation. This can be accessed using the creationDate
property of CKRecord
Created By
: the ID of the user who created the record. Can be accessed using the creatorUserID
property of CKRecord
Modified
: the date/time of modification. This can be accessed using the modificationDate
property of CKRecord
Modified By
: the ID of the user that made the last modification to the record. Can be accessed using the lastModifiedUserRecordID
property of CKRecord
User Records
In the last section you learned that CloudKit automatically creates a record of type User
for your databases. This record contains by default only the unique identifier for the user. This user record identifier is unique per container, which means that a single user will have the same identifier between zones and databases on the same container, but if you happen to use multiple containers on your app, the same user will have different identifiers for each one.
We can do quite a lot with this user record:
- Know whether the user is logged in to iCloud
- Get the user record from the container
- Get the user's full name
- Get the identifiers for the user's contacts who have corresponding records on the same container
- Update it with data that's useful for our app
- Be notified of changes in the user's iCloud account's status
All of the examples in this section are available in UserViewController.swift.
Knowing whether the user is logged in to iCloud
There are many situations where we might have to know if the user is logged in to iCloud on the current device to decide whether a certain feature of the app should be enabled or even prevent the user from doing anything if not logged in.
Notice: if you decide to prevent the user from using your app when an iCloud account is not available, make sure to include a very detailed explanation on your app's review notes for Apple, if you can't explain why your app needs authentication to work, your app may be rejected.
To get the status of the user's iCloud account, we use the accountStatus
method from CKContainer
Fetching the user record
To fetch the user record, we have to get its ID first. We use the fetchUserRecordID
method from CKContainer
to do this.
Now that we have the record ID for the user record, we can use the fetch
method from CKDatabase
to get the actual user record.
In the example above, I'm using publicCloudDatabase
, but I could be using privateCloudDatabase
. Which database you use will depend on your application, since I'm only using the public database in the sample app, I decided to use it here.
You can actually have two separate records for the same user: one on the public and another on the private database. Even though both will have the same identifier, the data they contain can be different. You can use the public record to store information such as an avatar and nickname and the private record to store e-mail, address and other sensitive data.
Getting the user's full name
To get a user's full name from iCloud, we need to ask for permission by using the requestApplicationPermission
method from CKContainer
, with the option .userDiscoverability
. There will be an alert asking the user for permission.
After getting the user's permission, we use the discoverUserIdentity
method from CKContainer
to get the user's identity. This identity contains the user's full name as a PersonNameComponents
value, which we can format using a PersonNameComponentsFormatter
Discovering user contacts who use the app
To get a list of records for the user's friends using the same app, we can use the discoverAllIdentities
method from CKContainer
.
Adding extra information to the user record
For our sample app, let's say we want to list the movie records with the name and avatar of the user who registered them. Unfortunately, Apple doesn't offer a way for us to get the user's iCloud avatar, but we can offer this feature by adding a custom field to the user record.
To accomplish this, we'll have to learn about a new class: CKAsset
CKAsset
is an object used to store large files on CloudKit. Apple recommends that any field that's larger than a few kb be stored using CKAsset
Working with CKAsset
is really simple: you initialize it with an URL to a local file you want to upload to CloudKit and add the CKAsset
as a value to one of a record's keys.
When that record gets saved, CloudKit will take care of everything. In the sample app I'm using it to store images that will be used as avatars and there's absolutely no validation or processing of the image, but for a real app you'd absolutely have to check the type of file and probably scale it down so if the user selects a huge image file it doesn't consume too much bandwidth.
I added a button to the interface which opens a UIImagePickerController
so the user can select a picture from the library to use as an avatar. The snippet bellow shows what happens after the user selects an image:
In the snippet above, imageURL
is a URL
to a local file, if you try to initialize a CKAsset
with a remote URL, there will be an exception and your app will crash.
The save
method is used both to create and update records. When you pass it an existing record, CloudKit will update the keys that have been changed since the record was last saved.
As you can see, it's easy to add a new custom field to the user record and upload files to CloudKit.
Observing changes to the iCloud account's status
Something that can happen while your app is running is the user can open up iCloud preferences and change the logged in iCloud account, or just log out.
If your app changes depending on the logged in user, you must update its state to reflect these changes.
To be notified of changes to the iCloud account's status, all you have to do is register an observer with the .CKAccountChanged
notification name.
In the sample app, I'm calling the method that's responsible for doing all of the user discovery stuff, so whenever the iCloud account changes, the interface will update accordingly.
Queries
Now that we know how to store data on CloudKit, let's learn how we can retrieve this data.
The most simple way to fetch a record from CloudKit is by using a record ID, like we've seen before when we fetched the user record.
If we want to perform more advanced queries, filtered based on other keys, we'll have to use the CKQuery
class. With CKQuery
we can specify a predicate to filter our search.
If you're not familiar with NSPredicate
, I recommend that you take a look at the documentation. It's a very powerful class that's used a lot with Apple's APIs.
To run a query on CloudKit, we'll be using the CKQueryOperation
class. Performing queries is one of many things in CloudKit that uses operations.
Fetching all records of a specific type
This is the most simple one. Let's run a query to get all movie records from our database. The first step is to construct a query with the record type and a predicate, since we want all records, we can just use a predicate with a value of true
.
Now that we have an operation, before executing it, we have to set the closures that will be called when new data is available or when there's an error with the operation.
CKQueryOperation
has two callbacks: one is called for every record that gets fetched from the cloud and the other one is called at the end of the operation, after all records have been downloaded (or if an error occurs).
There are two parameters in the completion callback that deserve mention: cursor
is an object of the type CKCursor
that can be present at the end of the operation in case there are more results to be downloaded. If your query returns a very large amount of records, you'll have to run multiple operations to fetch all of them, passing the cursor
from the last operation to each subsequent operation.
The error
parameter is also very important — through it you'll know whether an error occurred and what's the nature of the error. Some errors on CloudKit are recoverable, which means you shouldn't just throw an alert to the user the first time an error occurs. Depending on the error, CloudKit will even tell you how many seconds to wait before retrying the operation (and you should definitely follow that recommendation).
Finally, after configuring our operation, we execute it by adding it to the database.
Performing a textual search
Another very common type of query is the textual query. Users may want to search movies by title. Fortunately, CloudKit deals very well with this and we can construct a simple predicate to take care of it.
The predicate self contains %@
means "look for this value in every key that contains text".
Performing a search based on geographical coordinates
We can even do queries based on location using CloudKit. In my example I've used the device's current location to search for movies shot at locations within a 500km radius.
The predicate for this location based search looks like this:
In the snippet above, location
refers to the key in our record, currentLocation
is a CLLocation
value with the user's current location and radius
is a Float
with the radius (in km) to be used when doing the search.
Here's a gif showing these three types of queries in action:
You'll find the code for this section in QueryTableViewController.swift
Performing queries on the database gives us complete flexibility to get relevant information for our app, but CloudKit has something even cooler than this: we can create persistent queries that run every time a record on the database is updated and notify our app via push notifications. These persistent queries are called subscriptions.
Subscriptions
Remember I talked about sending notifications using CloudKit? That's what subscriptions allow us to do.
Through subscriptions, we can register to be notified every time some change happens on the database. Therefore, when a new record is inserted, CloudKit will send us a push notification. These notifications can be just content-available
notifications (silent), or regular notifications that show alerts and/or badge the app's icon.
Using silent notifications, we can keep our app up-to-date with the latest data every time a change is made to the database, because this type of notification gives our app the opportunity to perform a background fetch.
Creating a subscription
To create a subscription that sends notifications, we first need to get the user's permission to send notifications and tell the system we want to get remote notifications.
If you want to use only the silent notifications (content-available
), you don't have to ask permission, you can just call registerForRemoteNotifications
. In the sample app I'm using notifications with alerts and sounds, so I needed to ask for permission through UNNotificationCenter
.
Now that we have the user's permission, we can create a subscription with CloudKit. The subscription is an object of type CKSubscription
.
recordType
is the type of record we want to get notified about.
predicate
defines the query to be executed to determine whether a notification will be fired. Like I mentioned earlier, subscriptions are like persistent queries that run on the server after each update on the database, it's through this parameter that we determine which query this will be.
Remember the location-based query we constructed earlier? We could register a subscription using that same predicate, making the user get a notification every time a movie shot near his location is added.
options
is a list defining in which circumstances the notification will be fired. We can get notified when a record is created, an existing record gets updated or deleted, or all three at the same time.
Configuring the notification itself
With the subscription created, we now have to define how the notification for this subscription will look like. To accomplish this, we use CKNotificationInfo
:
alertLocalizationKey
is a key in the app's Localizable.strings
file to be used as the format for the alert. This parameter is necessary when you want to include data from the record in the alert. In the example, I'm including the title of the movie:
“movie_registered_alert” = “%@ has been registered, check it out!”;
alertLocalizationArgs
contains the keys from the record that should be used to populate the placeholders in the text. I'm using the title key.
desiredKeys
are the keys from the record that should be sent with the notification.
Saving the subscription
Now that we have created and configured the subscription, we just have to save it like any other record.
Remember that the subscription must be saved on the database for which you want to be notified.
With the configuration above, when creating a new record on another device, my Apple Watch and iPhone got the following notification:
The code for this section is in SubscriptionViewController.swift.
Architecting for sync
What we've seen until now has been an introduction to the basic concepts of CloudKit. To actually create an app that syncs user data between devices efficiently and correctly, there's a lot more to be done.
To try and help you develop this on your app, I've created a simple note taking app (like Apple Notes) which uses an offline-first architecture for synchronization.
The basic architecture looks like this:
Notes are saved locally to a Realm database. Through Realm Notifications, the sync engine is notified when a note is added, updated or deleted from the local database. These changes are recognized by the sync engine, which converts the local models to CKRecord
objects and sends them to CloudKit.
The same process happens in reverse when the change occurs on CloudKit: the app gets a remote notification, gets the information about which records have been added/changed/removed and replicates those changes to the local database. Changing the local database triggers an UI update.
Something you must be aware of when doing this is sync loops. If a change on Realm causes a change on CloudKit and a change on CloudKit causes a change on Realm, we have an infinite loop. To avoid this problem, the sync engine registers a notification token with Realm so changes made by itself don't trigger an upload to CloudKit.
Error handling
It's very important to keep an eye out for errors when dealing with CloudKit. Many developers just print
errors or show alerts for the user when an error occurs, but that's not always the best solution.
The first thing you have to check is whether the error is recoverable. There are two very common cases that can cause a recoverable error to occur on CloudKit.
Temporary error / timeout / bad internet / rate limit
Sometimes there can be a little glitch with the connection or Apple's servers that can cause temporary errors. Your app may also be calling CloudKit too frequently, in which case that server will refuse some requests to avoid excessive load. In those cases, the error
returned from CloudKit will be of the type CKError
, which contains a property retryAfterSeconds
. If this property is not nil, use the value it contains as a delay to try the failed operation again.
On my projects, I always have a helper function that looks like this:
This method helps dealing with recoverable CloudKit errors. It takes an error returned from a CloudKit operation, a block to be executed if the operation can be retried and it returns the error in case the operation can not be retried.
Conflict resolution
Another common error is a conflict between two database changes. The user may have modified a register on a device while offline and then made another, conflicting change, using another device. In this case, trying to save the record may result in an error of the type serverRecordChanged
.
The userInfo
property for this error will contain the original record before the modifications, the current record on the server and the current record on the client. It's up to your app to decide what to do with this information to resolve the conflict. Some apps show a panel for the user to choose which record to keep, some merge the content of the two records automatically, some just keep the most recently modified record.
The entire code for this section can be found on my GitHub.
Conclusion
That's it! I hope this article was useful for you to get a better understanding of CloudKit and I hope this inspired you with some ideas to use it for your next project.