본문 바로가기

iOS

[Combine] @dynamicMemberLookup 어트리뷰트로 assign에 사용할 keyPath 자동완성하기

지난 글에서 RxSwift와 Combine의 Binding 방식에 대해 알아보며, Combine에서도 RxSwift의 Binder 및 Bind와 같은 방식을 사용할 수 있는 방법을 고민해 보았습니다.

https://jazz-the-it.tistory.com/70

 

[Combine] Combine과 RxSwift의 UI Binding 방식 비교(Feat. assign과 KeyPath)

RxSwift와 Combine으로 Clean Architecture 구조 프로젝트를 각각 진행하며 느낀점 최근에 Combine과 RxSwift를 이용한 프로젝트를 각각 진행하고 있습니다. 두 프레임워크 모두 Swift에서 Reactive한 Programming을

jazz-the-it.tistory.com

 

근본적인 문제는 combine의 assign 메서드가 keyPath를 파라미터로 요구하기 때문에, 자동완성을 사용할 수 없다는 점이었습니다. 따라서 아래와 같은 방식으로 열거형을 정의해서 가능한 input에 대한 keyPath를 return하는 방식을 사용해 보았는데, 사실 코드가 꽤 길고 optional unwrapping의 위험도 있었습니다. 

extension CustomTextFieldView {

    enum BindableInput {
        case alert
        case currentStatus
        
        var keyPath: AnyKeyPath {
            switch self {
            case .alert: return \CustomTextFieldView.alertText
            case .currentStatus: return \CustomTextFieldView.viewStatus
            }
        }
    }
    
    var alertText: String {
        get { return alertlabel.text ?? "" }
        set { bindAlertText(newValue) }
    }
    
    var viewStatus: Int {
        get { return self.currentStatus }
        set { bindViewStatus(newValue) }
    }
    
    private func bindAlertText(_ alertText: String) {
        self.changeAlertLabelText(alertText)
        if !alertText.isEmpty {
            self.setTextFieldViewState(.alert)
        }
    }
    
    func bindableInput<T>(_ input: BindableInput) -> ReferenceWritableKeyPath<CustomTextFieldView, T> {
        return input.keyPath as! ReferenceWritableKeyPath<CustomTextFieldView, T>
    }
}

 

더 좋은 방법에 대해 고민하던 중, 링크에서 Builder Pattern 구현을 위해 @dynamicMemberLookup 어트리뷰트를 사용하는 예제를 보고, 동적으로 멤버 변수들에 대해 .(dot) syntax에 접근할 수 있다면 keyPath에도 마찬가지로 접근할 수 있을 거라는 생각이 들었고, 시도해 보았습니다!

@dynamicMemberLookup

dynamicMemberLookup의 개념은 여기에 잘 정의되어 있습니다. 어트리뷰트의 기능은 이름과 같이, 말 그대로 '동적 멤버 조회'이며 python과 같은 동적 언어와의 확장성을 위해 고안되었다고 합니다. apple의 SE0195에서 친절하게 소개하고 있습니다.

 

이 어트리뷰트의 사용하기 위해서는 dynamicMember key또는 keyPath를 인자로 하는 subscript를 구현해주면 됩니다.

key(String)을 인자로 할 경우 동일한 기능을 구현 가능하지만 type safety를 보장할 수 없고, 이러한 단점을 극복하기 위해 타입에 대한 정보를 알고 있는 keyPath를 사용할 수 있습니다.

 

@dynamicMemberLookup의 본래 목적은 keyPath를 통해 getter나 setter를 입력하는 것이지만, 그러지 않고 keyPath를 그대로 return할 수 있습니다. 아래는 member 변수들에 대한 keyPath를 가져오기 위한 KeyPathFindable 프로토콜입니다.

@dynamicMemberLookup
public struct KeyFinder<Base: AnyObject> {

    private var base: Base

    public init(_ base: Base) {
        self.base = base
    }

    public subscript<Value>(dynamicMember keyPath: ReferenceWritableKeyPath<Base, Value>) -> ReferenceWritableKeyPath<Base, Value> {
        return keyPath
    }
}

public protocol KeyPathFindable {
    associatedtype Base: AnyObject
    var kf: KeyFinder<Base> { get }
}

public extension KeyPathFindable where Self: AnyObject {
    var kf: KeyFinder<Self> {
        get { KeyFinder(self) }
        set { }
    }
}

extension NSObject: KeyPathFindable { }

KeyFinder Struct에 어트리뷰트를 붙여주고, 제네릭을 이용해서 keyPath를 찾을 대상이 될 타입을 정의합니다. 그리고 프로토콜 초기구현을 통해 kf라는 프로퍼티를 추가해 줍니다. 최종적으로 NSObject에 채택해 주면, 'NSObject.kf.'을 통해 KeyPath를 자동완성 시킬 수 있습니다.

Combine의 assign 메서드에, IDE의 자동완성 기능을 바탕으로 KeyPath를 넣어줄 수 있다.

이와 같이 KeyPath를 자동완성 시킬 수 있게 되었으니, 최종 목적이었던 Combine의 assign 메서드를 아래와 같이 사용할 수 있습니다.

이전과 달리 클래스 마다 구현해줄 필요도 없고, '.kf' 신택스를 이용해 편리하게 접근할 수 있습니다. 이외에도 다양한 keyPath를 요구하는 경우 사용할 수 있습니다.

output.nicknameAlert
    .assign(to: nickNameTextFieldView.kf.alertText,
            on: nickNameTextFieldView)
    .store(in: cancelBag)

참고

링크 : 비슷한 방식으로 RxSwift Observable의 멤버 변수를 편하게 캐스팅할 때도 사용할 수 있습니다.