MVVM 란?
Model-View-ViewModel로 구성된 아키텍처 패턴 중 하나로,
데이터를 처리하는 모델(Model), 사용자에게 보여지는 UI인 뷰(View), 뷰에 바인딩되어 모델과 뷰 사이를 이어주는 뷰-모델(View Model)로 분리한 패턴이다.
특징으로
Model과 View뿐만 아니라 Binding을 통하여 View와 View Model 간의 의존성까지 최소한 형태로, 데이터 처리 로직과 UI 간 상호 영향이 적어 모듈화를 통해 재사용성을 높이고 및 역할별로 Unit Test가 용이해진다.
대신 설계가 복잡하고 간단한 프로젝트에서는 View Model 설계가 오히려 능률이 떨어질 수 있다.
또한 Data Binding의 과정이 메모리효율이 안 좋다고 한다.
📚 Model
데이터, 네트워크 로직, 비즈니스 로직등을 담으며 데이터를 캡슐화하는 역할을 맡고 있다.
👀 View (UIView/ViewController)
사용자에게 보여지는 화면(UI, 레이아웃)이며 상호 작용으로 이벤트를 ViewModel에게 전달한다.
📱 View Model
핵심적인 비즈니스 로직으로 View에서 받은 이벤트를 처리하고 Binding을 통해 즉시 View로 전달한다.
MVVM 동작 흐름
1. 사용자가 화면 터치(View)를 통해 Input 전달받음
2. Command Pattern으로 ViewModel에 명령
3. ViewModel에서 Model에게 데이터를 요청 및 응답
4. 응답받은 데이터를 ViewModel에서 가공 및 저장
5. Data Binding을 통해 ViewModel 값이 변하면, View도 자동으로 업데이트
이런순으로 동작하게 만들어 줘야 합니다.
Data Binding
'View -> ViewModel 바인딩'과 'ViewModel -> View 바인딩'으로 쌍방향 소통이 가능 합니다.
여기서는 'ViewModel -> View 바인딩'을 통한 Binding을 배워봅시다.
(어디선가 데이터를 받아오면, ViewModel의 데이터들이 즉시 View와 결합되어 바로 업데이트되게 만들 예정입니다.)
여러 방법 중 Closure, Observable, Combine를 이용한 Binding을 해보려고 합니다.
Rx는 다음 기회에...
시계 만들어보자
MVVM 패턴으로 시계를 만들어 보자
Closure, Observable, Combine 이 3가지 방법을 통한 Binding을 알아봅시다.
뷰 및 모델 설정
기본 준비는 끝!
// 모델 (Model)
class Clock {
static var currentTime: (() -> String) = {
let today = Date()
let hours = Calendar.current.component(.hour, from: today)
let minutes = Calendar.current.component(.minute, from: today)
let minStr = String(format: "%02d", minutes)
let seconds = Calendar.current.component(.second, from: today)
let secStr = String(format: "%02d", seconds)
return "\(hours):\(minStr):\(secStr)"
}
}
// 뷰 컨트롤러 (ViewController)
class ClockViewController: UIViewController {
@IBOutlet weak var closureTimeLabel: UILabel!
@IBOutlet weak var observableTimeLabel: UILabel!
@IBOutlet weak var combineTimeLabel: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
startTimer()
}
// 매 초마다 시간을 업데이트
private func startTimer() {
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
}
}
}
Closure를 이용하는 방법
설명은 주석에 자세히 포함돼 있습니다.
Data Binding의 흐름에 맞춰 순서를 적어 놨으니 참고하시면 도움이 될 거예요.
번호가 없다고 생각하시고 읽으셔도 됩니다. (순서만 정리)
먼저 ViewModel을 만들어 줍시다.
// 뷰-모델 (ViewModel)
// 0. 뷰-모델 제작하기
class ClockViewModel {
// 수행될 동작을 담을 클로저 변수 생성 (어떠한 기능을 담는다)
var didChangeTime: ((ClockViewModel) -> Void)?
// 6-1. 매 초 마다 didChangeTime 클로저 호출
// 즉, 아래에서 시간을 closureTime에 저장할 때마다 didSet 호출.
// 어떠한 동작(didChangeTime)이 실행된다.
var closureTime: String {
didSet {
didChangeTime?(self)
}
}
// 생성 시 현재 시간을 담아줍니다.
init() {
closureTime = Clock.currentTime()
}
// 5. 매 초마다 호출되면서 closureTime에 시간을 담아줍니다.
func checkTime() {
closureTime = Clock.currentTime()
}
}
ViewController에서 동작을 구현
// 뷰 컨트롤러 (ViewController)
class ClockViewController: UIViewController {
@IBOutlet weak var closureTimeLabel: UILabel!
@IBOutlet weak var observableTimeLabel: UILabel!
@IBOutlet weak var combineTimeLabel: UILabel!
// 1. 뷰 모델 생성(이 때 didSet이 호출해도 동작이 실행되지 않음)
// 코드가 먼저 메모리에 올라간 뒤 viewDidLoad를 업데이트 시키키 때문.
private var viewModel = ClockViewModel()
override func viewDidLoad() {
super.viewDidLoad()
// 2. 실행 (didSet 호출은 없으므로 아직 동작은 안됨)
setBindings()
startTimer()
}
// 4. 매 초마다 시간을 업데이트 (checkTime 실행)
func startTimer() {
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.viewModel.checkTime()
}
}
// 3. 어떠한 행동은 이와같이 저장 된다.(didChangeTime에 클로저의 동작을 담아 저장 시킨다)
func setBindings() {
// 6-2. 실행이 되면 text에 시간을 업데이트 시킨다. (실행 시점은 didSet이 동작 될 타이밍에 업데이트)
// 6-3. 매 초마다 값이 업데이트 된다.
viewModel.didChangeTime = { [weak self] viewModel in
self?.closureTimeLabel.text = viewModel.closureTime
}
}
}
// didChangeTime 클로저와 Timer의 repeat으로 인해 강한 참조가 일어나므로 weak이나 unowned 사용 필수!
Observable을 이용하는 방법
변화를 관찰하며 동작하는 Observable 클래스를 만들어 줘야 한다.
여기도 설명은 주석에 포함되어 있습니다.
Data Binding의 흐름에 맞춰 순서를 적어 놨으니 참고하시면 도움이 될 거예요.
번호가 없다고 생각하시고 읽으셔도 됩니다. (순서만 정리)
먼저 관찰자(Observable)를 사용할 수 있게 만들어 줘야 합니다.
// 관찰자 (Observable)
// 모든 타입을 사용할 수 있게 제네릭으로 만들어 주자.
class Observable<T> {
typealias Listener = (T) -> Void
// 6. value가 변하면 didSet에 의해 변경된, value의 값을 갖고 listner 동작을 실행합니다.
// 7. 이 코드에선 저장된 동작을 반복 실행 (Timer로 인해 매초 반복)
var value: T? {
didSet {
self.listener?(value)
}
}
init(_ value: T?) {
self.value = value
}
// 클로저를 통해 동작을 담아줄 변수를 생성해 줍니다.
private var listener: (Listener)?
// 메서드(bind)대신 위의 클로저(listener)를 사용해도 되지만, 코드 정리를 위해 bind란 메서드를 만들어 줌
// 3-2. bind 실행 시, 클로저 안쪽의 동작들을 listner에 저장해 줍니다. (어떠한 동작을 저장)
// 여기선 TimeLabelText를 업데이트하는 동작(listener)을 저장
func bind(_ listener: @escaping Listener) {
listener(value) // 생략 가능, 여기선 시작되는 순간부터 초기값을 갖고 동작하기 위해 사용
self.listner = listener
}
}
ViewModel 정의
// 뷰-모델 (ViewModel)
class ClockViewModel {
// 만들어 놓은 Observable 객체를 생성해줍시다. (원하는 초기값으로 생성)
var observableTime: Observable<String> = Observable("Observable")
init() {
observableTime.value = Clock.currentTime()
}
// 5. value를 호출하여 시간을 업데이트되므로 didSet이 실행 됨
// observable은 String타입을 감싼 형태이므로 value로 접근하여 시간(String)값을 저장한다.
func checkTime() {
observableTime.value = Clock.currentTime()
}
}
ViewController에서 동작을 구현
// 뷰 컨트롤러 (ViewController)
class ClockViewController: UIViewController {
@IBOutlet weak var closureTimeLabel: UILabel!
@IBOutlet weak var observableTimeLabel: UILabel!
@IBOutlet weak var combineTimeLabel: UILabel!
// 1. 뷰 모델 생성(이 때 didSet이 호출해도 동작이 실행되지 않음)
// 코드가 먼저 메모리에 올라간 뒤 viewDidLoad를 업데이트 시키키 때문.
private var viewModel = ClockViewModel()
override func viewDidLoad() {
super.viewDidLoad()
// 2. 실행
setBindings()
startTimer()
}
// 4. 매 초마다 시간을 업데이트 (checkTime 실행)
func startTimer() {
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.viewModel.checkTime()
}
}
// 3-1. 처음 초기값(value)를 갖고 TimLabelText를 업데이트 시킵니다.
// 그리고 클로저 내부의 동작을 listner에 저장합니다.(순서 '3-2'에서 저장)
func setBindings() {
viewModel.observableTime.bind { [weak self] time in
self?.observableTimeLabel.text = time
}
}
}
Combine을 이용하는 방법
아직 Combine에 대해 자세히 공부하지 않아서, 대략적인 사용법만 적겠습니다.
ViewModel 정의
// 뷰-모델 (ViewModel)
import Combine
class ClockViewModel {
// @Published를 통해 데이터를 제공해 준다.
// 연산자 $를 통하여 Published에 속성에 접근할 수 있다
@Published var combineTime: String = "Combine"
func checkTime() {
combineTime = Clock.currentTime()
}
}
ViewController
// 뷰 컨트롤러 (ViewController)
import Combine
class ClockViewController: UIViewController {
@IBOutlet weak var closureTimeLabel: UILabel!
@IBOutlet weak var observableTimeLabel: UILabel!
@IBOutlet weak var combineTimeLabel: UILabel!
private var viewModel = ClockViewModel()
// 메모리 관리를 위해 취소를 자동으로 해주는 AnyCancellable 만들어 준다.
// (뷰컨이 해제되면 자동으로 cancel() 실행)
private var cancellables: Set<AnyCancellable> = []
override func viewDidLoad() {
super.viewDidLoad()
setBindings()
startTimer()
}
func startTimer() {
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.viewModel.checkTime()
}
}
func setBindings() {
viewModel.$combineTime
// compactMap을 통해 옵셔널을 제거해준다. Published<String>로 감싸져 있어 풀어준다.
.compactMap { String($0) }
// assign은 keyPath형식으로 객체 접근하여 참조하는 방식이라고 하네요.
// conbineTimeLabel의 text에 접근
.assign(to: \.text, on: conbineTimeLabel)
// 취소할 동작을 Set에 저장
.store(in: &cancellables)
}
}
전체를 합친 코드
ViewModel
// 뷰-모델 (ViewModel)
import Combine
class ClockViewModel {
var didChangeTime: ((ClockViewModel) -> Void)?
var closureTime: String {
didSet {
didChangeTime?(self)
}
}
var obseravbleTime: Observable<String> = Observable("Observable")
@Published var combineTime: String = "Combine"
init() {
closureTime = Clock.currentTime()
}
func checkTime() {
closureTime = Clock.currentTime()
obseravbleTime.value = Clock.currentTime()
combineTime = Clock.currentTime()
}
}
ViewController
// 뷰 컨트롤러 (ViewController)
import Combine
class ClockViewController: UIViewController {
@IBOutlet weak var closureTimeLabel: UILabel!
@IBOutlet weak var observableTimeLabel: UILabel!
@IBOutlet weak var combineTimeLabel: UILabel!
private var viewModel = ClockViewModel()
private var cancellable: Set<AnyCancellable> = []
override func viewDidLoad() {
super.viewDidLoad()
setBindings()
startTimer()
}
func startTimer() {
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.viewModel.checkTime()
}
}
func setBindings() {
viewModel.didChangeTime = { [weak self] viewModel in
self?.closureTimeLabel.text = viewModel.closureTime
}
viewModel.obseravbleTime.bind { [weak self] text in
self?.observableTimeLabel.text = text
}
viewModel.$combineTime
.compactMap { String($0) }
.assign(to: \.text, on: combineTimeLabel)
.store(in: &cancellable)
}
}
참고
전체 코드는 GitHub 를 참고하세요.
'UIKit > Swift' 카테고리의 다른 글
[iOS/Swift] 유닛 테스트(Unit Test) 사용 방법 (2) | 2023.02.20 |
---|---|
[iOS/Swift] 디버깅을 통해 메모리 누수를 찾아보자(Debug, Memory Leaks) (0) | 2023.02.17 |
[iOS/Swift] UINavigationBar 사용 방법 (0) | 2023.02.07 |
[iOS/Swift] UINavigationController 살펴보기 (0) | 2023.02.06 |
[iOS/Swift] 델리게이트 패턴 (Delegate) (0) | 2022.12.23 |