참고자료)
Jake Yeon - MVVM Pattern
이전에 MVC
패턴에 대해서 알아보았습니다. 이번 시간에는 MVC
패턴의 단점을 보안하기 위해서 나온 패턴인 MVVM
패턴에 대해서 예전에 학습한 내용을 기록해보겠습니다
처음 iOS 프로젝트를 진행하게 되면, Apple이 기본적으로 큰틀로 가지고 있는 MVC
패턴을 많이 사용하게 됩니다. 저도 아래 프로젝트에서 MVC
패턴을 활용했었습니다
만국박람회 (Exposition) 프로젝트 github - repository
해당 프로젝트를 진행하면서 View
와 Controller
의 의존도가 강해 ViewController
내의 코드를 Test할 수 없다는 것을 느꼈습니다. 이를 테스트하기 위해서는 View
와 비즈니스 로직을 분리할 필요성을 느꼈고 MVVM
패턴을 학습하고 사용하게 되었습니다
출처 : Raywenderich Design Patterns by Tutorials:MVVM
일단은 MVC
와 비슷한 점이 있는데 바로 Model
과 View
가 있다는 점이다. 하지만 여기에 ViewModel
이 새롭게 생겼다. 기존의 MVC
에서는 다양한 로직들을 Controller
에서 처리하다보니 Controller
가 커지게 되었고, 이를 해결하기 위해서 MVVM은 View
에 업데이트할 데이터를 ViewModel
에서 처리함으로써 기존에 Controller
에서 처리할 것들을 ViewModel
에서 처리함으로써 View
와 Model
을 서로 독립적으로 운영할 수 있게 되었다
Model
ViewModel
이 Model
을 소유하고 View
에 보여질 Data로 가공하여 갱신View
MVC
의 View
와 ViewController
가 모두 MVVM
의 View
에 속함ViewModel
에게 명령하고, ViewModel
이 업데이트 요청한 데이터를 보여준다ViewModel
View
에 실제로 보여질 데이터로 Model
의 데이터를 가공하는 역할View
에서 사용자의 상호작용에 대한 명령을 내려주면 이에 맞는 작업을 하고 View
를 변경해준다데이터 바인딩의 개념은 쉽게 말해서 Model과 UI 요소 간의 싱크를 맞춰주는 것이라 할 수 있다. 이 패턴을 통해서 View와 로직이 분리되어 있어도 한 쪽이 바뀌면 다른 쪽도 업데이트가 이루어져 데이터의 일관성을 유지할 수 있습니다. iOS에서 데이터 바인딩을 하는 방법은 다음과 같습니다
Swift, iOS 공부를 자주 마주치게 되는 반응형 프레임워크, 라이브러리인 Combine
, RxSwift
는 바인딩 과정을 간편하게 하며, 좀 더 반응적으로 사용자의 인터렉션을 대응할 수 있게 해준다.
해당 예제의 경우는
iOS Academy Swift5 MVVM
Swift 및 iOS에서 MVVM(Model View ViewModel)을 사용하는 방법-2021
Raywenderich Design Patterns by Tutorials:MVVM
를 참고 하여 작성하였습니다
해당 예제에서Observable
, WeatherViewModel
, WeatherViewController
이 각각 Model
, View
, ViewModel
를 맡게 됩니다
import UIKit
class WeatherViewController: UIViewController {
@IBOutlet weak var cityLabel: UILabel!
@IBOutlet weak var dateLabel: UILabel!
@IBOutlet weak var currentIcon: UIImageView!
@IBOutlet weak var currentSummaryLabel: UILabel!
@IBOutlet weak var forecastSummary: UITextView!
private let viewModel = WeatherViewModel()
override func viewDidLoad() {
// bind view model outputs to the views
viewModel.locationName.bind { [weak self] locationName in // ❽
self?.cityLabel.text = locationName // ❾
}
viewModel.date.bind { [weak self] date in
self?.dateLabel.text = date
}
viewModel.icon.bind { [weak self] image in
self?.currentIcon.image = image
}
viewModel.summary.bind { [weak self] summary in
self?.currentSummaryLabel.text = summary
}
viewModel.forecastSummary.bind { [weak self] forecast in
self?.forecastSummary.text = forecast
}
}
@IBAction func promptForLocation(_ sender: Any) {
...
let submitAction = UIAlertAction(
title: "Submit",
style: .default) { [unowned alert, weak self] _ in
guard let newLocation = alert.textFields?.first?.text else { return }
self?.viewModel.changeLocation(to: newLocation) // ❷, update ViewModel
}
...
}
//생략
}
ViewModel
을 View
가 소유하고 있는 것을 볼 수 있다ViewController
에 속해있는 View
들을 ViewModel
과 bind
를 수행해준다.promptForLocation(_:)
에서 ViewModel
의 메서드를 통해서 Model
를 업데이트 해준다이제 ViewModel
을 살펴보겠습니다
import Foundation
import UIKit.UIImage
public class WeatherViewModel {
private static let defaultAddress = "Anchorage, AK"
private let geocoder = LocationGeocoder()
let locationName = Observable("Loading...")
let date = Observable(" ")
let icon: Observable<UIImage?> = Observable(nil)
let summary = Observable(" ")
let forecastSummary = Observable(" ")
init() {
changeLocation(to: Self.defaultAddress)
}
func changeLocation(to newLocation: String) {
locationName.value = "Loading..."
geocoder.geocode(addressString: newLocation) { [weak self] locations in
guard let self = self else { return }
if let location = locations.first {
self.locationName.value = location.name
self.fetchWeatherForLocation(location)
return
}
self.locationName.value = "Not found"
self.date.value = ""
self.icon.value = nil
self.summary.value = ""
self.forecastSummary.value = ""
}
}
//생략
}
WeatherViewModel
에서 모든 Model
타입들이 Observable
타입으로 되어 있는 것을 볼 수 있다. Observable
클래스 정의부를 보겠습니다
import Foundation
final class Observable<T> {
typealias Listener = (T) -> Void // ❼
var listener: Listener?
var value: T {
didSet { // ❻
listener?(value) // ❻
}
}
init(_ value: T) { // ❺
self.value = value
}
func bind(listener: Listener?) {
self.listener = listener
listener?(value)
}
}
value
값이 변경될 때마다, bind
메서드를 통해서 저장된 listener
가 실행되는 것을 볼 수 있다
이렇게 View
와 ViewModel
은 서로 모르게 되는 관계가 되었습니다. 매번 시뮬레이터를 실행해서 View
를 확인하지 않아도, ViewModel
테스트를 통해서 View
의 동작을 확인할 수 있다
import XCTest
@testable import Grados
class WeatherViewModelTests: XCTestCase {
func testChangeLocationUpdatesLocationName() {
let expectation = self.expectation(
description: "Find location using geocoder")
let viewModel = WeatherViewModel()
viewModel.locationName.bind { // locationName의 초기값은 "Loading..."
if $0.caseInsensitiveCompare("Richmond, VA") == .orderedSame {
expectation.fulfill()
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
viewModel.changeLocation(to: "Richmond, VA")
}
waitForExpectations(timeout: 8, handler: nil)
}
}
MVVM
을 통해서 프로젝트를 끝나고, 이제껏 공부했던 내용을 정리하였는데 아키텍처나 디자인 패턴은 많은 개발자들의 고민이 들어가 있는 것을 또 한번 느끼게 된 계기인 것 같다. MVVM
패턴을 좀 더 효과적으로 사용하기 위해서는 Rx 혹은 Combine을 사용해야 된다고 하는데, 빠른 시일 내에 접해보고 싶습니다