[UIKit] Combine: Subscription

Junyoung Park·2022년 10월 2일
0

UIKit

목록 보기
46/142
post-thumbnail

Combine Framework FREE course: write you first iOS app - use Subscriptions & Publishers like Subject

Combine:Subscription

Subscriber

  • 퍼블리셔 - 섭스크라이버 연결하는 데이터 스트림(파이프라인)
  • sink, assign 사용하는 게 주요 방법

소스 코드

var subscription: Cancellable? = Timer.publish(every: 0.25, on: .main, in: .common)
    .autoconnect()
    .scan(0, { count, output in
        return count + 1
    })
    .throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true)
    .filter({ count in
        return count < 20
    })
    .sink { completion in
        print("Data Stream Completion: \(completion)")
    } receiveValue: { output in
        print("Received Value: \(output)")
    }

DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
    subscription = nil
}
  • 타이머 퍼블리셔가 특정 시간마다 새로운 데이터를 생성해 내보내고 이를 scan, throttle, filter 등 '걸러 주는' 함수를 통과한 최종 데이터를 sink를 통해 핸들링

let foodbank: Publishers.Sequence<[String], Never> = ["apple", "bread", "orange", "milk"].publisher
let foodSubscription = foodbank
    .sink { foodItem in
    print("Received Value: \(foodItem)")
}
var timer = Timer.publish(every: 0.5, on: .main, in: .common)
    .autoconnect()
let calendar = Calendar.current
let endDate = calendar.date(byAdding: .second, value: 2, to: Date())

enum SubscriptionError: LocalizedError {
    case TryMapLocalizedError
}

func throwAtEndDate(foodItem: String, date: Date) throws -> String {
    guard
        let endDate = endDate,
        endDate >= date else {
        throw SubscriptionError.TryMapLocalizedError
    }
    return "\(foodItem) at \(date)"
}

let timerSubscription = foodbank
    .zip(timer)
    .tryMap({ foodItem, timeStamp in
        try throwAtEndDate(foodItem: foodItem, date: timeStamp)
    })
    .sink { completion in
        switch completion {
        case .finished: print("Success")
        case .failure(let error):
            print(error.localizedDescription)
        }
    } receiveValue: { result in
        print(result)
    }
  • 시퀀스 형태의 퍼블리셔 스트림을 그대로 구독할 때 특정 시간 주기로 데이터를 받고자 할 때
  • zip을 통해 타이머 퍼블리셔의 시간 주기와 함께 해당 퍼블리셔를 구독하기
  • tryMap 등 에러를 throw할 수 있는 방법을 sink 이전에 두고 특정 조건에 더 이상 구독하지 않도록 구현
class MyClass {
    var anInt: Int = 0 {
        didSet {
            print("anInt was set to: \(anInt)")
        }
    }
}

var myObject = MyClass()
let myRange = (0...2)
let subscription = myRange
    .publisher
    .map{$0 * 10}
    .sink(receiveValue: { value in
        myObject.anInt = value
    })
  • sink, assign(on: to:) 등 퍼블리셔 데이터를 핸들링하는 방법을 통해 특정 값을 변경할 수 있음
  • myRange의 데이터 시퀀스에 따라 anInt 값이 변화하는 것을 didSet을 통해 추적 가능
class TextFieldViewController: UIViewController {
    private let label: UILabel = {
        let label = UILabel()
        label.font = .preferredFont(forTextStyle: .title1)
        label.textColor = .black
        label.text = "Test Label"
        label.numberOfLines = 0
        return label
    }()
    private let textField: UITextField = {
        let textField = UITextField()
        textField.borderStyle = .roundedRect
        return textField
    }()
    private var textMessage = CurrentValueSubject<String, Never>("Hello World!")
    private var cancellables = Set<AnyCancellable>()

    override func loadView() {
        let view = UIView()
        view.backgroundColor = .white
        self.view = view
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        setUI()
    }

    private func setUI() {
        label.translatesAutoresizingMaskIntoConstraints = false
        textField.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(label)
        view.addSubview(textField)
        label.topAnchor.constraint(equalTo: view.topAnchor, constant: 40).isActive = true
        label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 30).isActive = true
        label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -30).isActive = true
        textField.topAnchor.constraint(equalTo: label.bottomAnchor, constant: 30).isActive = true
        textField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 30).isActive = true
        textField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -30).isActive = true
        textField.addTarget(self, action: #selector(updateText), for: .editingChanged)
        textMessage
            .compactMap{$0}
            .map{"Typed: \($0)"}
            .assign(to: \.text, on: label)
            .store(in: &cancellables)
    }
    
    @objc private func updateText() {
        textMessage.value = textField.text ?? ""
    }
}
  • textMessage는 문자열 타입의 퍼블리셔, 초깃값을 가지고 있기 때문에 CurrentValueSubject로 구현
  • testMessage의 데이터를 구독하고 있는 label의 텍스트
  • assign 또는 sink를 통해 구현 가능
struct UserModel {
    let name: String
    let id: Int
}

class AssignViewModel {
    var user = CurrentValueSubject<UserModel, Never>(UserModel(name: "Mock", id: 1))
    var userId: Int = 1 {
        didSet {
            print("userId changed: \(userId)")
        }
    }
    var cancellables = Set<AnyCancellable>()
    
    init() {
//        user.map{$0.id}
//            .assign(to: \.userId, on: self)
//            .store(in: &cancellables)
        // user <- userId subscription as assigned
        
        user.map{$0.id}
            .sink { [weak self] value in
                guard let self = self else { return }
                self.userId = value
            }
            .store(in: &cancellables)
        // user <- userId subscription using sink (user -> userId)
    }
    
    deinit {
        print("AssignViewModel is deallocated")
    }
}

var viewModel: AssignViewModel? = AssignViewModel()
viewModel?.user.send(UserModel(name: "New Data", id: 2))
// send: user (publisher model) has been changed via send event
viewModel = nil
  • 특정 값 변화를 send를 통해 불러 일으킬 수 있음
  • 값 변화에 따라 UI 패치가 일어나는 sink 부분은 약한 참조를 통해 강한 참조 사이클을 사전 방지 가능
class MyModel: ObservableObject {
    @Published var lastUpdated: Date = Date()
    
    init() {
        Timer.publish(every: 1, on: .main, in: .common)
            .autoconnect()
            .assign(to: &$lastUpdated)
    }
}

struct ClockView: View {
    @StateObject private var clockModel = MyModel()
    
    var body: some View {
        Text("\(clockModel.lastUpdated)")
            .fixedSize()
            .padding(50)
    }
}
  • UIKit에서와 달리 SwiftUI에서는 관측값에 따라 계속해서 뷰를 그려주고 있기 때문에 UIKit에서의 데이터-UI 바인딩보다 편리하게 MVVM 스타일 적용 가능
  • MyModel이라는 ObservableObjectlastUpdatedPublished 프로토콜을 따르고 있는 퍼블리셔 → ClockView에서 UI를 그리는 직접적인 데이터
  • 해당 퍼블리셔는 또 다시 Timer 퍼블리셔를 구독하고, 해당 퍼블리셔의 값 변화에 따라 값이 변하는 assign 적용
let intSubject = PassthroughSubject<Int, Never>()
let subscription = intSubject
    .map{$0}
    .receive(on: DispatchQueue.main)
    .sink { value in
        print("Receive value \(value)")
        print("Thread: \(Thread.current)")
    }
intSubject.send(1)
DispatchQueue.global().async {
    intSubject.send(2)
}
  • 초깃값이 없는 PassthroughSubject를 통해 정수 형태 데이터를 담당하는 퍼블리셔
  • 백그라운드 스레드로 해당 값을 변경했을 때 받는 섭스크라이버의 입장에서는 receive 메소드를 통해 스레드 선택 가능
var cancellables = Set<AnyCancellable>()
let intSubject = PassthroughSubject<Int, Never>()

intSubject
    .subscribe(on: DispatchQueue.global())
    .sink { value in
        print("Value: \(value)")
        print("Thread: \(Thread.current)")
    }
    .store(in: &cancellables)

for number in 1...10 {
    intSubject.send(number)
    print("Sending Even at: \(Thread.current)")
}
  • subscribe 메소드를 사용할 수도 있음
let subscription = URLSession.shared.dataTaskPublisher(for: URL(string: "https://jsonplaceholder.typicode.com")!)
    .map { _ in
        print("Thread is main at mapping ? : \(Thread.current.isMainThread)")
        // false
    }
    .subscribe(on: DispatchQueue.main)
    .receive(on: DispatchQueue.main)
    .sink { completion in
    } receiveValue: { value in
        print("Thread is main at sinking ? : \(Thread.current.isMainThread)")
        // true
    }
  • URL 세션 등 비동기 네트워킹 데이터 이벤트를 사용하는 퍼블리셔
  • 들어오는 것은 백그라운드로, 들어온 데이터를 다루어 UI 패치에 사용할 때에는 메인 스레드를 적용하기 위한 방법
profile
JUST DO IT

0개의 댓글