본문 바로가기

iOS

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

RxSwift와 Combine으로 Clean Architecture 구조 프로젝트를 각각 진행하며 느낀점

최근에 Combine과 RxSwift를 이용한 프로젝트를 각각 진행하고 있습니다. 두 프레임워크 모두 Swift에서 Reactive한 Programming을 편리하게 만들어줍니다.

 

Combine보다는 RxSwift를 사용한 경험이 많았기에, Combine을 이용한 Clean Architecture를 구현하면서 자연스레 RxSwift와 비교하게 되는 일이 많았습니다.

 

그 중에서 가장 주요하게 느꼈던 점은, RxSwift가 참 편리하다는 것입니다. 개인적인 의견으로는 프로그래밍 상으로는 RxSwift가 훨씬 많은 기능을 제공하기에 간결한 코드, 쉬운 프로그래밍이 가능한 것 같습니다. 실제로 Combine에는 없고 RxSwift에만 있는 요소들이 있어서 직접 구현해서 사용할 정도였습니다. 물론 Combine은 Apple에서 직접 제공하는 만큼 RxSwift보다 훨씬 성능이 뛰어나다는 장점이 있습니다.

 

다시 돌아가서, Combine에서 MVVM 구조를 적용하면서 가장 아쉬웠던 것은 Traits의 부재였습니다. RxSwift에는 Traits라는 Observable의 서브클래스들이 존재하는데, 이와 관련된 Binder 및 ControlEvent 등은 input과 output을 구분하여 data를 binding하기에 좋은 인터페이스를 제공했습니다.

 

그래서 이번 글에서는 UI Components를 SubClassing하고, data를 Binding하는 방식 중심으로 RxSwift와 Combine을 비교해보겠습니다.

RxSwift: 'Extension Reactive where Base' 의 강력한 기능

extension Reactive where Base: MyPageInteractionView {
    public var isInteractionEnabled: Binder<Bool> {
        return Binder(base) { view, isEnabled in
            view.updateEnabledStatus(isEnabled)
        }
    }
    
    public var pushTimeSelected: Binder<String?> {
        return Binder(base) { view, pushTime in
            view.updatePushTime(pushTime: pushTime)
        }
    }
    
    public var pushSwitchIsOnBindable: Binder<Bool> {
        return Binder(base) { view, isOn in
            view.pushSwitch.setOn(isOn, animated: true)
        }
    }
}

위의 코드는 평소에 RxSwift에서 data에 대한 binding을 해주기 위해 작성하는 Reactive Extension 코드들입니다.

 

사용하는 쪽에서는 단순하게 아래와 같이 bind 메서드를 사용해서 binding을 할 수 있습니다. rx라는 프로퍼티를 extension해서 깔끔한 코드 및 인터페이스를 유지할 수 있도록 도와줍니다.

output.selectedPushTime
    .bind(to: self.timeSettingView.rx.pushTimeSelected)
    .disposed(by: self.disposeBag)

 

이외에도 Reactive Extension은 아래와 같이 사용할 수 있습니다. observer만이 아니라 Observable도 Custom할 수 있게 되는 것입니다.

extension Reactive where Base: RDDateTimePickerView {
    public var cancelButtonTapped: ControlEvent<Void> {
        return base.cancelButton.rx.tap
    }
    
    public var saveButtonTapped: Observable<String> {
        return base.saveButton.rx.tap
            .map { "\(base.selectedMeridium)" + "\(base.selectedHour)" + "\(base.selectedMinute)" }
            .asObservable()
    }
}

Combine: custom subscriber 및 subscription

Apple은 Combine에 존재하는 Subscriber 및 Publisher를 Custom 할 수 있도록 이들을 Protocol의 형태로 제공합니다.

 

클래스를 생성하고 상속하여, receive및 completion에 대한 method들을 구현해주기만 하면 됩니다.

 

Custom Subscriber, Publisher, Subscription는 여기, 저기에 아주 잘 설명되어 있습니다.

 

아래는 Subscriber만 Custom 했을 때의 구독 방식이고

let customSubscriber = CustomSubscriber()
publisher.subscribe(custom)

아래는 subscription, subscriber, publisher를 custom 했을 때의 구독 방식입니다.

let customPublisher = CustomPublisher()
let customSubscriber = CustomSubscriber()

customPublisher.subscriber(customSubscriber)

 

모두 사용하는 쪽에서만 나타냈기에 아주 간단해 보이지만, 링크를 보고 오신 분들은 custom한 구독을 구현하기 위해 꽤 많은 부분을 구현해야 한다는 것을 알 수 있으실 겁니다!

 

물론 위의 방식은 Apple에서도 권장하는 방식이 아니고, keyPath를 이용한 구독을 하라고 하네요! 그 방법을 봅시다.

Combine: Apple에서 권장하는 keyPath를 통한 assign 방식

먼저 아래는 assign의 명세입니다. Failure가 Never인 경우에만 assign을 사용할 수 있고, 보니까 주석에 예제도 있네요! 한번 살펴보겠습니다.

@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
extension Publisher where Self.Failure == Never {

    /// Assigns each element from a publisher to a property on an object.
    ///
    /// Use the ``Publisher/assign(to:on:)`` subscriber when you want to set a given property each time a publisher produces a value.
    ///
    /// In this example, the ``Publisher/assign(to:on:)`` sets the value of the `anInt` property on an instance of `MyClass`:
    ///
    ///     class MyClass {
    ///         var anInt: Int = 0 {
    ///             didSet {
    ///                 print("anInt was set to: \(anInt)", terminator: "; ")
    ///             }
    ///         }
    ///     }
    ///
    ///     var myObject = MyClass()
    ///     let myRange = (0...2)
    ///     cancellable = myRange.publisher
    ///         .assign(to: \.anInt, on: myObject)
    ///
    ///     // Prints: "anInt was set to: 0; anInt was set to: 1; anInt was set to: 2"
    ///
    ///  > Important: The ``Subscribers/Assign`` instance created by this operator maintains a strong reference to `object`, and sets it to `nil` when the upstream publisher completes (either normally or with an error).
    ///
    /// - Parameters:
    ///   - keyPath: A key path that indicates the property to assign. See [Key-Path Expression](https://developer.apple.com/library/archive/documentation/Swift/Conceptual/Swift_Programming_Language/Expressions.html#//apple_ref/doc/uid/TP40014097-CH32-ID563) in _The Swift Programming Language_ to learn how to use key paths to specify a property of an object.
    ///   - object: The object that contains the property. The subscriber assigns the object’s property every time it receives a new value.
    /// - Returns: An ``AnyCancellable`` instance. Call ``Cancellable/cancel()`` on this instance when you no longer want the publisher to automatically assign the property. Deinitializing this instance will also cancel automatic assignment.
    public func assign<Root>(to keyPath: ReferenceWritableKeyPath<Root, Self.Output>, on object: Root) -> AnyCancellable
}

아래는 주석을 해제한 예제 부분인데요, 프로퍼티 옵저버를 통해서 이벤트 수용 시의 액션을 지정할 수 있으며, assign은 특정 object의 keyPath를 통해서 원하는 property의 값에 publisher의 event를 할당할 수 있는 것으로 보이네요!

class MyClass {
    var anInt: Int = 0 {
    	didSet {
    		print("anInt was set to: \(anInt)", terminator: "; ")
    	}
    }
}

var myObject = MyClass()
let myRange = (0...2)
cancellable = myRange.publisher
	.assign(to: \.anInt, on: myObject)

// Prints: "anInt was set to: 0; anInt was set to: 1; anInt was set to: 2"

그렇다면 이를 UI Components 구현에 응용해보겠습니다. 아래 예시는 많은 부분이 생략되어 있지만, 그 구동 방식을 살펴볼 수 있도록 간단하게 구현했습니다. 아래 코드를 보시면 RxSwift에서 Binder를 구현했던 것처럼 비교적 간단한 방식으로 Custom한 Binding을 할 수 있는 것을 확인할 수 있습니다.

extension CustomTextFieldView {
    var alertText: String {
        get { return alertlabel.text ?? "" }
        set { bindAlertText(newValue) }
    }
    
    private func bindAlertText(_ alertText: String) {
        self.changeAlertLabelText(alertText)
        if !alertText.isEmpty {
            self.setTextFieldViewState(.alert)
        }
    }
}

let nicknameTextFieldView = CustomTextFieldView()

let nicknameAlert = ["첫 번째 경고문", "두 번째 경고문"].publisher

nicknameAlert
    .assign(to: \.alertText, on: nicknameTextFieldView)
    .store(in: self.cancelBag)

Combine: 공용 컴포넌트의 input에 대한 명세를 조금 더 확실히 하고, 자동완성을 이용하고 싶다.

위와 같이 구현을 하고 보니 조금 더 편리한 코드를 작성하고 싶었습니다. 그래서 특정 Components가 가진 bindable한 property에 대한 명세를 제공하면 좋겠다는 생각이 들었습니다.

 

아래와 같이 Generic을 이용해서 keyPath의 Type 부분을 받아오고, assign 메서드에 필요한 ReferenceWritableKeyPath를 return해 줬습니다. bindableInput을 메서드로 작성한 이유는 자동완성 기능을 이용하기 위해서입니다.

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

그리고 아래와 같이 사용할 수 있습니다.

let nicknameTextFieldView = CustomTextFieldView()

let nicknameAlert = ["첫 번째 경고문", "두 번째 경고문"].publisher

let nicknameTextFieldStatus = [1, 2, 3].publisher

nicknameAlert
    .assign(to: nicknameTextFieldView.bindableInput(.alert), on: nicknameTextFieldView)
    .store(in: self.cancelBag)
    
nicknameTextFieldStatus
    .assign(to: nicknameTextFieldView.bindableInput(.currentStatus), on: nicknameTextFieldView)
    .store(in: self.cancelBag)

 

위와 같이 하면 공용 Components를 구현하는 입장에서, 어느정도 RxSwift와 같이 깔끔한 방식으로 사용할 수 있습니다. keyPath를 이용하는 부분에서는 조금 더 고민이 필요해 보입니다.