Paulo Andrade

Keeper of Secrets.

twitter github stackoverflow linkedin email
NSOperation KVO Pitfall
Jun 1, 2018
3 minutes read

Recently, I encountered a bug while testing Secrets on one my older test devices running iOS 9. Apparently one of my NSOperation subclasses never finished on this device, and only on this device.

NSOperationQueue relies heavily on Key-Value Observing (KVO), it observes various keys of NSOperation to manage its lifecycle. Namely a queue will observe the values of isFinished, isExecuting, isCancelled and react accordingly.

In this case, the offending NSOperation subclass was asynchronous, so it was responsible for updating those properties and sending the necessary KVO notifications. My custom operation declared an enum with all the possible states and overrides isFinished and isExecuting to return based on that. To make sure KVO notification are sent it simply registers its state property as a dependent key for isFinished and isExecuting.

@objc enum State: Int { case pending, running, finished, failed }

@objc dynamic var state = State.pending

@objc class func keyPathsForValuesAffectingFinished() -> Set<String> {
    return [#keyPath(state)]
}

override var isFinished: Bool {
    switch self.state {
    case .pending, .running:
        return false
    case .finished, .failed:
        return true
    }
}

@objc class func keyPathsForValuesAffectingExecuting() -> Set<String> {
    return [#keyPath(state)]
}

override var isExecuting: Bool {
    switch self.state {
    case .pending, .finished, .failed:
        return false
    case .running:
        return true
    }
}

And if you’re running iOS 11 this all works fine.

Quick detour through Key-Value Coding

Key-Value Coding (KVC) and KVO go hand in hand. When using either of these, it helps to know how the other works.

In the case of KVC it’s useful to know that for scalar properties KVC will try to retrieve a value by calling the first method that matches one of these patterns: get<Key>, <key>, is<Key>, or _<key>, in that order.

Note the third pattern has an “is” prefix. This is there to support Cocoa’s naming conventions:

If the name of a declared property is expressed as an adjective, however, the property name omits the “is” prefix but specifies the conventional name for the get accessor, for example:

@property (assign, getter=isEditable) BOOL editable;

From NSOperation’s header you can see it follows this convention:

@interface NSOperation : NSObject
// ...
@property (readonly, getter=isExecuting) BOOL executing;
@property (readonly, getter=isFinished) BOOL finished;
// ...
@end

This means calling operation.value(forKey: "executing") eventually calls isExecuting and returns correctly.

The pitfall

Based on the previous section you can probably see where this is going. Although the name of accessor method is isExecuting in KVC terms the name of the key is actually executing. And that’s why my NSOperation subclass correctly implements keyPathsForValuesAffectingExecuting and not keyPathsForValuesAffectingIsExecuting.

But apparently, NSOperationQueue was registering itself as an observer of isExecuting on iOS 9. We can confirm this by overriding keyPathsForValuesAffectingValueForKey:

@objc override class func keyPathsForValuesAffectingValue(forKey key: String) -> Set<String> {
    NSLog("\(key)")
    return super.keyPathsForValuesAffectingValue(forKey: key)
}

On iOS 9 the above code prints isFinished, isReady and isExecuting, whereas on iOS 111 it prints finished, ready and executing and also all the previous keys. It continues to observe the is<Key>s to maintain backwards compatibility.

The pitfall is that unless you actually test on iOS 9, you’ll never run into this issue. The fix is to also register dependent keys for the incorrect “is”-prefixed keys.


  1. I’m not sure this was only fixed on iOS 11 or if it was fixed sooner on iOS 10. ↩︎



Back to posts