Dev

[Swift4] KeyPath Observe 사용하기

April 18, 2018

[Swift4] KeyPath Observe 사용하기

KeyPath를 이용한 Get Set

Swift에서는 KeyPath를 String 형태가 아닌 KeyPath 클래스를 이용하여 정적으로 접근할 수 있습니다.

struct A {
    var b: Int = 0
}

다음과 같은 구조체 A가 있는 경우, KeyPath를 이용하여 값을 변경하거나 접근할 수 있습니다.

var a = A()
a[keyPath: \A.b] = 10
print(a.b) // Output 10
print(a[keyPath: \A.b]) // Output 10

또한 KeyPath는 Observe도 제공하는데, 타입이 클래스이며, NSObject를 상속받아야 합니다. 그리고 관측할 속성은 @objc dynamic 키워드를 추가해줘야 합니다.

class A: NSObject {
    @objc dynamic var b = 0
}

또는 @objc를 속성마다 붙이기 귀찮다면 클래스 앞에 @objcMembers를 붙여주면 @objc를 붙이지 않아도 됩니다.

@objcMembers class A: NSObject {
    dynamic var b = 0
}

KeyPath를 이용한 Observation

클래스 A의 속성 i는 KeyPath Observe를 이용하여 값이 변경시 관측할 수 있습니다.

var a = A()
let observation: NSKeyValueObservation = A().observe(\.b, options: [.initial, .old, .new]) { (a, change) in 
    print(a, change.oldValue, change.newValue)
}
a.b = 1
a.b = 2
a.b = 3

초기값, 변경전 값, 변경후 값을 얻을 수 있습니다. 하지만 NSKeyValueObservation을 저장하지 않는다면 deinit 되면서 관측이 해제 됩니다.

RxSwift의 DisposeBag를 착안하여 관측하는 변수에 NSKeyValueObservation 를 담아두는 방식을 취하면 어떨까 합니다.

KeyPath Observation의 DisposeBag

KeyPath Observation의 Disposable를 위한 프로토콜 KeyPathObservationDisposable을 만듭니다.

protocol KeyPathObservationDisposable {

    /// Dispose the signal observation or binding.
    func dispose()

    /// Returns `true` is already disposed.
    var isDisposed: Bool { get }
}

그리고 NSKeyValueObservation 클래스는 KeyPathObservationDisposable를 따르며, dispose시 invalidate()를 호출하여 관측을 해제시킵니다.

extension NSKeyValueObservation: KeyPathObservationDisposable {
    func dispose() {
        self.invalidate()
    }

    var isDisposed: Bool {
        return observationInfo == nil
    }
}

그리고 KeyPathObservationDisposable를 담기 위한 KeyPathObservationDisposeBag를 만듭니다.

protocol KeyPathObservationDisposeBagProtocol: KeyPathObservationDisposable {
    func add(disposable: KeyPathObservationDisposable)
}

class KeyPathObservationDisposeBag: KeyPathObservationDisposeBagProtocol {
    private var disposables: [KeyPathObservationDisposable] = []

    func add(disposable: KeyPathObservationDisposable) {
        disposables += [disposable]
    }

    func dispose() {
        disposables.forEach { $0.dispose() }
        disposables.removeAll()
    }

    var isDisposed: Bool {
        return disposables.isEmpty
    }

    deinit {
        dispose()
    }
}
extension KeyPathObservationDisposable {
    func dispose(in disposeBag: KeyPathObservationDisposeBagProtocol) {
        disposeBag.add(disposable: self)
    }
}

이제 KeyPathObservationDisposeBag를 가지게 되는 프로토콜 KeyPathObservationDeallocatable을 만듭니다. 이때 associatedObject를 이용합니다.

// AssociatedObjectStore 소스 출처 : https://github.com/ReactorKit/ReactorKit
protocol AssociatedObjectStore {}

extension AssociatedObjectStore {
    func associatedObject<T>(forKey key: UnsafeRawPointer) -> T? {
        return objc_getAssociatedObject(self, key) as? T
    }

    func associatedObject<T>(forKey key: UnsafeRawPointer, default: @autoclosure () -> T) -> T {
        if let object: T = self.associatedObject(forKey: key) {
            return object
        }
        let object = `default`()
        self.setAssociatedObject(object, forKey: key)
        return object
    }

    func setAssociatedObject<T>(_ object: T?, forKey key: UnsafeRawPointer) {
        objc_setAssociatedObject(self, key, object, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
    }
}

protocol KeyPathObservationDeallocatable: class, AssociatedObjectStore {
    var keyPathDisposeBag: KeyPathObservationDisposeBag { get }
}

private var keyPathObservationDisposeBagKey = "KeyPathObservationDisposeBagKey"

extension KeyPathObservationDeallocatable {
    var keyPathDisposeBag: KeyPathObservationDisposeBag {
        return self.associatedObject(forKey: &keyPathObservationDisposeBagKey, default: KeyPathObservationDisposeBag())
    }
}

AssociatedObjectStoreReactorKit 프로젝트에서 발췌하였습니다.

이제 앞에서 정의했던 클래스 A는 다음과 같이 사용됩니다.

@objcMembers class A: NSObject, KeyPathObservationDeallocatable {
    dynamic var b = 0
}

let model = A()

model.observe(\.b, options: [.initial, .old, .new]) { (model, change) in }
    .dispose(in: model.keyPathDisposeBag)

KeyPath Observe의 Capture list

observe 함수의 인자 중 changeHandler는 클로저이므로, self를 쓰기 위해선 Capture list를 사용해야합니다. 예를 들면 다음과 같이 작성해야 합니다.

model.observe(\.b, options: [.initial, .old, .new]) { [weak self] (model, change) in }
    .dispose(in: model.keyPathDisposeBag)

레퍼런스 타입인 경우, 메모리 릭을 유의해야 하므로, weak 또는 unowned를 사용해야 하는데, 항상 nil 체크를 해야되는 문제가 있습니다. 그래서 다음과 같이 사용해보면 어떨까 합니다.

model.observe(\.b, options: [.initial, .old, .new]) { (`self, model, change) in }
    .dispose(in: model.keyPathDisposeBag)

이를 위해 ReactiveKit의 BondKeyPath Signal부분과 Delegated 프로젝트의 부분들을 일부 차용해보았습니다.

struct Extension<Base> {
    let base: Base
    init(_ base: Base) { self.base = base }
}

protocol ExtensionCompatible {
    associatedtype Compatible
    var ex: Extension<Compatible> { get }
}

extension ExtensionCompatible {
    var ex: Extension<Self> { return Extension(self) }
}

extension NSObject: ExtensionCompatible {}

extension Extension where Base: NSObject {
    func target<Target: AnyObject, T>(to target: Target,
                                       keyPath: KeyPath<Base, T>,
                                       options: NSKeyValueObservingOptions,
                                       changeHandler: @escaping (Target, Base, NSKeyValueObservedChange<T>) -> Void) -> NSKeyValueObservation {
        return base.observe(keyPath, options: options) { [weak target] (base, change) in
            guard let target = target else { return }
            changeHandler(target, base, change)
        }
    }
}

기존 코드에서 확장하는 방식이므로, ex로 접근하여 사용하도록 그룹화하였습니다. 그리고 changeHandler에서 Capture되지 않도록 하였습니다.

위 코드는 다음과 같이 사용할 수 있습니다.

model.target(to: self, keyPath: \.b, options: [.initial, .old, .new]) { (`self`, model, change) in }
    .dispose(in: model.keyPathDisposeBag)

참고자료

Array