Russell Gordon

Search CTRL + K

Getting to Know Core Data

If you've made it to Day 61 of Paul Hudson's 100 Days of SwiftUI tutorial series then you probably have a healthy appreciation for the quality and value of that free resource.

I teach high school computer science in Ontario, Canada. Although our curriculum is excellent student enrolment numbers have never been high enough to warrant interest from textbook publishers. On top of that, any textbook written for a particular programming language or framework would quickly become out of date. So in years past I have often ended up writing all of the material my students work with. Compared to, say, a biology teacher – I write both the textbook and the lesson plans.

So I am especially grateful for Paul's writing. He has a real knack for explaining topics within a context, in simple terms, often with a dash of humour. I am unequivocally in awe of the volume of his output. Both myself and my students have found his tutorials to be enormously helpful.

The core challenge of days 60 and 61 is to build an application that shows user data to illustrate connections between friends. At first, user data pulled from the remote JSON file is stored entirely in-memory. Later we are asked to modify the application so that data is kept inside a Core Data store – in this way, it would not be necessary to load the JSON file from the remote server each time the application opens.

In my experience Paul scaffolded concepts well leading up to this task – he provided prerequisite tasks that build necessary skills:

That said, from what I have read on Twitter and in the discussion forums Paul operates to support learners, there is a sense that learning Core Data is hard and that the Day 61 challenge can be especially tricky.

Additionally, changes to how the SwiftUI application lifecycle works as of Xcode 12 – released after Paul wrote these tutorials – provide additional wrinkles.

On that note, the purpose of this post is to share:

End Product

Here is a brief overview of what my Friends app looked like when it was complete:

I modified the core challenge in the following ways:

SwiftUI App Life Cycle and the Provided Template

The first bump in the road came when I chose to create my Friends app using the newer SwiftUI App life cycle option, rather than the UIKit App Delegate option that I'd used when following Paul's tutorials in the lead-up to days 60 and 61.

In theory, Xcode provides templates to get developers moving from common starting points.

Using Xcode 12.3, after creating a new project using the SwiftUI App lifecycle option, and ensuring that the Use Core Data option was enabled, I built and ran the project. Here is what I saw:

Running the example project in Xcode 12 for an iOS app with Core Data enabled results in a blank view.

Not a promising start – we should see toolbar buttons that allow us to add and remove items from a list.

I found the solution on the Apple Developer Forums. Here is a diff of the changes and here is the entire ContentView.swift file that corrects the issue.

Tip: Enable Core Data Debugging

When programming a user interface in SwiftUI, we get feedback instantly in Xcode Previews.

This makes it really fast to iterate and improve a user interface. It's one of the primary reasons I love working with the SwiftUI framework.

However, my initial experience programming with Core Data left me wondering exactly what was happening behind the scenes on a fairly regular basis.

Did that user record just get added to the Core Data store?

How could I be sure?

I needed a way to debug Core Data operations.

I found this article by Donny Wals and it was super helpful.

For the rest of my time completing the day 61 challenge, I ran my project with this launch argument enabled:

-com.apple.CoreData.SQLDebug 1

That was enough to help me see what was happening with Core Data behind the curtain.

Loading User Data at the Application Level

It seems pretty clear to me that with the days 60 and 61 challenge, Paul intended for learners to parse the JSON and eventually load that information into Core Data using the .onAppear modifier of the first view – we were told as much when setting up the Bookworm app:

All our managed objects live inside a managed object context, which is the thing that’s responsible for actually fetching managed objects, as well as for saving changes and more. You can have many managed object contexts if you want, but that’s quite a way away right now – realistically you’ll be fine with one for a long time yet.

We don’t need to create this managed object context, because Xcode already made one for us. Even better, it already added it to the SwiftUI environment, which is what makes the @FetchRequest property wrapper work – it uses whatever managed object context is available in the environment.

Even when using the newer the SwiftUI App lifecycle, we're in good shape based upon what the Xcode template provides.

In the ProjectNameApp.swift file (where ProjectName is whatever name you selected when creating the project in Xcode), there is this code:

@main
struct FriendsApp: App {
    let persistenceController = PersistenceController.shared

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.managedObjectContext, persistenceController.container.viewContext)
        }
    }
}

The .environment modifier on ContentView injects the managed object context into the environment, so that it is later available to SwiftUI views through the @Environment property wrapper, just like in the earlier tutorials from Paul:

@Environment(\.managedObjectContext) var moc

So what's the problem?

Well, I decided that I didn't want to parse the JSON file and load it into Core Data on the first view my app presents.

This speaks to separation of concerns. I know that it's generally not ideal to closely tie together the parts of your app that store data with the parts of your app that display the data.

I should have just rolled with it. I would have made my life easier. I expect that in a future tutorial, Paul will show learners how to better separate code for handling data from code for handling the user interface.

I didn't roll with it.

If you want to talk to Core Data outside of a SwiftUI view you need to know where to find the managed object context.

In the template Xcode 12 provides, that is via the PersistenceController struct from the Persistence.swift file. A static property provides access to a managed object context that runs on the main thread of an app:

PersistenceController.shared.container.viewContext

My next question was how to best load user data directly into Core Data.

One could decode the JSON into structures that exist in memory, and then load the data in those structures into Core Data objects, but that does not sound terribly memory efficient.

So how could I get the data directly into Core Data from the external JSON file?

I really didn't know how that might be done.

With some looking, I once again found a Donny Wals article that provided an answer.

Decoding JSON Directly into Core Data Objects

Essentially, decoding JSON directly into instances of Core Data classes is pretty much identical to decoding JSON directly into instances of structures or classes that you define yourself. You just need to provide a managed object context for the JSONDecoder class so that it can talk to the Core Data store.

So now, within the shared data task of URLSession, after pulling the JSON data down from Paul's website, I was kicking off parsing the JSON into Core Data with this segment of code:

// Now decode from JSON directly into Core Data managed objects
let decoder = JSONDecoder(context: PersistenceController.shared.container.viewContext)
if let decodedData = try? decoder.decode([CDUser].self, from: unwrappedData) {
	print("There were \(decodedData.count) users placed into Core Data")
} else {
	print("Could not decode from JSON into Core Data")
}

My CDUser+CoreDataClass.swift file looked like this:

//
//  CDUser+CoreDataClass.swift
//  Friends
//
//  Created by Russell Gordon on 2020-12-27.
//
//

import Foundation
import CoreData

@objc(CDUser)
public class CDUser: NSManagedObject, Decodable {

    // What properties to decode
    enum CodingKeys: CodingKey {
        case id
        case isActive
        case name
        case age
        case company
        case email
        case address
        case about
        case registered
        case tags
        case friends
    }

    required convenience public init(from decoder: Decoder) throws {
        
        // Attempt to extract the object
        guard let context = decoder.userInfo[.context] as? NSManagedObjectContext else {
            fatalError("NSManagedObjectContext is missing")
        }
        
        let entity = NSEntityDescription.entity(forEntityName: "CDUser", in: context)!
        
        self.init(entity: entity, insertInto: context)
        
        let container = try decoder.container(keyedBy: CodingKeys.self)
        
        id = try container.decode(String.self, forKey: .id)
        isActive = try container.decode(Bool.self, forKey: .isActive)
        name = try container.decode(String.self, forKey: .name)
        age = try container.decode(Int16.self, forKey: .age)
        company = try container.decode(String.self, forKey: .company)
        email = try container.decode(String.self, forKey: .email)
        address = try container.decode(String.self, forKey: .address)
        let untrimmedAbout = try container.decode(String.self, forKey: .about)
        about = untrimmedAbout.trimmingCharacters(in: CharacterSet(charactersIn: "\r\n"))
        
        // Decode the date
        // SEE: https://developer.apple.com/documentation/foundation/dateformatter#overview
        // SEE: https://developer.apple.com/documentation/foundation/iso8601dateformatter
        // SEE: https://makclass.com/posts/24-a-quick-example-on-iso8601dateformatter-and-sorted-by-function
        let dateAsString = try container.decode(String.self, forKey: .registered)
        let dateFormatter = ISO8601DateFormatter()
        registered = dateFormatter.date(from: dateAsString) ?? Date()

        // Decode all the tags and tie to this entity
        let tagsAsStrings = try container.decode([String].self, forKey: .tags)
        var tags: Set<CDTag> = Set()
        for tag in tagsAsStrings {
            let newTag = CDTag(context: context)
            newTag.name = tag
            // Associate each tag in the set with this user
            newTag.tagOrigin = self
            tags.insert(newTag)
        }
        tag = tags as NSSet
        
        // Decode all the friends and tie to this entity
        let friends = try container.decode(Set<CDFriend>.self, forKey: .friends)
        for friend in friends {
            // Associate each friend in the set with this user
            friend.friendOrigin = self
        }
        friend = friends as NSSet
                
    }
    
}

However... I was now seeing very odd behaviour.

Sometimes, 100 users, as expected, would be loaded from the JSON file into Core Data.

And sometimes, 99 users would be loaded. Or 98.

Or on occasion, the app would just crash. 😕

Tip: Enable Concurrency Debugging

I tend to become laser focused on solving one problem while programming when instead I should take a step back and examine the larger picture. Or... maybe just read resources that I come across more carefully.

Had I read a bit further in the Donny Wals article on using launch arguments for Core Data debugging, I would have found this key paragraph (emphasis and link mine):

One of the biggest frustrations you might have with Core Data is random crashes due to threading problems. You're supposed to use [the] managed object context and managed objects only on the threads that they were created on, and violating this rule might crash your app. However, usually your app won't crash and everything is fine. But then every now and then a random crash pops up. You can tell that it's Core Data related but you might not be sure where the error is coming from exactly.

By enabling this launch argument:

-com.apple.CoreData.ConcurrencyDebug 1

... you are asking iOS to enforce a contract – you promise, as the programmer, to only use a managed object context on the thread it was created on.

If you break that contract, the app will immediately crash (with a rather cute error message of "all that is left to us is honor").

This is a far superior state of affairs to having your app sometimes work and sometimes not.

The problem I had was that I was passing the managed object context for the main thread of my app to JSONDecoder, within a shared data task for URLSession – a task that will be run on a background thread:

URLSession.shared.dataTask(with: request) { data, response, error in
	
	// handle the result here – attempt to unwrap optional data provided by task
	guard let unwrappedData = data else {
		
		// Show the error message
		print("No data in response: \(error?.localizedDescription ?? "Unknown error")")
		
		return
	}
	
	// Now decode from JSON directly into Core Data managed objects
	let decoder = JSONDecoder(context: PersistenceController.shared.container.viewContext)
	if let decodedData = try? decoder.decode([CDUser].self, from: unwrappedData) {
		print("There were \(decodedData.count) users placed into Core Data")
	} else {
		print("Could not decode from JSON into Core Data")
	}
	
	// and so on...

}

That violates the Core Data concurrency contract.

Using Background Threads and Loading User Images

I will save you the tortured play-by-play recounting of my experience at this point.

To summarize, I eventually found this example project provided by Apple that illustrates best practices for loading JSON into Core Data on a background thread.

In the final version of my Friends app, within the initializer of my FriendsApp struct, I kick off loading user data like this, prior to any views being displayed:

// Kick off loading users from JSON or Core Data
_ = DataProvider()

Here is what my DataProvider class looks like.

Notice that each time operations are performed with Core Data, they occur on a background managed object context.

In the fetchAndSaveAvatars() method you will see how user images are loaded.

Again, note that a background managed object context is created within the shared data task on URLSession. This occurs 100 times, since there are 100 users.

It will not work to create a background managed object context outside the shared data task – that breaks the Core Data concurrency contract.

Additional Resources

As I mentioned earlier, I extended the day 61 challenge a bit further by adding the notion of an "inner circle" of friends presented on a second tab.

A badge icon is updated as friends are added to the "inner circle" list. The count of items in the badge is reduced once the user of my app views the friend's details from within the "inner circle" tab.

At present, it seems there is no direct way to add a notification badge to a tab item in SwiftUI – so we have to do it manually, as explained here.

I also found this NSPredicate cheat sheet to be useful.

Finally, I worked out how to continue using Xcode Previews to refine my SwiftUI views while still using Core Data.

You are welcome to download and browse the code of my finished Friends app to see how that was done. Xcode 12.2 or later is recommended to open and build that project.

This article became more lengthy than I intended. 😅

If you've found it useful, or noticed any errors, please let me know!