Reactive Programming의 대명사 그 자체인 Reactive Extension(이하 Rx), 그리고 그 중에서도 Apple 생태계 개발자를 위한 Rx 구현인 RxSwift, 그 중에서도 UIKit을 위한 구현인 RxCocoa를 보면 각종 UIKit의 Delegate 패턴에 대응하는 Observable을 제공하고 있다. Rx를 처음 접한 그 당시에는 ‘와 씻 완전 신세계다!’ 싶어 거의 모든 코드를 Rx 기반으로 작성했었는데, 그래서 Delegate 패턴에 대한 구현도 Rx를 많이 썼던 기억이 난다. 지금은 걍 Delegate를 직접 구현하는 것을 좀 더 선호하는 편이지만…
어쨌든, 최근에 진행하는 프로젝트에서 RxSwift 의존성을 Combine으로 이전하는 작업을 했다. 이 때 UIScrollViewDelegate
의 scrollViewDidScroll(_ scrollView: UIScrollView)
메서드가 호출될 때의 동작을 Combine으로 처리할 수 있도록 하는 작업도 같이 진행했는데, 이 처리에 대해서 좀 알아보도록 하자.
일단 세 가지의 타입이 필요하다. Publisher
프로토콜을 구현하는 타입과 Subscription
프로토콜을 구현하는 타입(각 프로토콜이 무슨 역할을 하는지는 문서를 통해 알아보도록 하자). 그리고 UIScrollViewDelegate
의 호출되는 메서드가 무엇인지 알려주기 위한 열거형 타입.
그리고 UIScrollViewDelegate
에 대한 구현이기 때문에 UIScrollView
에 대한 확장으로 선언하는게 써먹기 편할거라 생각했다. 그러니 결국 다음의 세 타입을 만들게 될 것이다.
UIScrollView.DelegateEvent
(이하DelegateEvent
):UIScrollViewDelegate
의 메서드가 호출될 때 호출될 메서드와 인자를 전달해줄 타입. 간단하게enum
으로 구현할 예정이다.UIScrollView.DelegateSubscription
(이하DelegateSubscription
): 구독시 구독자에게 이벤트를 전달해줄 타입으로(자세한 것을 애플의Combine.Subscription
문서를 확인), 실제로UIScrollViewDelegate
를 구현하는 타입이 될것이다.UIScrollView.DelegatePublisher
(이하DelegatePublisher
): 이벤트를 전달해줄 게시자 타입. 이 역시 애플의Combine.Publisher
문서를 확인하자.
위 세가지 타입을 기능만 하도록 구현하면 다음과 같을 것이다. 기본 구현 자체에 대해서는 따로 설명하진 않겠다. Subscription
과 Publisher
구현의 경우 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.delegate
는 UIView
에 UIGestureRecognizer
를 등록하듯이 여러 인스턴스를 추가할 수 있는게 아니고, 한 번에 하나의 delegate
만 할당할 수 있기 때문. 그렇기 때문에 아래와 같은 코드에서는 두 번째 delegatePublsher
를 호출할 때 첫번째로 호출한 delegatePublisher
는 scrollView
의 delegate
가 아니게 된다.
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.delegatePublisher
가UIScrollView
인스턴스 하나당 하나만 생성되어야 한다. 매번 새로 생성하면 이전에 생성한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
가 아닌 형태의 UIScrollViewDelegate
와 DelegatePublisher
를 함께 사용하고 싶을 수도 있지 않은가. 이 문제도 비교적 간단하게 해결할 수 있다. 아래 코드와 같이 DelegateSubscription
에 UIScrollView
에 이미 할당된 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 저장소에 만들어두었으니 한번 참고해보는 것도 좋겠다.