4.
Defining the Data Layer - Databases
Written by Josh Steele
In the last chapter, you learned how to fetch data from the Petfinder API and convert that data into objects in your model. To this point, the model objects, represented as structs in the code, exist in memory only. Unfortunately, when the user closes the app, any data the app has in memory gets released.
In this chapter, you’ll learn about data persistence in iOS apps. In the broadest sense, persistence involves storing data on the user’s device to prevent the app from having to download the data again in the future, at least in its entirety.
Some of the native persistence frameworks in iOS are User Defaults, Core Data and CloudKit, and of course, writing directly to the file system. You can also use third-party frameworks, such as those in Google Firebase.
Your focus for this chapter is Core Data, Apple’s framework for working with databases. It’s time to persist information and understand why persistence is a good standard practice in real-world iOS development.
Note: There are other iOS libraries that support persistence via databases. This book stays with the native framework, Core Data, to take advantage of tight integration with other frameworks such as SwiftUI. You’ll see this later in the chapter, and later in the book.
The benefits of persistence
Persistence is a must-have feature in most modern-day mobile apps. Here are some reasons why you should consider persistence early on in your design process.
Saving resources
One of the guiding principles in developing mobile apps is to be considerate of the device resources your app uses. For example, a call to the network touches on the following resources:
- Power: The device’s antenna requires power to communicate with the network.
- Data plan: Communication with the network may also use the user’s data plan if they are on a cellular network.
Repeated calls to the network can spend these resources at a higher rate than normal, especially if the app downloads the same data over and over again. If the app uses persistence, it can focus on only retrieving new data from the network, saving on valuable device resources.
Your app is always available, immediately
Once data is on a device, your app no longer needs network access to get data. Even though it may be stale, the onboard data can populate your app’s views until the networking layer can fetch new data. Without persistence, your views would remain unpopulated, which would make for a poor user experience.
Your app can maintain user state
Persistence isn’t only for data retrieved from the network. It can also store lightweight items such as user preferences inside User Defaults. The ability to store the user state of the app lets the user continue where they left off the last time they used the app.
Your app can live on many devices
If your app syncs data to iCloud via CloudKit, the user can also continue their session on their other devices. Recent changes to the CloudKit framework make cloud sync as easy as changing a few lines of code.
Now with a better understanding of why persistence is valuable in your app, it’s time to add it to the project!
Note: The starter project for this chapter will not compile! You’ll fix this as you add some code later on.
Defining a database schema
Earlier in the book, you set up structs to represent the various domain objects from the Petfinder API. Now you need to map those domain objects into something that Core Data can understand. You do this with a database schema.
Updating the database schema
In the starter project for this chapter, open Core/data/coreData. You’ll find a mostly completed PetSave.xcdatamodeld that contains the schema for the project:
You’re missing one entity in the schema - the pet’s breed! Add BreedEntity
and the following attributes:
The attributes are:
- id: Although entities have a built-in unique id, the entities here have their own id to help better translate between struct and classes. You’ll read more on using this later.
- mixed: A Boolean which states if the pet is a mixed breed.
- primary and secondary: Strings that describe the pet’s primary and secondary breeds.
- unknown: A Boolean that describes if the breed is unknown. This attribute is useful if that data isn’t retrieved from the API.
Core Data synthesizes classes behind the scenes for each of the entities in the schema by default. You can add extra functionality to the classes via extensions, which you’ll learn about later in this chapter.
Special case: enums
Many of the types in the project are enums, which don’t map to any of the types available in the schema. But enums in Swift can have associated values which means built-in Swift types can represent the enums in the database schema.
One of AnimalEntity
‘s properties is a String
that describes the pet’s age called ageValue
. You must store it as a string since you can’t store enums in the schema. But to take advantage of Swift’s type safety features, you should use the enum
when dealing with that value for the animal’s age elsewhere in the code.
Open Core/data/coreData/extensions/. Then open Animal+CoreData.swift. Find the extension to the AnimalEntity, and declare a computed property for the age:
extension AnimalEntity {
var age: Age {
//1
get {
guard let ageValue = ageValue,
let age = Age(rawValue: ageValue) else {
return Age.unknown
}
return age
}
//2
set {
self.ageValue = newValue.rawValue
}
}
//.....
}
In this computed property:
-
get
uses a pair ofguard let
statements to do some checks. First, it makes sure that a current value for the age exists. Second, it ensures that it’s convertible to anAge
enum with theAge(rawValue:)
initializer. If the guard fails, it returnsAge.unknown
. Otherwise, it returns theAge
enum. -
set
only deals with the string you store in the entity, so it setsageValue
to thenewValue.rawValue
property.
Defining relationships
So far, the entities you’ve defined have been basic building blocks for the bigger Animal
entity you need to build. The starter project includes many of the relationships that you’ll need. Here’s the current state of the AnimalEntity
in the schema:
AnimalEntity
connects to other entities in the schema by way of relationships. As you can see at the bottom of the image above, the entity stores relationships below the attributes. Some of the relationships are One-to-One, denoted by the red O, and others are One-to-Many, denoted by the red M.
To finish setting up AnimalEntity
, first select the newly created BreedEntity
and add a One-to-Many animal
relationship.
Note: At first, the Inverse part of the relationship will be set to No Inverse. This will look like the screenshot after the step below, once you set the other part of the relationship.
Then, select AnimalEntity
and add the breeds
relationship, which is a One-to-One relationship:
Note: Even though you call the relationship
breeds
, it’s only a One-to-One relationship. The pet may be a mix of many breeds, and the entity captures that in thebreed
’sprimary
andsecondary
properties.
OK, that completes your schema. So, how do you use it?
The Persistence class
Xcode projects set up to use Core Data include Persistence.swift. This file sets up the Core Data Stack and in-memory and on-disk stores, which act as scratch pads for your work until you persist them to the database. As part of this setup, it has access to the entities you declared in the schema earlier.
In-Memory store
Persistence.swift sets up two distinct stores for your app. The first is for in-memory objects. Near the top of the file you’ll find:
static var preview: PersistenceController = {
let result = PersistenceController(inMemory: true)
let viewContext = result.container.viewContext
for _ in 0..<10 {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
}
//......
The code currently uses the default Item
entity that comes with the Core Data-based project template. Later, you’ll change this to use AnimalEntity
, which will provide a set of data to your view previews, so you don’t have to rely on an on-disk database.
On-Disk store
For the on-disk store, there’s a much simpler declaration near the top of Persistence.swift:
struct PersistenceController {
static let shared = PersistenceController()
//...
This code provides access to a singleton of PersistenceController
, which lets you access the database from anywhere in your project.
To aid you in saving the context, add a static method to Persistence.swift:
static func save() {
// 1
let context =
PersistenceController.shared.container.viewContext
// 2
guard context.hasChanges else { return }
// 3
do {
try context.save()
} catch {
fatalError("""
\(#file), \
\(#function), \
\(error.localizedDescription)
""")
}
}
This code does a few simple things:
- You get a reference to the Core Data Context. In this case, using the on-disk store.
- You don’t need to save unless there are pending changes, so return if
hasChanges
is false. - The call to
context.save()
can throw, so wrap it in ado/catch
. Any errors get their information sent to afatalError
call.
The latter half of Persistence.swift initializes the NSPersistentContainer
and attempts to load the persistent stores.
There’s a place to handle errors encountered when loading the persistent stores. Typically, they only happen during development, but it may be worth alerting the user if the app encounters a disk space error.
Note: For much more on the Core Data stack, be sure to check out Core Data by Tutorials
Swift structs and Core Data classes
As you learned when working with entities in PetSave.xcdatamodeld earlier, Core Data works with classes. The PetSave app, up to this point, uses structs for the data model objects. Here’s how you can add some code to convert from the structs to Core Data classes and back again.
Implementing a CoreDataPersistable protocol
If you’ve had any experience with Core Data in the past and have had to worry about converting back and forth between structs and classes, you know there’s a lot of boilerplate code. Consider an example Person
struct, which has a corresponding PersonEntity
Core Data entity.
Here’s the code that you need to handle converting back and forth between struct and class:
struct Person {
var age: Int
var gender: Gender
var height: Double
var weight: Double
//....
}
extension Person {
init(managedObject: PersonEntity) {
self.age = managedObject.age
self.gender = Gender(rawValue: managedObject.gender)
self.height = managedObject.height
self.weight = managedObject.weight
//...
}
}
extension PersonEntity {
init (valueObject: Person) {
self.setValue(valueObject.age, forKey: "age")
self.setValue(
valueObject.gender.rawValue, forKey: "gender")
self.setValue(valueObject.height, forKey: "height")
self.setValue(valueObject.weight, forKey: "weight")
//...
}
}
This amount of code seems manageable for one class, but what if you have ten or twenty, or more, entities in your schema or many more properties? This creates a lot of code for each struct. On the surface, that code does the same thing: converting between structs and classes. You can reduce this boilerplate code by defining some protocols and default implementations.
First, add a new file in Core/data/coreData/extensions called CoreDataPersistable.swift. Here, add import CoreData
and the following protocol, UUIDIdentifiable
, that adopts Identifiable
:
import CoreData
protocol UUIDIdentifiable: Identifiable {
var id: Int? { get set }
}
This code ensures that each of the data model objects is identifiable by an Integer id
.
Next, add a CoreDataPersistable
protocol that adopts UUIDIdentifiable
. Add the following initializers and methods:
protocol CoreDataPersistable: UUIDIdentifiable {
//1
associatedtype ManagedType
//2
init()
//3
init(managedObject: ManagedType?)
//4
var keyMap: [PartialKeyPath<Self>: String] { get }
//5
mutating func toManagedObject(
context: NSManagedObjectContext) -> ManagedType
//6
func save(context: NSManagedObjectContext) throws
}
Here’s a breakdown of this protocol:
- This protocol uses generics and has an associated type. Associated types are placeholders for the concrete types you’ll pass in later when you adopt this protocol, which will let you bind a value type, struct, with a class type,
ManagedType
, at compile time. - This initializer sets up the object’s basic state.
- This initializer takes in the
ManagedType
object as a parameter. The initializer’s body will handle the conversion from class to struct. - To set values from the managed object to the struct, you need to map key paths in the struct to keys in the managed object. This array stores that mapping.
-
toManagedObject(context:)
saves the struct-based object to the Core Data store. -
save(context:)
saves the view context to disk, persisting the data.
Using KeyPaths to make initializers
With the protocol defined, it’s time to add some default method implementations. By doing this inside a protocol extension, you let the actual type extensions be as small as possible. Under the protocol definition, add the following protocol extension:
//1
extension CoreDataPersistable
where ManagedType: NSManagedObject {
//2
init(managedObject: ManagedType?) {
self.init()
//3
guard let managedObject = managedObject else { return }
//4
for attribute in managedObject.entity.attributesByName {
if let keyP = keyMap.first(
where: { $0.value == attribute.key })?.key {
let value =
managedObject.value(forKey: attribute.key)
storeValue(value, toKeyPath: keyP)
}
}
}
}
For this method:
- Only types where
ManagedType
inherits fromNSManagedObject
can use this extension. - The initializer takes in an optional
ManagedType
and calls the class’s default initializer. - A guard statement checks to confirm the passed in
managedObject
isn’tnil
. - For each attribute of the
managedObject
, the struct stores each KeyPath-Value pair via thestoreValue(_: toKeyPath:)
. This only gets attributes, not relationships.
Now add this after the previous code:
private mutating func storeValue(_ value: Any?,
toKeyPath partial: AnyKeyPath) {
switch partial {
case let keyPath as WritableKeyPath<Self, URL?>:
self[keyPath: keyPath] = value as? URL
case let keyPath as WritableKeyPath<Self, Int?>:
self[keyPath: keyPath] = value as? Int
case let keyPath as WritableKeyPath<Self, String?>:
self[keyPath: keyPath] = value as? String
case let keyPath as WritableKeyPath<Self, Bool?>:
self[keyPath: keyPath] = value as? Bool
default:
return
}
}
This method takes in a value and a KeyPath, specifically, an AnyKeyPath
. You then use a switch to check for the real form of the AnyKeyPath
. In this case, the KeyPath is some flavor of WritableKeyPath
. WritableKeyPath
lets you store the value in the struct. Note here that you have to specify each basic type that you could potentially handle. For example, there’s no handling of Double
values here.
Note: Curious why you have to jump through all these hoops? Structs in Swift don’t have the same methods available that classes do when accessing their properties. It’s one of the downsides of using structs instead of classes. Hopefully, Apple will provide better APIs in future versions of Swift.
Using the Mirror API to store values
Now you have to convert the struct objects to Core Data managed objects. Add the following implementation for toManagedObject(context:)
:
//1
mutating func toManagedObject(context: NSManagedObjectContext =
PersistenceController.shared.container.viewContext
) -> ManagedType {
let persistedValue: ManagedType
//2
if let id = self.id {
let fetchRequest = ManagedType.fetchRequest()
//3
fetchRequest.predicate = NSPredicate(
format: "id = %@", id as CVarArg)
if let results = try? context.fetch(fetchRequest),
let firstResult = results.first as? ManagedType {
persistedValue = firstResult
} else {
persistedValue = ManagedType.init(context: context)
self.id = persistedValue.value(forKey: "id") as? Int
}
} else {
//4
persistedValue = ManagedType.init(context: context)
self.id = persistedValue.value(forKey: "id") as? Int
}
return setValuesFromMirror(persistedValue: persistedValue)
}
In this method:
-
toManagedObject(context:)
is mutating because theid
gets saved back in the struct when creating the managed object. This lets you check for existing entries in the database. - This
if
block checks to see if the struct has a non-nilid
value. If so, the code within theif
block attempts to fetch that entry from the database. If successful,persistedValue
is set to that object. Otherwise, the initializer makes a new object and sets it topersistedValue
. - This is where you set the predicate for the fetch request. This uses a string with substitution variables and a variadic list of values that replace those arguments. Here, the
id
is cast to aCVarArg
and replaces the%@
in the string. - If the struct’s
id
isnil
, the initializer makes a new object and sets the struct’sid
to the managed object’sid
.
Below the previous code, add code using Mirror to help assign values to the managed object:
private func setValuesFromMirror(persistedValue: ManagedType) -> ManagedType {
//1
let mirror = Mirror(reflecting: self)
//2
for case let (label?, value) in mirror.children {
//3
let value2 = Mirror(reflecting: value)
//4
if value2.displayStyle != .optional || !value2.children.isEmpty {
//5
persistedValue.setValue(value, forKey: label)
}
}
return persistedValue
}
The Mirror API performs some introspection on the struct. The goal here is to map the values at the struct’s keyPaths to those in the managed object. Unfortunately, there isn’t a straightforward way to get a hold of the values at the key paths, so one has to resort to using Mirror
to look inside.
Here’s what this code does:
- Create a mirror of the current struct,
self
. - Loop over each of the
(label, value)
pairings in the mirror’schildren
property. - Make a mirror object for the current value in the loop.
- Check to make sure the child value isn’t optional, and ensure that the child value’s
children
collection isn’t empty. - If you make it this far, set the
(label, value)
pair on the managed object via itssetValue(_:, forKey:)
.
Finally, add:
func save(context: NSManagedObjectContext =
PersistenceController.shared.container.viewContext) throws {
try context.save()
}
This method saves the managed object context to disk. You implement it here in CoreDataPersistable
, so you don’t have to duplicate it in every structure that extends CoreDataPersistable
.
That’s a lot of code to transform back and forth between structs and Core Data classes. Was it worth it? Time to make a concrete implementation and find out.
Making a concrete implementation
Open Core/data/coreData/extensions. You’ll find many implementations of this protocol already in place. Create a new file, Breed+CoreData.swift, and add:
import CoreData
//1
extension Breed: CoreDataPersistable {
//2
var keyMap: [PartialKeyPath<Breed>: String] {
[
\.primary: "primary",
\.secondary: "secondary",
\.mixed: "mixed",
\.unknown: "unknown",
\.id: "id"
]
}
//3
typealias ManagedType = BreedEntity
}
Here’s a breakdown of this default implementation:
- This is an extension of the
Breed
struct and adoptsCoreDataPersistable
. - This is the key map connecting those keyPaths in
Breed
with the keys from the managed object. - The managed type for
Breed
isBreedEntity
.
That’s it! With this simple extension on the data model types, you can now convert back and forth between struct and Core Data class. Next, you’ll look at how to use this functionality to add data to the database.
Storing data
Storing, deleting and fetching data are three common interactions with the Core Data database. Here’s how easy it is to store data and test your methods.
Saving entities
You’ve seen that toManagedObject(context:)
gives you a flexible way to save the struct-based data model objects as Core Data entities. With this functionality in place, you can now start to convert the structs from the data model into Core Data objects.
In Persistence.swift, replace the contents of the for
loop near the top with:
for i in 0..<10 {
var animal = Animal.mock[i]
animal.toManagedObject(context: viewContext)
}
This code initializes entries into the in-memory store. It grabs the ith entry from the mock Animal
array and uses toManagedObject(context:)
to persist it to Core Data which will come in handy when previewing views later.
Converting model objects from the network
The project so far only has one place that uses the data from the network API. Open AnimalsNearYouView.swift and replace fetchAnimals
with:
func fetchAnimals() async {
do {
// 1
let animalsContainer: AnimalsContainer = try await
requestManager.perform(
AnimalsRequest.getAnimalsWith(
page: 1,
latitude: nil,
longitude: nil
)
)
for var animal in animalsContainer.animals {
// 2
animal.toManagedObject()
}
await stopLoading()
} catch {
print("Error fetching animals...\(error)")
}
}
Here’s what’s happening:
-
perform(_:)
connects to the Petfinder API and gets the animals in a structure. - Iterate over each animal and call
toManagedObject(context:)
to convert it from the structure to a Core Data object.
Since you’re transitioning to using AnimalEntity
instead of Animal
, update the type of the animals
property at the top of the struct:
@State var animals: [AnimalEntity] = []
Then update the previews struct to match this new property type:
struct AnimalsNearYouView_Previews: PreviewProvider {
static var previews: some View {
if let animals = CoreDataHelper.getTestAnimalEntities() {
AnimalsNearYouView(animals: animals, isLoading: false)
}
}
}
This code uses a helper method in CoreDataHelper.swift to get an array of entities to test with from the in-memory database.
AnimalRow.swift should also use AnimalEntity
, so change the type of the animal
property accordingly:
let animal: AnimalEntity
Since the entity’s name
property may be nil
, update the Text
view that displays the animal’s name:
Text(animal.name ?? "No Name Available")
Finally, update the previews struct to use a test AnimalEntity
using a helper method from CoreDataHelper
:
struct AnimalRow_Previews: PreviewProvider {
static var previews: some View {
if let animal = CoreDataHelper.getTestAnimalEntity() {
AnimalRow(animal: animal)
}
}
}
Preview the AnimalRow
in the preview canvas. You’ll see it’s identical to the view from the last chapter, possibly with a different name, but now populated with an AnimalEntity
:
Testing storing data
One way to test that structs are getting converted to entities correctly is to write unit tests.
Go to PetSaveTests/Tests/Core/data/coreData and open CoreDataTests.swift. Add a new test method called testToManagedObject()
:
func testToManagedObject() throws {
//1
let previewContext =
PersistenceController.preview.container.viewContext
//2
let fetchRequest = AnimalEntity.fetchRequest()
fetchRequest.fetchLimit = 1
fetchRequest.sortDescriptors =
[NSSortDescriptor(keyPath: \AnimalEntity.name,
ascending: true)]
guard let results = try? previewContext.fetch(fetchRequest),
let first = results.first else { return }
//3
XCTAssert(first.name == "CHARLA", """
Pet name did not match, was expecting Kiki, got
\(String(describing: first.name))
""")
XCTAssert(first.type == "Dog", """
Pet type did not match, was expecting Cat, got
\(String(describing: first.type))
""")
XCTAssert(first.coat.rawValue == "Short", """
Pet coat did not match, was expecting Short, got
\(first.coat.rawValue)
""")
}
Here’s what’s happening in this test code:
- This test method takes advantage of the in-memory store, which has a fixed set of pets already persisted.
- The
fetchRequest
onAnimalEntity
generates a fetch request.fetchLimit
limits the fetch to one result, and aguard
checks for a valid result. - If the result is valid, a series of
XCTestAssert
s test various fields of the result against the expected value fromAnimalsMock.json
.
Run the test. As expected, the testToManagedObject
passes.
Previewing views is another way to test the toManagedObject(context:)
. In fact, you did that in the last section! Great job! You were testing and you didn’t even realize it!
Deleting data
Deleting data from the Core Data store doesn’t use the CoreDataPersistable
protocol. CoreDataHelper.swift contains an extension on the Collection
type you can use to delete a collection of NSManagedObject
s.
extension Collection where Element == NSManagedObject, Index == Int {
func delete(at indices: IndexSet,
inViewContext viewContext: NSManagedObjectContext =
CoreDataHelper.context) {
indices.forEach { index in
viewContext.delete(self[index])
}
do {
try viewContext.save()
} catch {
fatalError("""
\(#file), \
\(#function), \
\(error.localizedDescription)
""")
}
}
}
This method removes the objects at the provided indices
from the data store. It does so by calling viewContext.delete(_:)
over each element. It then calls viewContext.save
to push the changes to the database.
Testing deletion
To test object deletion, go back to CoreDataTests.swift and add:
func testDeleteManagedObject() throws {
let previewContext =
PersistenceController.preview.container.viewContext
let fetchRequest = AnimalEntity.fetchRequest()
guard let results = try? previewContext.fetch(fetchRequest),
let first = results.first else { return }
let expectedResult = results.count - 1
previewContext.delete(first)
guard let resultsAfterDeletion = try? previewContext.fetch(fetchRequest)
else { return }
XCTAssertEqual(expectedResult, resultsAfterDeletion.count, """
The number of results was expected to be \(expectedResult) after deletion, was \(results.count)
""")
}
This test again uses the previewContext
but removes the first entry from the database, which causes the number of entries in the database to reduce by one. Run the test in Xcode. It passes! The deletion operation is working now.
Note: Throughout the book, tests will focus on the bigger features of the app, instead of attempting to attain a high level of test coverage. iOS Test-Driven Development by Tutorials is a great resource to learn more about testing your iOS apps.
Fetching data
Now with data stored in the Core Data database, you need to be able to fetch it to use it in your views. There are three ways to do this, the first of which is NSFetchRequest
.
Fetching from Core Data with NSFetchRequest
NSFetchRequest
can fetch data from a Core Data store. For example, CoreDataHelper.swift has this method:
// 1
static func getTestAnimalEntities() -> [AnimalEntity]? {
// 2
let fetchRequest = AnimalEntity.fetchRequest()
// 3
guard let results = try? previewContext.fetch(fetchRequest),
!results.isEmpty else { return nil }
return results
}
Here’s what’s going on:
- The method may return
nil
if no objects in the database match the request. - Each entity has a
fetchRequest
that returns anNSFetchRequest
for that entity. If necessary, you can usesortDescriptors
andpredicate
properties to customize the returned results. - A compound
guard
statement checks that a non-nil set of results comes back, and if so, it isn’t empty. If theguard
fails, the method returnsnil
. Otherwise, the method returns the results.
NSFetchRequest
is powerful, but Apple started to add some Core Data features to SwiftUI, starting with @FetchRequest
.
Using @FetchRequest
iOS 14 introduced the @FetchRequest
property wrapper, which gets entries from a Core Data store and provides them to a SwiftUI view. When the database changes, views with @FetchRequest
properties update automatically. This behavior is like the view having an @ObservedObject
property. Those properties respond to changes in the @Published
items of the ObservableObject
.
With @FetchRequest
, you can also perform operations on the data before the property returns values to the view, including sorting with SortDescriptor
s and filtering data with NSPredicate
s.
You may be thinking to yourself, “Self, won’t this possibly break patterns like MVVM that I may use when making my features?” Well, you’d be right.
In the AnimalsNearYouView
you updated earlier, replace the animals
property with:
@FetchRequest(
sortDescriptors: [
NSSortDescriptor(
keyPath: \AnimalEntity.timestamp, ascending: true)
],
animation: .default
)
var animals: FetchedResults<AnimalEntity>
This code now binds the fetch request in the property wrapper with the animals
property. Like @State
and other state-related property wrappers, @FetchRequest
will cause the body of the view to refresh if the underlying database data changes.
Notice how you added an NSSortDescriptor
. It’ll order the results by timestamp
in an ascending manner. It also has an animation
property you can use to indicate how the result should be animated when displayed.
Don’t forget to also update the previews struct since you’re no longer passing in a collection of animals and are instead letting the database get them for you:
static var previews: some View {
AnimalsNearYouView(isLoading: false)
.environment(\.managedObjectContext,
PersistenceController.preview.container.viewContext)
}
To make this work, you need to inject the view context into the SwiftUI Environment.
Open ContentView.swift. Add the following modifier to the AnimalsNearYouView
and SearchView
views:
.environment(\.managedObjectContext, managedObjectContext)
At the top of ContentView.swift, define the managedObjectContext
property:
let managedObjectContext =
PersistenceController.shared.container.viewContext
As you build out features later in the book, these views will fetch data with @FetchRequest
s.
So is @FetchRequest
in your view better than NSFetchRequest
in your view model?
Pros:
- UI updates: SwiftUI updates the UI for you behind the scenes.
- Code savings: You potentially save on code you may have written to keep the data structures up-to-date.
Cons:
- Testing: You lose the ability to do model-based testing on the code that fetches from the database.
- Data manipulation: You lose the ability to perform other methods on your data before the view can display it.
Which is better? It depends on how integrated you are with SwiftUI. Deep integration is the direction Apple is going since they introduced an improvement to @FetchRequest
in iOS 15.
Using @SectionedFetchRequest
In iOS 15, Apple introduced a new twist on @FetchRequest
: @SectionedFetchRequest
. Open AnimalsNearYouView.swift
and replace the @FetchRequest
at the top with this @SectionedFetchRequest
below:
@SectionedFetchRequest<String, AnimalEntity>(
sectionIdentifier: \AnimalEntity.animalSpecies,
sortDescriptors: [
NSSortDescriptor(keyPath: \AnimalEntity.timestamp,
ascending: true)
],
animation: .default
) private var sectionedAnimals:
SectionedFetchResults<String, AnimalEntity>
Besides sortDescriptors
and an animation
parameter, which were possible with @FetchRequest
, you now can specify a sectionIdentifier
that uses a keyPath
from the fetched type to group the fetched results by section. Views, such as List
s, use this sectioned data to help users organize the data they’re viewing.
Replace the existing ForEach
in the List
with:
ForEach(sectionedAnimals) { animals in
Section(header: Text(animals.id)) {
ForEach(animals) { animal in
NavigationLink(destination: AnimalDetailsView()) {
AnimalRow(animal: animal)
}
}
}
}
This code iterates through each section, generates a header from the section’s id and builds an AnimalRow
for each animal in the section. AnimalRow
is now inside a NavigationLink
that will push an AnimalDetailsView
when the user taps over a row. You’ll work on this view in a later chapter.
Build and run the app.
Enabling CloudKit support
Many users have different devices - an iPhone, an iPad or even a Mac - that may have the ability to run your app. They’re also logged into those devices with their Apple ID, which lets them share data across those devices if the app supports it.
Luckily it’s easy to add that support, so you’ll make those changes in PetSave next.
Updating the persistent container
It’s really simple to add support for CloudKit, at least when syncing data with your app’s private cloud database. In Persistence.swift, change NSPersistentContainer
to NSPersistentCloudKitContainer
:
let container: NSPersistentCloudKitContainer
init(inMemory: Bool = false) {
container = NSPersistentCloudKitContainer(name: "PetSave")
if inMemory {
container.persistentStoreDescriptions
.first?.url = URL(fileURLWithPath: "/dev/null")
}
//...
That’s it! Your app’s Core Data database will sync with the app’s private cloud instance as long as:
- iCloud capability: Your project has the iCloud capability enabled. You can find this in your project’s “Signing and Capabilities” tab.
- Apple ID: Your user signs in with their Apple ID when using your app.
Challenge
You’ve been testing NSFetchRequest
while testing saving and deleting. For a challenge, add a new test method for fetch. Here’s a list of general steps you’ll need to complete this challenge:
- Use the
previewContext
. - Make a fetch request for
AnimalEntity
. - Limit the number of results to one.
- Only accept results with the name “Ellie”.
- Assert that your results have the correct name.
Check out the project in the challenges folder for the solution.
Key points
- Persistence is vital to many modern-day mobile apps.
- Persistence lets your app have data when offline and helps maintain user state between sessions.
- Swift structs and Core Data classes don’t mix, but techniques like default protocol implementations, generics and key paths can help go back and forth between the two.
- In-memory stores are useful for testing, especially with previews, while your deployed app uses an on-disk store.
-
@FetchRequest
and@SectionedFetchRequest
are SwiftUI property wrappers that help keep your views up-to-date as the database changes underneath.
Where to go from here?
Congratulations, you learned a lot about using Core Data to implement persistence in your app! But you’ve only scratched the surface. There’s a lot more to discover about the concepts in this chapter.
Check out the tutorial on Core Data with SwiftUI that touches on properties like @FetchRequest
, and several tutorials on CloudKit, where you can learn how to see your database in CloudKit Dashboard.
Finally, you can learn more about Core Data with the Core Data by Tutorials book!
By finishing this chapter, you’ve also finished the first section of the book! Give yourself a pat on the back. When you’re ready, head on over to the next chapter, where you’ll start putting some of the techniques from this section into practice.