Detecting changes to a specific attribute of NSManagedObject

16,287

Solution 1

This type of circumstance is where you need a custom NSManagedObject subclass. You need the subclass because you are adding a behavior, reacting to a price change, to the managed object.

In this case, you would override the accessor for the price attribute. Create a custom subclass using the popup menu in the data model editor. Then select the price attribute and choose 'Copy Obj-C 2.0 Implementation to the Clipboard`. It will give you a lot of stuff but the key bit will look like this:

- (void)setPrice:(NSNumber *)value 
{
    [self willChangeValueForKey:@"price"];
    [self setPrimitivePrice:value];
    [self didChangeValueForKey:@"price"];
}

Just add the code to deal with the price change and you are done. Anytime a specific product's price changes, the code will run.

Solution 2

One point with regard to this thread,

The NSManagedObjectContextObjectsDidChangeNotification generated by Core Data indicates that a managed object has changed, but doesn't indicate which attribute has changed.

It actually does. The "changedValues" method can be used to query which attributes changed.

Something like,

 if([updatedObjects containsKindOfClass:[Config class]]){
    //if the config.timeInterval changed
    NSManagedObject *obj = [updatedObjects anyObject];
    NSDictionary *dict=[obj changedValues];
    NSLog(@"%@",dict);
    if([dict objectForKey:@"timeInterval"]!=nil){
      [self renderTimers];
    }
  }

Solution 3

You could take a look at KVO (Key Value Observing). Not sure if there are wrappers built into Core Data API, but I know it's part of Objective-C.

Solution 4

I thought I would document my design decisions here in case they're useful to others. My final solution was based on TechZen's answer.

First, I'll start with a short, and hopefully clearer, restatement of the problem:

In my application, I want to detect changes to a specific attribute (price) of a managed object (Product). Furthermore, I want to know about those changes whether they're made on the main or a background thread. Finally, I want to know about those changes even if the main thread currently does not have the changed Product object in its managed object context.

The NSManagedObjectContextObjectsDidChangeNotification generated by Core Data indicates that a managed object has changed, but doesn't indicate which attribute has changed. My kludgy solution was to create a Price managed object containing a single price attribute, and to replace the price attribute in Product with a to-one relationship to a Price managed object. Now, whenever a change is made to a Price managed object, the Core Data NSManagedObjectContextObjectsDidChangeNotification will contain that Price object in its NSUpdatedObjectsKey set. I simply need to get this information to the main thread. This all sounds good, but there's a hitch.

My Core Data store is being manipulated by two threads. This is done in the "usual" way—there is a managed object context for each thread and a single shared persistent store coordinator. After the background thread makes changes, it saves its context. The main thread detects the context save via the NSManagedObjectContextDidSaveNotification and merges the context changes using mergeChangesFromContextDidSaveNotification:. (Actually, since notifications are received in the same thread they're posted in, the NSManagedObjectContextDidSaveNotification is received on the background thread and passed to the main thread via performSelectorOnMainThread: for merging.) As a result of the merge, Core Data generates a NSManagedObjectContextObjectsDidChangeNotification indicating the changed objects. However, as far as I can tell, the NSManagedObjectContextObjectsDidChangeNotification only includes those objects which are currently represented in the receiving context. This makes sense from the perspective of updating the UI. If a managed object isn't being displayed, it probably won't be in the context, so there's no need to include it in the notification.

In my case, my main thread needs to know about changes made to managed objects whether or not they're currently in the main thread's context. If any price changes, the main thread needs to queue an operation to process that price change. Therefore, the main thread needs to know about all price changes even if those changes are made on a background thread to a product that's not currently being accessed on the main thread. Obviously, since NSManagedObjectContextObjectsDidChangeNotification only contains information about objects currently in the main thread's context, it doesn't meet my needs.

The second option I thought of was to use the NSManagedObjectContextDidSaveNotification generated by the background thread when it saves its context. This notification contains information about all changes to managed objects. I already detect this notification and pass it to the main thread for merging, so why not peek inside and see all of the managed objects that have changed? You'll recall that managed objects are not meant to be shared across threads. Consequently, if I start examining the contents of NSManagedObjectContextDidSaveNotification on the main thread, I get crashes. Hmm ... so how does mergeChangesFromContextDidSaveNotification: do it? Apparently, mergeChangesFromContextDidSaveNotification: is specifically designed to work around the "don't share managed objects across threads" restriction.

The third option I thought of was to register for NSManagedObjectContextDidSaveNotification on the background thread and while still on the background thread convert its contents into a special PriceChangeNotification containing object IDs instead of managed objects. On the main thread, I could convert the object IDs back into managed objects. This approach would still require the to-one Price relationship so that changes in prices are reflected as changes to Price managed objects.

I based my fourth option on TechZen's suggestion to override the price setter in the Product managed object. Rather than use a to-one relationship to force Core Data to generate the notifications I needed, I went back to using a price attribute. In my setPrice method, I post a custom PriceChangeNotification. This notification is received on the background thread and is used to construct a set of Product objects with price changes. After the background thread saves its context, it posts a custom PricesDidChangeNotification which includes the object IDs of all Product objects whose prices have changed. This notification can be safely transferred to the main thread and examined because it uses object IDs instead of managed objects themselves. On the main thread I can fetch the Product objects referenced by those object IDs and queue an operation to perform the lengthy "price change" calculation on a new background thread.

Share:
16,287

Related videos on Youtube

James Huddleston
Author by

James Huddleston

iOS Software Engineer at Symantec

Updated on August 07, 2020

Comments

  • James Huddleston
    James Huddleston over 3 years

    How can I detect changes to a specific attribute of an NSManagedObject? In my Core Data data model, I have a Product entity that represents a product for sale. The Product entity has several attributes: price, sku, weight, numberInStock, etc. Whenever the price attribute of a Product changes, I need to perform a lengthy calculation. Consequently, I would like to know when the price attribute of any Product changes, [edit] even if that change comes from merging a context saved on another thread. What is a good way to go about doing this? I have thousands of Product objects in my store; obviously it's not feasible to send each one an addObserver message.

    I have been using NSManagedObjectContextObjectsDidChangeNotification to detect changes, but it only notifies me that a managed object has changed, not which attribute of that object has changed. I could redo the calculation whenever there's any change to a Product, but that results in useless recalculations whenever an irrelevant attribute has changed. I'm considering making a Price entity (that only contains a price attribute) and using a to-one relationship between Product and Price. This way, I can detect changes to Price objects in order to kick off the calculation. This seems excessively kludgy to me. Is there a better way?

    Update:

    @railwayparade pointed out that I could use the changedValues method of NSManagedObject to determine which properties have changed for each updated object. I completely missed that method, and it would totally solve my problem if the changes weren't being made on a background thread and merged into the main thread's context. (See next paragraph.)

    I completely missed a subtlety about the way that NSManagedObjectContextObjectsDidChangeNotification works. As far as I can tell, when a managed object context saved on another thread is merged into a context on the main thread (using a mergeChangesFromContextDidSaveNotification:), the resulting NSManagedObjectContextObjectsDidChangeNotification only contains change information about objects that are currently in the main thread's managed object context. If a changed object isn't in the main thread's context, it won't be part of the notification. It makes sense, but wasn't what I was anticipating. Therefore, my thought of using a to-one relationship instead of an attribute in order to get more detailed change information actually requires examination of the background thread's NSManagedObjectContextDidSaveNotification, not the main thread's NSManagedObjectContextObjectsDidChangeNotification. Of course, it would be much smarter to simply use the changedValues method of NSManagedObject as @railwayparade helpfully pointed out. However, I'm still left with the problem that the change notification from the merge on the main thread won't necessarily contain all of the changes made on the background thread.

  • James Huddleston
    James Huddleston over 13 years
    As far as I know, with KVO I'd have to send an addObserver message to each Product object I want to observe. There are thousands of Product objects in my store. Loading them all in at launch just to observe them seems excessive. Adding an observer in awakeFromFetch seems better, but sometimes my context changes come from merging context saves made by another thread. I'm not sure how that would work.
  • James Huddleston
    James Huddleston over 13 years
    Which property of arrangedObjects would I observe? Or do you mean add an observer of the price attribute to each object in arrangedObjects? I'm not sure that would work. At any one time, the main thread only has references to a subset of the products. A background thread is updating products, including their prices, so sometimes the main thread is only aware that a product has changed via NSManagedObjectContextObjectsDidChangeNotification from a merge. It just can't tell whether it was the price or another attribute that changed. So it doesn't know whether to recalculate or not.
  • James Huddleston
    James Huddleston over 13 years
    Sometimes price changes are made on a background thread. The lengthy calculation is also performed on a background thread through an operation queue controlled by the main thread. If I understand your suggestion, I could modify the setter so it did something (e.g., broadcast a "price change" notification). My "calculation manager" would note when such a notification had been received. Then, when the background thread saved the context, I'd merge the context on the main thread and queue up an operation only if a notification had been received since the last merge. That doesn't seem too bad.
  • James Huddleston
    James Huddleston almost 13 years
    So, I get the updated objects set from the NSManagedObjectContextObjectsDidChangeNotification. And then, to find out what has changed for each updated NSManagedObject, I simply look through that object's changedValues dictionary. Awesome! It doesn't solve the cross-thread merging problem--when merging a context saved on a background thread, NSManagedObjectContextObjectsDidChangeNotification only includes changes to objects currently in the main thread's context--but it's definitely a super-useful method that I can use in plenty of other places. Thanks, @railwayparade!
  • strange
    strange over 12 years
    But then what do you do in case the Product managed object has just been inserted into the context and has yet not been saved. In that case its ObjectID would be non-existent (especially in the background thread). Moreover, how do you then handle deletions of Products (which would, I presume, require triggering of the lengthy calculation process). I'm stuck in a similar web of issues and can't decide what method to use.
  • James Huddleston
    James Huddleston over 12 years
    Since posting this, I've modified my design so that any Product change made on a background thread gets represented by an object (containing the necessary object IDs, keys, values, etc.). That object gets passed to the main thread for processing. This way, the main thread is always aware of Product changes since it actually performs them. I'm not delighted with this approach, but it seemed cleaner than merging contexts and passing additional change information via notifications. And yes, you'd need to save a context first if you want its object IDs to be meaningful in another context.
  • strange
    strange over 12 years
    Thanks James. Just to clarify, you are still using the overridden setter method to post the notification? If you are you can't possibly save the context as soon as you've set the amount (?). You'd presumably want the user to be able to undo/cancel etc. I'm trying something similar but sending an object in the Notification at the point 'Amount' is set isn't working for me when a new object gets inserted (since I use a different context for 'Edits'). The solution would be to use a single context but that would be ugly? Or do you actually mean you create a 'copy' of the Product with all keys?
  • James Huddleston
    James Huddleston over 12 years
    It might be worth posting your specific issue as a new question; maybe somebody else will have a better solution. But, for what it's worth, I am no longer using the setter method to post a notification. I do save the context as soon as the amount is set so that data is available to the background thread. There is no undo, but it doesn't make sense in my app anyway. Since the context is saved, I don't need to send a full copy of the Product object across threads. I can just send the object ID and key/value pairs for changed attributes. I do that through performSelectorOnMainThread:.
  • Eric Chen
    Eric Chen almost 12 years
    Thx for the tip ... I still think this should be the correct answer ;)
  • Raj Pawan Gumdal
    Raj Pawan Gumdal over 10 years
    There are also implications with KVO, I was using KVO for the same purpose but found a problem and have to now switch to a different approach. The problem which I faced - stackoverflow.com/a/19653644/260665
  • IlDan
    IlDan over 10 years
    have a look to changedValuesForCurrentEvent too
  • Jiejing Zhang
    Jiejing Zhang about 10 years
    Just tried this fix, it really works very well. print the changedValuse, it will a dict include the chagned field(key) -> newValue, like below changed values: { jobEnable = 1; }
  • Tim
    Tim about 9 years
    Despite being a paid iOS programmer for nearly five years now, this is the first time I've heard that you can actually override those accessors. This is an amazing answer, thank you so much :D
  • malhal
    malhal about 6 years
    Yep this is the correct answer for this problem of calculating something based on a property change, and there is a much easier way to do it now stackoverflow.com/a/46468201/259521
  • malhal
    malhal about 6 years
    KVO is useful for detecting specific object property changes (i.e. more specific than the general NSFetchedResultsController objectChanged) to update the UI but his problem should instead be solved at the model layer by overriding the setter property in his object as in TechZen's answer.
  • MH175
    MH175 over 2 years
    There is a gotcha with this. If you subsequently perform an undo operation, the changedValues dictionary will report no changes even though from your perspective the value has changed. If you are driving your UI relying on determining if something has changed after an undo operation your UI will be in an inconsistent state.

Related