NSArrayController and KVO dependent keys

Oct 27 2013

In the previous post I went through dependent keys with KVO. It is a very powerful concept that will help your code look much cleaner and declarative (even more if used in addition to Cocoa bindings).

In this post I’ll discuss a scenario that any developer that ever worked with Cocoa on the Mac will be very accustomed to: NSArrayController and KVO dependent keys.

NSArrayController, a subclass of NSObjectController (itself a subclass of NSController) is a controller class that manages a collection of objects. From the docs:

NSArrayController is a bindings compatible class that manages a collection of objects. Typically the collection is an array, however, if the controller manages a relationship of a managed object the collection may be a set. NSArrayController provides selection management and sorting capabilities.

NSArrayController is extremely powerful, particularly when used with Core Data and bindings.

There are times when you want to use an NSArrayController to manage objects inside a window or view controller and need to expose these objects (so that a client can observe or bind a view to them for example) without exposing the array controller itself. Basically you want to expose an NSArray property represented internally by an array controller’s arranged objects.

Let’s take an example. We have a Shop object with a very simple interface:

@interface Shop : NSObject

@property (readonly, strong, nonatomic) NSArray *products;

- (void)addProduct:(NSString *)product;
- (void)removeProduct:(NSString *)product;

@end

Internally, we will use an NSArrayController to manage these products.

@interface Shop (/* Private */)

@property (strong, nonatomic) NSArrayController *productsController;

@end

The products getter simply return the array controller’s arranged objects and the implementation of the addProduct: and removeProduct: is also fairly simple.

@implementation Shop

- (id)init
{
    self = [super init];
    if (self == nil) {
        return nil;
    }
    
    NSMutableArray *products = [NSMutableArray arrayWithObjects:@"Cheetah", @"Puma", @"Jaguar", @"Panther", @"Tiger", @"Leopard", @"Snow Leopard", @"Lion", @"Mountain Lion", nil];
    
    _productsController = [[NSArrayController alloc] initWithContent:products];
    
    return self;
}

- (NSArray *)products
{
    return [[self productsController] arrangedObjects];
}

- (void)addProduct:(NSString *)product
{
    [[self productsController] addObject:product];
}

- (void)removeProduct:(NSString *)product
{
    [[self productsController] removeObject:product];
}

@end

Since we want a client to be able to observe or bind to the products property, we need to make sure that observers are correctly notified when its content is updated.
Since products simply returns the array controller’s arranged objects it is thus entirely dependent on the arrangedObjects key and we should be able to implement +keyPathsForValuesAffectingValueForKey: accordingly.

+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key
{
    NSMutableSet *keyPaths = [NSMutableSet setWithSet:[super keyPathsForValuesAffectingValueForKey:key]];
    
    if ([key isEqualToString:@"products"]) {
        [keyPaths addObject:@"productsController.arrangedObjects"];
    }
    
    return keyPaths;
}

With this in place, however, if you observe the products key path and try to add a product, you might notice that the observer doesn’t seem to ever be notified.
After spending some time trying to figure out what is going on with your code you might come to the realization that there might be something wrong with NSArrayController. And you might indeed be right.

As a good debugger, before jumping to conclusions we will want to analyze the code flow in the working case and try to infer what is preventing it to work in our very special non-working case. So first thing first, let’s discuss what how dependent keys with KVO actually work.

From a comment on +keyPathsForValuesAffectingValueForKey: in the NSKeyValueObserving header:

Return a set of key paths for properties whose values affect the value of the keyed property.

When an observer for the key is registered with an instance of the receiving class, KVO itself automatically observes all of the key paths for the same instance, and sends change notifications for the key to the observer when the value for any of those key paths changes.

So essentially, KVO will inspect the observe the key paths you return from the +keyPathsForValuesAffectingValueForKey: method on the observed object and send change notifications when the value of any of these key path on the object changes.

The mechanism by which KVO detects changes is by inspection of the change dictionary (the one you get in -observeValueForKeyPath:ofObject:change:context:). Based on the observation options, the change dictionary will contain values for the following keys:

NSKeyValueChangeKindKey
NSKeyValueChangeNewKey
NSKeyValueChangeOldKey
NSKeyValueChangeIndexesKey
NSKeyValueChangeNotificationIsPriorKey

In order to detect value changes, KVO very likely observes the object’s key paths with the old and new options so that it can figure out whether a value was updated. Obviously, this requires a well-formed change dictionary.

Breaking news: NSArrayController doesn’t correctly populate the change dictionary.

Actually, this is far from being a breaking news. It’s a well-known bug that has existed since 10.3 and that is very unlikely to be fixed in the foreseeable future.

If you manually observe an array controller’s arranged objects key path with the old and new observation options, the change dictionary you will get when an object is inserted will look as following:

{
    kind = 1;
    new = "<null>";
    old = "<null>";
}

It thus explains why KVO wasn’t able to send change notifications to observers of a dependent key paths when the array controller’s arranged objects were updated: it was unable to figure out whether the value had changed, being null before and after.

There is, as always, a workaround. Even though the change dictionary is not correctly populated, observers are still notified with the content changes. One can thus manually track the content, fetch the new content when notified of the change and manually do the comparison if necessary.

In order to fix our example, we have two options:

  • Manually call willChangeValueForKey: and didChangeValueForKey: for the products key path in the addProduct: and removeProduct: methods. While this will certainly work for our simple case, it won’t scale very well since one will need to remember to call these methods every time the array controller’s content is modified. There are also cases (such as when working with Core Data) where contents might be modified under your feet without you having the opportunity to manually send these change messages.

  • Observe the array controller’s arranged objects and modify the value for the products key path in a KVO-compliant way to ensure that observers are correctly notified. A nice way to do this would be to make the products property readwrite and set a copy of the content every time it is updated.

static NSString * ShopProductsControllerArrangedObjectsContext = @"ShopProductsControllerArrangedObjectsContext";

[_productsController addObserver:self forKeyPath:@"arrangedObjects" options:(NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew) context:&ShopProductsControllerArrangedObjectsContext];

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
    if (context == &ShopProductsControllerArrangedObjectsContext) {
        NSArray *products = [[self productsController] arrangedObjects];
        [self setProducts:[NSArray arrayWithArray:products]];
    }
    else {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

Note that it’s extremely important to take a copy of the arranged objects array. In the case of an ordered collection, the array controller’s arranged objects will usually be an instance of _NSControllerArrayProxy which, even if a subclass of NSArray, will be mutated under your feet. In fact, by the time you receive the change notification the reference you have stored would have already been mutated, hiding the change.

By manually setting the products property with the new value KVO will notify any observers of the changes and correctly populate the change dictionary. If you decide to bind some view in the user interface to the products key path it will now work nicely, as expected.

Even though I am well aware of this bug I seem to constantly fall for it. By writing it down here’s hoping I won’t make that mistake again.

As usual I’ve uploaded the sample code as a gist so that’s it’s easier to follow.

blog comments powered by Disqus