Background thread Core Data object property changes doesn't reflect on UI

Viewed 831

Let say I want to add a new item in Playlist entity of CoreData and put it in background thread and push back it to main thread then reflect it on tableView. Well, that code is working fine without background thread implementation.

But when I apply below background kinda code, after createPlaylist is executed, tableView becomes to empty space(without any items showed up), though print(self?.playlists.count) gives the correct rows count.

When dealing with GCD, I put some heavy code in background queue and push back to main queue for UI update in same closure. But it seems not worked here, I google a quit of time but still cannot anchor the issue.

import UIKit
import CoreData

class PlayListViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
    var songs = [Song]()
    var position = 0
    
    let container = (UIApplication.shared.delegate as! AppDelegate).persistentContainer
    private var playlists = [Playlist]()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = UIColor(white: 1, alpha: 1)
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "playlistCell")
        configureLayout()
        getAllPlaylists()
    }

    // MARK: Core data functions
    func getAllPlaylists() {
        do {
            let context = self.container.viewContext
            playlists = try context.fetch(Playlist.fetchRequest())
            DispatchQueue.main.async { [weak self] in
                self?.tableView.reloadData()
            }
            print("count: \(playlists.count)")
//            printThreadStats()
        } catch {
            print("getAllPlaylists failed, \(error)")
        }
    }

    func createPlaylist(name: String) {
        container.performBackgroundTask { context in
            let newPlaylist = Playlist(context: context)
            newPlaylist.name = name
            
            do {
                try context.save()
                self.playlists = try context.fetch(Playlist.fetchRequest())

                DispatchQueue.main.async { [weak self] in
                    self?.tableView.reloadData()
                    print(self?.playlists.count)
                }
            } catch {
                print("Create playlist failed, \(error)")
            }
        }
    }

    // MARK: tableView data source implementation
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return playlists.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let playlist = playlists[indexPath.row]
        let cell = tableView.dequeueReusableCell(withIdentifier: "playlistCell", for: indexPath)
        cell.textLabel?.text = playlist.name
//        cell.detailTextLabel?.text = "2 songs"
        return cell
    }

    

auto generated fetchRequest and Property defining

import Foundation
import CoreData


extension Playlist {

    @nonobjc public class func fetchRequest() -> NSFetchRequest<Playlist> {
        return NSFetchRequest<Playlist>(entityName: "Playlist")
    }

    @NSManaged public var name: String?

}
2 Answers

For the first call of func getAllPlaylists(), you are calling this on main thread from viewDidLoad(). So following lines are executed on main thread.

let context = self.container.viewContext
playlists = try context.fetch(Playlist.fetchRequest())

Next time inside the createPlaylist method, you are performing add playlist task in background context (not on main thread). So following lines are executed on background thread.

self.playlists = try context.fetch(Playlist.fetchRequest())

Also note that, first time we are using viewContext to fetch playlists and second time a backgroundContext. This mix up causes the UI to not show expected result.

I think these two methods could be simplified to -

func getAllPlaylists() {
    do {
        let context = self.container.viewContext
        playlists = try context.fetch(Playlist.fetchRequest())
            
        // DispatchQueue.main.async not necessary, we are already on main thread
        self.tableView.reloadData()
            
        print("count: \(playlists.count)")
    } catch {
        print("getAllPlaylists failed, \(error)")
    }
}

func createPlaylist(name: String) {
    container.performBackgroundTask { context in
        let newPlaylist = Playlist(context: context)
        newPlaylist.name = name
            
        do {
            try context.save()
            
            DispatchQueue.main.async { [weak self] in
                self?.getAllPlaylists()
            }
        } catch {
            print("Create playlist failed, \(error)")
        }
    }
}

After 5 hours' digging today, I found the solution. I'd like put my solution and code below, because the stuff about "How to pass NSManagedObject instances between queues in CoreData" is quite rare && fragmentation, not friendly to newbies of SWIFT.

The thing is we want to do heavy CoreData task on background thread and reflect the changes in UI on foreground(main thread). Generally, we need to create a private queue context(privateMOC) and perform the heavy CoreData task on this private context, see below code.

For reuse purpose, I put CoreData functions separately.

import UIKit
import CoreData

struct CoreDataManager {
    let managedObjectContext: NSManagedObjectContext
    private let privateMOC = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
    let coreDataStack = CoreDataStack()
    
    static let shared = CoreDataManager()
    
    private init() {
        self.managedObjectContext = coreDataStack.persistentContainer.viewContext
        privateMOC.parent = self.managedObjectContext
    }
    
    func fetchAllPlaylists(completion: @escaping ([Playlist]?) -> Void) {
        privateMOC.performAndWait {
            do {
                let playlists: [Playlist] = try privateMOC.fetch(Playlist.fetchRequest())
                print("getAllPlaylists")
                printThreadStats()
                print("count: \(playlists.count)")
                completion(playlists)
            } catch {
                print("fetchAllPlaylists failed, \(error), \(error.localizedDescription)")
                completion(nil)
            }
        }
    }
    
    func createPlaylist(name: String) {
        privateMOC.performAndWait {
            let newPlaylist = Playlist(context: privateMOC)
            newPlaylist.name = name
            synchronize()
        }
    }
    
    func deletePlaylist(playlist: Playlist) {
        privateMOC.performAndWait {
            privateMOC.delete(playlist)
            synchronize()
        }
    }

    func updatePlaylist(playlist: Playlist, newName: String) {
        ...
    }
    
    func removeAllFromEntity(entityName: String) {
        ...
    }
    
    func synchronize() {
        do {
            // We call save on the private context, which moves all of the changes into the main queue context without blocking the main queue.
            try privateMOC.save()
            managedObjectContext.performAndWait {
                do {
                    try managedObjectContext.save()
                } catch {
                    print("Could not synchonize data. \(error), \(error.localizedDescription)")
                }
            }
        } catch {
            print("Could not synchonize data. \(error), \(error.localizedDescription)")
        }
    }
    
    func printThreadStats() {
        if Thread.isMainThread {
            print("on the main thread")
        } else {
            print("off the main thread")
        }
    }  
}

And Apple has a nice template for it Using a Private Queue to Support Concurrency

Another helpful link: Best practice: Core Data Concurrency

The real tricky thing is how to connect it with your view or viewController, the really implementation. See below ViewController code.

    // 1
    override func viewDidLoad() {
        super.viewDidLoad()

        // some layout code

        // execute on background thread
        DispatchQueue.global().async { [weak self] in
            self?.fetchAndReload()
        }
    }
    
    // 2
    private func fetchAndReload() {
        CoreDataManager.shared.fetchAllPlaylists(completion: { playlists in
            guard let playlists = playlists else { return }
            self.playlists = playlists
        })
        
        DispatchQueue.main.async {
            self.tableView.reloadData()
        }
    }
    
    // 3
    @objc func createNewPlaylist(_ sender: Any?) {
        let ac = UIAlertController(title: "Create New Playlist", message: "", preferredStyle: .alert)
        ac.addTextField { textField in
            textField.placeholder = "input your desired name"
        }
        ac.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
        
        ac.addAction(UIAlertAction(title: "Done", style: .default, handler: { [weak self] _ in
            guard let textField = ac.textFields?.first, let newName = textField.text, !newName.isEmpty else { return }
            // check duplicate
            if let playlists = self?.playlists {
                if playlists.contains(where: { playlist in
                    playlist.name == newName
                }) {
                    self?.duplicateNameAlert()
                    return
                }
            }

            DispatchQueue.global().async { [weak self] in
                CoreDataManager.shared.createPlaylist(name: newName)
                self?.fetchAndReload()
            }
        }))
        
        present(ac, animated: true)
    }

Let me break down it:

  1. First in viewDidload, we call fetchAndReload on background thread.
  2. In fetchAndReload function, it brings out all the playlist(returns data with completion handler) and refresh the table on main thread.
  3. We call createPlaylist(name: newName) in background thread and reload the table on main thread again.

Well, this is the 1st time I deal with Multi-threading in CoreData, if there is any mistake, please indicate it. Allright, that's it! Hope it could help someone.

Related