18 April 2018

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 또는 ReactiveKit의 DisposeBag를 착안하여 관측하는 속성을 가진 타입에 NSKeyValueObservation 를 담아두는 방식을 취하면 어떨까 합니다.

즉, 클래스 A의 생명주기에 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)


그리고 model의 생명주기를 KeyPathObservation이 따르므로, model에 keyPathDisposeBag에 KeyPathObservation을 추가하는 것을 숨길 수 있습니다.

extension KeyPathObservationDeallocatable where Self: NSObject {
    func subscribe<T>(keyPath: KeyPath<Self, T>,
                      options: NSKeyValueObservingOptions,
                      changeHandler: @escaping (Self, NSKeyValueObservedChange<T>) -> Void) {
        self.observe(keyPath, options: options, changeHandler: changeHandler).dispose(in: keyPathDisposeBag)
    }
}

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

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 프로젝트의 부분들을 일부 차용해보았습니다.

extension KeyPathObservationDeallocatable where Self: NSObject {
    func target<Target: AnyObject, T>(to target: Target,
                                      keyPath: KeyPath<Self, T>,
                                      options: NSKeyValueObservingOptions,
                                      changeHandler: @escaping (Target, Self, NSKeyValueObservedChange<T>) -> Void) {
        self.observe(keyPath, options: options) { [weak target] (`self`, change) in
            guard let target = target else { return }
            changeHandler(target, `self`, change)
        }.dispose(in: keyPathDisposeBag)
    }
}


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

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

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


KeyPath Binding

Rx 라이브러리들 코드를 보면 Observe 코드 안이 아닌 Bind 함수를 통해서 데이터를 직접 주입하는 방식을 취하기도 합니다. 이를 조금 차용해볼까 합니다.

Bind될 객체 또는 값의 생명주기에 따라 관측의 해제를 해줘야 한다고 생각됩니다.

KeyPath Binding 하는 코드는 다음과 같이 작성할 수 있습니다.

extension KeyPathObservationDeallocatable where Self: NSObject {
    func bind<Target: KeyPathObservationDeallocatable, Value>(from fromKeyPath: KeyPath<Self, Value>,
                                                              to target: Target,
                                                              at targetKeyPath: ReferenceWritableKeyPath<Target, Value>,
                                                              options: NSKeyValueObservingOptions) {
        self.observe(fromKeyPath, options: options) { [weak target] (_, value) in
            guard let newValue = value.newValue else { return }
            target?[keyPath: targetKeyPath] = newValue
            }
            .dispose(in: target.keyPathDisposeBag)
    }
}


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

@objcMembers class A: NSObject {
    dynamic var title: String? = ""
}
let a = A()
let label = UILabel()

a.bind(from: \.title, to: label, at: \.text, options: [.new, .initial])




다음은 위 내용들을 정리한 전체 코드입니다.

import ObjectiveC

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 KeyPathObservationDisposeBagProtocol: KeyPathObservationDisposable {
    func add(disposable: KeyPathObservationDisposable)
}

final 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)
    }
}

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

private var keyPathObservationDisposeBagKey = "KeyPathObservationDisposeBagKey"

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

protocol KeyPathObservationDisposable {

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

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

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

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

extension KeyPathObservationDeallocatable where Self: NSObject {
    func subscribe<T>(keyPath: KeyPath<Self, T>,
                      options: NSKeyValueObservingOptions,
                      changeHandler: @escaping (Self, NSKeyValueObservedChange<T>) -> Void) {
        self.observe(keyPath, options: options, changeHandler: changeHandler).dispose(in: keyPathDisposeBag)
    }

    func target<Target: AnyObject, T>(to target: Target,
                                      observe keyPath: KeyPath<Self, T>,
                                      options: NSKeyValueObservingOptions,
                                      changeHandler: @escaping (Target, Self, NSKeyValueObservedChange<T>) -> Void) {
        self.observe(keyPath, options: options) { [weak target] (`self`, change) in
            guard let target = target else { return }
            changeHandler(target, `self`, change)
        }.dispose(in: keyPathDisposeBag)
    }

    func bind<Target: KeyPathObservationDeallocatable, Value>(from fromKeyPath: KeyPath<Self, Value>,
                                                              to target: Target,
                                                              at targetKeyPath: ReferenceWritableKeyPath<Target, Value>,
                                                              options: NSKeyValueObservingOptions) {
        self.observe(fromKeyPath, options: options) { [weak target] (_, value) in
            guard let newValue = value.newValue else { return }
            target?[keyPath: targetKeyPath] = newValue
            }
            .dispose(in: target.keyPathDisposeBag)
    }
}

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

var model = A()
model.subscribe(\.b, options: [.initial, .old, .new]) { (model, change) in }
*/

참고자료