Combine 공부하기 (1)

김도연·2025년 4월 4일

iOS

목록 보기
4/8
post-thumbnail

강의를 들으며 공부했던 내용을 기록해본다...

Publishers and Subscribers

Creating and working with Publishers

import UIKit
import Combine

// Create a publisher that emits a single value (123)
let publisher = Just(123) // 단일 값 방출 pubisher

// Subscribe to the publisher and print the emitted value
let cancellable = publisher.sink { value in
// sink 사용하여 구독하고 값 출력
    print(value) // Output: 123
}

// Cancel the subscription (not really needed in this case)
// Just는 즉시 값을 방출하고 종료됨. 따라서 여기서는 큰 의미가 없음
cancellable.cancel()

// Create a publisher that emits numbers from an array
let numbersPublisher = [1,2,3,4,5,6].publisher

// Transform each number by multiplying it by 2
let doublePublisher = numbersPublisher.map { $0 * 2 }

// Subscribe and print each transformed value
let cancellable = doublePublisher.sink { value in
    print(value) // Output: 2, 4, 6, 8, 10, 12
}

Subscribing to Publishers

import Foundation
import Combine

// Create a timer publisher that emits values every 1 second
let timerPublisher = Timer.publish(every: 1, on: .main, in: .common)
// 1초마다 이벤트 발생
// 왜 Main thread ? -> UI updates...
// .common : 일반적인 실행 루프에서 동작

// Automatically start the timer and subscribe to its events
let cancellable = timerPublisher
    .autoconnect() // Automatically starts emitting values
    // 타이머 publisher는 기본적으로 수동 시작 필요 -> authconnect로 자동 시작
    .sink { timestamp in
        print("Timestamp: \(timestamp)") // Output: Prints time every second
    }

UITextField의 텍스트 변경 감지(UIKit)

import UIKit
import Combine

class ViewController: UIViewController {

    // Store subscriptions to avoid memory leaks
    private var cancellables: Set<AnyCancellable> = [] // 구독을 관리하기 위한 Container
    // view controller 해제될 때, 자동으로 subscribes are cancelled
    
    // Create a text field
    lazy var textField: UITextField = {
        let tf = UITextField()
        tf.placeholder = "Enter name"
        tf.translatesAutoresizingMaskIntoConstraints = false
        return tf
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = UIColor.green
        
        // Add text field to the view
        view.addSubview(textField)
        
        // Set up constraints (center the text field)
        textField.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        textField.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
        textField.widthAnchor.constraint(equalToConstant: 100).isActive = true
        
        // Create a publisher to observe text changes in the text field
        NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: textField)
            .compactMap { $0.object as? UITextField } // Get the text field from the notification
            .sink { textField in
                if let text = textField.text {
                    print(text) // Output the text whenever it changes
                }
            }
            .store(in: &cancellables) // Store subscription for automatic cancellation
    }
}
  • compactMap : NotificationCenter의 publisher는 Notification을 방출하는데, object를 UITextField로 안전하게 변환하기 위해 compactMap을 사용.
  • .store(in: &cancellables) : sink는 구독을 유지하는 역할 → 구독을 유지하지 않으면, 즉시 해제되어 작동하지 않게 됨
    • cancellables에 저장하면 뷰 컨트롤러가 해제될 때 자동으로 구독이 취소

기기 방향(Device Orientation) 변경을 감지(SwiftUI)

import SwiftUI
import Combine

@main
struct DeviceOrientationPublisherApp: App {
    
    // Store subscriptions to manage Combine publishers
    private var cancellables: Set<AnyCancellable> = [] // 앱이 실행되는 동안 구독을 유지하고, 필요할 때 해제
    
    init() {
        // Create a publisher to listen for device orientation changes
        NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)
            .sink { _ in
                let currentOrientation = UIDevice.current.orientation
                print(currentOrientation) // Output the current device orientation
            }
            .store(in: &cancellables) // Store subscription for automatic cancellation
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
  • 왜 NotificationCenter를 사용? : UIDevice.orientationDidChangeNotification은 iOS에서 기기 방향이 변경될 때 발생하는 알림. 이를 Combine의 퍼블리셔로 변환하여 sink로 처리할 수 있다!

Handling subscription lifecycles

import UIKit
import Combine

// Create a publisher that emits numbers from 1 to 10
let numbersPublisher = (1...10).publisher

// Subscribe and print each emitted value
let cancellable = numbersPublisher.sink { value in
    print(value) // Output: 1, 2, 3, 4, ..., 10 (printed immediately)
}

// Cancel the subscription after 2 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
    cancellable.cancel() // This won't stop the publisher because all values are already emitted
}
  • Sequence type → combine으로 Publisher
  • 2초 후 구독 취소. 그런데, numbersPublisher는 동기적(Synchronous)으로 즉시 모든 값을 방출하기 때문에 구독 취소가 의미가 없어짐(sink가 실행될 때 이미 모든 숫자가 출력). = cancel()을 2초 후에 호출해도 이미 모든 값이 출력된 상태라 효과가 없어짐
    • 비동기적으로 값을 방출하는 퍼블리셔(Timer, URLSession)와 함께 사용할 때 cancel()이 의미가 있다!

1초마다 증가하는 숫자를 화면에 표시(Timer, SwiftUI)

import SwiftUI
import Combine

// ViewModel that manages the counter value
class ContentViewModel: ObservableObject {
    
    @Published var value: Int = 0 // Published property that updates the view
    private var cancellable: AnyCancellable? // Store the subscription
    
    init() {
        // Create a timer publisher that fires every second
        let publisher = Timer.publish(every: 1.0, on: .main, in: .default)
            .autoconnect() // Automatically starts the timer
            .map { _ in self.value + 1 } // Increment value every second
        
        // Assign the published value to the 'value' property
        cancellable = publisher.assign(to: \.value, on: self)
    }
}

struct ContentView: View {
    
    @StateObject private var vm = ContentViewModel() // ViewModel instance
    
    var body: some View {
        VStack {
            Text("\(vm.value)") // Display the counter value
                .font(.largeTitle)
        }
        .padding()
    }
}

#Preview {
    ContentView()
}
  • .map { _ in self.value + 1 } : 기존 value 값에 +1 더해서 반환
  • assign(to: .value, on: self) : 퍼블리셔가 방출하는 값을 self.value에 할당
  • @Published를 사용하는 이유? → value가 변경될 때마다 자동으로 SwiftUI 뷰를 업데이트 하기 위해

Error handling and completion

import UIKit
import Combine

// Define a custom error
enum NumberError: Error {
    case operationFailed
}

// Create a publisher that emits numbers from an array
let numbersPublisher = [1, 2, 3, 4, 5].publisher

// First approach: Handle error with mapError (Stops on error)
let doubledPublisher = numbersPublisher
    .tryMap { number in
        if number == 4 {
            throw NumberError.operationFailed // Throw an error when number is 4
        }
        return number * 2 // Multiply number by 2
    }
    .mapError { error in
        return NumberError.operationFailed // Convert any error to operationFailed
    }

let cancellable = doubledPublisher.sink { completion in
    switch completion {
        case .finished:
            print("finished")
        case .failure(let error):
            print(error) // Print the error
    }
} receiveValue: { value in
    print(value) // Print the doubled values
}

/* Output
2
4
6
operationFailed
*/

/* Second approach: Handle error with catch (Continues execution) */

let doubledPublisher2 = numbersPublisher
    .tryMap { number in
        if number == 4 {
            throw NumberError.operationFailed // Throw an error when number is 4
        }
        return number * 2 // Multiply number by 2
    }
    .catch { error in
        if let numberError = error as? NumberError {
            print("Error occurred: \(numberError)") // Print error message
        }
        return Just(0) // Instead of stopping, emit 0 and continue
    }

let cancellable2 = doubledPublisher2.sink { completion in
    switch completion {
        case .finished:
            print("finished")
        case .failure(let error):
            print(error) // This will never be called because of catch
    }
} receiveValue: { value in
    print(value) // Print the values including 0 in case of an error
}

/* Output
2
4
6
Error occurred: operationFailed
0
10
finished
*/

1번 예제

  • tryMap : 값을 두 배로 만들지만, 숫자가 4이면 NumberError.operationFailed를 발생
  • mapError : 모든 에러를 같은 에러(NumberError.operationFailed)로 변환 ⇒ 에러 변환 후 스트림 종료
  • sink를 사용해 결과를 처리
    • finished : 정상적으로 종료되면 출력 → receiveValue 블록 처리
      • receiveValue: { value in print(value) // Print the doubled values }
    • failure : 에러가 발생하면 즉시 중단하고 에러 출력 → 자동 구독 취소 엔딩..인 것 같다

2번 예제

  • tryMap은 동일
  • catch : 에러가 발생하면 Just(0)을 방출하여 흐름을 유지 → 즉, 에러 발생 시 0을 방출하고 계속 진행.(에러를 다른 값으로 대체)

→ catch 블록을 사용하지 않으면, number == 4일 때 throw를 만나면서 스트림이 종료됨. 이후 어떤 값도 방출되지 않음 → sink가 즉시 .failure로 완료됨

profile
Kirby-like iOS developer

0개의 댓글