0% found this document useful (0 votes)
121 views85 pages

CloudKit With SwiftUI

Uploaded by

edwin
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
121 views85 pages

CloudKit With SwiftUI

Uploaded by

edwin
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 85

Quick Guides

for Masterminds

CloudKit
with SwiftUI
Learn how to share data between devices with CloudKit databases

J.D Gauchat
www.jdgauchat.com
Quick Guides for Masterminds
Copyright © 2022 John D Gauchat
All Rights Reserved

No part of this publication may be reproduced, distributed, or transmitted


in any form or by any means, including photocopying, recording, or other
electronic or mechanical methods, without the prior written permission of
the publisher, except in the case of brief quotations embodied in critical
reviews and certain other noncommercial uses permitted by copyright law.

Companies, services, or product names used in this book are for


identification purposes only. All trademarks and registered trademarks are
the property of their respective owners.

Apple™, iPhone™, iPad™, Mac™, among others mentioned in this work, are
trademarks of Apple Inc.

The information in this book is distributed without warranty. Although


every precaution has been taken in the preparation of this work, neither
the author nor the publisher shall have any liability to any person or entity
with respect to any loss or damage caused or alleged to be caused directly
or indirectly by the information contained in this work.

The source code for this book is available at


www.formasterminds.com

Copyright Registration Number: 1140725

1st Edition 2022


2nd Edition 2022
Table of Contents

ICLOUD

DATA IN THE CLOUD


ENABLING ICLOUD
TESTING DEVICES

CLOUDKIT
ENABLING CLOUDKIT
IMPLEMENTING CLOUDKIT
CUSTOM IMPLEMENTATION
RECORDS
ZONES
QUERY
ASYNCHRONOUS OPERATIONS
BATCH OPERATIONS
REFERENCES
CLOUDKIT DASHBOARD
CUSTOM CLOUDKIT APPLICATION
ASSETS
SUBSCRIPTIONS
ERRORS
DEPLOY TO PRODUCTION
ICLOUD
Data in the Cloud

These days, users own more than one device. If we create an application
that works on multiple devices, we must provide a way for the users to
share the data, otherwise they will have to insert the same information on
every device they own. But the only way to do it effectively is through a
server. Data from one device is stored on a server so that it can be
retrieved later from other devices. Setting up a server to run this kind of
systems is complicated and costly. To provide a standard solution, Apple
created a free system called iCloud. iCloud allows applications to
synchronize data across devices using Apple servers. The system includes
three basic services: Key-Value Storage, to store single values, Document
Storage, to store files, and CloudKit Storage, to store structured data in
public and private databases.
Enabling iCloud

iCloud must be enabled for each application. The system requires


entitlements to authorize our app to use the service and a container where
our app’s data will be stored. Fortunately, Xcode can set up everything for
us by just selecting an option in the Signing & Capabilities panel, found
inside the app’s settings window. The panel includes a + button at the top-
left corner to add a new capability to the application (Figure 1, number 1).
The button opens a view with all the capabilities available. The capability is
added by clicking on it and pressing Return.

Figure 1: Activating iCloud for our app

IMPORTANT: iCloud services are only available to developers that


are members of the Apple Developer Program. At the time of this
writing, the membership costs $99 US Dollars per year.

After iCloud is added to the app, it is shown on the panel, below the
signing section. From here, we can select the services we want to activate
and Xcode takes care of creating the entitlements. Figure 2, below, shows
the panel with all the options available.

Figure 2: Activating iCloud services



Testing Devices

The best way to test iCloud is by running the app in two different devices,
but Apple has made iCloud services available in the simulator as well.
Thanks to this feature, we can synchronize data between a device and the
simulator to test our app.
For the devices and the simulators to be able to access iCloud services, we
must register our iCloud account in the Settings app. We have to go to the
Home screen, access the Settings app, tap on the option Sign in to your
iPhone/iPad (Figure 3, center), and sign in to Apple services with our Apple
ID. We must repeat the process for every device or simulator we want to
use.

Figure 3: iCloud account in the simulator

IMPORTANT: The simulator does not update the information


automatically. Most of the time, you must force the update. If you
modify the value on your device and do not see it changing on the
simulator, open the Features menu at the top of the screen and
select the option Trigger iCloud Sync. This will synchronize the
application with iCloud and update the values right away.
CLOUDKIT
CloudKit is a database system in iCloud. Using this system, we can store
structured data online with different levels of accessibility. The system
offers three types of databases to determine who has access to the
information.

Private Database to store data that is accessible only to the


user.
Public Database to store data that is accessible to every user
running the app.
Shared Database to store data the user wants to share with
other users.

CloudKit databases have specific purposes and functionalities. The Private


database is used when we want the user to be able to share private
information among his or her own devices (only the user can access the
information stored in this database), and the Public and Shared databases
are used to share information between users. (The information stored in
the Public database is accessible to all the users running our app, and the
information stored in the Shared database is accessible to the users the
user decides to share the data with.)
The data is stored in the database as records and records are stored in
zones. The Private and Public databases include a default zone, but the
Private and Shared databases also work with custom zones (called Shared
Zones in a Shared database), as illustrated below.

Figure 4: Databases configuration



Enabling CloudKit

The first step to use CloudKit is to activate it from the Signing & Capabilities
panel.

Figure 5: CloudKit service

CloudKit requires a container to manage the databases. If the container is


not automatically generated by Xcode when we activate the CloudKit
service, we must create it ourselves from the + button at the bottom of the
list (see Figure 5). The recommended name for the container is the app's
bundle identifier, which we can get from the options at the top of the
panel.
Because CloudKit uses Remote Notifications to report changes in the
databases, when we activate CloudKit, Xcode automatically includes an
additional service called Push Notifications.

Figure 6: Push Notifications

Remote Notifications are like Local Notifications, but instead of being


posted by the app they are sent from a server to inform our app or the
user that something changed or needs attention. The Remote Notifications
posted by CloudKit are sent from Apple servers when something changes
in a database. Because this may happen not only when the user is working
with the app but also when the app is in the background, to get these
notifications, we must add the Background Mode capability and activate
two services called Background Fetch and Remote Notifications.

Figure 7: Background Mode

Do It Yourself: Create a Multiplatform project. Click on the app’s


settings option at the top of the Navigator Area and open the Signing
& Capabilities panel. Click on the + button at the top-left corner of
the panel to add a capability. Select the iCloud option and press
return. Repeat the process to add the Background Modes capability.
In the Background Modes section, check the options Background
fetch and Remote Notifications (Figure 7). In the iCloud section,
check the option CloudKit (Figure 5). Press the + button to add a
container. Insert the app's bundle identifier for the container's name
and press the OK button (you can find the bundle identifier at the
top of the panel). If the name of the container appears in red, press
the Refresh button to upload the information to Apple servers.

IMPORTANT: Remote Notifications can only be tested on a real


device (they do not work on the simulator).
Implementing CloudKit

Although we can access a CloudKit database and manually create, modify


and delete records, as we will see later, this requires us to take care not
only of the process of keeping the database up to date, but also check for
errors and synchronize devices. Because these tasks are usually the same
for most applications, Apple provides an API that works along with Core
Data to automatically share the data stored on the device with a CloudKit
database. All we need to do is to create the Core Data stack with the
NSPersistentCloudKitContainer class instead of the NSPersistentContainer class.
After this, the Core Data's Persistent Store is automatically synchronized
with CloudKit servers and the data is available on every device logged in to
the same iCloud account. The NSPersistentCloudKitContainer class is a subclass
of the NSPersistentContainer class and therefore it includes the same initializer.

NSPersistentCloudKitContainer(name: String)—This initializer


creates a Persistent Store with the name specified by the name
argument.

In addition to common properties, like the viewContext property to return a


reference to the context, this subclass also includes the following methods
in case our application needs to retrieve records manually.

record(for: NSManagedObjectID)—This method returns a


CKRecord object with the record that corresponds to the Core Data
object specified by the for argument. The argument is the object's
identifier (returned by the objectID property). If no record is found, the
method returns nil.
records(for: [NSManagedObjectID])—This method returns an
array of CKRecord objects with the records that correspond to the Core
Data objects specified by the for argument. The argument is an array
of identifiers (returned by the objectID property).
recordID(for: NSManagedObjectID)—This method returns a
CKRecord.ID value with the identifier of the record that corresponds to
the Core Data object specified by the for argument. The argument is
the object's identifier (returned by the objectID property).
recordIDs(for: [NSManagedObjectID])—This method returns an
array of CKRecord.ID values with the identifiers of the records that
correspond to the Core Data objects specified by the for argument.
The argument is an array of object identifiers (returned by the objectID
property).

Thanks to this amazing API, creating an application that stores information


locally with Core Data and synchronizes the data with a CloudKit database
is extremely simple. All we have to do is to define the Core Data stack with
the NSPersistentCloudKitContainer class and then create the Core Data
application as always. As an example, we are going to create an application
that stores countries and cities. We need two entities called Cities and
Countries. The Cities entity needs an attribute of type String called name
and a To-One relationship called country.

Figure 8: Cities entity

And the Countries entity needs an attribute of type String called name and
a To-Many relationship called cities, as shown below.
Figure 9: Countries entity

There is one more requirement for the model to be ready to work with
CloudKit. We must select the Configuration (Figure 10, number 1) and
check the option Used with CloudKit in the Data Model Inspector panel
(Figure 10, number 2). This makes sure that if we create other
configurations later, the system knows which one must be synchronized
with CloudKit servers.

Figure 10: Used with CloudKit option

Do It Yourself: Create a Core Data model from the File menu. Add
two entities to the model called Countries and Cities with their
respective attributes and relationships, as shown in Figures 8 and 9.
Select the Default configuration (Figure 10, number 1), open the
Data Model Inspector panel on the right, and check the Used with
CloudKit option (Figure 10, number 2).
IMPORTANT: If you later want to create additional Entities to store
information only on the device, you can add a new configuration to
the Core Data model, assign the Entities to that configuration, and
keep the Used with CloudKit option unchecked. All the objects
stored for those Entities will not be synchronized with CloudKit.

Once the application is configured to work with CloudKit and the Core Data
model is ready, we can work on our code. First, we must initialize the Core
Data stack using the NSPersistentCloudKitContainer class.

Listing 1: Preparing Core Data to work with CloudKit



import SwiftUI
import CoreData

class ApplicationData: ObservableObject {


let container: NSPersistentCloudKitContainer

static var preview: ApplicationData = {


let model = ApplicationData(preview: true)
return model
}()
init(preview: Bool = false) {
container = NSPersistentCloudKitContainer(name: "Model")
if preview {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
}
}

The container is set as usual, but instead of using an NSPersistentContainer


value, we use an NSPersistentCloudKitContainer value to synchronize the
Persistent Store with CloudKit servers.
As always, we need to inject the context into the environment from the App
structure.

Listing 2: Injecting the context into the environment



import SwiftUI

@main
struct TestApp: App {
@StateObject var appData = ApplicationData()

var body: some Scene {


WindowGroup {
ContentView()
.environment(\.managedObjectContext, appData.container.viewContext)
}
}
}

And that's all it takes. From now on, every change introduced in the
Persistent Store is going to be uploaded to CloudKit and every device
running the application is going to be automatically synchronized. All that
is left is to design the views to display the objects or add new ones. Here is
the initial view for our example.

Listing 3: Listing the countries stored in the Persistent Store



import SwiftUI
import CoreData

struct ContentView: View {


@Environment(\.managedObjectContext) var dbContext
@FetchRequest(sortDescriptors: [SortDescriptor(\Countries.name, order: .forward)]) var
listCountries: FetchedResults<Countries>
@State private var openSheet: Bool = false

var body: some View {


NavigationStack {
List {
ForEach(listCountries) { country in
NavigationLink(destination: ShowCitiesView(selectedCountry: country)) {
Text(country.name ?? "Undefined")
}
}
}
.navigationBarTitle("Countries")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Add Country") {
openSheet = true
}
}
}
.sheet(isPresented: $openSheet) {
InsertCountryView()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.environment(\.managedObjectContext, ApplicationData.preview.container.viewContext)
}
}

This view defines a @FetchRequest property to load the objects of type


Countries from the Persistent Store and then lists the values with a List view.
The view includes a button to open the InsertCountryView view to let the user
insert a new country.

Listing 4: Inserting new countries in the Persistent Store



import SwiftUI
import CoreData

struct InsertCountryView: View {


@Environment(\.managedObjectContext) var dbContext
@Environment(\.dismiss) var dismiss
@State private var inputName: String = ""

var body: some View {


VStack {
HStack {
Text("Country:")
TextField("Insert Country", text: $inputName)
.textFieldStyle(.roundedBorder)
}
HStack {
Spacer()
Button("Save") {
let text = inputName.trimmingCharacters(in: .whitespaces)
if !text.isEmpty {
let newCountry = Countries(context: dbContext)
newCountry.name = text
do {
try dbContext.save()
} catch {
print("Error saving country")
}
dismiss()
}
}
}
Spacer()
}.padding()
}
}
struct InsertCountryView_Previews: PreviewProvider {
static var previews: some View {
InsertCountryView()
.environment(\.managedObjectContext, ApplicationData.preview.container.viewContext)
}
}

There is nothing new in this view. We create a new Countries object with the
value inserted by the user when the Save button is pressed and save the
context with the save() method, but because the application is connected to
CloudKit the system automatically creates a record from the Countries object
and uploads it to CloudKit servers.
The following is the ShowsCitiesView view opened when a country is selected
by the user.
Listing 5: Listing the cities stored in the Persistent Store

import SwiftUI
import CoreData

struct ShowCitiesView: View {


@FetchRequest(sortDescriptors: [], predicate: NSPredicate(format: "FALSEPREDICATE"))
var listCities: FetchedResults<Cities>
@State private var openSheet: Bool = false
let selectedCountry: Countries?

init(selectedCountry: Countries?) {
self.selectedCountry = selectedCountry
if selectedCountry != nil {
_listCities = FetchRequest(sortDescriptors: [SortDescriptor(\Cities.name, order:
.forward)], predicate: NSPredicate(format: "country = %@", selectedCountry!), animation:
.default)
}
}
var body: some View {
List {
ForEach(listCities) { city in
Text(city.name ?? "Undefined")
}
}
.navigationBarTitle(selectedCountry?.name ?? "Undefined")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Add City") {
openSheet = true
}
}
}
.sheet(isPresented: $openSheet) {
InsertCityView(country: selectedCountry)
}
}
}
struct ShowCitiesView_Previews: PreviewProvider {
static var previews: some View {
NavigationStack {
ShowCitiesView(selectedCountry: nil)
.environment(\.managedObjectContext, ApplicationData.preview.container.viewContext)
}
}
}

This view lists the cities with a List view, as we did before for the countries,
but because we only need to show the cities that belong to the selected
country, we initialize the @FetchRequest property wrapper with a false
predicate and then use the Countries object returned by the selectedCountry
property to create a new FetchRequest structure with a predicate that filters
the cities by country.
The ShowCitiesView view also includes a button to let the user insert new
cities. The following is the view opened by the sheet() modifier when the
button is pressed.

Listing 6: Inserting new cities in the Persistent Store



import SwiftUI
import CoreData

struct InsertCityView: View {


@Environment(\.managedObjectContext) var dbContext
@Environment(\.dismiss) var dismiss
@State private var inputName: String = ""
let country: Countries?

var body: some View {


VStack {
HStack {
Text("City:")
TextField("Insert City", text: $inputName)
.textFieldStyle(.roundedBorder)
}
HStack {
Spacer()
Button("Save") {
let text = inputName.trimmingCharacters(in: .whitespaces)
if !text.isEmpty {
let newCity = Cities(context: dbContext)
newCity.name = text
newCity.country = country
do {
try dbContext.save()
} catch {
print("Error saving city")
}
dismiss()
}
}
}
Spacer()
}.padding()
}
}
struct InsertCityView_Previews: PreviewProvider {
static var previews: some View {
InsertCityView(country: nil)
.environment(\.managedObjectContext, ApplicationData.preview.container.viewContext)
}
}

Again, we just create a new Cities object with the value inserted by the user
when the Save button is pressed and the system takes care of creating the
record an uploading it to CloudKit.
The application shows the list of countries and cities stored in the
Persistent Store, and allows the user to insert new values, as previous Core
Data applications, but now all the information is uploaded to CloudKit
servers and automatically shared with other devices.

Figure 11: Working with CloudKit


Do It Yourself: Create a Swift file called ApplicationData.swift for the
model in Listing 1. Remember to assign the name of your Core Data
model to the NSPersistentCloudKitContainer initializer. Update the App
structure with the code in Listing 2 and the ContentView.swift file
with the code in Listing 3. Create SwiftUI View files called
InsertCountryView.swift, ShowCitiesView.swift, and
InsertCityView.swift for the codes in Listings 4, 5, and 6. Run the
application in two devices. Press the Add Country button and insert a
new country. After a few seconds, you should see the same value
appear on the second device. Repeat the process for the cities.
Custom Implementation

Of course, we can also implement CloudKit on our own. This requires


accessing the container and the database, and manually managing the
records inside. First, we need access to the CloudKit container. This is a
space in Apple's servers designated to our app. The framework provides a
class called CKContainer to access the container and the databases it
contains. Because an app may have more than one container, the class
includes an initializer to get a reference to a specific container and a type
method to get a reference to the container by default.

CKContainer(identifier: String)—This initializer creates the


CKContainerobject that references the container identified with the
name specified by the identifier argument. It is required when
working with multiple containers or the container's name is different
from the Bundle's identifier.
default()—This type method returns the CKContainer object that
references the container by default (the container named after the
Bundle's identifier).

Container objects provide the following properties to get access to the


databases.

privateCloudDatabase—This property returns a CKDatabase object


with a reference to the user's Private database.
publicCloudDatabase—This property returns a CKDatabase object
with a reference to the app's Public database.
sharedCloudDatabase—This property returns a CKDatabase object
with a reference to the user's Shared database.
Records

Once we have decided which database we are going to use, we must


generate records to store the user's data. Records are objects that store
information as key/value pairs, like dictionaries. These objects are classified
by types to determine the characteristics of the record. For example, if we
want to store records that contain information about books, we can use
the type "Books", and if later we want to store records with information
about authors, we can use the type "Authors". (A type is analog to the
Entities in Core Data.) The framework provides the CKRecord class to create
and manage records. The class includes the following initializer.

CKRecord(recordType: String, recordID: CKRecord.ID)—This


initializer creates a CKRecord object of the type and with the ID
specified by the arguments. The recordType argument is a custom
identifier for the type, and the recordID argument is the record's
identifier.

Records are identified with an ID that includes a name and a reference to


the zone the record belongs to. (If a custom ID is not specified, the record
is stored with an ID generated by CloudKit.) To create and access the ID and
its values, the CKRecord class defines the ID class with the following
initializers and properties.

CKRecord.ID(recordName: String)—This initializer creates a


object to identify a record. The recordName argument is
CKRecord.ID
the name we want to give to the record (it must be unique).
CKRecord.ID(recordName: String, zoneID: CKRecordZone.ID)
—This initializer creates a CKRecord.ID object to identify a record
stored with the name and in the zone specified by the arguments. The
recordName argument is the name we want to give to the record, and
the zoneID argument is the identifier of the custom zone where we
want to store it.
recordName—This property returns a string with the name of the
record.
zoneID—This property returns a CKRecordZone.ID object with the ID of
the zone the record belongs to.

The CKRecord class offers properties to set or get the record's ID and other
attributes. The following are the most frequently used.

recordID—This property returns the CKRecord.ID object that identifies


the record.
recordType—This property returns a string that determines the
record's type.
recordChangeTag—This property returns a string with the tag
assigned to the record (each record is assigned a tag by the server).
creationDate—This property returns a Date value with the date in
which the record was created.
modificationDate—This property returns a Date value that indicates
the last time the record was modified.

Because the values of a record are stored as key/value pairs, we can use
square brackets to read and modify them (as we do with dictionaries), but
the class also includes the following methods.

setObject(Value?, forKey: String)—This method sets or updates a


value in the record. The fist argument is the value we want to store,
and the forKey argument is the key we want to use to identify the
value. The value must be any of the following types: NSString,
NSNumber, NSData, NSDate, NSArray, CLLocation, CKAsset, and Reference.
object(forKey: String)—This method returns the value associated
with the key specified by the forKey argument. The value is returned
as a generic CKRecordValue type that we must cast to the right data
type.
Zones

As illustrated in Figure 4, the Public database can only store records in a


default zone, but the Private and Shared databases can include custom
zones. In the case of the Private database, the custom zones are optional
(although they are required for synchronization, as we will see later).
Zones are like sections inside a database to separate records that are not
directly related. For example, we may have an app that stores locations,
like the names of countries and cities, but also allows the user to store a
list of Christmas gifts. In cases like this, we can create a zone to store the
records that include information about countries and cities and another
zone to store the records that include information about the gifts. The
CloudKit framework provides the CKRecordZone class to represent these
zones. The class includes an initializer to create custom zones and a type
method to get a reference to the zone by default.

CKRecordZone(zoneName: String)—This initializer creates a


object to represent a zone with the name specified by
CKRecordZone
the zoneName argument.
default()—This type method returns the CKRecordZone object that
represents the zone by default.
Query

When we want to access data stored in CloudKit, we must download the


records from the database and read their values. Records may be fetched
from a database one by one using their ID or in a batch using a query. To
define a query, the framework provides the CKQuery class. The class
includes the following initializer and properties.

CKQuery(recordType: String, predicate: NSPredicate)—This


initializer creates a CKQuery object to fetch multiple records from a
database. The recordType argument specifies the type of records we
want to fetch, and the predicate argument determines the matching
criteria we want to use to select the records.
recordType—This property sets or returns a string that determines
the type of records we want to fetch.
predicate—This property sets or returns an NSPredicate object that
defines the matching criteria for the query.
sortDescriptors—This property sets or returns an array of
objects that determine the order of the records
NSSortDescriptor
returned by the query.
Asynchronous Operations

CloudKit is an online service and therefore any task may take time to
process. For this reason, the CloudKit framework uses asynchronous
operations to access the information on the servers. An operation must be
created for every process we want to perform on a database, including
storing, reading, and organizing records.
These operations are like Swift asynchronous tasks but created from
classes defined in the Foundation framework. The CloudKit framework
includes its own subclasses of the Foundation classes to define operations.
There is a base class called CKDatabaseOperation, and then several subclasses
for every operation we need. Once an operation is defined, it must be
added to the CKDatabase object that represents the database we want to
modify. The CKDatabase class offers the following method for this purpose.

add(CKDatabaseOperation)—This method executes the operation


specified by the argument on the database. The argument is an object
of a subclass of the CKDatabaseOperation class.

Although we can create single operations and assign them to the database,
as we will see later, the CKDatabase class also offers convenient methods to
generate and execute the most common. The following are the methods
available to process records.

record(for: CKRecord.ID)—This asynchronous method fetches the


record with the ID specified by the for argument. The method returns
a CKRecord object with the record, or an error if the record is not
found.
save(CKRecord)—This asynchronous method stores a record in the
database. (If the record exists, it is updated.) The argument is a
reference to the record we want to store.
deleteRecord(withID: CKRecord.ID)—This asynchronous method
deletes from the database the record with the identifier specified by
the withID argument.

The following are the methods provided by the CKDatabase class to process
zones.

recordZone(for: CKRecordZone.ID)—This asynchronous method


fetches the zone with the ID specified by the for argument. The
method returns a CKRecordZone object representing the zone that was
fetched or an error if the zone is not found.
allRecordZones()—This asynchronous method fetches all the zones
available in the database. The method returns an array of
CKRecordZone objects representing the zones that were fetched or an
error if no zones are found.
save(CKRecordZone)—This asynchronous method creates a zone in
the database. The argument is the object representing the zone we
want to create.
deleteRecordZone(withID: CKRecordZone.ID)—This
asynchronous method deletes from the database the zone with the ID
specified by the withID argument.

To fetch multiple records, the CKDatabase class includes the following


convenient methods.

records(matching: CKQuery, inZoneWith: CKRecordZone.ID?,


desiredKeys: [CKRecord.FieldKey]?, resultsLimit: Int)—This
asynchronous method performs the query specified by the matching
argument in the zone specified by the inZoneWith argument. The
desiredKeys argument is an array of the values we want the records
to include, and the resultsLimit argument determines the number of
records we want to fetch (the value 0 returns all the records that
match the query). The method returns a tuple with two values called
matchResults and queryCursor. The matchResults value is an array of tuples
with two values: the record identifier and a Result with the records and
errors found. On the other hand, the queryCursor value is a cursor we
can use to fetch more records that match this query.
records(continuingMatchFrom: Cursor, desiredKeys:
Dictionary, resultsLimit: Int)—This asynchronous method creates
an operation that fetches records starting from the cursor specified by
the continuingMatchFrom argument. The cursor is an object that
configures a query to retrieve the remaining results of a previous
query. The desiredKeys argument is an array of the values we want
the records to include, and the resultsLimit argument determines the
number of records we want to fetch (the value 0 returns all the
records that match the query). The method returns a tuple with two
values called matchResults and queryCursor. The matchResults value is an
array of tuples with two values: the record identifier and a Result
enumeration value with the records and errors found. On the other
hand, the queryCursor value is a cursor we can use to fetch more
records that match this query.
Batch Operations

Although the methods provided by the CKDatabase class are very convenient
and easy to implement, they only perform one request at a time. The
problem is that CloudKit servers have a limit on the number of operations
we can perform per second (currently 40 requests per second are allowed),
so if our application relies heavily on CloudKit, at one point some of the
requests might be rejected if we send them one by one. The solution is to
create operations that allow us to perform multiple requests at once. The
CloudKit framework defines three subclasses of the CKDatabaseOperation
class for this purpose. The CKModifySubscriptionsOperation class creates an
operation to add or modify subscriptions, the CKModifyRecordZonesOperation
class is used to add or modify record zones, and the CKModifyRecordsOperation
class is for adding and modifying records.

CKModifySubscriptionsOperation(subscriptionsToSave:
[CKSubscription], subscriptionIDsToDelete:
[CKSubscription.ID])—This initializer returns an operation that adds
or modifies one or more subscriptions. The subscriptionsToSave
argument is an array with the subscriptions we want to add or modify,
and the subscriptionIDsToDelete argument is an array with the IDs of
the subscriptions we want to delete from the server.
CKModifyRecordZonesOperation(recordZonesToSave:
[CKRecordZone], recordZoneIDsToDelete: [CKRecordZone.ID])
—This initializer returns an operation that adds or modifies one or
more record zones. The recordZonesToSave argument is an array with
the record zones we want to add or modify, and the
recordZoneIDsToDelete is an array with the IDs of the record zones
we want to delete from the server.
CKModifyRecordsOperation(recordsToSave: [CKRecord],
recordIDsToDelete: [CKRecord.ID])—This operation adds or
modifies one or more records. The recordsToSave argument is an
array with the records we want to add or modify, and the
recordIDsToDelete argument is an array with the IDs of the records
we want to delete from the server.

These operations must be initialized first and then added to the database
with the add() method of the CKDatabase class. Each initializer offers the
options to modify elements and remove them. If we only need to perform
one task, the other can be omitted with the value nil.
References

Records of different types are usually related. For example, along with
records of type Countries we may have records of type Cities to store
information about the cities of each country. To create these relationships,
records include references. References are objects that store information
about a connection between one record and another. They are created
from the Reference class defined inside the CKRecord class. The following are
its initializers.

CKRecord.Reference(recordID: CKRecord.ID, action:


CKRecord.ReferenceAction)—This initializer creates a Reference
object pointing to the record identified with the ID specified by the
recordID argument. The action argument is an enumeration that
determines what the database should do with the record when the
record that is referencing is deleted. The possible values are none
(nothing is done) and deleteSelf (when the record referenced by the
reference is deleted, the record with the reference is deleted as well).
CKRecord.Reference(record: CKRecord, action:
CKRecord.ReferenceAction)—This initializer creates a Reference
object pointing to the record specified by the record argument. The
action argument is an enumeration that determines what the
database should do with the record when the record that is
referencing is deleted. The possible values are none (nothing is done)
and deleteSelf (when the record referenced by the reference is deleted,
the record with the reference is deleted as well).

References in CloudKit are called Back References because they are


assigned to the record that is the children of another record. Following our
example, the reference should be assigned to the city and not the country,
as illustrated next.
Figure 12: Back references


CloudKit Dashboard

CloudKit creates a model of our app's database on its servers as records


are added to the database. For example, if our app stores records of type
"Books", the first time a record is created, CloudKit adds the type "Books"
to the list of record types available for our app and creates fields to
represent each of the values in the record. This way, the system sets up the
database's structure from the data we store during development, saving us
the trouble of configuring the database beforehand. (We do not have to
create a model as we do with Core Data.) But there are some configuration
parameters that the system cannot determine and we need to set up
ourselves. For this purpose, Apple provides the CloudKit dashboard. This is
an online control panel that we can use to manage the CloudKit databases,
add, update, or remove records, and configure the schema.
The panel is available at icloud.developer.apple.com/dashboard/ or by
clicking on the CloudKit Dashboard button at the bottom of the iCloud
section in the Signing & Capabilities panel. The dashboard's home page
includes four buttons to access the tools available. We can configure the
database, check how our database is performing, check user activity, and
manage our account.

Figure 13: Buttons in the main menu


If we click on the CloudKit Database button, a panel is loaded to edit the
database. The panel includes an option at the top to select the container
(Figure 14, number 1), a bar on the left to edit the data and the schema,
and a bar on the right to show and edit the values.

Figure 14: Database panel

The bar on the left includes a button to select from two configurations:
Development and Production (Figure 14, number 2). The Development
option shows the configuration of the database used during development.
This is the database we use as we develop our app. The Production option
shows the configuration of the database that we are going to deliver with
our app (the one that is going to be available to our users).
During development, we can store information in the database for testing.
Below the configuration option is the Data section (Figure 14, number 3)
where we can edit the data stored by our application, including records,
zones, and subscriptions. The panel also offers an option on the right to
add records (Figure 14, number 4), and buttons to select the database we
want to access (Public, Private, or Shared), select the zone, and indicate
how we want to access the records.

IMPORTANT: Notice that the Public Database is selected by default.


If you want to see the records stored by the examples in this guide,
you need to click on this button and select the Private Database
instead.

As we already explained, the schema (the database model) is automatically


generated by CloudKit servers when we save records from our app during
development. For instance, if our app creates a Books record, the server
creates a record type called Books and adds it to the database model. In
theory, this is enough to create the model, but in practice we always need
to erase record types or values that we don't use anymore and add or
modify others that the app may need later. For this purpose, the
dashboard provides access to the schema on the left-side bar.

Figure 15: Database schema

From the Record Types option, we can add, modify, or delete record types
(Entities) and their values (Attributes). The option to add a new record type
is at the top of the panel (Figure 16, number 1), and the record types
already created are listed below (Figure 16, number 2).

Figure 16: Record types


Custom CloudKit Application

Using the tools introduced above, we can implement CloudKit on our own,
but there are several ways to do it. It all depends on the characteristics of
our application and what we want to achieve. An alternative is to centralize
all the logic in the model. For instance, the following is a possible
implementation of the application created before with Core Data to store
countries and cities.

Listing 7: Defining a model for CloudKit



import SwiftUI
import CloudKit

struct Country {
var name: String?
var record: CKRecord
}
struct City {
var name: String?
var record: CKRecord
}
struct CountryViewModel: Identifiable {
var id: CKRecord.ID
var country: Country

var countryName: String {


return country.name ?? "Undefined"
}
}
struct CityViewModel: Identifiable {
var id: CKRecord.ID
var city: City

var cityName: String {


return city.name ?? "Undefined"
}
}
class ApplicationData: ObservableObject {
@Published var listCountries: [CountryViewModel] = []
@Published var listCities: [CityViewModel] = []
var database: CKDatabase!
init() {
let container = CKContainer.default()
database = container.privateCloudDatabase

Task(priority: .high) {
await readCountries()
}
}
func insertCountry(name: String) async {
let id = CKRecord.ID(recordName: "idcountry-\(UUID())")
let record = CKRecord(recordType: "Countries", recordID: id)
record.setObject(name as NSString, forKey: "name")

do {
try await database.save(record)
await MainActor.run {
let newCountry = Country(name: record["name"], record: record)
let newItem = CountryViewModel(id: record.recordID, country: newCountry)
listCountries.append(newItem)
listCountries.sort(by: { $0.countryName < $1.countryName })
}
} catch {
print("Error: \(error)")
}
}
func insertCity(name: String, country: CKRecord.ID) async {
let id = CKRecord.ID(recordName: "idcity-\(UUID())")
let record = CKRecord(recordType: "Cities", recordID: id)
record.setObject(name as NSString, forKey: "name")

let reference = CKRecord.Reference(recordID: country, action: .deleteSelf)


record.setObject(reference, forKey: "country")

do {
try await database.save(record)
await MainActor.run {
let newCity = City(name: record["name"], record: record)
let newItem = CityViewModel(id: record.recordID, city: newCity)
listCities.append(newItem)
listCities.sort(by: { $0.cityName < $1.cityName })
}
} catch {
print("Error: \(error)")
}
}
func readCountries() async {
let predicate = NSPredicate(format: "TRUEPREDICATE")
let query = CKQuery(recordType: "Countries", predicate: predicate)

do {
let list = try await database.records(matching: query, inZoneWith: nil, desiredKeys: nil,
resultsLimit: 0)

await MainActor.run {
listCountries = []
for (_, result) in list.matchResults {
if let record = try? result.get() {
let newCountry = Country(name: record["name"], record: record)
let newItem = CountryViewModel(id: record.recordID, country: newCountry)
listCountries.append(newItem)
}
}
listCountries.sort(by: { $0.countryName < $1.countryName })
}
} catch {
print("Error: \(error)")
}
}
func readCities(country: CKRecord.ID) async {
let predicate = NSPredicate(format: "country = %@", country)
let query = CKQuery(recordType: "Cities", predicate: predicate)

do {
let list = try await database.records(matching: query, inZoneWith: nil, desiredKeys: nil,
resultsLimit: 0)

await MainActor.run {
listCities = []
for (_, result) in list.matchResults {
if let record = try? result.get() {
let newCity = City(name: record["name"], record: record)
let newItem = CityViewModel(id: record.recordID, city: newCity)
listCities.append(newItem)
}
}
listCities.sort(by: { $0.cityName < $1.cityName })
}
} catch {
print("Error: \(error)")
}
}
}

We begin by defining two structures, Country and City, to store the name of
the country and city, and also a reference to the record downloaded from
the CloudKit database, and two more for our view model, CountryViewModel
and CityViewModel. These view models identify each value by the record ID
(CKRecord.ID) and include a computed property to return a string with the
name.
The observable object defines the @Published properties we need to store
the data locally and show it to the user. The listCountries property is an array
with the list of countries already inserted in the database, and the listCities
property is another array with the list of cities available for a specific
country. Another property included in this class is database. This property
stores a reference to the CloudKit's database, so we can access it from
anywhere in the code. The property is initialized in the init() method with a
reference to the Private Database. (We use the private database because
we only want the user to be able to share the data between his or her own
devices.)
The observable object also includes methods to add and read records. For
instance, the insertCountry() and insertCity() methods are going to be called
from the views when the user inserts a new country or city. Their task is to
create the records and upload them to CloudKit. The process begins by
defining a record ID, which is a unique value that identifies each record. For
the countries, we use the string "idcountry" followed by a random value
generated by the UUID() function. Using this ID, we create a CKRecord object
of type Countries, then add a property called "name" with the value
received by the method, and finally save it in CloudKit servers with the
save() method of the CKDatabase object. This method generates an operation
that communicates with the servers asynchronously. If there is no error, we
add the record to the listCountries property, which updates the views and the
screen.
The method to add a city is the same, with the exceptions that we must
define the type of records as Cities and add an extra attribute to the record
with a reference to the country the city belongs to. For this purpose, we
create a Reference object with the country's record ID received by the
method and an action of type deleteSelf, so when the record of the country is
deleted, this record is deleted as well.
Next are the methods we need to implement to read the countries and
cities already stored in the database. In the readCountries() method, we
define a predicate with the TRUEPREDICATE keyword and a query for
records of type Countries. The record type asks the server to only look for
records of type Countries, and the TRUEPREDICATE keyword determines
that the predicate will always return true, so we get back all the records
available. If the query doesn't return any errors, we get the records from
the matchResults value and add them to the listCountries array to update the
views.
The readCities() method is very similar, except that this time we are getting
the list of cities that belong to the country selected by the user. (The view
that shows the cities only opens when the user taps on a row to select a
country.) The rest of the process is the same. We get the records that
represent the cities, create the CityViewModel structures with them, and
store them in the listCities array.

IMPORTANT: This example assumes that you have assigned the


bundle's name to the container and therefore we get a reference to
the container with the default() type method. If the name assigned to
the container is different than the bundle's, you can specify it with
the CKContainer initializer, as in CKContainer(identifier:
"iCloud.com.mydomain.MyContainer").

For the interface, we need a total of four views: a view to show the list of
countries, a view to allow the user to insert a new country, a view to show
the list of cities that belong to the selected country, and another view to
allow the user to insert a new city. The following is the initial view.

Listing 8: Listing the countries in the Private Database



struct ContentView: View {
@EnvironmentObject var appData: ApplicationData
@State private var openSheet: Bool = false

var body: some View {


NavigationStack {
List {
ForEach(appData.listCountries) { country in
NavigationLink(destination: ShowCitiesView(selectedCountry: country)) {
Text(country.countryName)
}
}
}
.navigationBarTitle("Countries")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Add Country") {
openSheet = true
}
}
}
.sheet(isPresented: $openSheet) {
InsertCountryView()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environmentObject(ApplicationData())
}
}

The countries stored in the CloudKit database are retrieved by the


readCountries() method in our model. This method is called when the
observable object is initialized, so all the view has to do is to list the
countries stored in the listCountries property.
To let the user add a new country, the view includes a sheet() modifier that
opens the InsertCountryView view. The following is our implementation of this
view.
Listing 9: Storing countries in the Private database

import SwiftUI
import CloudKit

struct InsertCountryView: View {


@EnvironmentObject var appData: ApplicationData
@Environment(\.dismiss) var dismiss

@State private var inputName: String = ""


@State private var buttonDisabled: Bool = false

var body: some View {


VStack {
HStack {
Text("Country:")
TextField("Insert Country", text: $inputName)
.textFieldStyle(.roundedBorder)
}
HStack {
Spacer()
Button("Save") {
let text = inputName.trimmingCharacters(in: .whitespaces)
if !text.isEmpty {
buttonDisabled = true

Task(priority: .high) {
await appData.insertCountry(name: text)
dismiss()
}
}
}.disabled(buttonDisabled)
}
Spacer()
}.padding()
}
}
struct InsertCountryView_Previews: PreviewProvider {
static var previews: some View {
InsertCountryView().environmentObject(ApplicationData())
}
}

This view includes a TextField view to insert the name of the country and a
button to save it in the database. When the button is pressed, we call the
insertCountry() method in the model. The method creates the record with the
value inserted by the user and calls the save() method on the database to
store it in CloudKit servers.
Next is the view necessary to show the list of cities available for each
country. The view is called ShowCitiesView and opens when the user taps on a
row in the initial view to select a country.

Listing 10: Listing the cities of a country



import SwiftUI
import CloudKit

struct ShowCitiesView: View {


@EnvironmentObject var appData: ApplicationData
@State private var openSheet: Bool = false
let selectedCountry: CountryViewModel

var body: some View {


VStack {
List {
ForEach(appData.listCities) { city in
Text(city.cityName)
}
}
}
.navigationBarTitle(selectedCountry.countryName)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Add City") {
openSheet = true
}
}
}
.sheet(isPresented: $openSheet) {
InsertCityView(country: selectedCountry.id)
}
.task {
await appData.readCities(country: selectedCountry.id)
}
}
}
struct ShowCitiesView_Previews: PreviewProvider {
static var previews: some View {
ShowCitiesView(selectedCountry: CountryViewModel(id: CKRecord.ID(recordName: "Test"),
country: Country(name: "Test", record: CKRecord(recordType: "Cities", recordID:
CKRecord.ID(recordName: "Test")))))
.environmentObject(ApplicationData())
}
}

This view includes a property of type CountryViewModel called selectedCountry


to receive the information about the selected country. Form this property,
we get the country's record ID and call the readCities() method in the model
when the view appears to retrieve the cities available for that country. The
view creates a list with these values and then includes a sheet() modifier to
let the user add more. The modifier opens a view called InsertCityView for
this purpose.

Listing 11: Storing cities in the Private Database



import SwiftUI
import CloudKit

struct InsertCityView: View {


@EnvironmentObject var appData: ApplicationData
@Environment(\.dismiss) var dismiss

@State private var inputName: String = ""


@State private var buttonDisabled: Bool = false
let country: CKRecord.ID

var body: some View {


VStack {
HStack {
Text("City:")
TextField("Insert City", text: $inputName)
.textFieldStyle(.roundedBorder)
}
HStack {
Spacer()
Button("Save") {
let text = inputName.trimmingCharacters(in: .whitespaces)
if !text.isEmpty {
buttonDisabled = true

Task(priority: .high) {
await appData.insertCity(name: text, country: self.country)
dismiss()
}
}
}.disabled(buttonDisabled)
}
Spacer()
}.padding()
}
}
struct InsertCityView_Previews: PreviewProvider {
static var previews: some View {
InsertCityView(country: CKRecord.ID(recordName: "Test"))
.environmentObject(ApplicationData())
}
}

The view receives the country's record ID to know which country the city
belongs to, and then calls the insertCity() method in the model with the
name inserted by the user and this ID to create a Cities record connected
to that Countries record in the CloudKit database.

Do It Yourself: Create a new Multiplatform project. Open the Signing


& Capabilities panel, add the iCloud capability, and check the
CloudKit option (Figure 5). Add a container with the app's bundle as
the name. Add the Background Modes capability and check the
options Background fetch and Remote Notifications. Create a Swift
file called ApplicationData.swift for the model in Listing 7. Update
the ContentView view with the code in Listing 8. Create SwiftUI View
files with the names InsertCountryView.swift, ShowCitiesView.swift,
and InsertCityView.swift for the codes in Listings 9, 10, and 11,
respectively. Remember to inject the ApplicationData object into the
environment for the app and the previews, as we did in Listing 2.
Run the application on a device and press the Add Country button to
insert a country. Select the country and press the Add City button to
add a city.

The application is ready. When the user inserts a new value, the code
creates a record and uploads it to CloudKit servers, but if we stop and start
the application again, the countries are not shown on the screen anymore.
This is because we haven't defined the required indexes.
CloudKit automatically creates indexes for every key we include in the
records, except for the record’s identifier. Therefore, when we query the
Cities records by their country attribute to get the cities that belong to the
country selected by the user, CloudKit knows how to find and return those
records, but when we try to retrieve the Countries records without a
predicate, CloudKit tries to fetch them by the record identifiers and fails
because there is no index associated to that attribute (called recordName).
To create an index for this attribute, we need to go to the dashboard, click
on the Indexes option in the Schema section, and click on the Countries
record type to modify it.
When we click on a record, the panel shows the list of indexes available. By
default, the Countries records contains three indexes for the name
attribute, but no index for the record's identifier.

Figure 17: Record's indexes

To add an index, we must press the Add Basic Index button at the bottom
of the list. The panel opens a form to create a new index.
Figure 18: Index configuration

There are three types of indexes: Queryable (it can be included in a query),
Searchable (it can be searched), and Sortable (it can be sorted). By default,
all these indexes are associated to custom attributes but not to the record's
identifier. When we query the database from the readCountries() method in
the model, we do not specify any field in the predicate and therefore the
system fetches the records by their identifiers, which is described in the
database as recordID (recordName). For this reason, to retrieve the
countries in our example, we must add a Queryable index to the
recordName field of the Countries record type, as shown in figure 18.
Once we select the recordName field and the Queryable index, we can
press the Save button to save the changes. Now, if we run the application
again from Xcode, the records added to the database are shown on the
screen.

Do It Yourself: Open the CloudKit dashboard


(icloud.developer.apple.com/dashboard/), select your app's
container, click on the Indexes option in the Schema section. Click on
the record type Countries, click on the Add Basic Index button, and
add the index for the recordName field, as shown in Figure 18. (You
may have to wait a few seconds for the record type to be created
after a new record is added from the app.) Press the Save Changes
button to save the changes and run the application again. You should
see on the screen the countries you have inserted before.
Assets

Records may also include files, and the files may contain anything from
pictures to sound or even videos. To add a file to a record, we must create
an asset with the CKAsset class. The class includes the following initializer.

CKAsset(fileURL: URL)—This initializer creates a CKAsset object with


the content of the file in the location determined by the fileURL
argument.

The assets are added to a record with a key, as any other value. For our
example, we are going to store a picture in the record of every city and add
a view to show the picture when the city is selected.

Figure 19: Interface to work with assets

The following are the changes we have to introduce to the insertCity()


method in the model to get the URL of the image and assign the asset to
the record.

Listing 12: Storing assets



func insertCity(name: String, country: CKRecord.ID) async {
let id = CKRecord.ID(recordName: "idcity-\(UUID())")
let record = CKRecord(recordType: "Cities", recordID: id)
record.setObject(name as NSString, forKey: "name")

let reference = CKRecord.Reference(recordID: country, action: .deleteSelf)


record.setObject(reference, forKey: "country")

let bundle = Bundle.main


if let fileURL = bundle.url(forResource: "Toronto", withExtension: "jpg") {
let asset = CKAsset(fileURL: fileURL)
record.setObject(asset, forKey: "picture")
}
do {
try await database.save(record)
await MainActor.run {
let newCity = City(name: record["name"], record: record)
let newItem = CityViewModel(id: record.recordID, city: newCity)
listCities.append(newItem)
listCities.sort(by: { $0.cityName < $1.cityName })
}
} catch {
print("Error: \(error)")
}
}

In the new insertCity() method introduced in Listing 12, we get the URL of an
image included in the project called Toronto, create a CKAsset object with it,
and assign the object to the record of every city with the "picture" key.
Now, besides the name, a file with this image will be stored for every city
inserted by the user. To get the asset back, we must add a computed
property to the view model, as shown next.

Listing 13: Preparing the image for the view



struct CityViewModel: Identifiable {
let id: CKRecord.ID
let city: City

var cityName: String {


return city.name ?? "Undefined"
}
var cityPicture: UIImage {
if let asset = city.record["picture"] as? CKAsset, let fileURL = asset.fileURL {
if let picture = UIImage(contentsOfFile: fileURL.path) {
return picture
}
}
return UIImage(named: "nopicture")!
}
}

How to read the assets stored in the record depends on the type of
content managed by the asset. In this example, we must use the asset's
URL to create a UIImage object to show the image to the user. For this
purpose, the cityPicture property introduced in Listing 13 reads the value in
the record with the "picture" key, casts it as a CKAsset object, and gets the
asset's URL from the fileURL property. If the process is successful, we create
a UIImage object with the image in this URL and return it, otherwise, we
return a UIImage object with a placeholder image (nopicture).
The following are the modifications we must introduce to the ShowCitiesView
view to provide the NavigationLink view required for the user to be able to
select a city and open a view to see the city's picture.

Listing 14: Opening a view to show the asset



List {
ForEach(appData.listCities) { city in
NavigationLink(destination: ShowPictureView(selectedCity: city)) {
Text(city.cityName)
}
}
}

The NavigationLink view in Listing 14 opens a view called ShowPictureView to


show the city's picture. The following is our implementation of this view.

Listing 15: Showing the asset



import SwiftUI
import CloudKit

struct ShowPictureView: View {


@EnvironmentObject var appData: ApplicationData
let selectedCity: CityViewModel

var body: some View {


VStack {
Image(uiImage: selectedCity.cityPicture)
.resizable()
.scaledToFit()
Spacer()
}.navigationBarTitle(selectedCity.cityName)
}
}
struct ShowPictureView_Previews: PreviewProvider {
static var previews: some View {
ShowPictureView(selectedCity: CityViewModel(id: CKRecord.ID(recordName: "Test"), city:
City(name: "Test", record: CKRecord(recordType: "Cities", recordID: CKRecord.ID(recordName:
"Test")))))
.environmentObject(ApplicationData())
}
}

This view receives the information about the selected city through the
selectedCity property. From this property, we get the city's picture and name
and show them to the user. As a result, every time the user selects a city,
the asset is turned into an image and displayed on the screen.

Do It Yourself: Update the insertCity() method in the ApplicationData


class with the code in Listing 12, the CityViewModel structure in the
model with the code in Listing 13, and the ShowCitiesView view with
the code in Listing 14. Download the nopicture and the Toronto
images from our website. Add the nopicture image to the Asset
Catalog and the Toronto file to the project (we read this file from the
bundle). Create a SwiftUI View file with the name
ShowPictureView.swift for the code in Listing 15. Run the application
on a device, select a country and add a new city. Select the city. You
should see the Toronto image on the screen. In this example, we
always load the same image from the bundle, but you can get them
from the camera or the Photo Library.
Subscriptions

The previous example fetches the records available and shows them on the
screen every time the app is launched. This means that records added to
the database from another device will not be visible until the app is
launched again. This is not the behavior expected by the user. When
working with applications that store information online, users expects the
information to be updated as soon as it becomes available. To provide this
feature, CloudKit uses subscriptions.
Subscriptions are queries stored by our application in CloudKit servers.
When a change occurs in the database, the query detects the modification
and triggers the delivery of a Remote Notification from the iCloud servers
to the copy of the app that registered the subscription.
Database subscriptions are created from the CKDatabaseSubscription class (a
subclass of a generic class called CKSubscription). The class includes the
following initializer.

CKDatabaseSubscription(subscriptionID: String)—This initializer


creates a CKDatabaseSubscription object that represents a subscription
with the ID specified by the subscriptionID argument.

Subscriptions are also added to CloudKit servers with operations. The


CKDatabase class offers convenient methods to create them.

save(CKSubscription)—This asynchronous method stores in the


servers the subscription specified by the argument.
deleteSubscription(withID: String)—This asynchronous method
removes from the server the subscription with the identifier specified
by the withID argument.

After a subscription is registered on the server, we must listen to Remote


Notifications and download the changes. The first thing our application
needs to do to be able to receive these notifications is to register with the
iCloud servers. The UIApplication class offers the following method for this
purpose.

registerForRemoteNotifications()—This method registers the app


in iCloud servers to receive Remote Notifications. A token is generated
to identify each copy of our app, so the notifications are delivered to
the right user.

To report to our application that a Remote Notification was received, the


UIApplication object calls a method in the app's delegate. The following is the
method defined by the UIApplicationDelegate class for this purpose.

application(UIApplication, didReceiveRemoteNotification:
Dictionary, fetchCompletionHandler: Block)—This method is
called by the application on its delegate when a Remote Notification is
received. The didReceiveRemoteNotification argument is a dictionary
with information about the notification, and the
fetchCompletionHandler argument is a closure that we must execute
after all the custom tasks are performed. The closure must be called
with a value that describes the result of the operation. For this
purpose, UIKit offers the UIBackgroundFetchResult enumeration with the
values newData (new data was downloaded), noData (no data was
downloaded), and failed (the app failed to download the data).

Setting up a subscription on CloudKit servers requires us to follow several


steps. To begin with, we must call the registerForRemoteNotifications() method of
the UIApplication object as soon as the application is launched to tell the
system that we want to register the application to receive Remote
Notifications from iCloud servers (Apple's Push Notification service). For
this purpose, we must create a custom class that conform to the
UIApplicationDelegate protocol and implement the application(UIApplication,
method. If the registration is successful, the
didFinishLaunchingWithOptions:)
system calls the delegate method introduced above every time a
notification is received, so we need to implement that method as well, as
shown next.

Listing 16: Processing Remote Notifications from a custom app delegate



import UIKit
import CloudKit

class CustomAppDelegate: NSObject, UIApplicationDelegate {


let appData = ApplicationData.shared

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions:


[UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
application.registerForRemoteNotifications()
Task(priority: .background) {
await appData.configureDatabase()
}
return true
}
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo:
[AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping
(UIBackgroundFetchResult) -> Void) {
let notification = CKNotification(fromRemoteNotificationDictionary: userInfo) as?
CKDatabaseNotification
guard notification != nil else {
completionHandler(.failed)
return
}
appData.checkUpdates(finishClosure: { (result) in
completionHandler(result)
})
}
}

When the application is launched, we perform two operations. We call the


registerForRemoteNotifications() method on the UIApplication object to register the
application with iCloud servers and execute a method called
that we are going to define later in our ApplicationData class
configureDatabase()
to create the subscription and the custom zone for the first time.
The registerForRemoteNotifications() method prepares the application to receive
notifications, but the notifications are processed by the second delegate
method. The first thing we have to do in this method is to check whether
the notification received is a notification sent by a CloudKit server. For this
purpose, the CloudKit framework includes the CKNotification class with
properties we can use to process the dictionary and read its values. The
class includes the following initializer.

CKNotification(fromRemoteNotificationDictionary:
Dictionary)—This initializer creates a CKNotification object from the
information included in the notification (the value of the userInfo
parameter in the delegate method).

Because CloudKit servers can send other types of notifications, we also


check whether the notification received is of type CKDatabaseNotification. In
case of success, we proceed to download the data. But before, we must
consider that the system requires us to report the result of the operation.
Notifications may be received when the application is closed or in the
background. If this happens, the system launches our application and puts
it in the background to allow it to contact the servers and process the
information. But because this process consumes resources, the system
needs to know when the operation is over and therefore it requires us to
report it by calling the closure received by the completionHandler parameter
with a value of the UIBackgroundFetchResult enumeration that determines
what happened (newData if we downloaded new data, noData if there was
nothing to download, and failed if the process failed). By calling the closure,
we tell the system that the process is over, but because the operations are
performed asynchronously, we cannot do it until they are finished. That's
the reason why, in the example of Listing 16, we execute a method in the
model called checkUpdates() that takes a closure. This method downloads the
new information and executes the closure when finished. This way, we can
call the completionHandler closure after all the operations have been
processed.

IMPORTANT: The UIApplicationDelegate class also defines an


asynchronous method to receive remote notifications called
application(UIApplication, didReceiveRemoteNotification: Dictionary). In this
example, we are using concurrent operations, but if you need to
perform asynchronous operations, you may implement this method
instead.

Of course, the protocol methods defined in Listing 16 are only called if we


assigned the class as the app delegate with the @UIApplicationDelegateAdaptor
property wrapper from the App structure.

Listing 17: Assigning the app's delegate



import SwiftUI

@main
struct TestApp: App {
@UIApplicationDelegateAdaptor(CustomAppDelegate.self) var appDelegate
@StateObject var appData = ApplicationData.shared

var body: some Scene {


WindowGroup {
ContentView()
.environmentObject(appData)
}
}
}

Because we are working with an app delegate, we need our model to be a


singleton, so we can reference it from anywhere in our code. The following
are the new properties we need to include in our model and the changes
required by the initializer. (This example assumes that we are working with
the model introduced in Listing 7.)

Listing 18: Defining the properties to control the subscription and the
custom zone

class ApplicationData: ObservableObject {
@AppStorage("subscriptionSaved") var subscriptionSaved: Bool = false
@AppStorage("zoneCreated") var zoneCreated: Bool = false
@AppStorage("databaseToken") var databaseToken: Data = Data()
@AppStorage("zoneToken") var zoneToken: Data = Data()

@Published var listCountries: [CountryViewModel] = []


@Published var listCities: [CityViewModel] = []
var database: CKDatabase!

static let shared = ApplicationData()

private init() {
let container = CKContainer.default()
database = container.privateCloudDatabase

Task(priority: .high) {
await readCountries()
}
}

Subscriptions only report changes in customs zones. Therefore, if we want


to receive notifications, in addition to creating the subscription we also
have to create a record zone and store all our records in it. Therefore, the
first thing we do in the model in Listing 18 is to store two Boolean values in
the App Storage system called "subscriptionSaved" and "zoneCreated".
These values will be used later to know whether we have already created
the subscription and the custom zone.
This model also includes two additional @AppStorage properties that we will
use later to keep track of the current updates received from the server, and
a static property to provide a unique instance we can access from anywhere
in the code.
The next step is to implement the methods in charge of contacting the
CloudKit servers and processing the information. From the application's
delegate, we called two methods in the model: the configureDatabase()
method to create the subscription and the zone, and the checkUpdates()
method to download and process the information. The following is our
implementation of the configureDatabase() method.

Listing 19: Configuring the database



func configureDatabase() async {
if !subscriptionSaved {
let newSubscription = CKDatabaseSubscription(subscriptionID: "updatesDatabase")
let info = CKSubscription.NotificationInfo()
info.shouldSendContentAvailable = true
newSubscription.notificationInfo = info

do {
try await database.save(newSubscription)
await MainActor.run {
subscriptionSaved = true
}
} catch {
print("Error: \(error)")
}
}
if !zoneCreated {
let newZone = CKRecordZone(zoneName: "listPlaces")
do {
try await database.save(newZone)
await MainActor.run {
zoneCreated = true
}
} catch {
print("Error: \(error)")
}
}
}

The first thing we do in the configureDatabase() method is to check the value
of the subscriptionSaved property to know if a subscription was already
created. If not, we use the CKDatabaseSubscription initializer to create a
subscription with the name "updatesDatabase" and then define the
notificationInfo property to configure the notifications that are going to be
sent by the server. For this purpose, the framework defines the
CKNotificationInfo class. This class includes multiple properties to configure
Remote Notifications for CloudKit, but database subscriptions only require
us to set the shouldSendContentAvailable property with the value true. After this,
the subscription is saved on the server with the save() method of the
CKDatabase object and the value true is assigned to the subscriptionSaved
property if the operation is successful.
We follow the same procedure to create the custom zone. We check the
value of the zoneCreated property to know if there is a custom zone already
on the server, and if not, we create one called "listPlaces" to store our
records. If the operation is successful, we assign the value true to the
zoneCreated property to indicate that the zone was already created.
Next, we must define the checkUpdates() method to download and process
the changes in the database. But first, we need to think about how we are
going to organize our code. Every process executed in CloudKit servers is
performed by asynchronous operations. This means that we need to think
about the order in which the operations are executed. For some, the order
doesn't matter, but for others it is crucial. For instance, we cannot store
records in a zone before the zone is created. We could use Swift
concurrency to control the order in which tasks are performed, but
CloudKit operations are already concurrent. Therefore, depending on the
requirements of our application, it may be better to just implement
closures that execute code after the operations are over, and this is the
approach we take in this example.
The procedure is as follows. Every time we call the checkUpdates() method to
download the data, we send a closure to the method with the code we
want to execute once the operation is over. This way, we make sure that
the operations are over before doing anything else.
Listing 20: Initiating the process to get the updates from the server

func checkUpdates(finishClosure: @escaping (UIBackgroundFetchResult) -> Void) {
Task(priority: .high) {
await configureDatabase()
downloadUpdates(finishClosure: finishClosure)
}
}

The checkUpdates() method calls the configureDatabase() method again to make


sure that the database is configured properly.
To simplify the code, we moved the statements to an additional method
called downloadUpdates(). So after we confirm that the zone was created, we
call this method with a reference to the closure received by the
checkUpdates() method. (We pass the closure from one method to another so
we can execute it after all the operations are over, as we will see next.)

IMPORTANT: Passing closures from one method to another is a way


to control the order in which the code is executed when we use
concurrent operations. We chose this programming pattern for this
example because it simplifies the code, but as we mentioned before,
in some cases may be better to implement Swift concurrency.

Before implementing the downloadUpdates() method and process the changes


in the database, we ought to study the operations provided by the CloudKit
framework for this purpose. The operation to fetch the list of changes
available in the database is created from a subclass of the
CKDatabaseOperation class called CKFetchDatabaseChangesOperation. This class
includes the following initializer.

CKFetchDatabaseChangesOperation(previousServerChangeT
oken: CKServerChangeToken?)—This initializer creates an
operation to fetch changes from a database. The argument is a token
that determines which changes were already fetched. If we specify a
token, only the changes that occurred after the token was created are
fetched.

The class also includes properties to define completion handlers (closures)


for every step of the process.

recordZoneWithIDChangedBlock—This property sets a closure


that is executed to report which zones present changes. The closure
receives a value of type CKRecordZone.ID with the identifier of the zone
that changed.
changeTokenUpdatedBlock—This property sets a closure that is
executed to provide the last database token. The closure receives an
object of type CKServerChangeToken with the current token that we can
store to send to subsequent operations.
fetchDatabaseChangesResultBlock—This property sets a closure
that is executed when the operation is over. The closure receives a
Result enumeration to report the success or failure of the operation.
The enumeration value includes a tuple and a CKError value to report
errors. The tuple includes two values: a CKServerChangeToken object
with the last token and a Boolean value that indicates if there are
more changes available.

After the completion of the CKFetchDatabaseChangesOperation operation, we


must perform another operation to download the changes. For this
purpose, the framework includes the CKFetchRecordZoneChangesOperation class
with the following initializer.

CKFetchRecordZoneChangesOperation(recordZoneIDs:
[CKRecordZone.ID], configurationsByRecordZoneID:
Dictionary)—This initializer creates an operation to download
changes from a database. The recordZoneIDs argument is an array
with the IDs of all the zones that present changes, and the
configurationsByRecordZoneID argument is a dictionary with
configuration values for each zone. The dictionary takes
CKRecordZone.ID objects as keys and options determined by an object
of the ZoneConfiguration class included in the
CKFetchRecordZoneChangesOperation class. The class includes three
properties to define the options: desiredKeys (array of strings with the
keys we want to retrieve), previousServerChangeToken (CKServerChangeToken
object with the current token), and resultsLimit (integer that determines
the number of records to retrieve).

The CKFetchRecordZoneChangesOperation class also includes properties to define


completion handlers (closures) for every step of the process.

recordWasChangedBlock—This property sets a closure that is


executed when a new or updated record is downloaded. The closure
receives two values: a CKRecord.ID with the identifier of the record
that changed, and a Result enumeration value to report the success or
failure of the operation. The enumeration includes two values: a
CKRecord object with the record that changed and a CKError value to
report errors.
recordWithIDWasDeletedBlock—This property sets a closure that
is executed when the operation finds a deleted record. The closure
receives two values: a CKRecord.ID object with the identifier of the
record that was deleted, and a string with the record's type.
recordZoneChangeTokensUpdatedBlock—This property sets a
closure that is executed when the change token for the zone is
updated. The closure receives three values: a CKRecordZone.ID with the
identifier of the zone associated to the token, a CKServerChangeToken
object with the current token, and a Data structure with the last token
sent by the app to the server.
recordZoneFetchResultBlock—This property sets a closure that is
executed when the operation finishes downloading the changes of a
zone. The closure receives two values: a CKRecordZone.ID with the
zone's identifier, and a Result enumeration value to report the success
or failure of the operation. The enumeration includes two values: a
tuple and a CKError value to report errors. In turn, the tuple includes
three values: a CKServerChangeToken object with the current token, a
Data structure with the last token sent to the server, and a Boolean
value that indicates if there are more changes available.
fetchRecordZoneChangesResultBlock—This property sets a
closure that is executed after the operation is over. The closure
receives a Result enumeration value to report errors.

CloudKit servers use tokens to know which changes were already sent to
every instance of the app, so the information is not downloaded twice
from the same device. If a device stores or modifies a record, the server
generates a new token, so the next time a device accesses the servers only
the changes introduced after the last token was created will be
downloaded.

Figure 20: Tokens


In the process depicted in Figure 20, the app in Device 1 stores a new
record in the server (Record 1). To report the changes, the server generates
a new token (A). When the app in Device 2 connects to the server, the
server detects that this device does not have the latest token, so it returns
Record 1 and the current token (A) to update the state in this device. If
later the user decides to create a new record from Device 2 (Record 2), a
new token will be created (B). The next time Device 1 connects to the
server, it will find that its token is different from the server's token, so it
will download the modifications inserted after token A.
Tokens are great because they allow us to only get the latest changes, but
this process is not automatic, we are responsible of storing the current
tokens and preserve the state of our app. The server creates a token for
the database and a token for each of the custom zones. For our example,
we need two tokens: one to keep track of the changes in the database and
another for the custom zone created by the configureDatabase() method. To
work with these values, we are going to use two variables called
changeToken, for the database token, and fetchChangeToken, for the token of
our custom zone, and we are going to store them permanently with the
@AppStorage properties defined before in the model (databaseToken and
zoneToken). All this process is performed by the downloadUpdates() method.

Listing 21: Downloading the updates from the server



func downloadUpdates(finishClosure: @escaping (UIBackgroundFetchResult) -> Void) {
var changeToken: CKServerChangeToken!
var changeZoneToken: CKServerChangeToken!
if let token = try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self,
from: databaseToken) {
changeToken = token
}
if let token = try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self,
from: zoneToken) {
changeZoneToken = token
}
var zonesIDs: [CKRecordZone.ID] = []
let operation = CKFetchDatabaseChangesOperation(previousServerChangeToken:
changeToken)
operation.recordZoneWithIDChangedBlock = { zoneID in
zonesIDs.append(zoneID)
}
operation.changeTokenUpdatedBlock = { token in
changeToken = token
}
operation.fetchDatabaseChangesResultBlock = { result in
guard let values = try? result.get() else {
finishClosure(UIBackgroundFetchResult.failed)
return
}
if zonesIDs.isEmpty {
finishClosure(UIBackgroundFetchResult.noData)
} else {
changeToken = values.serverChangeToken

let configuration = CKFetchRecordZoneChangesOperation.ZoneConfiguration()


configuration.previousServerChangeToken = changeZoneToken
let fetchOperation = CKFetchRecordZoneChangesOperation(recordZoneIDs: zonesIDs,
configurationsByRecordZoneID: [zonesIDs[0]: configuration])

fetchOperation.recordWasChangedBlock = { recordID, result in


guard let record = try? result.get() else {
print("Error")
return
}
if record.recordType == "Countries" {
Task(priority: .high) {
let index = self.listCountries.firstIndex(where: { item in
return item.id == record.recordID
})
await MainActor.run {
let newCountry = Country(name: record["name"], record: record)
let newItem = CountryViewModel(id: record.recordID, country: newCountry)
if index != nil {
self.listCountries[index!] = newItem
} else {
self.listCountries.append(newItem)
}
self.listCountries.sort(by: { $0.countryName < $1.countryName })
}
}
}
}
fetchOperation.recordWithIDWasDeletedBlock = { recordID, recordType in
if recordType == "Countries" {
Task(priority: .high) {
let index = self.listCountries.firstIndex(where: {(item) in
return item.id == recordID
})
await MainActor.run {
if index != nil {
self.listCountries.remove(at: index!)
}
self.listCountries.sort(by: { $0.countryName < $1.countryName })
}
}
}
}
fetchOperation.recordZoneChangeTokensUpdatedBlock = { zoneID, token, data in
changeZoneToken = token
}
fetchOperation.recordZoneFetchResultBlock = { zoneID, result in
guard let values = try? result.get() else {
print("Error")
return
}
changeZoneToken = values.serverChangeToken
}
fetchOperation.fetchRecordZoneChangesResultBlock = { result in
switch result {
case .failure(_):
finishClosure(UIBackgroundFetchResult.failed)
return
default:
break
}
if changeToken != nil {
if let data = try? NSKeyedArchiver.archivedData(withRootObject: changeToken!,
requiringSecureCoding: false) {
Task(priority: .high) {
await MainActor.run {
self.databaseToken = data
}
}
}
}
if changeZoneToken != nil {
if let data = try? NSKeyedArchiver.archivedData(withRootObject: changeZoneToken!,
requiringSecureCoding: false) {
Task(priority: .high) {
await MainActor.run {
self.zoneToken = data
}
}
}
}
finishClosure(UIBackgroundFetchResult.newData)
}
self.database.add(fetchOperation)
}
}
database.add(operation)
}

This is a very long method that we need to study piece by piece. As


mentioned before, we start by defining the properties we are going to use
to store the tokens (one for the database and another for the custom
zone). Next, we check if there are tokens already stored in the @AppStorage
properties. Because the tokens are instances of the CKServerChangeToken
class, we cannot store their values directly in App Storage, we must first
convert them into Data structures. This is the reason why, when we read
the values, we cast them as Data with the as? operator and then unarchive
them with the unarchivedObject() method of the NSKeyedUnarchiver class.
Next, we configure the operations necessary to get the updates from the
server. We must perform two operations on the database, one to
download the list of changes available and another to download the actual
changes and show them to the user. The operations are performed and
then the results are reported to the closures assigned to their properties.
The first operation we need to perform is the CKFetchDatabaseChangesOperation
operation. The initializer requires the previous token to get only the
changes that are not available on the device, so we pass the value of the
changeToken property. Next, we define the closures for each of its properties.
This operation includes three properties, one to report the zones that
changed, one to report the creation of a new database token, and another
to report the conclusion of the operation. The first property defined in our
example is recordZoneWithIDChangedBlock. The closure assigned to this
property is executed every time the system finds a zone whose content has
changed. In this closure, we add the zone ID to an array to keep a reference
of each zone that changed.
Something similar happens with the closure assigned next to the
changeTokenUpdatedBlock property. This closure is executed every time the
system decides to perform the operation again to download the changes in
separate processes. To make sure that we only receive the changes that we
did not process yet, we use this closure to update the changeToken property
with the current token.
The last property we have defined for this operation is
fetchDatabaseChangesResultBlock. The closure assigned to this property is
executed to let the app know that the operation is over, and this is how we
know that we have all the information we need to begin downloading the
changes with the second operation. This closure receives a Result
enumeration value, which includes a tuple with two values and an Error
value to report errors. If no values are returned, we execute the finishClosure
closure with the value failed and the operation is over. On the other hand, if
there are values available, we check if the zoneIDs array contains any zone
ID. If it is empty, it means that there are no changes available and therefore
we execute the finishClosure closure with the value noData, but if the array is
not empty, we store the last token in the changeToken variable and configure
the CKFetchRecordZoneChangesOperation operation to download the changes.
The CKFetchRecordZoneChangesOperation operation is performed over the zones
that changed, so we must initialize it with the array of zone identifiers
generated by the previous operation. The initializer also requires a
dictionary with the zone identifiers as keys and ZoneConfiguration objects that
include the previous token for each zone as values. Because in this example
we only work with one zone, we read the first element of the zonesIDs array
to get the identifier of our custom zone and provide a ZoneConfiguration
object with the current token for the zone stored in the changeZoneToken
variable.
This operation works like the previous one. The changes are fetched, and
the results are reported to the closures assigned to its properties. The first
property declared in Listing 21 is recordWasChangedBlock. The closure assigned
to this property is called every time a new or updated record is received.
Here, we check if the record is of type Countries and store it in the
corresponding array. When the record is of type Countries, we use the
firstIndex(where:) method to look for duplicates. If the record already exists in
the array, we update its values, otherwise, we add the record to the list.
The closure of the recordWithIDWasDeletedBlock property defined next is
executed every time the app receives the ID of a deleted record (a record
that was deleted from the CloudKit database). In this case, we do the same
as before but instead of updating or adding the record we remove it from
the list with the remove() method.
The closures assigned to the next two properties,
recordZoneChangeTokensUpdatedBlock and recordZoneFetchResultBlock, are executed
when the process completes a cycle, either because the system decides to
download the data in multiple processes, or the operation finished fetching
the changes in a zone. Depending on the characteristics of our application,
we may need to perform some tasks in these closures, but in our example,
we just store the current token in the changeZoneToken variable so the next
time the operation is performed we only get the changes we have not
downloaded yet.
Finally, the closure assigned to the fetchRecordZoneChangesResultBlock property
is executed to report that the operation is over. The closure receives a Result
value to report errors. If there is an error, we call the finishClosure closure
with the value failed to tell the system that the operation failed, otherwise,
we store the current tokens in the @AppStorage properties and call the
finishClosure closure with the value newData, to tell the system that new data
has been downloaded. Notice that to store the tokens we must turn them
into Data structures and encode them with the archivedData() method of the
NSKeyedArchiver class.
Lastly, after the definition of each operation and their properties, we call
the add() method of the CKDatabase object to add them to the database.
There is one more change we must perform in our model for the
subscription to work. So far, we have stored the records in the zone by
default, but as we already mentioned, subscriptions require the records to
be stored in a custom zone. The following are the changes we must
introduce to the insertCountry() and insertCity() methods to store the records
inside the listPlaces zone created before.

Listing 22: Storing the records in a custom zone



func insertCountry(name: String) async {
await configureDatabase()

let text = name.trimmingCharacters(in: .whitespaces)


if !text.isEmpty {
let zone = CKRecordZone(zoneName: "listPlaces")
let id = CKRecord.ID(recordName: "idcountry-\(UUID())", zoneID: zone.zoneID)
let record = CKRecord(recordType: "Countries", recordID: id)
record.setObject(text as NSString, forKey: "name")

do {
try await database.save(record)
await MainActor.run {
let newCountry = Country(name: record["name"], record: record)
let newItem = CountryViewModel(id: record.recordID, country: newCountry)
listCountries.append(newItem)
listCountries.sort(by: { $0.countryName < $1.countryName })
}
} catch {
print("Error: \(error)")
}
}
}
func insertCity(name: String, country: CKRecord.ID) async {
await configureDatabase()

let text = name.trimmingCharacters(in: .whitespaces)


if !text.isEmpty {
let zone = CKRecordZone(zoneName: "listPlaces")
let id = CKRecord.ID(recordName: "idcity-\(UUID())", zoneID: zone.zoneID)
let record = CKRecord(recordType: "Cities", recordID: id)
record.setObject(text as NSString, forKey: "name")

let reference = CKRecord.Reference(recordID: country, action: .deleteSelf)


record.setObject(reference, forKey: "country")

let bundle = Bundle.main


if let fileURL = bundle.url(forResource: "Toronto", withExtension: "jpg") {
let asset = CKAsset(fileURL: fileURL)
record.setObject(asset, forKey: "picture")
}
do {
try await database.save(record)
await MainActor.run {
let newCity = City(name: record["name"], record: record)
let newItem = CityViewModel(id: record.recordID, city: newCity)
listCities.append(newItem)
listCities.sort(by: { $0.cityName < $1.cityName })
}
} catch {
print("Error: \(error)")
}
}
}

All we have to do to store a record in a custom zone is to create the


CKRecordZone object and assign its ID to the record ID by including it in the
initializer of the CKRecord.ID object.
Notice that the first thing we do in both methods is to call the
configureDatabase() method. We call this method again, so every time a record
is inserted, we check that the subscription and the zone were already
added to the database.
Do It Yourself: Create a Swift file called CustomAppDelegate.swift for
the class in Listing 16. Update the App structure with the code in
Listing 17. Update the ApplicationData class with the properties and
initializer of Listing 18. Add the methods of Listings 19, 20, and 21 to
the ApplicationData class. Update the insertCountry() and insertCity()
methods of the ApplicationData class with the code in Listing 22.
Update the PreviewProvider structures for each view to load the model
from the shared property (environmentObject(ApplicationData.shared)). Run
the application in two different devices and insert a new country.
You should see the country appear on the screen of the second
device.
Errors

Errors are an important part of CloudKit. The service is highly dependent


on the network and how reliable it is. If the device is disconnected or the
connection is not good enough, the operations might not be performed or
data might be lost. CloudKit does not provide a standard solution for these
situations, it just returns an error and expects our app to solve the
problem. If the user creates a new record but at that moment the device is
disconnected from the Internet, our app is responsible for registering the
incident and trying again later.
The most common error is related to the user's iCloud account. Every user
must have an iCloud account to access CloudKit servers. If an iCloud
account is not set on the device or has restrictions due to Parental Control
or Device Management, the app will not be able to connect to the servers.
The CKContainer class offers the following method to check the status of the
user's account.

accountStatus()—This asynchronous method attempts to access the


user's iCloud account and returns a CKAccountStatus enumeration to
report the current state. The enumeration includes the values
couldNotDetermine, available, restricted, and noAccount.

If the status of the iCloud account changes while the app is running, the
system posts a notification that we can use to perform updates and
synchronization tasks.

CKAccountChanged—This notification is posted by the system


when the status of the user's iCloud account registered on the device
changes.

We should always check if the servers are available before trying to


perform an operation and warn the user about it. For instance, we can
modify the insertCountry() method in our model to check the status of the
connection before introducing a new record.

Listing 23: Checking CloudKit availability



func insertCountry(name: String) async {
await configureDatabase()

do {
let container = CKContainer.default()
let status = try await container.accountStatus()
if status != CKAccountStatus.available {
print("iCloud Not Available")
return
}
} catch {
print("Error: \(error)")
return
}
let text = name.trimmingCharacters(in: .whitespaces)
if !text.isEmpty {
let zone = CKRecordZone(zoneName: "listPlaces")
let id = CKRecord.ID(recordName: "idcountry-\(UUID())", zoneID: zone.zoneID)
let record = CKRecord(recordType: "Countries", recordID: id)
record.setObject(text as NSString, forKey: "name")

do {
try await database.save(record)
await MainActor.run {
let newCountry = Country(name: record["name"], record: record)
let newItem = CountryViewModel(id: record.recordID, country: newCountry)
listCountries.append(newItem)
listCountries.sort(by: { $0.countryName < $1.countryName })
}
} catch {
print("Error: \(error)")
}
}
}

This example checks the status of the account and prints a message on the
console if an error occurs or the status is other than available. If an error
occurs, the code returns from the function without letting the user insert
the new record.

Do It Yourself: Update the insertCountry() method in the ApplicationData


class with the code in Listing 23. Run the application on a device and
activate Airplane Mode from Settings. Add a new country. You
should see a CKError on the console that reads "Network
Unavailable".

In the last example, we just checked whether an error occurred or not and
proceeded accordingly, but we can also identify the type of error returned
by the operation. Errors are structures that conform to the Error protocol.
Every time we want to read an error, we must cast it to the right type. In
CloudKit, the errors are of type CKError, a structure that includes the
following property to return the error code.

code—This property returns a value that identifies the error. The


property is of type CKError.Code; an enumeration defined by the
CKError structure with values that represent all the errors produced by
CloudKit. The list of values available is extensive. The most frequently
used are partialFailure, networkUnavailable, networkFailure, serviceUnavailable,
unknownItem, operationCancelled, changeTokenExpired, quotaExceeded,
zoneNotFound, and limitExceeded.

The following example implements the recordZone() method of the


CKDatabase object to check whether a zone exists in the database. In the
catch block, we cast the value of the error parameter as a CKError structure
and then compare the value of its code property with the value zoneNotFound
of the Code enumeration. If the values match, it means that the zone we
tried to access does not exist.

Listing 24: Checking for errors



func checkZones() async {
let newZone = CKRecordZone(zoneName: "myNewZone")
do {
try await database.recordZone(for: newZone.zoneID)
} catch {
if let error = error as? CKError {
if error.code == CKError.Code.zoneNotFound {
print("Not found")
} else {
print("Zone Found")
}
}
}
}

Do It Yourself: Add the checkZones() method in Listing 24 to the


ApplicationData class. Call this method from the initializer (checkZones()).
Run the application again. You should see the message "Not Found"
on the console because we are trying to access a zone with a
different name than the one we have created before.
Deploy to Production

In CloudKit's dashboard, at the bottom of the panel on the left, there is a


list of options to work with the database schema. We can export the
schema, import a schema from our computer, reset the schema to start
from scratch, and deploy the schema to production. This last option is the
one we need to select when we want to prepare our app for distribution
(to be sold in the App Store).
The Deploy Schema Changes option opens a panel where we can see the
features that are going to be transferred to the Production environment.
This includes record types and indexes, but it does not include records
(values added for testing). If we agree, we must press the Deploy button to
finish the process, and our database in CloudKit will be ready for
distribution.

IMPORTANT: The Production environment is used by apps that are


submitted to Apple for distribution. This step is required for your
application to be published in the App Store. If you don't deploy the
changes to production, the database is not going to be available to
your users.
Find more books at
www.formasterminds.com

J.D Gauchat
www.jdgauchat.com

You might also like