18

The short version of the question:
I have a class with a ton of declared properties, and I want to keep track of whether or not there have been any changes to it so that when I call a save method on it, it doesn't write to the database when it isn't needed. How do I update an isDirty property without writing custom setters for all of the declared properties?

The longer version of the question:
Let's say that I have a class like this:

@interface MyObject : NSObject
{
@property (nonatomic, retain) NSString *myString;
@property (nonatomic, assign) BOOL     myBool;
// ... LOTS more properties
@property (nonatomic, assign) BOOL     isDirty;
}

...

@implementation MyObject
{
@synthesize myString;
@synthesize myBool;
// ... LOTS more synthesizes :)
@synthesize isDirty;
}

Attempt 1
My first thought was to implement setValue:forKey: like this:

- (void)setValue:(id)value forKey:(NSString *)key {
    if (![key isEqualToString:@"isDirty"]) {
        if ([self valueForKey:key] != value) {
            if (![[self valueForKey:key] isEqual:value]) {
                self.isDirty = YES;
            }
        }
    }
    [super setValue:value forKey:key];
}

This works perfectly until you set the value directly with a setter (i.e. myObject.myString = @"new string";), in which case setValue:forKey: isn't called (I'm not sure why I thought that it would be, lol).

Attempt 2
Observe all properties of self.

- (id)init
{
    // Normal init stuff
    // Start observing all properties of self
}

- (void)dealloc
{
    // Stop observing all properties of self
}

- (void)observeValueForKeyPath:(NSString *)keyPath 
                      ofObject:(id)object 
                        change:(NSDictionary *)change 
                       context:(void *)context
{
    // set isDirty to true
}  

I'm pretty sure that this will work, but I think that there must be a better way. :) I also want this to be automatic, so that I don't have to maintain a list of properties to watch. I can easily see forgetting to add a property to the list when maintaining this down the road and then trying to figure out why my object sometimes doesn't get saved.

Hopefully I'm overlooking a much simpler approach to this problem!

Final Solution
See my answer below for my final solution to this. It is based on the answer provided by Josh Caswell, but is a working example.

lnafziger
  • 25,760
  • 8
  • 60
  • 101
  • Have to admit, there are only 2 ways I can think to do this, one is going to be with the custom setter methods, the other would be to figure out a method of HASHing the object prior to writing out to file, and comparing the HASH calculated with a HASH stored with the file itself on disk. Good question though! – trumpetlicks Jul 09 '12 at 15:58
  • Yeah, I thought about the hash, but since you can have the same hash with two objects that aren't identical, you risk not saving the object when it needs to be saved. Granted, this probably won't happen very often, but that would make it even harder to track down when it did! – lnafziger Jul 09 '12 at 16:00
  • Actually that shouldnt be true with a proper (good) hash algorithm. A good HASH algorithm should have VERY FEW collisions. Good HASH algorithms will change ~50% of the message digest bits in the case where only 1 input bit has changed, and will also take into account ordering of input (i.e. if on 1 run a is passed through before b, then on a 2nd run b is passed through before a, even if a and be havent changed, the output digest will be different). – trumpetlicks Jul 09 '12 at 16:02
  • Yeah, I get that, but the key from your response is "VERY FEW" collisions.... – lnafziger Jul 09 '12 at 16:21
  • I dont think it would be something you would have to worry about is what I was keying in on. There is a reason why MD5 checksums and HASHes come with many files on the internet! It is also the reason they are used as the input to many digital signature algorithms. – trumpetlicks Jul 09 '12 at 16:23

5 Answers5

6

A little introspection should help out here. The runtime functions can give you a list of all the object's properties. You can then use those to tell KVO that dirty is dependent on that list. This avoids the maintainability problem of having to update the list of properties by hand. The one caveat is that, like any other solution involving KVO, you won't be notified if the ivar is changed directly -- all access must be through setter methods.

Register to observe self's dirty key path in init, and add this method, creating and returning an NSSet with the names of all the class's properties (except @"dirty", of course).

#import <objc/runtime.h>

+ (NSSet *)keyPathsForValuesAffectingDirty 
{
    unsigned int num_props;
    objc_property_t * prop_list;
    prop_list = class_copyPropertyList(self, &num_props);

    NSMutableSet * propSet = [NSMutableSet set];
    for( unsigned int i = 0; i < num_props; i++ ){
        NSString * propName = [NSString stringWithFormat:@"%s", property_getName(prop_list[i])];
        if( [propName isEqualToString:@"dirty"] ){
            continue;
        }
        [propSet addObject:propName];
    }
    free(prop_list);

    return propSet;
}

Now an observation of dirty will be triggered whenever any of this class's properties are set. (Note that properties defined in superclasses are not included in that list.)

You could instead use that list to register as an observer for all the names individually.

jscs
  • 63,694
  • 13
  • 151
  • 195
  • Hey, cool! I was planning on using this technique to observe all of the values individually, but didn't think of using it in keyPathsForValuesAffecting<>. The one thing that I don't understand though, is how dirty is updated. This will provide a change notification for dirty when one of the dependent keys is changed, but the actual value of dirty remains unchanged.... I would somehow need to **update** dirty when the dependent keys change. – lnafziger Jul 09 '12 at 18:02
  • Just do it in `observeValueForKeyPath:ofObject:change:context:`, same as you would if you were observing each key individually. You're sort of lying to KVO about the dependency, but you can do the update of `dirty` manually. Of course, you have to set the **ivar** in `observeValue...`. – jscs Jul 09 '12 at 18:04
  • Sooo... Observe `dirty` and if I see that it has changed, (even though it really hasn't) change it to YES? Then, in order to set it back to NO (after it has been saved, or immediately after it is loaded) I would just change the ivar directly I guess (otherwise it would automatically change it back to YES since it changed). – lnafziger Jul 09 '12 at 18:09
  • +1 I like it. I'll try it later today and see how it works out. Thanks for the input! – lnafziger Jul 09 '12 at 18:10
  • Right, you'll always have to handle the `dirty` ivar directly. It may be a little awkward, thus my last sentence. Hope it works out. – jscs Jul 09 '12 at 18:11
2

It may be a bit overkill depending on your needs, but CoreData provides everything's needed to manage object states and changes. You can use a memory based data store if you do not want to deal with files, but the most powerful setup uses SQLite.

So then, your objects (based on NSManagedObject) will inherit a handful of useful methods, like -changedValues which lists the changed attributes since the last commit or -committedValuesForKeys: nil which returns the last committed attributes.

Overkill possibly, but you do not have to reinvent the wheel, you do not need to use a third party library, and it will need only a few lines of code to make it work nicely. Memory usage will be impacted quite a fair bit, but not necessarily for the bad if you choose to use a SQLite datastore.

Core Data apart, using KVO is the way to go to implement your own snapshot mechanism or change manager.

  • Not overkill at all. This is exactly the kind of problem that Core Data is designed to solve and does very well. I'm not always a huge fan of Core Data, but for this kind of problem it is exactly the tool to use. If you're implementing methods with names like "save" and "isDirty" you're reinventing Core Data and should stop. – Rob Napier Jul 09 '12 at 16:18
  • I would love to use Core Data (I've used it in other projects) but this is for a multi-platform project so I need to stay with SQLite. Thanks for the answer though! – lnafziger Jul 09 '12 at 16:22
  • @RobNapier I wish that I didn't have to reinvent Core Data, but since it is a multi-platform project, it actually provided some of the inspiration here! – lnafziger Jul 09 '12 at 16:26
  • Multi-platform and you use Objective-C? For sure I wouldn't have guessed. Is KVO even supported on the platforms you're targeting? – fabrice truillot de chambrier Jul 09 '12 at 16:27
  • @fabricetruillotdechambrier The data **source** (SQLite3 database) has to be multi-platform and shared. Our data models are, of course, specific to the objective-c version. – lnafziger Jul 09 '12 at 16:37
  • Ah yes, of course. My bad. What about importing the data from that SQLite database into a Core Data managed one? – fabrice truillot de chambrier Jul 09 '12 at 16:51
  • I had considered this as well, but would prefer not to do the conversion from one database system to another and back all of the time.... – lnafziger Jul 09 '12 at 17:54
  • By the way, +1 even though I can't use this solution. For other situations it is a perfectly acceptable answer! – lnafziger Jul 09 '12 at 17:55
  • @Inafziger, a fair point about cross-platform databases. I've faced this many times as well. It is much easier if you maintain a single data model. Your self-observation approach is likely best. You can use class_copyProperityList() to do this in a more automatic way. – Rob Napier Jul 09 '12 at 18:03
2

My final solution (Thanks Josh Caswell for the example!):

- (id)init
{
    if (self = [super init])
    {
        [self addObserver:self forKeyPath:@"isDirty" options:0 context:NULL];
    }
    return self;
}

- (void)dealloc
{
    [self removeObserver:self forKeyPath:@"isDirty"];
}

- (BOOL)loadData
{
    // Load the data, then if successful:
    isDirty = NO;
    return YES;
}

- (BOOL)saveData
{
    if (!self.isDirty)
    {
        return YES;
    }

    // Save the data, then if successful:
    isDirty = NO;
    return YES;
}

// isDirty is dependant on ALL of our declared property.
+ (NSSet *)keyPathsForValuesAffectingIsDirty
{
    unsigned int    num_props;
    objc_property_t *prop_list = class_copyPropertyList(self, &num_props);

    NSMutableSet * propSet = [NSMutableSet set];
    for( unsigned int i = 0; i < num_props; i++ )
    {
        NSString * propName = [NSString stringWithFormat:@"%s", property_getName(prop_list[i])];
        if(![propName isEqualToString:@"isDirty"] )
        {
            [propSet addObject:propName];
        }
    }

    free(prop_list);

    return propSet;
}

// If any of our declared properties are changed, this will be called so set isDirty to true.
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
    if ([keyPath isEqualToString:@"isDirty"])
    {
        isDirty = YES;
    }
}
lnafziger
  • 25,760
  • 8
  • 60
  • 101
  • Did this solution end up working well for you? It seems to be a common problem and I was thinking I might try this solution. – TylerJames Nov 02 '12 at 15:50
  • Yes, it has been working well for me, just remember that if you change a property via its ivar that isDirty will not be set. – lnafziger Nov 03 '12 at 00:55
0

I don't know what all of your properties are, but you could try "superclassing" them. Create an object ObservedObjectand then make custom classes for all of your objects that are subclasses of this object. Then either put an isDirty property on ObservedObject and look at it, or send a notification to your program when it is changed. This might be a lot of work if you have many different types of objects, but if you have mostly many of the same object it shouldn't be too bad.

I'm interested to see if this is a viable solution or if a good solution can be found for this kind of problem.

Dustin
  • 6,783
  • 4
  • 36
  • 53
0

One option would be in your save method to get the old version myObject and do something like

if (![myOldObject isEqual:myNewObject]) {

 //perform save

}
Garrett
  • 5,580
  • 2
  • 31
  • 47
  • Yeah, that defeats the purpose of isDirty though. I could just save it every time, but with your way I would have to read it every time, do the compare, and then save it if needed. Even more work than the original plan.... – lnafziger Jul 09 '12 at 18:03