3

I'm trying to write a wrapper around URLSessionTask in Swift. According to the documentation

All task properties support key-value observing.

So I want to keep this behavior and make all the properties on my wrapper also KVO-compliant (usually delegating to the wrapped task) and fully accessible to Objective-C. I'll describe what I'm doing with one property, but I basically want to do the same thing for all properties.

Let's take the property state of URLSessionTask. I create my wrapper like this:

@objc(MyURLSessionTask)
public class TaskWrapper: NSObject {
    @objc public internal(set) var underlyingTask: URLSessionTask?
    @objc dynamic public var state: URLSessionTask.State {
        return underlyingTask?.state ?? backupState
    }
    // the state to be used when we don't have an underlyingTask
    @objc dynamic private var backupState: URLSessionTask.State = .suspended

    @objc public func resume() {
        if let task = underlyingTask {
            task.resume()
            return
        }
        dispatchOnBackgroundQueue {
            let task:URLSessionTask = constructTask()
            task.resume()
            self.underlyingTask = task
        }
    }
}

I added @objc to the properties so they are available to be called from Objective-C. And I added dynamic to the properties so they will be called via message-passing/the runtime even from Swift, to make sure the correct KVO-Notifications can be generated by NSObject. This is supposed to be enough according to Apple's KVO chapter in the "Using Swift with Cocoa and Objective-C" book.

I then implemented the static class methods necessary to tell KVO about dependent key paths:

// MARK: KVO Support
extension TaskWrapper {
    @objc static var keyPathsForValuesAffectingState:Set<String> {
        let keypaths:Set<String> = [
            #keyPath(TaskWrapper.backupState),
            #keyPath(TaskWrapper.underlyingTask.state)
        ]
        return keypaths
    }
}

Then I wrote a unit test to check whether the notifications are called correctly:

var swiftKVOObserver:NSKeyValueObservation?

func testStateObservation() {
    let taskWrapper = TaskWrapper()
    let objcKVOExpectation = keyValueObservingExpectation(for: taskWrapper, keyPath: #keyPath(TaskWrapper.state), handler: nil)
    let swiftKVOExpectation = expectation(description: "Expect Swift KVO call for `state`-change")
    swiftKVOObserver = taskWrapper.observe(\.state) { (_, _) in
        swiftKVOExpectation.fulfill()
    }
    // this should trigger both KVO versions
    taskWrapper.underlyingTask = URLSession(configuration: .default).dataTask(with: url)
    self.wait(for: [swiftKVOExpectation, objcKVOExpectation], timeout: 0.1)
}

When I run it, the test crashes with an NSInternalInconsistencyException:

*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Cannot remove an observer <_XCKVOExpectationImplementation 0x60000009d6a0> for the key path "underlyingTask.state" from < MyURLSessionTask 0x6000002a1440>, most likely because the value for the key "underlyingTask" has changed without an appropriate KVO notification being sent. Check the KVO-compliance of the MyURLSessionTask class.'

But by making the underlyingTask-property @objc and dynamic, the Objective-C runtime should ensure that this notification is sent, even when the task is changed from Swift, right?

I can make the test work correctly by sending the KVO-notifications for the underlyingTask manually like this:

@objc public internal(set) var underlyingTask: URLSessionTask? {
    willSet {
        willChangeValue(for: \.underlyingTask)
    }
    didSet {
        didChangeValue(for: \.underlyingTask)
    }
}

But I'd much rather avoid having to implement this for every property and would prefer to use the existing keyPathsForValuesAffecting<Key> methods. Am I missing something to make this work? Or should it work and this is a bug?

Joachim Kurz
  • 2,875
  • 6
  • 23
  • 43
  • Is `underlyingTask` `dynamic`? – Willeke Feb 28 '18 at 09:27
  • @Willeke I was sure I made it dynamic, but apparently I didn't. -.- Will try again. – Joachim Kurz Feb 28 '18 at 09:29
  • @Willeke you were right. Making the `underlyingTask` dynamic actually solves the issue ‍♂️. If you add this as an answer I'm happy to accept it. Hope that the question itself at least helps people trying to figure out how to do and test KVO with Swift. :D – Joachim Kurz Feb 28 '18 at 09:50

1 Answers1

1

Property underlyingTask isn't dynamic.

Willeke
  • 14,578
  • 4
  • 19
  • 47