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.
-
I’m not sure this was only fixed on iOS 11 or if it was fixed sooner on iOS 10. ↩︎