apple/DesignPattern & Architecture

[Swift] MVVM Pattern

lgvv 2022. 4. 26. 14:15

✅ MVVM Pattern

 

아래의 문서를 구입하여 영어 문서를 번역하고 이해한 것을 바탕으로 글을 작성하고 있습니다.

https://www.raywenderlich.com/books/design-patterns-by-tutorials/v3.0/chapters/10-model-view-viewmodel-pattern

 

Design Patterns by Tutorials, Chapter 10: Model-View-ViewModel Pattern

Use this pattern when you need to transform models into another representation for a view. This pattern compliments MVC especially well. You’ll embark on a new project — CoffeeQuest — to help you find the best coffee shops around.

www.raywenderlich.com

 

 

 

 

MVVM Pattern

MVVM Pattern

 

 

MVVM(Model - View- ViewModel)은 세 개의 개별 그룹으로 분리하는 구조적 디자인 패턴입니다.

🟠 Model은 앱 데이터를 보유합니다. 일반적으로 구조체 혹은 간단한 클래스입니다.

🟠 View는 화면에 시각적 요소와 컨트롤을 표시합니다. 일반적으로 UIView를 상속받아 사용하는 하위 클래스입니다.

🟠 ViewModel은 모델 정보를 view에 표시할 수 있는 값으로 변환합니다. 일반적으로 클래스라서 reference를 통해서 전달할 수 있습니다.

 

이 패턴은 아마 친숙하게 들릴거에요! MVC 패턴과 매우 유사하기 때문입니다. 위의 사진의 클래스 다이어크램에는 뷰 컨트롤러가 포함되어 있습니다. MVVM에 존재하지만 그 역할은 최소화됩니다.

 

이번 장에서는 viewModels을 구현하고 이를 포함하도록 프로젝트를 구성하는 방법을 배웁니다. viewModels이 하는 일에 대한 간단한 예제로 시작한 다음 MVC를 가져와 MVVM으로 리팩토링 합니다!

 

✅ When should you use it?

view에 대한 다른 표현으로 model을 변환해야할 때 이 패턴을 사용하세요! 예를 들어 viewModel을 사용하여 Date를 date-formatted 문자열로 변환하거나 십진수를 currecncy-formatted로 변환하거나 등 여러 유용한 변환을 수해앟ㄹ 수 있습니다.

(개인해석: model의 데이터타입을 viewModel에서 내가 원하는 형식으로 가공하여 view에 뿌려준다는 의미, 조금 더 쉽게 말해서 비니지스 로직 부분이 여기에 포함된다는 의미일 수도 있습니다.)

 

이 패턴은 MVC를 잘 보완합니다. viewModel이 없으면 viewController에 model간 변환코드(비지니스 로직이나 데이터 가공 등) 를 넣을 수 있습니다. 하지만 viewController는 이미 많은 작업을 수행하고 있습니다. viewDidLoad 및 다른 view의 lifecycle 이벤트 IBAction을 통해 view를 핸들링하는 콜백 그리고 몇몇 태스크 또한 수행합니다.

 

이것은 개발자들이 농담으로 "MVC: Massive View Controller"라고 부르는 것으로 이어집니다.

하는 일이 너무 많아서 뚱뚱해진다 !_!

 

그렇다면 viewController를 과도하게 채우는 것을 어떻게 피할 수 있을까요? 그것은 아주 쉽습니다!! 바로 MVC이외에 다른 패턴을 사용합니다. MVVM은 model에서 view로 변환이 필요한 대규모 viewController를 줄이는 좋은 방법입니다.

 

✅ Playgound example

예를 들어 애완 동물을 입양하는 앱의 일부로 "Pet View"를 만들 것입니다. 아래 코드를 추가하세여!

 

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 모델을 정의합니다. 모든 애완 동물에는 name, birthday, rarity, image가 있습니다. 이러한 속성을 view에 뿌려줘야 하지만 V직접 보여줄 수는 없습니다. 이 프로퍼티들은 viewModel을 우선 거쳐 변환되어야 합니다.

 

아래 코드를 추가하세요!

 

// 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. private라는 프로퍼티로 pet과 calendar를 호출하고 생성합니다. 그리고 init(pet:)을 통해서 세팅합니다.

2. 다음으로 name과 image를 위한 연산 프로퍼티를 선언하빈다. 여기서 pet의 name과 image를 각기 리턴합니다.

이것은 수정없이 변환할 수 있는 가장 심플한 변환 방법입니다. 만약 모든 pet's의 이름에 prefix(접두사)를 추가하도록 디자인을 변경하도록 원한다면 연산 프로퍼티로 name이 선언된 부분을 바꿔주면 됩니다!! 

3. 다음으로는 ageText를 연산 프로퍼티로 선언합니다. 그런 다음 ageText를 또 다른 계산 속성으로 선언했습니다. 여기서 calendar를 사용하여 오늘과 애완동물 탄생일 사이의 차이를 계산하여 이를 suffix로 "years old"를 붙여서 String으로 변환한 후 birthday를 반환합니다. view에서 다른 문자열 형식 변환을 수행할 필요 없이 이제 우리는 바로 사용할 수 있습니다.

4. 마지막으로, 최종 계산된 값으로 연산 프로퍼티로 adoptionFeeText를 생성했습니다. 여기에서 pet의 adoption cost를 기반으로 rarity를 결정합니다. 다시 말하지만 String으로 변환하여 view에서 가공없이 직접적으로 사용할 수 있습니다.

 

이제 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")
  }
}

여기에서 perView와 4개의 subView를 생성합니다. 애완동물의 이름, 나이, 입양비를 표시하기 위한 3개의 다른 UILabel과 imageView를 표시하는 것들입니다!

 

init(frame: )안에서 각각의 뷰를 포지셔닝하고 생성할 수 있습니다. 마지막으로 fatalError를 발생시켜 init?(coder: )가 발생되지 않음을 나타냅니다.

 

이제 이 클래스를 실행할 준비가 완료되었습니다. 아래의 코드를 추가하세요!

// 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 view에서 공통 frame을 전달하여 view에 전달합니다.

4. 다음으로 viewModel을 사용하여 view를 만듭니다.

5. 마지막으로 playgroundPage.current.liveView가 view를 보여줍니다. 이것은 playground의 렌더링을 도와주는 표준입니다.

 

실제로 렌더링 된 view

 

 

Stuart는 정확히 어떤 종류의 애완 동물입니다. 물론 쿠키 괴물입니다. 그들은 매우 희귀합니다!

 

이 예시에서 할 수 있는 한 가지 최종 개선사항이 있습니다. PetViewModel 클래스에 닫는 중괄호 뒤에 extension을 추가합니다.

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

이 방법을 사용하면 인라인으로 수행하는 대신에 viewModel을 사용하여 view를 구성합니다.

 

이전에 입력한 코드를 교체합니다!!

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

// ✅ 위의 코드를 아래의 코드로 교체합니다.

viewModel.configure(view)

이것은 viewModel에 view의 configuration을 넣는 가장 깔끔한 방법입니다. 실제로 이것을 하기 원할 수도 있고 아닐 수도 있습니다.

하나의 view와 함께 viewModel만 사용하는 경우에는 configure메소드를 넣는 것이 유용할 수 있습니다. 다만, 둘 이상의 veiw가 있는 경우에는 viewModel에 모든 logic을 넣으면 복잡해집니다. 별도로 구성 코드를 갖는 것이 더 간단할 수 있습니다.

 

물론 아웃풋은 이전과 같아야 합니다.

 

 

✅ What should you be careful about?

MVVM은 앱에 model에서 view로의 변환이 많이 필요한 경우 잘 작동합니다. 다만 모든 객체가 model, view 또는 viewModel의 범주에 깔끔하게 완벽하게 들어 맞지는 않습니다. 대신 다른 디자인 패턴과 함께 MVVM을 사용해야만 합니다.

 

또한 MVVM은 처음 앱을 만들 때 그다지 유용하지 않을수도 있습니다. MVC가 더 나은 출발점이 될 수 있습니다. 앱의 요구 사항이 변경되면 변화하는 요구 사항에 따라 다른 디자인 패턴을 선택해야 할 수 있습니다. MVVM은 나중에 정말로 필요할 때 앱 사용 기간에 도입해도 됩니다.

 

Don’t be afraid of change — instead, plan ahead for it.

 

✅ Tutorial project

이 섹션 전체에서는 Coffee Quset라는 앱에 기능을 추가합니다.

 

이 앱은 Yelp에서 제공하는 주변 커피숍을 표시합니다. CocoaPods을 사용하여 YelpAPI Yelp 검색을 위한 도우미 라이브러리를 가져옵니다. 이전에 CooaPods을 사용하여 사용한적 없더라도 이미 프로젝트에 전부 포함되어 있습니다.

 

https://github.com/lgvv/DesignPattern/tree/main/model-view-viewmodel-pattern/CoffeeQuest

 

GitHub - lgvv/DesignPattern: ✨ 디자인 패턴을 공부합니다!

✨ 디자인 패턴을 공부합니다! Contribute to lgvv/DesignPattern development by creating an account on GitHub.

github.com

 

위의 프로젝트이다!! 코드가 mapView를 사용했지만 아주 깔끔한 MVVM패턴이다.

다만 더 고민해 봐야할 것은 여러개의 view에서 viewModel을 함꼐 사용할 때 어떻게 처리할지 고민해보자.

그리고 guard와 if let에서 사용법에 대한 좀 좋은 예시가 있어서 코드를 꼭 보쟈!!

 

 

 

 

✅ Key points

이 장에서는 MVVM 패턴에 대해서 공부했습니다. 핵심 사항은 다음과 같습니다.

1. MVVM은 viewController를 슬림하게 만들어 작업하기 쉽게 만드빈다. 따라서 "Massive ViewController" 문제를 해결합니다.

2. viewModel은 객체를 가져와 viewController에 전달하고 view에 표시할 수 있는 형태로 변환(가공)하는 클래스입니다. 또는 Date, Demical과 같은 타입은 view에서 직접적으로 사용할 수 있게 String 또는 어떤 타입으로 변환해주고 이는 UILabel과 UIView에서 실제로 보여줄 수 있습니다.

3. 하나의 view만 있는 viewModel을 사용하는 경우 모든 구성을 viewModel에 넣는 것이 좋습니다. 그러나 둘 이상의 view를 사용하는 경우 viewModel에 모든 논리를 넣으면 viewModel이 복잡해 질 수 있습니다. 구성 코드를 각 view로 분리하는게 이 경우에는 더 간단할 수 있습니다. (위의 내용 중 configuration을 사용하는 내용입니다)

4. 앱이 작은 경우에는 MVC가 더 좋은 출발점이 될 수 있습니다. 앱의 요구사항이 변경되면 변화하는 요구 사항에 따라 다른 디자인 패턴을 선택해야할 수 있습니다.

'apple > DesignPattern & Architecture' 카테고리의 다른 글

[Swift] Adapter Pattern  (2) 2022.05.13
[Swift] Factory Pattern  (0) 2022.05.08
[Swift] Builder Pattern  (0) 2022.04.22
[Swift] Observer Pattern  (0) 2022.04.19
[Swift] Memento Pattern  (0) 2022.04.13