[Combine] Publishers - 1

rbw·2023년 12월 9일
0

Combine

목록 보기
3/11

Publishers

https://www.apeth.com/UnderstandingCombine/publishers/publishers.html

이번엔 퍼블리셔에 대해서 알아보겠슴니다

Combine은 꽤 많은 기본 제공 퍼블리셔를 제공합니다.

첫째로, Foundation 퍼블리셔입니다. 얘네들 덕분에, 기존 코드를 쉽게 조정할 수 있습니다. NotifcationCenter, Notification, URLSession, TImer, KVO에 대한 퍼블리셔가 존재합니다.

두번째로, 일반적인 퍼블리셔가 있습니다. 예를 들어 Future 퍼블리셔는 모든 비동기 연산을 퍼블리셔로 일반화합니다. Published, Subject는 필요에 따라 값을 게시하는 KVO 개념을 일반화한 퍼블리셔입니다.

마지막으로는 값 퍼블리셔가 있씁니다. 단순히 해당 값을 게시하고, 테스트 및 디버깅에 유용합니다. 먼저 기본 제공 퍼블리셔를 나열한 다음에는 직접 퍼블리셔를 작성하는데 필요한 사항에 대해 이야기하겠습니다. Combine에는 UIControl 이벤트에 대한 퍼블리셔가 없으므로 직접 작성해보겠슴니다.

Notification Center Publisher

이 친구는 메시지를 수신하도록 등록한 개체가 수신할 메시지를 게시하는 데 사용되는 브로드캐스트 매커니즘입니다. 해당 게시자는 기본적으로 제공하는 게시자입니다.

func publisher(for: Notification.Name, object: AnyObject? = nil)

NotificationCenter.default.publisher(for: .zopCodeDidChange)
    .compactMap { $0.userInfo["zip"] as? String }
    .assign(to: \.currentZip, on: self)
    .store(in: &storage)

addObserver를 호출하는 대신 퍼블리셔를 가져와서 파이프라인을 구성할 수 있습니다.

이를 사용한다면, 앱이 백그라운드로 전환되거나 음악 플레이어가 대기열의 다음 곡으로 이동하는 등의 특정 이벤트를 앱에 알릴 수 있습니다 또 개념적으로 멀리 떨어져 있는 객체가 통신할 수 있도록 해줍니다.

Data Task Publisher

URLSession의 dataTask 대신 dataTaskPublisher를 사용할 수 있습니다.

URLSession.shared.dataTaskPublisher(for: url)
    .map { $0.data }
    .replaceError(with: Data())
    .compactMap { UIImage(data:$0) }
    .recevie(on: DispatchQueue.main)
    .assign(to: \.image, on: self.iv)

기존의 dataTask 와 달리, resume 메서드 없이 구독할 때 시작됩니다. 그리과 컴플리션 핸들러가 없으며, 게시자가 완료되면 그 때 게시됩니다.

또 실패하는 경우에, 다운스트림 오퍼레이터에서 실패를 포착하여(더 이상 내려가지 않게) 실패에 으답하고 업스트림 퍼블리셔에 다시 구독하여 재시도하는 .retry도 가능합니다.

Future and Deferred

Futuredata task publisher가 하는일을 일반화한 퍼블리셔로, 어떤 비동기 작업을 시작하고 구독이 완료되거나 오류를 발생하면 이를 게시합니다. 주로 완료 핸들러가 있는 함수들은 얘로 캡슐화하기 좋은 후보입니다.

Future 를 초기화하면 얘한테 함수를 전달합니다. 이 함수는 promise라는 하나의 매개변수를 받습니다. 이 매개변수 자체가 Completion Function 입니다. 완료함수가 호출되면 우리가 할 일은 프로미스를 호출하여 퓨처에 게시할 때가 되었다는 신호를 보내는 것입니다.

프로미스 함수는 하나의 매개변수 Result를 받습니다. 성공의 경우, 해당 사례의 연관값을 방출하여 다운스트림 구독자게에 게시합니다. 그런 다음 .finished 완료를 보냅니다

실패의 경우, 연관값은 오류(Error)이며 이를 실패로 처리하고 일반적인 방식으로 해당 에러를 파이프라인 아래로 보냅니다.

예제를 보면 이해가 더 잘될거라 생각함니다.

let s = "Nordhoff High School, Ojai, California"
CLGeocoder().geocodeAddressString(s) { placemarks, error in
    guard let placemarks = placemarks else { return }
    let p = placemarks[0]
    let mp = MKPlacemark(placemark:p)
    if let coord = mp.location?.coordinate {
        // latitude: 34.4418801, longitude: -119.2671168
        // ...
    }
}

먼저 위 코드는 비동기 작업입니다. Future로 표현한다면, 값을 받아오면 성공, 실패 처리를 하여 프로미스 함수를 호출하면 될거 같네요

enum MyError : Error { case oops }

let future = Future<CLLocationCoordinate2D, Error> { promise in
    let s = "Nordhoff High School, Ojai, California"
    CLGeocoder().geocodeAddressString(s) { placemarks, error in
        let result = Result<CLLocationCoordinate2D, Error> {
            if let error = error { throw error }
            guard let placemarks = placemarks else { throw MyError.oops }
            let p = placemarks[0]
            let mp = MKPlacemark(placemark:p)
            if let coord = mp.location?.coordinate { return coord }
            throw MyError.oops
        }
        promise(result)
    }
}

전부 다 Future로 감싼 후에, resultpromise에 전달하여 호출하는 모습을 볼 수 있슴니다. 이제 Sinkfuture에 연결한다면 값을 받을 수 있슴니다.

하지만 잠재적인 문제로, sink를 연결하지 않으면 어떻게 될까요? 어쨌든 Future 함수가 실행된다는 점입니다. 구독자가 없는데도 불구하고 실행된다면 저희가 알고있던 동작방식이 아니겠져. 이를 해결하는 방법은 Deferred로 래핑하면 됩니다.

Deferred 퍼블리셔는 매우 간단합니다. 다른 퍼블리셔를 반환하는 함수로 초기화되지만, 구독이 이루어질 떄까지 해당 함수를 실행하지 않으므로 해당퍼블리셔를 생성하지 않습니다

사용은 간단합니다.

let deferredFuture = Deferred {
    // 위의 let future에 할당하는 부분을 고대로 여기에 작성하면된다.
    Future<CLLocationCoordinate2D, Error> { promise in
        ...
    }
}

위 코드를 작성하고 실행하면 아무 일도 일어나지 않습니다. 구독자를 연결하지 않았으므로 저희가 원하는 결과라고 할 수 있슴니다.

Timer Publisher

얘는 지정된 시간 간격이 경과 후 신호를 방출하는 객체입니다. 보통 반복 타이머로, 시간 각ㄴ격이 경과할 때마다 실행됩니다. 이 친구도 퍼블리셔로 대체 가능합니다.

static func publish(
    every: TimeInterval, tolerance: TimeInterval? = nil, 
    on: RunLoop, in: RunLoop.Mode)

일반적인 런루프는 메인스레드이고, 모드는 .common입니다

타이머 퍼블리셔는 지금까지 설명한 퍼블리셔와는 다소 다른 퍼블리셔입니다. 단순 구독만으로는 예약하고 실행을 시작하기에 충분하지 않습니다. 이러한 작업을 수행하려면 퍼블리셔에 연결하도록 지시해야합니다.

  • 퍼블리셔에 .autoconnect()를 적용합니다. 이렇게 하면 connect 함수가 자동으로 퍼블리셔에 전송됩니다.
  • 퍼블리셔에 대한 참조를 유지하면서 수동으로 connect() 메시지를 보냅니다.

다음으로 타이머를 중지하는 경우입니다.

  • 구독자를 취소합니다. (Cancel the subscriber)
  • 타이머 퍼블리셔에서 connect()를 호출한 경우 해당 호출은 취소 가능 객체를 반환합니다. 해당 객체에 대한 참조를 유지했다면 취소를 해당 객체로 보낼 수 있습니다. 또 Cancellablestore(in:)을 구현하므로 AnyCancellable로 감싸거 취소로 보낼 수 있슴니다.

예시 코드로, 현재 재생중인 노래를 추적하기 위해 진행률을 바꾸는 코드가 있슴니다.

self.timer = Timer.scheduledTimer(
    timeInterval: 0.5,
    target: self, selector: #selector(checkFraction),
    userInfo: nil, repeats: true)

위 코드를 타이머 퍼블리셔로 대체한 코드임니다.

var timerCancellable = Set<AnyCancellable>()
func makeTimer() {
    self.timerCancellable.first?.cancel()
    let timerPublisher = Timer.publish(every: 0.5, on: .main, in: .common)
    let timerPipeline = 
        Subscribers.Sink<Date,Never>(receiveCompletion:{_ in}) {
            [unowned self] _ in self.checkFraction()
        }
    timerPublisher.subscribe(timerPipeline)
    timerPublisher.connect()
        .store(in:&self.timerCancellable)
}

makeTimer 함수에서는 타이머 퍼블리셔에 대한 참조를 길게 유지하고, connect() 후에 반환된 Cancellable 객체를 AnyCancelleble로 감싸서 유지했슴니다. 만약 수동으로 취소해야하는경우 self.timerCancellable.removeAll()로 가능합니다. 또 unowned self로 참조 사이클을 방지하였습니다.

만약 정확한 타이밍이 중요한 경우에 이전에 방출된 값과 비교하여 정확한 간격을 도출할 수도 있습니다. 아래 예시코드임니다.

Timer.publish(every: 1, on: .main, in: .default)
    .autoconnect()
    .scan((prev:Date.distantPast, now:Date())) { (prev:$0.now, now:$1) }
    .map { $0.now.timeIntervalSince($0.prev) }
    .sink { print($0) }
    .store(in: &storage)

해당 파이프라인의 끝에 도달하는 값은 타이머가 마지막으로 실행된 이후 경과된 시간입니다.

profile
hi there 👋

0개의 댓글