NSKeyValueObservation.invalidate()は極力避ける


はじめに

SwiftでのNSObject KVOには、NSKeyValueObservationを使うことがほとんどです。
Using Key-Value Observing in Swift | Apple Developer Documentation

今回、NSKeyValueObservationで発生したクラッシュ調査でいくつか発見があったので、こちらに記しておきます。

ポイント

  • NSKeyValueObservation.invalidate()は極力避ける
  • NSKeyValueObservationは、nullableで保持し、インスタンス解放で無効化する

なぜ、NSKeyValueObservation.invalidate()は極力避けるのか?

なぜなら、マルチスレッド環境で、NSRangeExceptionを起こすからです。

今回調査していたクラッシュもそれで、以下のようなスタックトレースでクラッシュしていました。

Fatal Exception: NSRangeException
Cannot remove an observer <_NSKeyValueObservation 0x282f97810> for the key path “xxxxx” from <XXXXX 0x28213c9e0> because it is not registered as an observer.
...
0  CoreFoundation                 0x1b0a5927c __exceptionPreprocess
1  libobjc.A.dylib                0x1afc339f8 objc_exception_throw
2  CoreFoundation                 0x1b09634b0 -[NSCache init]
3  Foundation                     0x1b13ff430 -[NSObject(NSKeyValueObserverRegistration) _removeObserver:forProperty:]
4  Foundation                     0x1b13ff1ec -[NSObject(NSKeyValueObserverRegistration) removeObserver:forKeyPath:]
5  Foundation                     0x1b14bf48c -[NSOperationQueue removeObserver:forKeyPath:]
6  Foundation                     0x1b13fef2c -[NSObject(NSKeyValueObserverRegistration) removeObserver:forKeyPath:context:]
7  libswiftFoundation.dylib       0x1dee06b04 NSKeyValueObservation.invalidate()
8  *****                          0x1059594e4 (Missing)
9  *****                          0x1059585fc (Missing)
10 *****                          0x105956b90 (Missing)
11 *****                          0x10594a4b4 (Missing)
12 libdispatch.dylib              0x1b0498a38 _dispatch_call_block_and_release
13 libdispatch.dylib              0x1b04997d4 _dispatch_client_callout
14 libdispatch.dylib              0x1b047cafc _dispatch_root_queue_drain
15 libdispatch.dylib              0x1b047d248 _dispatch_worker_thread2
16 libsystem_pthread.dylib        0x1b06791b4 _pthread_wqthread

調査の結果、マルチスレッドで、invalidate()が呼ばれると起こることがわかりました。
検証のために、以下のようなサンプルコードで、Playgroundで動作させてみると、invalidate()をマルチスレッドで呼ぶと例外が発生することがわかります。

import Foundation

class MyObjectToObserve: NSObject {
    @objc dynamic var myDate = NSDate(timeIntervalSince1970: 0) // 1970
    func updateDate() {
        myDate = myDate.addingTimeInterval(Double(2 << 30)) // Adds about 68 years.
    }
}

class MyObserver: NSObject {
    @objc var objectToObserve: MyObjectToObserve
    var observation: NSKeyValueObservation?

    init(object: MyObjectToObserve) {
        objectToObserve = object
        super.init()

        observation = observe(
            \.objectToObserve.myDate,
            options: [.old, .new]
        ) { object, change in
            print("myDate changed from: \(change.oldValue!), updated to: \(change.newValue!)")
        }
    }
}

let observed = MyObjectToObserve()
let observer = MyObserver(object: observed)

observed.updateDate()

/* No problem is to call invalidate() repeatedly by the same thread. */
// observer.observation?.invalidate()
// observer.observation?.invalidate()

let group = DispatchGroup()

(0..<10).forEach { _ in
    DispatchQueue.global().async(group: group) {
        /* Leads to a crash by NSRangeException */
        // observer.observation?.invalidate()
        observer.observation = nil
    }
}

group.wait()

observed.updateDate()

例外の原因は、こちらです。

Asking to be removed as an observer if not already registered as one results in an NSRangeException. You either call removeObserver:forKeyPath:context: exactly once for the corresponding call to addObserver:forKeyPath:options:context:, or if that is not feasible in your app, place the removeObserver:forKeyPath:context: call inside a try/catch block to process the potential exception.

Key-Value Observing Programming Guide - Removing an Object as an Observer

実際にinvalidate()の中で、removeObserver(_:forKeyPath:context:)が呼ばれています。

一方、observer.observationへnilをアサインして解放すれば、例外は起こりません。
これは、NSKeyValueObservation.deinitでは、invalidate()が呼ばれますが、マルチスレッドで解放されてもdeinitが一度しか呼ばれないため、例外が発生しないのだと思われます。

そのため、NSKeyValueObservationを扱うときは、何かしらの理由で、letで宣言しない限りは、invalidate()を呼ばす、単に解放してあげる方法が安全です。

たとえば、あるメソッド内で以下を実装したとき、そのメソッドが必ずシングルスレッドで呼ばれることを将来に渡り保証するのは難しいからですからね。

observation?.invalidate()
observation = nil

余談

NSKeyValueObservationは、Appleの公式サイト ではドキュメントないので、ずっと困っていたのですが、なんと、こちらにありました。
swift/NSObject.swift at master · apple/swift · GitHub

NSObject.observe(_:keyPath:options:changeHandler:)やNSKeyValueObservationは、Swiftのstdlibが提供しているNSObject KVO拡張だったのですね。