soranoba
soranoba Author of soranoba.net
programming

SwiftのProtocolに対してKVOを行う

前回から引き続きKVOについての投稿です.
SwiftにおけるKVOは対象のpropertyの制限 (@objc dynamic) は広く知られていますが, Protocolに対してKVOを行うのもハードルが高かったりします.
delegateを自分で実装した方が早い気もしますが, Property Binding的なことをしようとすると, KVOの方が直感的なコードを書けるので悩ましいところです.
この記事では, Swift Protocolに対してKVOをする方法を紹介します.

KVOに対応したProtocol定義

@objc protocol ObservableRunnable {
    @objc dynamic var isRunning: Bool { get }
}

@objc dynamicを明示する為に, @objc protocolで実装する必要があります.
Swift EnumやTupleといった物が使用できなくなるので, KVOに対応する必要があるものだけを切り出したProtocolとするのが良いでしょう.

protocol Runnable: NSObject, ObservableRunnable {
    func start()
    func stop()
}

その他の定義は継承したProtocolを定義し, そちらで定義するのが良いでしょう.
本来であれば@objc protocol側にNSObjectの継承が必須であることを明示したいのですが, @objc protocolはクラスの継承を明示する方法がありません. (Inheritance from non-protocol type 'NSObject'となります).
一方で, Swift protocolは記述できるので, ここにNSObjectの継承が必須であることを明示しています.

ちなみに, NSObjectが継承されていないと以下のようなランタイムエラーになります.

Unrecognized selector -[Sample.CustomRunner addObserver:forKeyPath:options:context:]
class CustomRunner: NSObject, Runnable {
    @objc dynamic var isRunning: Bool = false

    func start() {
        isRunning = true
    }

    func stop() {
        isRunning = false
    }
}

クラスの実装はこのようになります.

ネストしたkeyPathでKVOを行う

private var runner: Runnable = CustomRunner()
@objc private var observableRunner: ObservableRunnable & NSObject {
    return runner
}

override func viewDidLoad() {
    super.viewDidLoad()

    observations = [
        observe(\.observableRunner.isRunning, options: [.new]) { (_, changed) in
            print("1: \(changed.newValue!)")
        },
    ]
}

ネストしたkeyPathを使ってKVOをする場合は簡単です.
KVO可能に見えるように@objcをつけたpropertyを用意し, そのpropertyに対してKVOをするだけです.

しかし, この方法だと前回の記事のクラッシュが発生する可能性があるので, ネストしないkeyPathで実装する方法に変えていきます.

ネストしないkeyPathでKVOを行う

observableRunner.observe(\.isRunning, options: [.new]) { (_, changed) in
    print("2: \(changed.newValue!)")
},

このように書けば上手くいきそうですが, これはコンパイルエラーになります.

Member 'observe' cannot be used on value of protocol type 'NSObject & ObservableRunnable'; use a generic constraint instead

Genericsが解決できないようです.
これを解決するやり方が, stackoverflowにポストされていたので, これをベースに実装すると以下のようになります.

private func observeRunner<T: ObservableRunnable & NSObject, Value>(
    _ object: T, keyPath: KeyPath<T, Value>, options: NSKeyValueObservingOptions = [],
    changeHandler: @escaping (T, NSKeyValueObservedChange<Value>) -> Void
) -> NSKeyValueObservation {
    return object.observe(keyPath, options: options, changeHandler: changeHandler)
}

observeRunner(observableRunner, keyPath: \.isRunning, options: [.new]) { (_, changed) in
    print("3: \(changed.newValue!)")
}

Generics的には全く同じことのはずにも関わらず, こちらはコンパイルが通ります.
これでProtocolに対して安全にKVOできそうです.

今回のコードはここに公開しています.

参考文献


(Updated: )

comments powered by Disqus