Episode #413

Refactor Core Data Context Handling

Series: Making a Podcast App From Scratch

11 minutes
Published on October 24, 2019

This video is only available to subscribers. Get access to this video and 573 others.

Our current SubscriptionStore is too tied to the main core data context. In this episode we'll split this behavior on to a new type that will manage persistence for us, as well as implement a solution to solve the problem of core data being initialized asynchronously. We want to delay our app's UI until we have a context we can use.

Episode Links

Creating an Import Operation

To manage asynchronous operations for Core Data we'll use our BaseOperation which is largely based on the work in the Advanced Operations series.

import Foundation

class BaseOperation : Operation {

    override var isAsynchronous: Bool {
        return true
    }

    private var _executing = false {
        willSet {
            willChangeValue(forKey: "isExecuting")
        }
        didSet {
            didChangeValue(forKey: "isExecuting")
        }
    }

    override var isExecuting: Bool {
        return _executing
    }

    private var _finished = false {
        willSet {
            willChangeValue(forKey: "isFinished")
        }

        didSet {
            didChangeValue(forKey: "isFinished")
        }
    }

    override var isFinished: Bool {
        return _finished
    }

    override func start() {
        _executing = true
        execute()
    }

    func execute() {
        fatalError("You must override this")
    }

    func finish() {
        _executing = false
        _finished = true
    }
}

Now we'll create an operation to import all the episodes from a feed and save them to the database. To make sure that we use the context on the save thread it was created on, we'll wait for the execute method before creating our background context.

import Foundation
import CoreData

class ImportEpisodesOperation : BaseOperation {

    private let podcastId: String
    private let feedLoader = PodcastFeedLoader()
    private var context: NSManagedObjectContext!
    private var subscriptionStore: SubscriptionStore!

    init(podcastId: String) {

    }

    override func execute() {

    }
}

Splitting the Responsibility of Subscription Store

As the queries in the SubscriptionStore work on the main core data context, we will have to change this. Create a new type called PersistenceManager to manage creating the Core Data stack and maintaining a reference to the main context for us:

import Foundation

class PersistenceManager {
    static var shared = PersistenceManager()

    private let persistentContainer: NSPersistentContainer

    var mainContext: NSManagedObjectContext {
        return persistentContainer.viewContext
    }

    func newBackgroundContext() -> NSManagedObjectContext {
        return persistentContainer.newBackgroundContext()
    }

    private init() {
        persistentContainer = NSPersistentContainer(name: "Subscriptions")
    }

    func initializeModel() {
        persistentContainer.loadPersistentStores { (storeDescription, error) in
            if let error = error {
                fatalError("Core Data error: \(error.localizedDescription)")
            } else {
                print("Loaded Store: \(storeDescription.url?.absoluteString ?? "nil")")
            }
        }
    }
}

Our view context and background context will be only valid after the loading of the persistent store has completed. So far we have avoid this being an issue because we had no startup logic that used the context. However this is changing and we now need to consider that view controllers won't be able to immediately use the context.

To avoid this, we'll add a condition and a completion block to defer any other logic to execute until the persistence store is loaded.

import Foundation
import CoreData

class PersistenceManager {
    static var shared = PersistenceManager()

    private let persistentContainer: NSPersistentContainer
    private var isLoaded = false

    var mainContext: NSManagedObjectContext {
        precondition(isLoaded)
        return persistentContainer.viewContext
    }

    func newBackgroundContext() -> NSManagedObjectContext {
        precondition(isLoaded)
        return persistentContainer.newBackgroundContext()
    }

    private init() {
        persistentContainer = NSPersistentContainer(name: "Subscriptions")
    }

    func initializeModel(then completion: @escaping () -> Void) {
        persistentContainer.loadPersistentStores { (storeDescription, error) in
            if let error = error {
                fatalError("Core Data error: \(error.localizedDescription)")
            } else {
                self.isLoaded = true
                print("Loaded Store: \(storeDescription.url?.absoluteString ?? "nil")")
                completion()
            }
        }
    }
}

In the initializeModel method, we pass in a completion block so we can get notified when the stack is ready.

Delaying the Loading of View Controllers

We want to delay the loading of the main view controllers until the Core Data store is loaded. To do this we will disable launching with the storyboard and create a new window, which will show a blank screen. This blank screen will be displayed until Core Data is loaded.

var window: UIWindow?

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

    window = UIWindow()

    Theme.apply(to: window!)

    window?.rootViewController = UIViewController()
    window?.makeKeyAndVisible()

    PersistenceManager.shared.initializeModel(then: {
        FeedImporter.shared.startListening()

        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        self.window?.rootViewController = storyboard.instantiateInitialViewController()            
    })

    return true
}

Refactoring Subscription Store to use Persistence Manager

Next, we'll refactor SubscriptionStore to use the PersistenceManager. Now we can pass the main or background context to the store instead of always using the main context.

import Foundation
import CoreData

class PersistenceManager {
    static var shared = PersistenceManager()

    private let persistentContainer: NSPersistentContainer
    private var isLoaded = false

    var mainContext: NSManagedObjectContext {
        precondition(isLoaded)
        return persistentContainer.viewContext
    }

    func newBackgroundContext() -> NSManagedObjectContext {
        precondition(isLoaded)
        return persistentContainer.newBackgroundContext()
    }

    private init() {
        persistentContainer = NSPersistentContainer(name: "Subscriptions")
    }

    func initializeModel(then completion: @escaping () -> Void) {
        persistentContainer.loadPersistentStores { (storeDescription, error) in
            if let error = error {
                fatalError("Core Data error: \(error.localizedDescription)")
            } else {
                self.isLoaded = true
                print("Loaded Store: \(storeDescription.url?.absoluteString ?? "nil")")
                completion()
            }
        }
    }
}

Updating the View Controller

Since we're no longer using a shared subscription store, we'll have to initialize this property in viewDidLoad. We'll pass in the main context for it to use similar to before.

private var store: SubscriptionStore!   

private var podcast: Podcast? {
    didSet {
        podcastViewModel = podcast.flatMap {
            PodcastViewModel(podcast: $0,
                             isSubscribed: store.isSubscribed(to: $0.id))
        }
    }
}

override func viewDidLoad() {
    super.viewDidLoad()

    store = SubscriptionStore(context: PersistenceManager.shared.mainContext)

    tableView.backgroundColor = Theme.Colors.gray5
    tableView.separatorColor = Theme.Colors.gray4

    headerViewController = children.compactMap { $0 as? PodcastDetailHeaderViewController }.first
    headerViewController.subscribeButton.addTarget(self, action: #selector(subscribeTapped(_:)), for: .touchUpInside)

    loadPodcast()
}

@objc private func subscribeTapped(_ sender: SubscribeButton) {
    guard let podcast = podcast else { return }
    let isSubscribing = !sender.isSelected
    do {

        if isSubscribing {
            try store.subscribe(to: podcast)
        } else {
            try store.unsubscribe(from: podcast)
        }
        sender.isSelected.toggle()
    } catch {
        let action = isSubscribing ? "Subscribing to Podcast" : "Unsubscribing from Podcast"
        let alert = UIAlertController(title: "Error \(action)", message: error.localizedDescription, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
    }
}

While loading the MyPodcast view, we'll refer to the main context of the persistence manager.

private var store: SubscriptionStore!

override func viewDidLoad() {
    super.viewDidLoad()

    store = SubscriptionStore(context: PersistenceManager.shared.mainContext)

    loadSubscriptions()
    subscriptionChangedObserver = NotificationCenter.default
        .addObserver(SubscriptionsChanged.self,
                     sender: nil,
                     queue: .main,
                     using: { change in
                        self.loadSubscriptions()
        })
}

private func loadSubscriptions() {
    do {
        subscriptions = try store.fetchSubscriptions()
        tableView.reloadData()
    } catch {
        print("ERROR: ", error.localizedDescription)
    }
}

This episode uses Xcode 11.0, Swift 5.1.