GGURUPiOS

[Swift/Combine] UIKit(MVVM) 에서 Combine 다루기 본문

Combine

[Swift/Combine] UIKit(MVVM) 에서 Combine 다루기

꾸럽 2024. 2. 20. 18:55

안녕하세요

이번시간에는 UIKit에서 Combine을 다루는 몇가지 방법에 대해 포스팅하려고 합니다.

프로젝트를 CleanArchitecture + MVVM 으로 짜고 있는데, Rx를 안쓰고 Combine을 이용하려니 헷갈리는 부분이 많네요.

 


MVVM패턴에서 Binding을 View-ViewModel 간의 reactive 한 코드를 짤 때 Combine으로도 짜고 싶었습니다.

구글링과 각종 문서를 통해 공부를 해보다보니 Combine과 Rx는 많이 비슷하지만 가장 큰 단점이 있었습니다.

 

일단 Rx는 이벤트를 방출할수 있는 UIKit 객체에 Observable들을 제공합니다. (RxCocoa)
예를들어 button.rx.tap 같은 것들이요.

 

하지만 Combine은 존재하지 않습니다. (CombineCocoa라는 라이브러리가 오픈소스에 있긴합니다)

 

일단, 이러한 단점들을 제외하고 한 번 일반적인 방법을 볼게요.

 

1. @Published 프로퍼티 래퍼를 통한 Output 바인딩 

 

ViewModel

- 이벤트를 방출할수 있도록 프로퍼티 래퍼 @Published 로 변수 선언

- loadTitle() : 네트워킹 코드로 가정. 단순 title 값을 바꾸는 작업

 

View(VC)

- bind() : Output을 바인딩하는 메서드

- sink 혹은 assign(to:on:) 으로 이벤트 구독


바로 할당을 해도되면 assign,
다른 작업이 필요하면 sink 로 구독해주자.

class DetailViewModel {
    @Published private(set) var title = "제목"
    
    // title을 바꾸는 네트워킹 코드라고 가정
    func loadTitle() {
        // 네트워킹 작업
        title = "제목2"
    }
}


final class ViewController: UIViewController {
    
    private var cancellables = Set<AnyCancellable>()
    
    private let someLabel: UILabel = {
        let label = UILabel()
        label.text = ""
        return label
    }()
    
    // 편의상 VM을 내부에서 생성
    var viewModel = DetailViewModel()
    
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(someLabel)
        
        bind()
        viewModel.loadTitle()
    }
    
    func bind() {
//         1. sink 로 구독.
        viewModel.$title
            .sink { [weak self] title in
                self?.someLabel.text = title
                print(title)
            }
            .store(in: &cancellables)
        
// 2. rx의 bind 처럼, assign을 이용해 바로 할당 (키패스 사용)
        viewModel.$title
            .assign(to: \.text!, on: someLabel)
            .store(in: &cancellables)
    }
}

 

2. 그렇다면 Input은 어떻게 받을까?

2-1 Subject를 이용하자

Input을 선언후 VC로부터 받아주자.

 

class DetailViewModel {

    @Published private(set) var title = "제목"
    
    private var subscriptions = Set<AnyCancellable>()
    
    // Input
    private var loadData: AnyPublisher<Void, Never> = PassthroughSubject<Void, Never>().eraseToAnyPublisher()
     
    // Input 을 파라미터로 받는다.
    func transform(input: AnyPublisher<Void, Never> {
     	self.loadData = input
        
        // 받은 Input을 변수에 할당 후 input 구독
        self.loadData
        	.sink {
            	// 네트워킹 코드
                print("네트워킹 코드")
                self.title = "제목 2"
            }
            .store(in: &subscription)
     }
}

 

2-2 transform 메서드에 Input을 받아서 output 을 리턴해보자

좀 더 한번에 처리하기 위해, output을 리턴해볼게요.

일단, 구조체는 프로퍼티 래퍼를 쓸 수 가 없으므로.. AnyPublisher로 바꿔줍시다.

    // Input
    struct Input {
        let loadData: AnyPublisher<Void, Never>
    }
    // Output
    struct Output {
        let title: AnyPublisher<String, Never>
    }

 

그 다음, transform(input:) -> Output 메서드를 마저 작성해 줍시다

func transform(input: Input) -> Output {
        let publisher = input.loadData
            .map {
                String("제목3")
            }
            .eraseToAnyPublisher()
        
        return Output(title: publisher)
    }

 

그 다음, View에서 바인딩 코드를 작성해 줍시다.

func bind(to viewModel: DetailViewModel) {
        
        let input = DetailViewModel.Input(
            loadData: buttonTappedSubject.eraseToAnyPublisher()
        )
        
        let output = viewModel.transform(input: input)
        
        output
            .title
            .sink { [weak self] str in
                self?.someLabel.text = str
            }
            .store(in: &cancellables)
            
    }

- Input 생성 후, viewModel의 transform 메서드를 실행시킬 때, 파라미터로 넣어줍니다.

- output 으로 넘어오는 publisher를 구독합니다. 

 

바인딩이 된 이후 동작 과정 (예를들어)

- 유저가 데이터 요청 버튼을 누릅니다.

- VC에 있는 buttonTappedSubject에 .send() 메서드를 통해 이벤트를 방출시킵니다.

- viewModel Output.title 퍼블리셔에 이벤트가 전달되고, String("제목3")이 방출됩니다.

- 구독 클로져 ( self?.someLabel.text = str 가 실행됩니다 )

 

3. Input을 subject으로 받지말고, RxCocoa 처럼 바로 넘기고 싶어요.

안타깝게도 combine에는 UIKit객체들은 퍼블리셔가 없기 때문에 따로 커스텀해주거나 라이브러리를 사용해야한다.

 

현재 까지의 과정은

1. 버튼이 눌린다.

2. 버튼 액션(메서드)이 불린다.

3. 그 메서드 내부에서 subject에 이벤트를 .send() 로 전달한다.

 

하지만 

퍼블리셔 자체가 내부구현되어있다면?

1. 버튼이 눌린다.

2. 버튼 퍼블리셔가 구독되어 있다면 이벤트를 방출한다.

 

===> 뷰컨트롤러안에 Subject가 필요없고, 구독 이후에는 따로 호출을 안해도 됨.

 

Publisher를 생성하는 코드를 만들자.

extension을 통해 UIControl클래스에 publisher를 만들수 있는 코드를 만들어주자. 

import Combine
import UIKit

extension UIControl {
    func controlPublisher(for event: UIControl.Event) -> UIControl.EventPublisher {
        return UIControl.EventPublisher(control: self, event: event)
      }
    
    // Publisher
    struct EventPublisher: Publisher {
        typealias Output = UIControl
        typealias Failure = Never
        
        let control: UIControl
        let event: UIControl.Event
        
        func receive<S>(subscriber: S) where S : Subscriber, Never == S.Failure, UIControl == S.Input {
            let subscription = EventSubscription(control: control, subscrier: subscriber, event: event)
            subscriber.receive(subscription: subscription)
        }
    }
    
    // Subscription
    fileprivate class EventSubscription<EventSubscriber: Subscriber>: Subscription where EventSubscriber.Input == UIControl, EventSubscriber.Failure == Never {

        let control: UIControl
        let event: UIControl.Event
        var subscriber: EventSubscriber?
        
        init(control: UIControl, subscrier: EventSubscriber, event: UIControl.Event) {
            self.control = control
            self.subscriber = subscrier
            self.event = event
            
            control.addTarget(self, action: #selector(eventDidOccur), for: event)
        }
        
        func request(_ demand: Subscribers.Demand) {}
        
        func cancel() {
            subscriber = nil
            control.removeTarget(self, action: #selector(eventDidOccur), for: event)
        }
        
        @objc func eventDidOccur() {
            _ = subscriber?.receive(control)
        }
    }
}

 

원하는 UIKit 객체에 퍼블리셔를 추가해주자

 

자 이제 퍼블리셔를 만드는 코드를 구현했으니 퍼블리셔 추가.

 

아래 예제는 버튼을 예로 만듬

extension UIButton {
    var tapPublisher: AnyPublisher<Void, Never> {
        controlPublisher(for: .touchUpInside)
            .map { _ in }
            .eraseToAnyPublisher()
    }
}

 

그 다음, button을 VC에 추가해준다음, Input에 Subject를 쓰지말고, button의 퍼블리셔로 바꿔주자

final class ViewController: UIViewController {
    
//  button에 퍼블리셔를 구현했기 때문에, 서브젝트는 이제 필요 X
//  private var buttonTappedSubject = PassthroughSubject<Void, Never>()
    
    private var cancellables = Set<AnyCancellable>()
    
    private let someLabel: UILabel = {
        let label = UILabel()
        label.text = "원래 제목"
        label.textColor = .white
        label.frame = CGRect(x: 60, y: 60, width: 120, height: 40)
        label.backgroundColor = .blue
        return label
    }()
    
    private let button: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("버튼", for: .normal)
        button.frame = CGRect(x: 120, y: 120, width: 40, height: 40)
        button.backgroundColor = .systemBlue
        return button
    }()
    
    // 편의상 VM을 내부에서 생성
    var viewModel = DetailViewModel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(someLabel)
        view.addSubview(button)
        
        bind(to: viewModel)
    }
    
    func bind(to viewModel: DetailViewModel) {
        
        let input = DetailViewModel.Input(
            loadData: button.tapPublisher // button의 탭퍼블리셔로 바꿔줌
        )
        
        let output = viewModel.transform(input: input)
        
        output
            .title
            .sink { [weak self] str in
                self?.someLabel.text = str
            }
            .store(in: &cancellables)
            
    }
}

 

 

 

5. 최종정리

ViewModel

import Foundation
import Combine


class DetailViewModel {
    
    // Input
    struct Input {
        let loadData: AnyPublisher<Void, Never>
    }
    // Output
    struct Output {
        let title: AnyPublisher<String, Never>
    }
    
    private var subscriptions = Set<AnyCancellable>()
    
    // title을 바꾸는 네트워킹 코드라고 가정
    func loadTitle() -> String {
        // 네트워킹 작업
        return "제목 \(Int.random(in: 1...10))"
    }
    
    func transform(input: Input) -> Output {
        let publisher = input.loadData
            .map { [weak self] _ in
                return self?.loadTitle() ?? "제목 1"
            }
            .eraseToAnyPublisher()
        
        
        // 필요한 경우 Input 구독 후 처리
        input.loadData.sink { _ in
            // 들어온 데이터로 다른 작업 처리
            
        }.store(in: &subscriptions)
        
        return Output(title: publisher)
    }
}

 

 

ViewController

final class ViewController: UIViewController {
    
//  button에 퍼블리셔를 구현했기 때문에, 서브젝트는 이제 필요 X
//  private var buttonTappedSubject = PassthroughSubject<Void, Never>()
    
    private var cancellables = Set<AnyCancellable>()
    
    private let someLabel: UILabel = {
        let label = UILabel()
        label.text = "제목2"
        label.textColor = .white
        label.frame = CGRect(x: 60, y: 60, width: 120, height: 40)
        label.backgroundColor = .blue
        return label
    }()
    
    private let button: UIButton = {
        let button = UIButton(type: .system)
        button.setTitle("버튼", for: .normal)
        button.frame = CGRect(x: 120, y: 120, width: 40, height: 40)
        button.backgroundColor = .systemBlue
        return button
    }()
    
    // 편의상 VM을 내부에서 생성
    var viewModel = DetailViewModel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(someLabel)
        view.addSubview(button)
        
        bind(to: viewModel)
    }
    
    func bind(to viewModel: DetailViewModel) {
        
        
        let input = DetailViewModel.Input(
            // button의 탭퍼블리셔로 input을 넣어준다.
            loadData: button.tapPublisher
        )
        
        let output = viewModel.transform(input: input)
        
        output
            .title
            .sink { [weak self] str in
                self?.someLabel.text = str
            }
            .store(in: &cancellables)
            
    }
}

 

생각보다 구글에 UIKit + Combine 예제가 많이없다.

 

Combine 자체가 유킷보단 스유에 최적화 된 느낌.

나중에 유킷에도 퍼블리셔들이 추가될지는 모르겠다.

 

 

 


참고 

https://velog.io/@aurora_97/Combine-UIKit%EC%97%90%EC%84%9C-Combine-%ED%8E%B8%ED%95%98%EA%B2%8C-%EC%93%B0%EA%B8%B0

https://betterprogramming.pub/uikit-mvvm-combine-912c80c02262