soranoba
soranoba Author of soranoba.net
programming

KVOのクラッシュ地獄から脱却する

iOSにはKey-Value Observing (KVO)というpropertyの変更を監視する仕組みがあります.
NSObjectが提供する機能である為, Swiftで使うにはいくつもの制限があり必ずしも使いやすいとは言えませんが, サードパーティライブラリを使うことなく利用できるのは大変便利です. そして, AVPlayerなどを扱う際には避けて通れない機能でもあります.
しかし, KVOを利用すると解放済みオブジェクトに対して通知が行ってクラッシュすることがあり, 長らく悩まされてきました.
長年の悩みの原因の1つが解消されたので, それを紹介します.

値を保持するオブジェクトのobserve(_:options:changeHandler:)を利用する

この記事のコードはgithub.com/soranoba/iOS-SandBox/tree/KVO_OVERRELEASED_OR_SMASHEDにあります.

ここでは, ViewController -> Container -> ObservableObjectの参照関係で, ObservableObjectの値変更をKVOで監視します.

@objc class ObservableObject: NSObject {
    @objc dynamic var value: Int = 0
}

@objc class Container: NSObject {
    @objc dynamic var object = ObservableObject()
}

class ViewController: UIViewController {
    private var container = Container()
    private var observation: NSKeyValueObservation?
}

まずは, 必ずクラッシュを再現できるコードです. コードはこの部分です.

observation = container.observe(\.object.value, options: [.new]) { _, _ in
    print("changed")
}

KVO自体は複数のオブジェクトをまたいで監視することができるので, つい書いてしまいがちです. 実際, 多くの場合で問題なく機能します.
しかし, 実際に上記のコードを実行してみると (Dangerのボタンを複数回タップする) とすぐにクラッシュします.

Xcodeのバージョンによって異なるスタックトレースになりますが, Xcode 11.3では KVO_IS_RETAINING_ALL_OBSERVERS_OF_THIS_OBJECT_IF_IT_CRASHES_AN_OBSERVER_WAS_OVERRELEASED_OR_SMASHED (長い!!) で, Xcode 11.4では_NSSetLongLongValueAndNotifyでした.

次はクラッシュしないコードです. コードはこの部分です.

observation = container.object.observe(\.value, options: [.new]) { _, _ in
    print("changed")
}

違いが分かりにくいのでdiffで見ると以下のような違いがあります. メソッドの第一引数の辺りに注目してください.

- observation = container.observe(\.object.value, options: [.new]) { _, _ in
+ observation = container.object.observe(\.value, options: [.new]) { _, _ in
      print("changed")
  }

このコードは実行してみると (Safe-1のボタンを複数回タップする) とクラッシュしません.
つまり, なんのオブジェクトのインスタンスメソッドを呼ぶか, KeyPathがネストしているかで変わるということが分かります.

observe(_:options:changeHandler:)の裏で行われていること

ではobserver(_:options:changeHandler:)のコードを読んでいきます.

///when the returned NSKeyValueObservation is deinited or invalidated, it will stop observing
public func observe<Value>(
        _ keyPath: KeyPath<Self, Value>,
        options: NSKeyValueObservingOptions = [],
        changeHandler: @escaping (Self, NSKeyValueObservedChange<Value>) -> Void)
    -> NSKeyValueObservation {
    return NSKeyValueObservation(object: self as! NSObject, keyPath: keyPath, options: options) { (obj, change) in

NSObject.swift#L253-L259より

ここではNSKeyValueObservationを作成しているだけです. 第一引数にselfが渡っている点だけ覚えておきます.

@nonobjc init(object: NSObject, keyPath: AnyKeyPath, options: NSKeyValueObservingOptions, callback: @escaping (NSObject, NSKeyValueObservedChange<Any>) -> Void) {
    _ = Helper.swizzler
    let path = _bridgeKeyPathToString(keyPath)
    self.object = object
    self.path = path
    self.callback = callback
    super.init()
    objc_setAssociatedObject(object, associationKey(), self, .OBJC_ASSOCIATION_RETAIN)
    __KVOKeyPathBridgeMachinery._withBridgeableKeyPath(from: path, to: keyPath) {
        object.addObserver(self, forKeyPath: path, options: options, context: nil)
    }
}

NSObject.swift#L187-L198より

NSObject.observe(_:options:changeHandler:)の第一引数のインスタンスメソッドaddObserver(_:forKeyPath:options:context:)が呼び出されることが分かります.

container.observe(\.object.value, options: [.new]) {_, _ in }
container.addObserver(helper, forKeyPath: \.object.value, options: [.new], context: nil)

つまり, この2つのコードが大体同じということです. また, objc_setAssociatedObjectで監視対象 (上記のcontainer) がHelperの参照を保持するようにしています.

@nonobjc func invalidate() {
    guard let object = self.object else { return }
    object.removeObserver(self, forKeyPath: path, context: nil)
    objc_setAssociatedObject(object, associationKey(), nil, .OBJC_ASSOCIATION_ASSIGN)
    self.object = nil
}

NSObject.swift#L200-L205より

invalidate()は単にremoveObserver(_:forKeyPath:context:)を呼び出すだけです. NSKeyValueObservationの解放時に自動的に実行されます.
注目するべきは, objectが解放されている場合はremoveObserver(_:forKeyPath:context:)が実行されないという点です.
removeObserver(_:forKeyPath:context:)が呼び出されていない場合, KVOの通知がまだ実行される場合があります.

はい. ここが怪しそうですね.

では, 先述のコードでの参照関係を図示してみます. まずはクラッシュするケース.

reference-graph


NSKeyValueObservationが解放される前に, Containerが先に解放された場合にKVOが解除されなさそうです.
では, クラッシュしないケースを見てみましょう.

reference-graph


ObservableObjectの値が変更されるということは, どこかで参照が保持されているということなので, KVOの解除漏れが発生することはなさそうです.
では, クラッシュするケースをContainerが解放される前にNSKeyValueObservationを解放してみましょう.

+ observation = nil
  container = Container()
  observation = container.observe(\.object.value, options: [.new]) { _, _ in
      print("changed")
  }

実際に実行してみると (Safe-2のボタンを複数回タップしても) クラッシュしないことが分かります.
これでクラッシュとお別れできそうです.
KVO完全に理解した!!!

(Updated: )

comments powered by Disqus