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の通知がまだ実行される場合があります.
はい. ここが怪しそうですね.
では, 先述のコードでの参照関係を図示してみます. まずはクラッシュするケース.

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

ObservableObject
の値が変更されるということは, どこかで参照が保持されているということなので, KVOの解除漏れが発生することはなさそうです.
では, クラッシュするケースをContainer
が解放される前にNSKeyValueObservation
を解放してみましょう.
+ observation = nil
container = Container()
observation = container.observe(\.object.value, options: [.new]) { _, _ in
print("changed")
}
実際に実行してみると (Safe-2
のボタンを複数回タップしても) クラッシュしないことが分かります.
これでクラッシュとお別れできそうです.
KVO完全に理解した!!!
記事が気に入ったらチップを送ることができます!
You can give me a cup of coffee :)
Kyash ID: soranoba
Amazon: Wish List
GitHub Sponsor: github.com/sponsors/soranoba
PayPal.Me: paypal.me/soranoba
(Updated: )