반응형

[최종 수정일 : 2018.05.02]

원문 : https://www.raywenderlich.com/192471/design-patterns-by-tutorials-mvvm

Design Patterns by Tutorials: MVVM

이 글은 Raywenderlich의 디자인 패턴 튜토리얼(Design Patterns by Tutorials)의 10장 Model-View-ViewModel을 발췌한 내용입니다. 디자인 패턴(Design patterns)은 어떤 언어나 플랫폼에서 개발하던간에 매우 유용합니다. 모든 개발자는 구현할 줄 알아야 하고, 가장 중요한 적용시기를 알아야 합니다. 이 책에서 배우게 될것입니다.

Model-View-ViewModel(MVVM)은 객체를 3개의 다른 그룹으로 분리하는 구조를 가진 디자인 패턴 입니다.

  • 모델(Models) : 앱 데이터를 가지고 있습니다. 일반적으로 구조체나 간단한 클래스 입니다.
  • 뷰(Views) : 화면에 시각적인 요소와 컨트롤들을 보여줍니다. 일반적으로 UIView의 하위 클래스 입니다.
  • 뷰 모델(View Models) : 모델(model)정보를 뷰에 표시할수 있는 값으로 변환합니다. 일반적으로 클래스를 사용하기에, 참조(references)로 전달될수 있습니다.


이 패턴이 익숙한가요? 네, 이 패턴은 Model-View-Controller(MVC)와 매우 비슷합니다. 상단의 클래스 다이어그램에 뷰 컨트롤러(view controller)가 포함되어 있는 것을 주의하세요. 뷰 컨트롤러(view controller)는 MVVM에 존재하지만, 역할은 최소화됩니다.

언제 사용해야 하나요?(When Should You Use It?)

이 패턴은 뷰에 대해 다른 표현으로 모델을 변경할 필요가 있을때 사용합니다. 예를들어, Date를 날짜-포멧(date-formatted) StringDicimal을 통화-포멧(currency-formatted) String으로 변환하거나 다른 많은 유용한 변환을 하기위해 뷰 모델을 사용할 수 있습니다.

이 패턴은 특히 MVC를 보완합니다. 뷰 모델 없이, 뷰 컨트롤러에서 모델을 뷰로 변형하는 코드를 넣을수 있습니다. 하지만, 뷰 컨트롤러는 이미 꽤 많은(quite a bit) 일을 하고 있습니다: viewDidLoad와 뷰 라이프사이클(lifecycle) 이벤트를 처리하며, IBActions를 사용하여 뷰 콜백(callback)을 처리하고 몇가지 다른 작업도 있습니다.

이로인해 개발자들은 MVC: Massive View Controller라는 농담을 합니다.


어떻게 하면 뷰 컨트롤러가 무거워지는(overstuffing: 지나치게 채워지는) 것을 피할수 있을까요? 그것은 쉽습니다 - MVC 패턴 이외의 다른 패턴을 사용하는 것입니다. MVVM은 모델을 뷰로 전환하는데 필요한 무거운 뷰컨트롤러를 가볍게 만드는 훌륭한 방법입니다.

Playground 예제(Playground Example)

예제 다운로드

starter디렉토리에서 IntermediateDesignPatterns.xcworkspace를 열고나서 MVVM페이지를 열어주세요.

예제에서, 앱의 일부분인 애완동물을 가져오는 Pet View를 만들것입니다. 다음에 오는 코드예제(Code Example)추가하세요.

import PlaygroundSupport
import UIKit

// MARK: - Model
public class Pet {
  public enum Rarity {
    case common
    case uncommon
    case rare
    case veryRare
  }
  
  public let name: String
  public let birthday: Date
  public let rarity: Rarity
  public let image: UIImage
  
  public init(name: String,
              birthday: Date,
              rarity: Rarity,
              image: UIImage) {
    self.name = name
    self.birthday = birthday
    self.rarity = rarity
    self.image = image
  }
}

여기에서, Pet이름의 모델을 정의합니다. 모든 애완동물(pet)은 이름(name), 생일(birthday), 희귀성(rarity), 이미지(image)을 가지고 있습니다. 이러한 속성들을 뷰에 보여주는게 필요하지만, birthday와 rarity은 직접적으로 보여지지 않습니다. 먼저 뷰 모델로 변환하는게 필요할 것입니다.

다음으로, 플레이그라운드의 끝부분에 다음 코드를 추가합니다.

// MARK: - ViewModel
public class PetViewModel {
  
  // 1
  private let pet: Pet
  private let calendar: Calendar
  
  public init(pet: Pet) {
    self.pet = pet
    self.calendar = Calendar(identifier: .gregorian)
  }
  
  // 2
  public var name: String {
    return pet.name
  }
  
  public var image: UIImage {
    return pet.image
  }
  
  // 3
  public var ageText: String {
    let today = calendar.startOfDay(for: Date())
    let birthday = calendar.startOfDay(for: pet.birthday)
    let components = calendar.dateComponents([.year],
                                             from: birthday,
                                             to: today)
    let age = components.year!
    return "\(age) years old"
  }
  
  // 4
  public var adoptionFeeText: String {
    switch pet.rarity {
    case .common:
      return "$50.00"
    case .uncommon:
      return "$75.00"
    case .rare:
      return "$150.00"
    case .veryRare:
      return "$500.00"
    }
  }
}

위의 코드에서 무엇을 했는지 봅시다.

  1. 우선, pet와 calender 비공개 속성을 생성하였고, init(pet:)에서 설정됩니다.
  2. 다음으로, name과 image에 대해 두개의 계산(computed) 속성을 선언하였으며, 각각의 애완동물의 name과 image을 반환합니다. 이는 가장 단순하게 변환을 수행할 수 있습니다 : 수정하지 않고 값을 반환하기. 만약 모든 애완동물의 이름에 접두사(prefix)를 추가하는 설계로 변경하길 원한다면, 여려분은 여기에서 name을 수정하여 쉽게 처리할 수 있습니다.
  3. 다음으로, 다른 계산(computed) 속성인 ageText를 선언하였으며, 오늘 날짜와 애완동물의 birthday간의 차이를 계산하기 위해 calender 를 사용하였고, 바로 뒤에 "years old" 문자열(string)을 반환합니다. 다른 문자열 포멧화를 실행하지 않고, 뷰에 이 값을 직접 보여주는게 가능할 것입니다.
  4. 마지막으로, 마지막 계산(computed) 속성인 adoptionFeeText을 생성하였으며, 희귀성(rarity)을 기반으로 애완동물의 비용을 결정하였습니다. 이것은 다시한번 String로 반환하며, 직접 표시할 수 있습니다.

이제 애완동물의 정보를 표시하기 위해 UIView가 필요합니다. 플레이그라운드의 끝부분에 다음 코드를 추가하세요.

// MARK: - View
public class PetView: UIView {
  public let imageView: UIImageView
  public let nameLabel: UILabel
  public let ageLabel: UILabel
  public let adoptionFeeLabel: UILabel
  
  public override init(frame: CGRect) {
    
    var childFrame = CGRect(x: 0, y: 16,
                            width: frame.width,
                            height: frame.height / 2)
    imageView = UIImageView(frame: childFrame)
    imageView.contentMode = .scaleAspectFit
    
    childFrame.origin.y += childFrame.height + 16
    childFrame.size.height = 30
    nameLabel = UILabel(frame: childFrame)
    nameLabel.textAlignment = .center
    
    childFrame.origin.y += childFrame.height
    ageLabel = UILabel(frame: childFrame)
    ageLabel.textAlignment = .center
    
    childFrame.origin.y += childFrame.height
    adoptionFeeLabel = UILabel(frame: childFrame)
    adoptionFeeLabel.textAlignment = .center
    
    super.init(frame: frame)
    
    backgroundColor = .white
    addSubview(imageView)
    addSubview(nameLabel)
    addSubview(ageLabel)
    addSubview(adoptionFeeLabel)
  }
  
  @available(*, unavailable)
  public required init?(coder: NSCoder) {
    fatalError("init?(coder:) is not supported")
  }
}

여기에서, 4개의 하위 뷰를 가진 PetView를 만들었습니다: 애완동물의 image를 보여주는 하나의imageView와 애완동물의 이름, 나이와 적용된 비용을 표시하기 위해 3개의 다른 라벨(label)입니다. init(frame:)으로 각 뷰를 생성하고 위치를 지정합니다. 마지막으로, 지원되지 않는 것을 나타내기 위해 init?(coder:)에서 fatalError를 던집니다(throw).

이 클래스는 이미 수행할 준비가 되었습니다. 플레이그라운드의 끝부분에 다음 코드를 추가하세요.

// MARK: - Example
// 1
let birthday = Date(timeIntervalSinceNow: (-2 * 86400 * 366))
let image = UIImage(named: "stuart")!
let stuart = Pet(name: "Stuart",
                 birthday: birthday,
                 rarity: .veryRare,
                 image: image)

// 2
let viewModel = PetViewModel(pet: stuart)

// 3
let frame = CGRect(x: 0, y: 0, width: 300, height: 420)
let view = PetView(frame: frame)

// 4 
view.nameLabel.text = viewModel.name
view.imageView.image = viewModel.image
view.ageLabel.text = viewModel.ageText
view.adoptionFeeLabel.text = viewModel.adoptionFeeText

// 5
PlaygroundPage.current.liveView = view

다음은 무엇을 했는지 봅시다.

  1. 우선, stuart이름을 가진 새로운 pet을 만들었습니다.
  2. 다음으로, stuart을 사용하여 viewModel을 만들었습니다.
  3. 다음으로, iOS에서의 공통 frame크기를 전달하여 view를 만들었습니다.
  4. 다음으로, viewModel을 사용하여 view의 하위 뷰들을 구성하였습니다.
  5. 마지막으로, PlaygroundPage.current.liveView에 view를 설정하여 표준 Assistant editor에서 랜더링하기 위해 플레이그라운드에 알려줍니다.

이러한 동작을 보려면, View -> Assistant Editor -> Show Assistant Editor를 선택하여 랜더링된 view에 체크하세요.


Stuart는 어떤 종류의 애완동물인가요? 그것은 물론 쿠키 괴물입니다. 그것들은 매우 귀합니다(very rare).

이 예제에 대해 마지막 개선사항이 있습니다. PetViewModel에 대해 클래스 닫는 중괄호(closing curly brace) 다음에 확장(extension)을 추가하세요.

extension PetViewModel {
  public func configure(_ view: PetView) {
    view.nameLabel.text = name
    view.imageView.image = image
    view.ageLabel.text = ageText
    view.adoptionFeeLabel.text = adoptionFeeText
  }
}

뷰를 구성하기 위해 인라인(inline) 대신에 뷰 모델(view model)을 사용하는 메소드를 사용할 수 있습니다.

이전에 입력한 다음 코드를 찾습니다.

// 4 
view.nameLabel.text = viewModel.name
view.imageView.image = viewModel.image
view.ageLabel.text = viewModel.ageText
view.adoptionFeeLabel.text = viewModel.adoptionFeeText

그리고나서 다음 코드를 교체합니다.

viewModel.configure(view)

모든 뷰의 구성 로직을 뷰 모델로 넣는 것은 깔끔한 방법입니다. 실제로 이 작업을 수행 할수도 있고 하지 않을 수도 있습니다. 뷰 하나로 뷰 모델을 사용하는 경우에, 뷰 모델에 구성 메소드를 넣는것이 좋습니다. 하지만 두개 이상의 뷰가로 뷰 모델을 사용하는 경우에, 뷰 모델에 모든 로직을 넣으면 혼란스러울 수 있습니다. 이 경우에 각각 뷰에 별도의 구성 코드를 갖는 것이 더 간단할 수 있습니다.

출력 결과는 이전과 동일해야 합니다.

스튜어트(Stuart), 쿠키를 나눠줄꺼야? 아니? 아.. 어서…!

무엇을 주의해야 하나요?(What Should You Be Careful About?)

앱에서 모델을 뷰로(model to view) 전환하기가 많이 필요한 경우에 MVVM은 잘 동작합니다. 하지만, 모든 객체가 모델, 뷰, 뷰모델의 카테고리로 깔끔하게 맞춰지는것은 아닙니다. 그 대신에, 다른 디자인 패턴과 함께 MVVM을 사용해야 합니다.

더욱이(Furthermore), 앱을 처음만들때에는 MVVM이 유용하지 않을수도 있습니다. MVC가 더 좋은 출발점이 될수 있습니다. 앱의 요구사항이 바뀌는 것처럼, 바뀌는 요구사항에 따라 다른 디자인 패턴을 선택해야 할 것입니다. MVVM이 정말로 필요할때 앱에 적용할 수 있습니다.

바뀌는 것을 두려화하지 마세요. - 대신, 미리 계획하세요.

튜토리얼 프로젝트(Tutorial Project)

이 섹션(section) 전체에서, Coffee Quest라는 앱에 기능을 추가할 것입니다.

starter디렉토리에서, (.xcodeproj가 아닌) CoffeeQuest/CoffeeQuest.xcworkspace를 Xcode로 엽니다.

이 앱은 Yelp가 제공하는 근처 커피숍을 표시합니다. Yelp 검색을 위한 라이브러리 YelpAPI를 CocoaPods으로 사용합니다. 이전에 CocoaPods을 사용해보지 않았어도 괜찮습니다. 필요한 모든 것은 시작 프로젝트에 포함되어 있습니다. 기억해야하는 유일한 것은 CoffeeQuest.xcodeproj 파일 대신에 CoffeeQuest.xcworkspace을 여는 것입니다.

주의 
CocoaPods에 대해 더 자세히 배우길 원한다면, http://bit.ly/cocoapods-tutorial에 있는 무료 튜토리얼을 읽어보세요.

앱을 실행하기 전에, 먼저 Yelp API 키를 등록해야 합니다.

웹 브라우져에서 다음 URL로 이동합니다.

계정이 없으면 계정을 만들거나 로그인 하세요. 다음으로, Create App양식에 다음을 입력하세요. (또는 이전에 앱을 만든경우에는 기존 API Key를 사용합니다 ):

  • 앱 이름(App Name) : Coffee Quest
  • 앱 웹사이트(App Website) : (공백으로 남겨둡니다)
  • 용도(Industry) : Business를 선택
  • 회사(Company) : (공백으로 남겨둡니다)
  • 연락처 이메일(Contact Emaril) : (여러분의 이메일 주소)
  • 설명(Description) : Coffee search app
  • Yelp API 약관을 읽고 동의함(I have read and accepted the Yelp API Terms) : 체크

양식은 다음처럼 보일것입니다.


게속하기 위해 Create New App을 누르고, 성공 메시지를 볼수 있습니다.


여러분의 API key를 복사하고 Xcode의 CoffeeQuest.xcworkspace로 돌아갑니다.

File hierarchy에서 APIKeys.swift을 열고, 표시된 곳에 API 키를 붙여넣기(paste your API key) 하세요.

빌드하고 앱을 실행합니다.


시뮬레이터의 기본 위치는 샌프란시스코로 설정되어 있습니다. 그 도시에 많은 커피숍이 있습니다.

주의
Debug -> Location을 클릭하고 다른 옵션을 선택하여 시뮬레이터의 위치를 변경할 수 있습니다.

이 지도의 핀(pins)은 따분합니다(boring). 어떤 커피숍이 실제 좋았는지 보여주는게 더 좋지 않을까요?

File hierarchy에서 MapPin.swift을 여세요. MapPin은 좌표(coordinate), 제목(title), 평점(rating)을 가지며, 지도로 표시할수 있는 것으로 변환합니다. 이 말이 익숙하시나요? 맞습니다. 그것이 실제. 뷰 모델입니다!

먼저, 이 클래스에 더 나은 이름을 붙일 필요가 있습니다. 파일의 상단에 있는 MapPin에서 오른쪽 클릭하고 Refactor -> Rename을 선택합니다.


새 이름 BusinessMapViewModel을 입력하고 Rename을 클릭합니다. 이렇게 하면 클래스 이름과 파일 계층도 내의 파일 이름 모두 이름이 변경될 것입니다.

다음으로, File hierarchy에서 Model그룹을 선택하고 이름을 변집하기 위해 Enter를 누릅니다. ViewModels로 이름을 변경합니다.

마지막으로, 노란색 CoffeeQuest그룹을 클릭하고 Sort by name을 선택합니다. 궁극적으로, 여러분의 File hierarchy는 다음 처럼 보일 것입니다.


BusinessMapViewModel은 MapKit에서 제공되는 일반적인 바닐라 핀(plain-vanilla pins) 대신에, 재미있는 맵 주석(annotations)을 표시하려면 몇가지 속성이 필요 합니다. .

BusinessMapViewModel안쪽에, 기존에 하나 있는 속성 뒤에 다음에 오는 속석을 추가하세요 : 현재 컴파일러 오류는 무시하세요

public let image: UIImage
public let ratingDescription: String

기본 핀 이미지 대신에 image를 사용하고 사용자가 주석(annotation)을 탭 할때마다 부 제목으로 ratingDescription을 표시할 것입니다.

다음으로, init(coordinate:name:rating:)을 교체합니다.

public init(coordinate: CLLocationCoordinate2D,
            name: String,
            rating: Double,
            image: UIImage) {
  self.coordinate = coordinate
  self.name = name
  self.rating = rating
  self.image = image
  self.ratingDescription = "\(rating) stars"
}

이 초기화를 사용하여 image를 받고 rating 으로 ratingDescription을 설정합니다.

MKAnnotation확장의 끝에 다음에 오는 계산 속성(computed property)를 추가하세요.

public var subtitle: String? {
  return ratingDescription
}

이것은 하나가 선택될때 주석 설명에 부 제목(subtitle)으로 ratingDescription을 사용하는 것을 말합니다.

이제 컴파일러 오류를 수정할 수 있습니다. File hierarchy에서 ViewController.swift를 열고 파일의 끝까지 스크롤 합니다.

다음에 오는 것으로 addAnnotations()를 교체합니다.

private func addAnnotations() {
  for business in businesses {
    guard let yelpCoordinate = 
      business.location.coordinate else {
        continue
    }

    let coordinate = CLLocationCoordinate2D(
      latitude: yelpCoordinate.latitude,
      longitude: yelpCoordinate.longitude)

    let name = business.name
    let rating = business.rating
    let image: UIImage
    
    // 1
    switch rating {
    case 0.0..<3.5:
      image = UIImage(named: "bad")!
    case 3.5..<4.0:
      image = UIImage(named: "meh")!
    case 4.0..<4.75:
      image = UIImage(named: "good")!
    case 4.75...5.0:
      image = UIImage(named: "great")!
    default:
      image = UIImage(named: "bad")!
    }
    
    let annotation = BusinessMapViewModel(
      coordinate: coordinate,
      name: name,
      rating: rating,
      image: image)
    mapView.addAnnotation(annotation)
  }
}

이 메소드는 어떤 image를 사용할지 결정하기 위해 rating으로 switch 하는 것을 제외하면, 이전과 비슷합니다(//을 보세요). 고품질의 카페인은 개발자에게 개박하(catnip)과 같으며, bad처럼 3.5 보다 작은 별에 라벨을 붙입니다. 여러분은 높은 수준이어야 합니다. 그렇죠? ;]

앱을 빌드하고 실행합니다. 이제 똑같이 보일것입니다.. 똑같죠? 왜 그렇죠?

지도는 image에 대해서 알지 못합니다. 오히려, 사용자정의 핀 주석 이미지를 제공하기 위해 델리게이터 메소드를 재정의해야 합니다. 그것이 이전과 똑같이 보이는 이유입니다.

addAnnotations() 바로 뒤에 다음에 오는 메소드를 추가하세요.

public func mapView(_ mapView: MKMapView,
                    viewFor annotation: MKAnnotation)
                    -> MKAnnotationView? {
  guard let viewModel = 
    annotation as? BusinessMapViewModel else {
      return nil
  }

  let identifier = "business"
  let annotationView: MKAnnotationView
  if let existingView = mapView.dequeueReusableAnnotationView(
    withIdentifier: identifier) {
    annotationView = existingView
  } else {
    annotationView = MKAnnotationView(
      annotation: viewModel,
      reuseIdentifier: identifier)
  }

  annotationView.image = viewModel.image
  annotationView.canShowCallout = true
  return annotationView
}

이것은 단순히 우리의 BusinessMapViewModel 객체중 하나인 주어진 주석에 대해 올바른 이미지를 보여주는 MKAnnotationView를 생성합니다.

빌드하고 실행하면 사용자정의 이미지를 볼수 있을것입니다. 하나를 탭하고, 커피숍 이름과 평점을 볼수 있을것입니다.


대부분의 샌프란시스코 커피 숍은 실제 별 4개 이상인 것으로 나타났고, 여러분은 한번에(glance) 최고의 상점을 찾을수 있습니다.

여기에서 어디로 가야하나요?(Where to Go From Here?)

여러분은 이 챕터에서 MVVM 패턴에 대해 배웠습니다. 이것은 무거운 뷰 컨트롤러 증후군과 싸우고 모델을 뷰로(model to view) 변환하는 코드를 관리하는데 도움이되고 훌륭한 패턴입니다.

하지만, 무거운 뷰 컨트롤러 문제를 완전히 해결하지는 못합니다. 뷰 컨트롤러가 뷰 모델을 만들기 위해 rating을 switch하는것이 이상해 보이지 않나요? 새로운 case또는 전혀 다른 뷰 모델을 사용하고 싶으면 어떻게 될까요? 이를 처리하기 위해서 다른 패턴을 사용해야 합니다 : 팩토리(Factory) 패턴

이 튜토리얼에서 배운것이 즐거웠다면, 튜토리얼로 디자인 패턴(the Design Patterns by Tutorials) 완성하기 책을 보세요.

디자인 패턴은 매우 유용하며, 어떤 언어나 플랫폼을 개발하든 관계 없습니다. 작업에 알맞는 패턴을 사용하면 시간을 절약할 수 있으며, 팀의 유지보수 작업을 줄이고 궁극적으로는 적은 노력으로 더 멋진것을 만들수 있습니다. 모든 개발자는 반드시 디자인 패턴과, 언제 어떻게 적용해야 하는지 알아야 합니다. 그것을 튜토리얼로 디자인 패턴(the Design Patterns by Tutorials) 책에서 배우게 될것입니다.

MVC, Delegate, Strategy와 같은 기본 빌드 패턴 에서 Factory, Prototype, Multicase Delegate 패턴과 같은 더 고급 패턴까지, 그리고 덜 일반적이지만 믿을수 없을만큼 여전히 유용한 Flyweight, Command, Chain of Responsibility와 같은 패턴들로 마무리합니다.


반응형
Posted by 까칠코더
,