iOS

Operator, Combine Framework in Swift

열목 2022. 12. 11. 17:00

 

 Combine 프레임워크는 앱이 이벤트를 처리하는 방법에 대한 선언적 접근 방식을 제공합니다. 지정된 이벤트 소스에 대해 단일 처리 체인, 즉 파이프라인을 만들 수 있습니다. 파이프라인은 데이터 처리 단계의 출력이 다음 단계의 입력으로 이어지는 형태로 연결된 구조로, 이렇게 연결된 데이터 처리 단계는 한 여러 단계가 서로 동시에, 또는 병렬적으로 수행될 수 있어 효율성이 향상됩니다. 이러한 각 부분은 이전 단계에서 받은 요소에 대해 고유한 작업을 수행하는 Operator입니다. 예를 들어, Combine으로 구독할 수 있는 Notification을 생성했을 때, Notification을 받은 후 operator를 사용하여 이벤트 전달 내용을 사용자 정의하고 최종 결과를 사용하여 UI를 업데이트할 수 있습니다.

 

다음과 같은 다양한 operator를 학습해보겠습니다. map, filter, reduce 등 많은 연산자가 swift 표준 라이브러리의 연산자와 유사한 작업을 수행합니다.

  • map(_:)
  • tryMap(_:)
  • flatMap(_:)
  • filter(_:)
  • debounce(for:scheduler:options:)
  • receive(on:)

map(_:)

func map<T>(_ transform: @escaping (Self.Output) -> T)
	-> Publisher.Map<Self, T>

 upstream publisher로부터 온 값을 제공한 클로저로 변환합니다. 사용자가 UITextField의 text를 변경했을 때 발생하는 Notification으로 예시를 들어보겠습니다. NotificationCenter publisher의 Failure는 Never로, 에러가 발생할 리 없다는 것을 이미 알고 있죠. 에러를 던질 수 있다면, tryMap(_:) 연산자를 사용합니다.

@IBOutlet var myTextField: UITextField!
var mySubscriber: AnyCancellable?

// Publisher
let myPublisher = NotificationCenter.default
	.publisher(for: UITextField.textDidChangeNotification, object: myTextField)
	.map { notification in
	    (notification.object as! UITextField).text!
	}

// Subscriber
mySubscriber = myPublisher
		.assign(to: \.myText, on: myViewModel)


// 에서 1로 변경되었습니다.
// 1에서 12로 변경되었습니다.
// 12에서 123로 변경되었습니다.
// 123에서 12로 변경되었습니다.

 위와 같이 publisher의 output 타입을 변환하여 뷰 모델 객체의 프로퍼티에 할당해줄 수 있습니다. 물론 위의 코드에서 map(_:)은 publisher에서 수행하든, subscriber에서 수행하든, 같은 결과를 끌어낼 수 있습니다.

 


tryMap(_:)

func tryMap<T>(_ transform: @escaping (Self.Output) throws -> T) 
	-> Publishers.TryMap<Self, T>

 upstream publisher로부터 온 값을 제공된, 에러를 던지는 클로저를 사용해 변환합니다. stream에서 발생하는 모든 에러를 Failure로 변환하는 기능을 추가한다는 점을 제외하면 map(_:)과 같습니다. 실제로 이 연산자의 출력은 Failure가 swift의 Error 프로토콜을 준수하는 Publisher입니다. 에러가 발생할 수 있는 디코드 작업을 수행하는 예를 들어서, 다음과 같이 사용합니다. 

let myPublisher = NotificationCenter.default.publisher(for: myNewDownload)
	.map { $0.userInfo?["data"] as! Data }
	.tryMap { data in
	    let decoder = JSONDecoder()
	    try decoder.decode(MyModel.self, from: data)
	}

 이는 아래와 같이 decode() 연산자로 간단하게 작성할 수도 있습니다. 또한 catch 연산자는 원래의 publisher를 새로운 것으로 교체하여 오류를 복구할 수 있습니다.

.map { ... }
.decode (MyModel.self, JSONDecoder())
.catch { Just(MyModel.placeholder) }

// Just는 subscriber에게 Output을 한 번 내보내고 종료되는 convenience publisher입니다.

catch같은 에러 처리 연산자로는 다음과 같은 것들이 있습니다.

  • retry(_:): 실패한 upstream publisher에 대한 구독을 다시 생성하려고 명시한 만큼 시도.
  • mapError(_:): 에러를 다른 에러로 변환.
  • setFailureType(to:): upstream publisher에 의해 선언된 Failure 타입을 바꿈. combineLatest(_:)로 publisher들을 결합할 때, 그것들의 에러 타입이 맞지 않을 때 사용.

flatMap(_:)

 위 내용과 이어서, catch()를 사용하여 다른 publisher로 대체하여 복구하면 그 에러에 대해서는 성공적이지만, 해당 구독을 종료했기 때문에 그 이후에 Notification을 받아서 연산을 수행하지는 못합니다. 따라서 원래의 upstream에 대한 연결을 유지하면서 placeholder를 사용하는 기능을 사용해야 합니다. 그러기 위해서 flatMap(_:)을 사용합니다.

let myPublisher = NotificationCenter.default.publisher(for: myNewDownload)
	.map { $0.userInfo?["data"] as! Data }
	.flatMap {
	    return Just(data)
	        .decode(MyModel.self, JSONDecoder())
	        .catch {
	            return Just(MyModel.placeholder)
	        }
	}
	.publisher(for \.name) // my model 내부에 접근하여 name 프로퍼티 추출

 map() 연산자에서 변환한 데이터를 가지고 디코딩하고, catch하고, flatMap에 반환합니다. 따라서 flatMap이 publisher를 구독할 것이며 결과적으로 publisher는 절대 실패할 수 없습니다. 또한 publisher(for:)를 사용하여 type safe key path를 통해 새로운 my model의 name 프로퍼티를 내보내는 Publisher를 생성합니다.


filter(_:)

func filter(_ isIncluded: @escaping (Self.Output) -> Bool) 
	-> Publishers.Filter<Self>

 제공한 클로저에 맞는 값을 republish. 즉, isIncluded는 값 하나를 republish 할지 여부를 나타내는 Boolean 값을 반환하는 클로저입니다. filter(_:)를 사용해서 다음과 같이 텍스트필드의 텍스트 수가 5 이하면 무시하도록 할 수 있습니다.

let myPublisher = ... // textField publisher
mySubscriber = myPublisher
	.filter { $0.count > 5 }

debounce(for:scheduler:options:)

func debounce<S>(
    for dueTime: S.SchedulerTimeType.Stride,
    schduler: S,
    options: S.SchedulerOptions? = nil
) -> Publishers.Debounce<Self, S> where S : Scheduler

 이벤트 간에 지정된 시간 간격 이후 값을 publish.

 

Parameter

dueTime

 값을 게시하기 전에 publisher가 기다려야 하는 시간

 

scheduler

 스케줄러

 

options

 Publisher의 값 전달을 사용자 정의하는 스케줄러 옵션. 각 스케줄러에서 자유롭게 정의할 수 있습니다.

 

 debounce(for:scheduler:options:) 연산자를 사용하여 값이 upstream publisher로부터 전달되는 간격을 제어할 수 있습니다. downstream으로 전달되는 값의 수를 지정한 비율로 줄여야 하는 대용량 이벤트를 처리하는 데 유용합니다. 예를 들어 텍스트필드의 텍스트로 네트워크 통신을 한다고 할 때, 텍스트가 변경될 때마다 네트워크 작업을 하게 되면 부담이 됩니다. 다음 예시와 같이 0.5초의 간격을 두고 처리하게 된다면, 그것을 해결할 수 있습니다.

mySubscriber = myPublisher
	.debounce(for: .second(0.5), scheduler: RunLoop.main)
	// do something with the value

Scheduled Operator

 특정 이벤트가 전달되는 시기와 장소를 설명하는 데 도움을 줍니다. 기본적으로 RunLoop와 DispatchQueue에 의해 지원되며 이러한 연산자의 몇 가지 예에는 특정 미래 시간까지 이벤트 전달을 연기하는 delay와 같은 연산자와 이벤트가 지정된 속도보다 빠르지 않게 전달되도록 보장하는 throttle이 있습니다. 또한 수신된 downstream 이벤트가 특정 스레드나 대기열에서 전달되도록 보장하는 receive(on:) 연산자도 있습니다.

.flatMap { ... }
.publisher(for: \.name)
// 작업을 메인 스레드로 옮김.
// 결과는 (String, Never)로,
// scheduled operator는 일반적으로 Output 및 Failure가 변경되지 않음.
.receive(on: RunLoop.main)

참고

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)