[Combine] 예제 2 & MVVM[SwiftUI/UIKit]
1. SwiftUI MVVM + Combine
NumbersView.swift
import SwiftUI
/*
@StateObject
- 객체를 직접 생성 및 소유한다
- 뷰가 다시 그려져도 객체는 재생성되지않는다
- 해당 뷰에서 객체롤 초기화 및 관리를 위해 사용
- 상태 유지에 효율적
- 루트뷰에서 주로 사용
- 객체를 초기화하고 SwiftUI 뷰 상태에 저장하는데 사용하는 속성 래퍼
- 뷰가 존재하는 한 객체가 저장되고 뷰와 함께 삭제됨을 의미한다
- 일반적으로 @StateObject를 사용하는 것은 하나의 뷰가 아닌 여러 뷰에 필요한 클래스 객체에 실용적이다
@ObservedObject
- 상태 변경시 뷰를 다시 생성
- 객체를 외부에서 주입받음
- 뷰가 다시 그려지면 객체도 다시 주입됨
- 상위 뷰에서 객체를 전달받아 사용시
- 객체가 자주 재생성됨
- 서브뷰 또는 전달받는뷰
공통점
- observable 객체를 구독하는 property wrapper
- 구독중인 observable 객체가 변경되면 뷰에 업데이트 시켜주는 기능
차이점
- 둘다 observableObject를 구독하여 값이 변경되면 뷰에 반영하는 property wrapper
- 상태 변경시 @ObservedObject는 뷰를 다시 생성하지만 @StateObject는 다시 생성하지않고 동일 뷰가 사용(효율)
- 기본적으로 @StateObject를 사용하되, 해당 프로퍼티를 subView에 주입해야 한다면 @ObservedObject로 선언하여 사용할 것
- subView에 @StateObject를 주입하면 해당 @StateObject의 수명 주기가 두 곳에서 관리가 되므로 의존성을 줄이기 위해 @ObservedObject를 사용
- https://hackernoon.com/lang/ko/Swiftuis-5-주요-속성-래퍼-및-이를-효과적으로-사용하는-방법
- https://ios-development.tistory.com/1160
*/
struct NumbersView: View {
@StateObject private var viewModel = NumbersVM()
var body: some View {
VStack(alignment: .trailing) {
TextField("", text: $viewModel.number1)
.textFieldStyle(RoundedBorderTextFieldStyle())
TextField("", text: $viewModel.number2)
.textFieldStyle(RoundedBorderTextFieldStyle())
TextField("", text: $viewModel.number3)
.textFieldStyle(RoundedBorderTextFieldStyle())
TextField("", text: $viewModel.number4)
.textFieldStyle(RoundedBorderTextFieldStyle())
Divider()
Text(viewModel.resultValue)
.fontWeight(.bold)
.foregroundStyle(.white)
}
.padding(.horizontal, 100)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.yellow)
}
}
#Preview {
NumbersView()
}
NumbersViewModel.swift
import Combine
import UIKit
/*
비즈니스 로직
- 데이터 상태를 VM이 가지고 있다
- 즉 완성된 데이터를 VM이 가지고 있다
2가지 상태의 데이터
- 뷰모델로 들어오는 Input
- 뷰모델로 나가는 Output == 비즈니스 로직을 타서 완성된 데이터가 뷰모델에서 나가는 것
https://hackernoon.com/lang/ko/Swiftuis-5-주요-속성-래퍼-및-이를-효과적으로-사용하는-방법
*/
final class NumbersVM: ObservableObject {
private var subscriptions = Set<AnyCancellable>()
// MARK: - Input: 뷰모델로 들어오는 데이터
@Published var number1: String = ""
@Published var number2: String = ""
@Published var number3: String = ""
@Published var number4: String = ""
// MARK: - Output: 뷰모델로 나가는 데이터
@Published var resultValue: String = ""
init() {
print(#fileID, #function, #line, "- ")
Publishers
.CombineLatest4($number1,
$number2,
$number3,
$number4)
.map { testValue1, testValue2, testValue3, testValue4 -> Int in
return testValue1.getNumber() +
testValue2.getNumber() +
testValue3.getNumber() +
testValue4.getNumber()
}
.map { String($0) }
// resultValue에 직접 꽂을건데 객체이므로 자기자신의 속성으로
.assign(to: \.resultValue, on: self)
/*
assign대신 이렇게 해도됨
.sink { value in
self.resultValue = value
}
*/
.store(in: &subscriptions)
}
}
2. NumbersViewModel 리팩토링
- 로직을 따로 뺴자
- 1번과 2번방식을 추천
//
// NumbersVM.swift
// CombineTutorial-example
//
// Created by 김동현 on 7/18/25.
//
import Combine
import UIKit
/*
비즈니스 로직
- 데이터 상태를 VM이 가지고 있다
- 즉 완성된 데이터를 VM이 가지고 있다
2가지 상태의 데이터
- 뷰모델로 들어오는 Input
- 뷰모델로 나가는 Output == 비즈니스 로직을 타서 완성된 데이터가 뷰모델에서 나가는 것
https://hackernoon.com/lang/ko/Swiftuis-5-주요-속성-래퍼-및-이를-효과적으로-사용하는-방법
*/
final class NumbersVM: ObservableObject {
private var subscriptions = Set<AnyCancellable>()
private lazy var resultPublisher: AnyPublisher<String, Never> =
Publishers
.CombineLatest4($number1,
$number2,
$number3,
$number4)
.map { testValue1, testValue2, testValue3, testValue4 -> Int in
return testValue1.getNumber() +
testValue2.getNumber() +
testValue3.getNumber() +
testValue4.getNumber()
}
.map { String($0) }
.eraseToAnyPublisher()
private var resultPublisher2: AnyPublisher<String, Never> {
Publishers
.CombineLatest4($number1,
$number2,
$number3,
$number4)
.map { testValue1, testValue2, testValue3, testValue4 -> Int in
return testValue1.getNumber() +
testValue2.getNumber() +
testValue3.getNumber() +
testValue4.getNumber()
}
.map { String($0) }
.eraseToAnyPublisher()
}
// MARK: - Input: 뷰모델로 들어오는 데이터
@Published var number1: String = ""
@Published var number2: String = ""
@Published var number3: String = ""
@Published var number4: String = ""
// MARK: - Output: 뷰모델로 나가는 데이터
@Published var resultValue: String = ""
init() {
print(#fileID, #function, #line, "- ")
// 1번 방식
setupBinding()
// 2번 방식
resultPublisher
.assign(to: \.resultValue, on: self)
.store(in: &subscriptions)
// 3번 방식
resultPublisher2
.assign(to: \.resultValue, on: self)
.store(in: &subscriptions)
}
private func setupBinding() {
Publishers
.CombineLatest4($number1,
$number2,
$number3,
$number4)
.map { testValue1, testValue2, testValue3, testValue4 -> Int in
return testValue1.getNumber() +
testValue2.getNumber() +
testValue3.getNumber() +
testValue4.getNumber()
}
.map { String($0) }
// resultValue에 직접 꽂을건데 객체이므로 자기자신의 속성으로
.assign(to: \.resultValue, on: self)
/*
assign대신 이렇게 해도됨
.sink { value in
self.resultValue = value
}
*/
.store(in: &subscriptions)
}
}
2. UIKit MVVM + Combine
기존 NumbersViewController.swift
- 기존 코드는 ViewController에 로직이 들어있으니 mvvm으로 만들어보자.
```swift
import UIKit
import Combine
import CombineCocoa
final class NumbersViewController: UIViewController {
@IBOutlet weak var number1: UITextField!
@IBOutlet weak var number2: UITextField!
@IBOutlet weak var number3: UITextField!
@IBOutlet weak var result: UILabel!
var subscriptions = Set
override func viewDidLoad() {
super.viewDidLoad()
// MARK: - ViewModel에 Input 넣어주기
Publishers
.CombineLatest3(number1.textPublisher,
number2.textPublisher,
number3.textPublisher)
.map { testValue1, testValue2, testValue3 -> Int in
return testValue1.getNumber() +
testValue2.getNumber() +
testValue3.getNumber()
}
/*
.sink { value in
print(#fileID, #function, #line, "- value: \(value)")
}
*/
.map { String($0) }
.assign(to: \.text, on: result)
.store(in: &subscriptions)
} } ```
NumbersViewController.swift
import UIKit
import Combine
import CombineCocoa
final class NumbersViewController: UIViewController {
@IBOutlet weak var number1: UITextField!
@IBOutlet weak var number2: UITextField!
@IBOutlet weak var number3: UITextField!
@IBOutlet weak var result: UILabel!
var subscriptions = Set<AnyCancellable>()
private var viewModel: NumbersVM = NumbersVM()
override func viewDidLoad() {
super.viewDidLoad()
// MARK: - ViewModel에 Input 넣어주기
number1.textPublisher
.compactMap { $0 }
.assign(to: \.number1, on: viewModel) // viewModel이 가지고 있는 number1 속성에 꽂는다
.store(in: &subscriptions)
number2.textPublisher
.compactMap { $0 }
.assign(to: \.number2, on: viewModel)
.store(in: &subscriptions)
number3.textPublisher
.compactMap { $0 }
.assign(to: \.number3, on: viewModel)
.store(in: &subscriptions)
// MARK: - ViewModel에서 나오는 데이터 바인딩하기
viewModel.$resultValue
.compactMap { $0 }
.map { String($0) }
.assign(to: \.text, on: result) // result.text에 resultValue를 해줘라
.store(in: &subscriptions)
// Publishers
// .CombineLatest3(number1.textPublisher,
// number2.textPublisher,
// number3.textPublisher)
// .map { testValue1, testValue2, testValue3 -> Int in
// return testValue1.getNumber() +
// testValue2.getNumber() +
// testValue3.getNumber()
// }
// /*
// .sink { value in
// print(#fileID, #function, #line, "- value: \(value)")
// }
// */
// .map { String($0) }
// .assign(to: \.text, on: result)
// .store(in: &subscriptions)
}
}
NumbersVM.swift
import Combine
import UIKit
/*
비즈니스 로직
- 데이터 상태를 VM이 가지고 있다
- 즉 완성된 데이터를 VM이 가지고 있다
2가지 상태의 데이터
- 뷰모델로 들어오는 Input
- 뷰모델로 나가는 Output == 비즈니스 로직을 타서 완성된 데이터가 뷰모델에서 나가는 것
https://hackernoon.com/lang/ko/Swiftuis-5-주요-속성-래퍼-및-이를-효과적으로-사용하는-방법
*/
final class NumbersVM: ObservableObject {
private var subscriptions = Set<AnyCancellable>()
private lazy var resultPublisher: AnyPublisher<String, Never> =
Publishers
.CombineLatest4($number1,
$number2,
$number3,
$number4)
.map { testValue1, testValue2, testValue3, testValue4 -> Int in
return testValue1.getNumber() +
testValue2.getNumber() +
testValue3.getNumber() +
testValue4.getNumber()
}
.map { String($0) }
.eraseToAnyPublisher()
private var resultPublisher2: AnyPublisher<String, Never> {
Publishers
.CombineLatest4($number1,
$number2,
$number3,
$number4)
.map { testValue1, testValue2, testValue3, testValue4 -> Int in
return testValue1.getNumber() +
testValue2.getNumber() +
testValue3.getNumber() +
testValue4.getNumber()
}
.map { String($0) }
.eraseToAnyPublisher()
}
// MARK: - Input: 뷰모델로 들어오는 데이터
@Published var number1: String = ""
@Published var number2: String = ""
@Published var number3: String = ""
@Published var number4: String = ""
// MARK: - Output: 뷰모델로 나가는 데이터
@Published var resultValue: String = ""
init() {
print(#fileID, #function, #line, "- ")
// 1번 방식
setupBinding()
// 2번 방식
/*
resultPublisher
.assign(to: \.resultValue, on: self)
.store(in: &subscriptions)
*/
// 3번 방식
/*
resultPublisher2
.assign(to: \.resultValue, on: self)
.store(in: &subscriptions)
*/
}
private func setupBinding() {
Publishers
.CombineLatest4($number1,
$number2,
$number3,
$number4)
.map { testValue1, testValue2, testValue3, testValue4 -> Int in
return testValue1.getNumber() +
testValue2.getNumber() +
testValue3.getNumber() +
testValue4.getNumber()
}
.map { String($0) }
// resultValue에 직접 꽂을건데 객체이므로 자기자신의 속성으로
.assign(to: \.resultValue, on: self)
/*
assign대신 이렇게 해도됨
.sink { value in
self.resultValue = value
}
*/
.store(in: &subscriptions)
}
}
Leave a comment