Reactive Programming의 대명사 그 자체인 Reactive Extension(이하 Rx), 그리고 그 중에서도 Apple 생태계 개발자를 위한 Rx 구현인 RxSwift, 그 중에서도 UIKit을 위한 구현인 RxCocoa를 보면 각종 UIKit의 Delegate 패턴에 대응하는 Observable을 제공하고 있다. Rx를 처음 접한 그 당시에는 ‘와 씻 완전 신세계다!’ 싶어 거의 모든 코드를 Rx 기반으로 작성했었는데, 그래서 Delegate 패턴에 대한 구현도 Rx를 많이 썼던 기억이 난다. 지금은 걍 Delegate를 직접 구현하는 것을 좀 더 선호하는 편이지만…

어쨌든, 최근에 진행하는 프로젝트에서 RxSwift 의존성을 Combine으로 이전하는 작업을 했다. 이 때 UIScrollViewDelegatescrollViewDidScroll(_ scrollView: UIScrollView) 메서드가 호출될 때의 동작을 Combine으로 처리할 수 있도록 하는 작업도 같이 진행했는데, 이 처리에 대해서 좀 알아보도록 하자.

일단 세 가지의 타입이 필요하다. Publisher 프로토콜을 구현하는 타입과 Subscription 프로토콜을 구현하는 타입(각 프로토콜이 무슨 역할을 하는지는 문서를 통해 알아보도록 하자). 그리고 UIScrollViewDelegate의 호출되는 메서드가 무엇인지 알려주기 위한 열거형 타입.

그리고 UIScrollViewDelegate에 대한 구현이기 때문에 UIScrollView에 대한 확장으로 선언하는게 써먹기 편할거라 생각했다. 그러니 결국 다음의 세 타입을 만들게 될 것이다.

  • UIScrollView.DelegateEvent(이하 DelegateEvent): UIScrollViewDelegate의 메서드가 호출될 때 호출될 메서드와 인자를 전달해줄 타입. 간단하게 enum으로 구현할 예정이다.
  • UIScrollView.DelegateSubscription(이하 DelegateSubscription): 구독시 구독자에게 이벤트를 전달해줄 타입으로(자세한 것을 애플의 Combine.Subscription 문서를 확인), 실제로 UIScrollViewDelegate를 구현하는 타입이 될것이다.
  • UIScrollView.DelegatePublisher(이하 DelegatePublisher): 이벤트를 전달해줄 게시자 타입. 이 역시 애플의 Combine.Publisher 문서를 확인하자.

위 세가지 타입을 기능만 하도록 구현하면 다음과 같을 것이다. 기본 구현 자체에 대해서는 따로 설명하진 않겠다. SubscriptionPublisher 구현의 경우 SwiftBySundell의 Building custom Combine publishers in Swift을 참고하였으니 해당 아티클을 체크하길 바란다.

enum DelegateEvent {
    /// `scrollViewDidScroll(_ scrollView: UIScrollView)` 에 대응하는 케이스
    case didScroll(UIScrollView)
}

class DelegateSubscription<S: Subscriber>: NSObject, UIScrollViewDelegate, 
    Subscription where S.Input == DelegateEvent, S.Failure == Never {
    
    private var subscriber: S?

    init(subscriber: S) {
        self.subscriber = subscriber
    }

    func request(_ demand: Subscribers.Demand) { /* Do Nothing */ }

    func cancel() {
        subscriber = nil
    }

    // UIScrollViewDelegate 구현

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        _ = subscriber?.receive(.didScroll(scrollView))
    }
}

struct DelegatePublisher: Publisher {
    typealias Output = DelegateEvent
    typealias Failure = Never

    private let scrollView: UIScrollView

    init(scrollView: UIScrollView) {
        self.scrollView = scrollView
    }

    func receive<S>(subscriber: S) where S: Subscriber, Never == S.Failure, DelegateEvent == S.Input {
        let subscription = DelegateSubscription(subscriber: subscriber)
        subscriber.receive(subscription: subscription)
        scrollView.delegate = subscription
    }
}

위와 같이 구현하고 UIScrollView의 확장에 계산 프로퍼티로 delegatePublisher를 만들어주면 사용할 수 있다.

extension UIScrollView {
    var delegatePublisher: DelegatePublisher {
        return DelegatePublisher(scrollView: self)
    }
}

let scrollView = UIScrollView() 

let cancellable = scrollView
    .delegatePublisher
    .sink { event in
        switch event {
        case .didScroll(let scrollView):
            // Do Something With `scrollView`
        }
    }

제법 간단하다. 하지만 이 때 문제가 하나 있다. UIScrollView.delegateUIViewUIGestureRecognizer를 등록하듯이 여러 인스턴스를 추가할 수 있는게 아니고, 한 번에 하나의 delegate만 할당할 수 있기 때문. 그렇기 때문에 아래와 같은 코드에서는 두 번째 delegatePublsher를 호출할 때 첫번째로 호출한 delegatePublisherscrollViewdelegate가 아니게 된다.

var first = scrollView.delegatePublisher.sink { _ in print("Useless!") }
var second = scrollView.delegatePublisher.sink { _ in print("Only Works!") }

// 아래와 같이 해결할 수는 있지만 매번 사용할 때마다 `share()`로
// 별도 인스턴스를 만들어줘야 하기 때문에 실수하기에 너무 좋은 코드이다.

let sharedDelegatePublisher = scrollView.delegatePublisher.share()
first = sharedDelegatePublisher.sink { _ in print("It Works!") }
second = sharedDelegatePublisher.sink { _ in print("It Works, Too!") }

그렇다면 어떻게 해야 delegatePublisher를 여러번 호출해도 계속 써먹을 수 있을까? 이를 위해서는 두 가지 조건을 만족해야 한다.

  • UIScrollView.delegatePublisherUIScrollView 인스턴스 하나당 하나만 생성되어야 한다. 매번 새로 생성하면 이전에 생성한 Publisher에 대한 Subscription은 의미가 없어지니까.
  • 위에서 생긴 하나의 DelegatePublisher를 여러 Subscriber가 공유할 수 있어야 한다. Subscriber가 생길때마다 delegate를 할당하면 결국 여전히 최후의 Subscriber만 이벤트를 받을 수 있으니까.

첫번째 조건은 바꿔 말하자면 UIScrollView 인스턴스가 특정되면 거기에 연결된 DelegatePublisher도 특정할 수 있어야 한다는 얘기다. 이에 대해서는 RxSwift가 해결한 방법을 참고했다. 이 친구들은 RxCocoa 내에 DelegateProxyType이라는 타입을 만들어서 별도의 대리자를 만들었고, 이 친구를 UIScrollView 인스턴스에다가 Associated Object로 할당을 하는 식으로 해결하였다. 물론 DelegateProxyType이 Delegate 역할을 하는 것은 아니고, 일종의 연결점이라고 보는게 낫겠지만…(proxy라는 말을 괜히 쓴게 아님)

두번째 조건은 잘 생각해보면 어디서 본 말이다. 바로 Publishers.Share다. 애플 문서상에서 이 친구의 정의를 보면 아래와 같이 나와있다.

A publisher that shares the output of an upstream publisher with multiple subscribers.

Upstream Publisher의 배출을 여러 Subscriber와 공유하는 Publisher. 결국 두번째 조건은 Share를 사용하면 해결된다. 이를 바탕으로 구현하면 다음과 같은 코드가 만들어진다.

extension UIScrollView {
    var delegatePublisher: AnyPublisher<DelegateEvent, Never> {
        /**
        `ObjectIdentifier`는 `AnyObject` 인스턴스를 받거나 `Any.Type` 형태의
        메타타입을 받아서 ID를 생성할 수 있다. 이 경우에는 `UIScrollView` 인스턴스에
        유일한 `DelegatePublisher`를 할당하는게 목표이니 메타타입을 통해 ID를 생성한다.
        
        사실 이렇게 안하고 아래처럼 특정 문자열을 만들어서 그에 대한 UnsafeRawPointer를 만들어도 무방할 것 같다.
        let someStr = "SomeStr"
        let rawPointer = &someStr
         */
        let objectIdentifier = ObjectIdentifier(DelegatePublisher.self)
        let integerIdentifier = Int(bitPattern: objectIdentifier)
        let rawPointer = UnsafeRawPointer(bitPatter: integerIdentifier)!

        if let existingPublisher = objc_getAssociatedObject(self, rawPointer) as? AnyPublisher<DelegateEvent, Never> {
            return existingPublisher
        } else {
            let newPublisher = DelegatePublisher(scrollView: self)
                // 여러 Subscriber가 하나의 Upstream Publisher를 공유
                .share()
                // 반환되는 타입을 맞추기 위함
                .eraseAnyPublisher()
            /**
            `.OBJC_ASSOCIATION_RETAIN` policy를 지정하면 UIScrollView가
            DelegatePublisher에 대해 강한 참조를 갖게 되는데, `.OBJC_ASSOCIATION_ASSIGN`
            policy를 사용해 약한 참조를 갖게 하면 일부 케이스에서 잘못된 메모리 참조가 일어나기 때문에
            `.OBJC_ASSOCIATION_RETAIN`를 사용해 강한 참조를 만들어주는게 좋다
             */
            objc_setAssociatedObject(self, rawPointer, publisher, .OBJC_ASSOCIATION_RETAIN)
            return newPublisher
        }
    }
}

이렇게 하면 UIScrollView 하나당 유일한 delegatePublisher가 생겨나기 때문에 여러번 구독해도 문제가 생기지 않는다. 물론 꼭 Associated Object를 사용할 필요는 없다. 차라리 약한 참조를 갖는 Dictionary 타입을 따로 만들어서 글로벌 스토어로 쓰는게 통제하기는 더 좋을지도 모르겠다. 근데 역시 그건 좀 귀찮아서…😅

다만 여전히 아쉬움은 남는다. Publisher가 아닌 형태의 UIScrollViewDelegateDelegatePublisher를 함께 사용하고 싶을 수도 있지 않은가. 이 문제도 비교적 간단하게 해결할 수 있다. 아래 코드와 같이 DelegateSubscriptionUIScrollView에 이미 할당된 delegate를 전달하여 약한 참조로 들고 있도록 하고, 모든 UIScrollViewDelegate 메서드를 구현토록 해서 이를 formerDelegate에 전달해주도록 하는 것이다. 물론 delegatePublisher를 먼저 구독한 후 delegate를 새로 할당하면 기대한 대로 움직이지 않겠지만…(이쯤되면 걍 UIScrollView 자체를 subclassing 하는게 나을듯)

class DelegateSubscription<S: Subscriber>: NSObject, UIScrollViewDelegate, Subscription where S.Input == DelegateEvent, S.Failure == Never {
    private weak var originalDelegate: UIScrollViewDelegate?
    private var subscriber: S?

    init(with subscriber: S, originalDelegate: UIScrollViewDelegate?) {
        self.subscriber = subscriber
        self.originalDelegate = originalDelegate
    }

    ...

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        originalDelegate?.scrollViewDidScroll?(scrollView)
        _ = subscriber?.receive(.didScroll(scrollView))
    }

    // 이하 나머지 UIScrollViewDelegate 메서드 구현(`originalDelegate`에게 이벤트를 전달하기 위함)
}

이걸로 나름 간단하게 Combine을 이용해 Delegate 패턴에 대응하는 방법을 알아봤다. 전체 코드는 따로 Github 저장소에 만들어두었으니 한번 참고해보는 것도 좋겠다.

참고