[iOS] MVVM Pattern

Youngwoo Lee·2021년 11월 9일
1
post-thumbnail

MVVM 학습 계기

참고자료)
Jake Yeon - MVVM Pattern

이전에 MVC 패턴에 대해서 알아보았습니다. 이번 시간에는 MVC 패턴의 단점을 보안하기 위해서 나온 패턴인 MVVM 패턴에 대해서 예전에 학습한 내용을 기록해보겠습니다

처음 iOS 프로젝트를 진행하게 되면, Apple이 기본적으로 큰틀로 가지고 있는 MVC 패턴을 많이 사용하게 됩니다. 저도 아래 프로젝트에서 MVC 패턴을 활용했었습니다

만국박람회 (Exposition) 프로젝트 github - repository

해당 프로젝트를 진행하면서 ViewController 의 의존도가 강해 ViewController 내의 코드를 Test할 수 없다는 것을 느꼈습니다. 이를 테스트하기 위해서는 View와 비즈니스 로직을 분리할 필요성을 느꼈고 MVVM 패턴을 학습하고 사용하게 되었습니다


MVVM Design Pattern

출처 : Raywenderich Design Patterns by Tutorials:MVVM

일단은 MVC와 비슷한 점이 있는데 바로 ModelView가 있다는 점이다. 하지만 여기에 ViewModel이 새롭게 생겼다. 기존의 MVC에서는 다양한 로직들을 Controller에서 처리하다보니 Controller가 커지게 되었고, 이를 해결하기 위해서 MVVM은 View에 업데이트할 데이터를 ViewModel에서 처리함으로써 기존에 Controller에서 처리할 것들을 ViewModel에서 처리함으로써 ViewModel을 서로 독립적으로 운영할 수 있게 되었다

Model

  • 데이터를 캡슐화하여 정의
  • ViewModelModel을 소유하고 View에 보여질 Data로 가공하여 갱신

View

  • 이전의 MVCViewViewController가 모두 MVVMView에 속함
  • 화면에 출력하는 책임과 사용자와의 상호작용에 대응하는 책임을 가지고 있음
  • 사용자의 상호작용에 맞는 동작을 ViewModel에게 명령하고, ViewModel이 업데이트 요청한 데이터를 보여준다

ViewModel

  • View에 실제로 보여질 데이터로 Model의 데이터를 가공하는 역할
  • View에서 사용자의 상호작용에 대한 명령을 내려주면 이에 맞는 작업을 하고 View를 변경해준다

Data Binding

데이터 바인딩의 개념은 쉽게 말해서 Model과 UI 요소 간의 싱크를 맞춰주는 것이라 할 수 있다. 이 패턴을 통해서 View와 로직이 분리되어 있어도 한 쪽이 바뀌면 다른 쪽도 업데이트가 이루어져 데이터의 일관성을 유지할 수 있습니다. iOS에서 데이터 바인딩을 하는 방법은 다음과 같습니다

  • KVO
  • Delegation
  • Functional Reactive Programming
  • Property Observer

Swift, iOS 공부를 자주 마주치게 되는 반응형 프레임워크, 라이브러리인 Combine, RxSwift는 바인딩 과정을 간편하게 하며, 좀 더 반응적으로 사용자의 인터렉션을 대응할 수 있게 해준다.

Data Binding (Property Observer) 적용

해당 예제의 경우는
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
    }
    ...
  }
   
  //생략
}
  • MVVM의 관계도인 맨 위 그림 처럼, ViewModelView가 소유하고 있는 것을 볼 수 있다
  • ViewController에 속해있는 View 들을 ViewModelbind 를 수행해준다.
  • 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 가 실행되는 것을 볼 수 있다

Testable ViewModel

이렇게 ViewViewModel 은 서로 모르게 되는 관계가 되었습니다. 매번 시뮬레이터를 실행해서 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을 사용해야 된다고 하는데, 빠른 시일 내에 접해보고 싶습니다

profile
iOS Developer Student

0개의 댓글