开发者

NSFetchedResultsController with relationship not updating

开发者 https://www.devze.com 2023-04-07 07:15 出处:网络
Let\'s say I have two entities, Employee and Department. A department has a to-many relationship with an employee, many employees can be in each department but each employee only belongs to one depart

Let's say I have two entities, Employee and Department. A department has a to-many relationship with an employee, many employees can be in each department but each employee only belongs to one department. I want to display all of the employees in a table view sorted by data that is a property of the department they belong to using an NSFetchedResultsController. The problem is that I want my table to update when a department object receives changes just like it does if the regular properties of employee change, but the NSFetchedResultsController doesn't seem to track related objects. I've gotten passed this issue partially by doing the following:

for (Employee* employee in department.employees) {
    [employee willChangeValueForKey:@"dept"];
}

/* Make Changes to department object */

for (Employee* employee in department.employees) {
    [employee didChangeValueForKey:@"dept"];
}

This is obviously not ideal but it does cause the employee ba开发者_StackOverflow中文版sed FRC delegate method didChangeObject to get called. The real problem I have left now is in the sorting a FRC that is tracking employee objects:

NSEntityDescription *employee = [NSEntityDescription entityForName:@"Employee" inManagedObjectContext:self.managedObjectContext];
NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"department.someProperty" ascending:NO];

This works great and sorts the employees correctly the first time it's called, the problem is that when I make changes to some property of a department that should change the sorting of my employee table, nothing happens. Is there any nice way to have my employee FRC track changes in a relationship? Particularly I just need some way to have it update the sorting when the sort is based on a related property. I've looked through some similar questions but wasn't able to find a satisfactory solution.


The NSFetchedResultsController is only designed to watch one entity at a time. Your setup, while it makes sense, it a bit beyond what the NSFetchedResultsController is currently capable of watching on its own.

My recommendation would be to set up your own watcher. You can base it off the ZSContextWatcher I have set up on GitHub, or you can make it even more straightforward.

Basically, you want to watch for NSManagedObjectContextDidSaveNotification postings and then reload your table when one fire that contains your department entity.

I would also recommend filing a rdar with Apple and asking for the NSFetchedResultsController to be improved.


Swift

Because the NSFetchedResultsController is designed for one entity at a time, you have to listen to the NSManagedObjectContextObjectsDidChangeNotification in order to be notified about all entity relationship changes.

Here is an example:

//UITableViewController
//...
override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)

    NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChangeHandler(notification:)), name: .NSManagedObjectContextObjectsDidChange, object: mainManagedContext)
}

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)

    NotificationCenter.default.removeObserver(self, name: .NSManagedObjectContextObjectsDidChange, object: mainManagedContext)
}

@objc fileprivate func managedObjectsDidChangeHandler(notification: NSNotification) {
    tableView.reloadData()
}
//...


This is a known limitation of NSFetchedResultsController: it only monitors the changes of you entity's properties, not of its relationships' properties. But your use case is totally valid, and here is how to get over it.

Working Principle

After navigating a lot of possible solutions, now I just create two NSFetchedResultsController: the initial one (in your case, Employee), and another one to monitor the entities in the said relationship (Department). Then, when a Department instance is updated in the way it should update your Employee FRC, I just fake a change of the instances of affiliated Employee using the NSFetchedResultsControllerDelegate protocol. Note that the monitored Department property must be part of the NSSortDescriptors of its NSFetchedResultsController for this to work.

Example code

In your example if would work this way:

In your view controller:

var employeesFetchedResultsController:NSFetchedResultsController!
var departmentsFetchedResultsController:NSFetchedResultsController!

Also make sure you declare conformance to NSFetchedResultsControllerDelegate in the class declaration.

In viewDidLoad():

override func viewDidLoad() {         
    super.viewDidLoad()
    // [...]
    employeesFetchedResultsController = newEmployeesFetchedResultsController()
    departmentsFetchedResultsController = newDepartmentsFetchedResultsController()
    // [...]
}

In the departmentsFetchedResultsController creation:

func newDepartmentsFetchedResultsController() {
    // [specify predicate, fetchRequest, etc. as usual ]
    let monitoredPropertySortDescriptor:NSSortDescriptor = NSSortDescriptor(key: "monitored_property", ascending: true)
    request.sortDescriptors = [monitoredPropertySortDescriptor]
    // continue with performFetch, etc
}

In the NSFetchedResultsControllerDelegate methods:

That's where the magic operates:

    func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {

    if controller == departmentsFetchedResultsController {
        switch(type){
        case .insert, .delete, .update:
             managedObjectContext.performAndWait {
                let department = anObject as! Department
                for employee in (department.employees ?? []) {
                    // we fake modifying each Employee so the FRC will refresh itself.
                    let employee = employee as! Employee // pure type casting
                    employee.department = department
                }
             }
            break

        default:
        break
        }
    }
}

This fake update of the department of each impacted employee will trigger the proper update of employeesFetchedResultsController as expected.


SwiftUI

I haven't seen posts that directly addressed this issue in SwiftUI. After trying solutions outlined in many posts, and trying to avoid writing custom controllers, the single factor that made it work in SwiftUI—which was part of the previous post from harrouet (thank you!)—is:

Make use of a FetchRequest on Employee.

If you care about, say, the employee count per department, the fake relationship updates did not make a difference in SwiftUI. Neither did any willChangeValue or didChangeValue statements. Actually, willChangeValue caused crashes on my end. Here's a setup that worked:

import CoreData
struct SomeView: View {
    @FetchRequest var departments: FetchedResults<Department>
    // The following is only used to capture department relationship changes
    @FetchRequest var employees: FetchedResults<Employee>
    var body: some View {
        List {
            ForEach(departments) { department in
                DepartmentView(department: department,
                               // Required: pass some dependency on employees to trigger view updates
                               totalEmployeeCount: employees.count)
            }
        }
        //.id(employees.count) does not trigger view updates
    }
} 
struct DepartmentView: View {
    var department: Department
    // Not used, but necessary for the department view to be refreshed upon employee updates
    var totalEmployeeCount: Int
    var body: some View {
        // The department's employee count will be refreshed when, say,
        // a new employee is created and added to the department
        Text("\(department) has \(department.employees.count) employee(s)")
    }
}

I don't know if this fixes all the potential issues with CoreData relationships not propagating to views, and it may present efficiency issues if the number of employees is very large, but it worked for me.

An alternative that also worked for establishing the right employee count without grabbing all employees—which may address the efficiency issue of the above code snippet—is to create a view dependency on a NSFetchRequestResultType.countResultType type of FetchRequest:

// Somewhere in a DataManager:
import CoreData
final class DataManager {
    static let shared = DataManager()
    let persistenceController: PersistenceController
    let context: NSManagedObjectContext!
    init(persistenceController: PersistenceController = .shared) {
        self.persistenceController = persistenceController
        self.context = persistenceController.container.viewContext
    }
    func employeeCount() -> Int {
        var count: Int = 0
        context.performAndWait {
            let fetchRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "Employee")
            fetchRequest.predicate = nil
            fetchRequest.resultType = NSFetchRequestResultType.countResultType
            do {
                count = try context.count(for: fetchRequest)
            } catch {
                fatalError("error \(error)")
            }
        }
        return count
    }
}

And the main View becomes:

import CoreData
struct SomeView: View {
    @FetchRequest var departments: FetchedResults<Department>
    // No @FetchRequest for all employees
    var dataManager = DataManager.shared
    var body: some View {
        List {
            ForEach(departments) { department in
                DepartmentView(department: department,
                               // Required: pass some dependency on employees to trigger view updates
                               totalEmployeeCount: dataManager.employeeCount())
            }
        }
        //.id(dataManager.employeeCount()) does not trigger view updates
    }
}
// DepartmentView stays the same.

Again, this may not resolve all possible relationship dependencies, but it gives hope that view updates can be prompted by considering various types of FetchRequest dependencies within the SwiftUI views.

A note that DataManager needs NOT be an ObservableObject being observed in the View for this to work.

0

精彩评论

暂无评论...
验证码 换一张
取 消

关注公众号