-
Publisher와 Subscriber, Combine Framework in SwiftiOS 2022. 12. 9. 01:48
Combine은 시간 경과에 따라 값을 처리하기 위한 프레임워크입니다. 이벤트 처리 코드를 중앙 집중화하고 Networking, Key-Value Observing, Notification 및 Callback과 같은 비동기적인 데이터의 흐름을 단순화하여 가독성이 좋아지고, 유지보수하기가 쉬워집니다.
Combine Features
Generic
Combine은 swift로 작성되었습니다. 따라서 Generic과 같은 swift 기능을 활용할 수 있습니다.
Type safe
type safe하기 때문에, 런타임이 아닌 컴파일 시점에 오류를 포착할 수 있습니다.
Composition first
핵심 개념(Publisher, Subscriber, Operator)은 간단하고 이해하기 쉬우며, 부분적인 기능들이 합쳐지면 시너지 효과를 낼 수 있습니다.
Request driven
Subscriber가 Publisher로부터 값을 수신하겠다고 선언하면, Publisher는 값 downstream을 자유롭게 내보낼 수 있습니다. 요청 기반이므로, 앱의 메모리 사용량과 성능을 더욱 신중하게 관리할 수 있습니다.
Key Concepts
Publisher
시간이 지남에 따라 발생한 이벤트에 대한 일련의 값을 송신합니다.
Subscriber
Publisher로부터 값을 수신합니다. Subscriber는 값을 수신할 때 해당 값에 대해 작업을 수행합니다.
Operator
여러 가지 연산자를 사용해서 이벤트 전달 내용(값)을 변경하고 최종 결과를 사용해서 앱의 사용자 인터페이스를 업데이트할 수 있습니다.
Publisher
다음은 Publisher 프로토콜입니다.
protocol Publisher { associatedtype Output associatedtype Failure: Error func subscribe<S: Subscriber>(_ subscriber: S) where S.Input == Output, S.Failure == Failure }
프로토콜의 associatedtype을 사용해서 내보내는 값의 타입을 나타내고 있습니다. 오류를 내보낼 수 없는 경우, associatedtype에 대해 Never를 사용할 수 있습니다.
Subscriber가 Publisher와의 연결을 선언한 이후에, 그 관계는 게시가 완료되었거나, 에러가 있었거나, 또는 Subscriber가 구독을 취소하기로 선택했을 때, Publisher가 값 전송을 중지하기로 결정하면서 끝이 납니다.
Publisher 예시 with Subject
Publisher 프로토콜을 직접 구현하기 보다는, Combine 프레임워크에서 제공하는 여러가지 타입 중, 상황에 맞는 것을 Publisher로 사용하는 것이 좋습니다.
예시:
- Subject의 concrete subclass인 PassthroughSubject나 CurrentValueSubject를 사용.
- property 앞에 @Published 어노테이션을 추가해서 값이 변경될 때마다 이벤트를 내보내는 Publisher를 얻을 수 있음.
위 예시 말고도, 다양한 Convenience Publisher가 있습니다. 여기에서 모두 다루어볼 수 없으므로, Apple 문서를 참고하세요. Subject는 매우 간단하며 강력합니다. 이 글에서는 PassthroughSubject를 다루어 보겠습니다.
다음은 Subject 프로토콜입니다.
protocol Subject: Publisher, AnyObject { func send(_ value: Output) func send(completion: Subscribers.Completion<Failure>) }
Subject는 send(_:) 메서드를 사용해서 명령적으로 값을 주입함으로써 여러 downstream Subscriber에게 값을 내보내는 Publisher입니다. 아래 사진과 같이, 값이 upstream Publisher에 의해 생성되는 경우에도 마찬가지입니다. 따라서, Subject는 역할에 따라 Publisher처럼 행동하기도 하고, Subscriber처럼 행동하기도 합니다.
PassthroughSubject는 생성할 때 초깃값을 가지지 않으며, 가장 최근에 내보낸 값을 저장하지 않습니다. 그와 반대로 CurrentValueSubsject는 초깃값을 가지며, 가장 최근에 내보낸 값의 버퍼를 저장합니다. 따라서 value { get set } property를 통해서 가장 최근의 값을 읽거나, 값을 변경하여 send(_:)와 같은 효과를 볼 수 있고, 그 역도 마찬가지 입니다. 사용자 정의 에러를 만들고 PassthroughSubject에 값을 주입해보겠습니다.
// 사용자 정의 에러 enum MyError: Error { case failure } // PassthroughSubject, Output 및 Failure 타입을 지정하고 초깃값없이 생성 let subject = PassthroughSubject<String, MyError>() let cancellable = subject.sink(receiveCompletion: { completion in switch completion { case .failure(MyError.failure): print("failed") case .finished: print("finished") } }) { receivedValue in print(receivedValue) } // 명령형으로 값을 보낼 수 있음. // sink()의 receiveValue 클로저를 거쳐 "Hello" 출력 subject.send("Hello") // completion signal // sink()의 receiveCompletion 클로저를 거쳐 "failed" 출력 subject.send(completion: .failure(MyError.failure)) // completion signal을 보낸 후에는 subscription 스트림이 해제됨. // 아래의 후속 호출은 아무런 영향이 없음. subject.send(completion: .finished) subject.send("Hello?")
sink(receiveCompletion:receiveValue:)는 클로저 기반으로 Publisher에 Subscriber를 부착하는 메서드로, 여기에서는 주입한 값(Subscriber 입장에서 Input과 Failure)을 처리하는 기능을 합니다.
send() 메서드 또한 파라미터에 따라 다양합니다.
- send(Output): subscriber에게 값을 송신.
- send(): void 값을 송신. 이 경우, Output은 Void여야 함. 어떠한 값 없이 signal만으로 처리할 때 Void와 함께 사용.
- send(completion: Subscribers.Completion<Failure>): completion signal을 송신. Completion<Failure>는 열거형으로, 정상적으로 종료됐다는 case인 finished와, 에러때문에 publishing을 종료했다는 case인 failure를 가지고 있음.
Publisher 예시 with Notification
NotificationCenter의 publisher(for:object:) 정의입니다.
extension NotificationCenter { func publisher(for name: Notification.Name, object: AnyObject? = nil) -> NotificationCenter.Publisher }
다음은 Publisher 프로토콜을 준수하는 NotificationCenter.Publisher의 정의입니다.
extension NotificationCenter { // notification이 발생했을 때, elements를 내보내는 publisher struct Publisher: Publisher { typealias Output = Notification typealias Failure = Never let center: NotificationCenter let name: Notification.Name let object: AnyObject? init(center: NotificationCenter, name: Notification.Name, object: AnyObject? = nil) func receive<S>(subscriber: S) where S: Subscriber, S.Failure == Never, S.Input == Notification } }
이니셜라이저를 보면, name은 Notification.Name 타입입니다. 따라서 다양한 Notification을 내보낼 수 있겠죠. 예를 들어, UITextField.textDidChangeNotification를 사용해서 텍스트가 변경될 때마다 Notification을 boradcast할 수 있습니다. 또한 object는 AnyObject? 타입입니다. AnyObject를 내가 원하는 타입으로 다운캐스팅해서 사용할 수 있습니다. 그 예로, 텍스트필드에서 Notification을 내보낸다면, UITextField로 다운캐스팅 후 object.text와 같이 텍스트필드의 텍스트를 뽑아내서 Output으로서 내보낼 수 있습니다.
간단한 예시로 NotificationCenter의 Publisher를 사용해서 Notification을 내보내고 구독 및 수신해서 출력해보겠습니다.
// Notification.Name 생성 let myNotification = Notification.Name("Yeolmok") // NotificationCenter.Publisher 생성 let myPublisher = NotificationCenter.Publisher = Notification.default.publisher(for: myNotification) // Notification 구독 및 수신 시 출력 myPublisher.sink { receivedNotification in print("알림이 왔습니다. \(receivedNotification)") } // 이후 Notification 발송 NotificationCenter.default.post(Notification(name: myNotification))
Subscriber
다음은 Subscriber 프로토콜입니다.
protocol Subscriber { associatedtype Input associatedtype Failure: Error func receive(subscription: Subscription) func receive(_ value: Subscription.Demand) func receive(completion: Subscribers.Completion<Failure>) }
각 receive()에는 다음과 같은 규칙이 있습니다.
- subscribe() 호출에 대한 응답으로 Publisher는 정확히 한 번 receive(subscription:)를 호출.
- Publisher는 Subscriber가 요청한 후, downstream으로 0개 이상의 값을 제공할 수 있음.
- Publisher는 최대 한 번의 completion을 보낼 수 있으며 해당 completion은 Publisher가 정상적으로 완료되었거나 에러가 발생했음을 나타냄. 완료 신호를 받으면 더 이상 값을 내보낼 수 없음.
Publisher에 아래 연산자(Operator)를 사용함으로써 Subscriber를 제공받을 수 있습니다.
- sink(receiveCompletion:receiveValue:)
- assign(to:on:)
sink(receiveCompletion:receiveValue:) Declaration
func sink( receiveCompletion: @escaping ((Subscribers.Completion<Self.Failure>) -> Void), receiveValue: @escaping ((Self.Output) -> Void) ) -> AnyCancellable
Publisher의 예시에서 sink(receiveCompletion:receiveValue:)를 사용해서 Publisher로부터 값을 받아서 처리했었죠. receiveCompletion 클로저로 completion을 처리하고, receiveValue 클로저로 받은 값을 처리합니다. 클로저를 제공하기만 하면 모든 값이 수신되었을 때, 클로저가 호출될 것이고 원하는 작업을 수행할 수 있습니다. 예를 들어, 받은 값을 처리하고 UICollectionView를 reloadData()해야할 때, 그 내용을 receiveValue 클로저에 첨부하면 되겠죠. Failure가 Never일 때, receiveCompletion 없이 sink(receiveValue:)를 사용할 수 있습니다. 다음은 간단한 sink() 예시입니다.
let myPublisher = ... // Publisher of <String, Never> // Subscriber let cancellable = myPublisher.sink { value in // Do Something with value } // ... // 구독을 종료하기 위해 호출할 수 있는 cancellation token. // cancel()은 제일 아래 Cancellation 단락에서 다루겠습니다. cancellable.cancel()
assign(to:on:) Declaration
func assign<Root>( to keyPath: ReferenceWritableKeyPath<Root, Self.Output>, on object: Root ) -> AnyCancellable
assign(to:on:)은 Failure가 Never일 때 사용할 수 있습니다. 지정된 객체 인스턴스의 지정된 key path property에 새로 수신한 값을 할당합니다.
기본적으로 Key-Path Expression은 '\type name.path' 로 사용합니다. 타입 추론으로 타입을 결정할 수 있는 문맥에서는 type name을 생략하고 '\.path'와 같이 사용할 수 있습니다. 다음은 간단한 예시입니다.
class Student { var name: String var grade = 1 { didSet { print("\(name)은 \(oldValue)학년에서 \(grade)학년이 되었습니다.") } } init(name: String) { self.name = name } } let yeolmok = Student(name: "yeolmok") let gradeRange = (2...4) gradeRange.publisher .assign(to: \.grade, on: yeolmok) .cancel() // yeolmok은 1학년에서 2학년이 되었습니다. // yeolmok은 2학년에서 3학년이 되었습니다. // yeolmok은 3학년에서 4학년이 되었습니다.
Cancellation
Publisher는 정상적으로 완료되거나 실패할 때까지 값을 계속 내보냅니다. Publisher를 더 이상 구독하지 않으려면 취소할 수 있습니다. Publisher가 이벤트 전달을 완료하기 전에 구독을 종료하는 기능이 종종 필요하기 때문에 cancellation이 존재합니다. sink() 및 assign()에 의해 생성된, Cancellable 프로토콜을 준수하는 Subscriber 타입은 모두 cancel() 메서드를 제공받습니다.
protocol Cancellable { func cancel() func store<C>(in: inout C) } // convenience final class AnyCancellable: Cancellable {}
cancel()을 호출하면 할당된 리소스를 해제합니다. 또한 타이머, 네트워크 액세스, 디스크 입출력과 같은 부작용을 방지합니다. AnyCancellable 인스턴스는 메모리에서 해제될때 자동으로 cancel()을 호출합니다. 따라서 명시적으로 cancel()을 호출하지 않아도 되며, swift에서 제공하는 강력한 메모리 관리 기능에 의존하기만 하면 됩니다.
추가적으로 cancel()은 구독을 끊음으로써(취소 이후에 downstream으로 값 전달 불가능) 메모리를 관리하는 반면에, store(in:)는 Cancellable 인스턴스를 지정한 collection에 저장함으로써 Publisher와 Subscriber의 관계를 유지하면서 메모리를 관리하기 위해 사용하는 것 같은데, 공식 문서에서 확인한 내용이 아닌, 내용을 종합하여 도출한 개인적인 생각이기 때문에 사실인지는 모르겠습니다.
참고
WWDC 2019 Introducing Combine
(https://developer.apple.com/videos/play/wwdc2019/722)
WWDC 2019 Combine in Practice
(https://developer.apple.com/videos/play/wwdc2019/721)
Apple Documentation
(https://developer.apple.com/documentation/combine)
Key-Path Expression
'iOS' 카테고리의 다른 글
Actor in Swift concurrency (0) 2022.12.31 Diffable DataSource (2) 2022.12.29 Localization in iOS (0) 2022.12.14 Operator, Combine Framework in Swift (0) 2022.12.11 Async/await in Swift (0) 2022.12.03