GGURUPiOS
[Swift/Combine] UIKit(MVVM) 에서 Combine 다루기 본문
안녕하세요
이번시간에는 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://betterprogramming.pub/uikit-mvvm-combine-912c80c02262