iOS Architecture Patterns 뿌시기: Model-View-ViewModel (MVVM) 보기

28 분 소요

Thanks to Radu Dan for allowing the translation.

Reference: Battle of the iOS Architecture Patterns: A Look at Model-View-ViewModel (MVVM)

본 글은 위의 내용에 대한 번역본입니다.

순서는 다음과 같습니다.

  1. MVC
  2. MVVM
  3. MVP
  4. MVP-C
  5. VIPER
  6. VIP

iOS Architecture Patterns 뿌시기: Model-View-ViewModel (MVVM) 보기

유명한 아키텍처 패턴을 사용하여 실제 축구선수 iOS 게임을 빌드 해봅시다.

아키텍처 시리즈 — Model View ViewModel (MVVM)

아키텍처 시리즈 — Model View ViewModel (MVVM)

동기 (Motivation)

iOS 앱을 개발하기 전에 프로젝트의 구조에 대해 생각해야 합니다. 나중에 앱의 일부분을 다시 볼 때 코드의 조각을 추가하는 방법과 다른 개발자들과 “언어” 라고 알려진 형식을 고려해야 합니다.

이 글은 시리즈의 두번째 글이며 MVVM 에 대해 다룹니다. (MVC 에 대한 첫번째 글은 마지막 유용한 링크에 나타나 있습니다.)

각 패턴에 대한 빌드 시간과 장점과 단점을 확인하지만 가장 중요한 것은 실제 구현과 소스 코드를 확인해 볼 것입니다.

코드를 보기 원한다면 이 글을 건너뛰어도 됩니다. 코드는 GitHub 에 오픈 소스로 공개되어 있습니다.

iOS 앱에 아키텍처 패턴이 필요한 이유? (Why an Architecture Pattern for Your iOS App?)

고려해야 할 가장 중요한 것은 유지될 수 있는 앱을 가지는 것입니다. View 가 있다는 것을 알고 View Controller 가 Y 가 아닌 X 를 수행해야 한다는 것도 알고 있습니다. 더 중요한 것은 다른 사람들도 알고 있다는 것입니다.

좋은 아키텍처 패턴을 고르는 것에 대한 이점은 다음과 같습니다:

  • 유지보수가 쉬움
  • 비지니스 로직을 테스트하기 쉬움
  • 다른 팀원들과 공통 언어로 개발
  • 역할 분리 (각 기능별 분리)
  • 더 적은 버그

요구사항 정의 (Defining the Requirements)

6개 또는 7개의 화면을 가진 iOS 애플리케이션이 주어지고 iOS 세계에서 가장 유명한 아키텍처 패턴을 사용하여 개발할 것입니다: MVC, MVVM, MVP, VIPER, VIP, 그리고 Coordinators.

데모앱은 Football Gather 라고 불리며 아마추어 축구 경기의 점수를 추적하는 간단한 앱입니다.

주요 요소 (Main features)

가능한 것:

  • 앱에 선수 추가
  • 선수에게 팀 할당
  • 선수 수정
  • 경기에 대한 카운트다운 타이머 설정

화면 목업 (Screen mockups)

iOS 앱인 "Football Gather" 에 화면 목업

iOS 앱인 “Football Gather” 에 화면 목업

백엔드 (Backend)

이 앱은 Vapor web framework 로 개발된 웹 앱으로 구동됩니다. Vapor 3 initial articlearticle about Migrating to Vapor 4 에서 앱을 확인할 수 있습니다.

MVVM 이란? (What Is MVVM?)

MVVM 은 Model-View-ViewModel 의 약자로 RxSwift 에서 자연스럽게 사용되는 아키텍처 패턴으로 View Model 을 통해 UI 요소를 Model 클래스에 바인딩 할 수 있습니다.

2005년에 John Gossman 이 제안한 새로운 패턴으로 View Controller 에서 Model 을 추출하는 역할을 합니다. View Controller 와 Model 사이의 상호작용은 View Model 이라 불리는 새로운 레이어를 통해 수행됩니다.

Model

  • Model 은 MVC 에서 사용했던 것과 동일한 레이어 이며 데이터와 비지니스 로직을 캡슐화 하는데 사용됩니다.

통신 (Communication)

  • 예를 들어 사용자가 액션을 시작하는 것과 같이 view layer 에서 어떠한 것이 발생하면 View Model 을 통해 model 로 전달됩니다.
  • 예를 들어 새로운 데이터를 사용할 수 있고 UI 를 업데이트 해야하는 경우와 같이 model 이 변경되면 model 은 View Model 에 알려줍니다.

View

  • View 와 View Controller 는 시각적 요소가 있는 레이어 입니다.
  • View 는 버튼, 라벨, 그리고 table view 와 같은 UI 요소를 포함하고 View Controller 는 View 의 소유자 입니다.
  • 이 레이어는 MVC 와 동일하지만 View Controller 는 일부이며 View Model 을 참조하도록 변경됩니다.

통신 (Communication)

  • View 는 Model 과 직접적으로 통신할 수 없습니다. 모든 것은 View Model 을 통해서 수행됩니다.

View Model

  • View/View Controller 와 Model 사이에 위치하는 새로운 레이어 입니다.
  • 바인딩을 통해 Model 에서 변경사항이 있을경우 UI 요소를 업데이트 합니다.
  • View 의 표준 표현 입니다.
  • View 에 인터페이스를 제공합니다.

통신 (Communication)

  • Model 과 View/View Controller 의 레이어 모두와 통신이 가능합니다.
  • 바인딩을 통해 Model 레이어의 데이터 변경을 트리거 합니다.
  • 데이터가 변경될 때 해당 변경사항이 유저 인터페이스에 전달되었는지 확인하고 바인딩을 통해 View 를 업데이트 합니다.

MVVM 의 다른 점 (Different Flavours of MVVM)

MVVM 을 적용하는 방법은 어떤 바인딩 구현을 고르냐에 따라 다릅니다:

  • RxSwift 같은 서드파티를 사용
  • KVO — key-value observing
  • 수동으로 사용

데모 앱에서 수동 접근방식을 설명할 것입니다.

어떻게 그리고 언제 MVVM 을 사용할까 (How and When To Use MVVM)

View Controller 가 많은 작업을 수행하고 커져 보이는 경우 MVVM 과 같은 다른 패턴을 살펴볼 수 있습니다.

장점 (Advantages)

  • View Controller 다이어트
  • 데이터를 처리하는 전용 레이어를 가지므로 비지니스 로직을 테스트하기에 용이합니다.
  • 더 나은 분리 형태를 제공

단점 (Disadvantages)

  • MVC 와 동일합니다. 제대로 적용하지 않고 SRP (단일책임원칙 (single responsibility principle)) 을 지키지 않으면 View Model 이 비대해 질 수 있습니다.
  • 소규모 프로젝트 (예를 들어 해커톤 앱/프로토타입) 에서는 과도하고 너무 복잡할 수 있습니다.
  • 서드파티를 적용하면 앱 크기가 증가하고 퍼포먼스에 영향을 줄 수 있습니다.
  • UIKit 을 사용하는 iOS 앱 개발에 자연스럽지 않습니다. 반면에 SwiftUI 로 개발된 앱에 대해서는 완변한 방법입니다.

아래에서 이 코드 아키텍처 패턴에 대해 자세히 알려주는 링크를 볼 수 있습니다.

코드에 MVVM 적용 (Applying MVVM to Our Code)

적용은 간단합니다. 각 View Controller 에서 새로운 레이어 View Model 로 비지니스 로직을 추출합니다.

LoginViewController 를 비지니스 로직과 분리 (Decoupling LoginViewController from business logic)

변환:

  • viewModel — view 상태와 model 업데이트를 처리하는 새로운 레이어
  • 서비스는 이제 View Model 레이어의 부분입니다.

viewDidLoad 메서드에서 configureRememberMe() 함수를 호출합니다. 다음은 View 가 “Remember Me” UISwitch 와 사용자 이름의 값에 대해 View Model 에 어떻게 요청하는지 관찰할 수 있습니다:

private func configureRememberMe() {
    rememberMeSwitch.isOn = viewModel.rememberUsername // [1] set switch on / off based on the preferred mode of the user
    if viewModel.rememberUsername { // [2] set the stored username to the textfield
        usernameTextField.text = viewModel.username
    }
}

Login 과 Register 액션에 대해 서비스 요청을 처리하기 위해 View Model 을 호출합니다. 서버 API 호출이 끝날 때 UI 를 업데이트 하기위해 클로저를 사용합니다.

@IBAction func login(_ sender: Any) {
    guard let userText = usernameTextField.text, userText.isEmpty == false,
          let passwordText = passwordTextField.text, passwordText.isEmpty == false else {
              AlertHelper.present(in: self, title: "Error", message: "Both fields are mandatory.")
              return
          } // [1] Extract the username and the password from the fields
    showLoadingView() // [2] Display a loading spinner
    viewModel.performLogin(withUsername: userText, andPassword: passwordText) { [weak self] error in // [3] Tell the view model to Login
        DispatchQueue.main.async {
            self?.hideLoadingView() // [4] Service finished, update UI
            self?.handleServiceResponse(error: error)
        }
    }
}

private func handleServiceResponse(error: Error?) {
    if let error = error {
        AlertHelper.present(in: self, title: "Error", message: String(describing: error)) // [5] Handle the error
    } else {
        handleSuccessResponse()
    }
}

// [6] We navigate to the next screen, PlayerList
private func handleSuccessResponse() {
    storeUsernameAndRememberMe()
    performSegue(withIdentifier: SegueIdentifier.playerList.rawValue, sender: nil)
}

// [7] Storing the details is done in the ViewModel
private func storeUsernameAndRememberMe() {
    viewModel.setRememberUsername(rememberMeSwitch.isOn)
    
    if rememberMeSwitch.isOn {
        viewModel.setUsername(usernameTextField.text)
    } else {
        viewModel.setUsername(nil)
    }
}

LoginViewModel 은 다음의 프로퍼티로 정의됩니다:

struct LoginViewModel {
    private let loginService: LoginService
    private let usersService: StandardNetworkService
    private let userDefaults: FootballGatherUserDefaults
    private let keychain: FootbalGatherKeychain
}

LoginViewController (사용자를 등록하는데 사용하는LoginServiceStandardNetworkService, 그리고 저장소 관리: UserDefaults 그리고 Keychain 래퍼) 에서 전달된 서비스가 있습니다. 이 모든 것은 초기화 구문을 통해 주입됩니다:

init(loginService: LoginService = LoginService(),
     usersService: StandardNetworkService = StandardNetworkService(resourcePath: "/api/users"),
     userDefaults: FootballGatherUserDefaults = .shared,
     keychain: FootbalGatherKeychain = .shared) {
    self.loginService = loginService
    self.usersService = usersService
    self.userDefaults = userDefaults
    self.keychain = keychain
}

이것은 mock 서비스나 저장소 사용하는 경우 유닛 테스트에 유리합니다.

public API 는 깔끔하고 간단합니다:

// [1] Checks in the UserDefaults storage if we have set Remember Me option
var rememberUsername: Bool {
    return userDefaults.rememberUsername ?? true
}

// [2] The username that was stored in case rememberUsername is true
var username: String? {
    return keychain.username
}

// [3] Stores the RememberMe boolen property
func setRememberUsername(_ value: Bool) {
    userDefaults.rememberUsername = value
}

// [4] Store the username in the Keychain
func setUsername(_ username: String?) {
    keychain.username = username
}

그리고 2개의 서버 API 를 호출합니다:

func performLogin(withUsername username: String, andPassword password: String, completion: @escaping (Error?) -> ()) {
    let requestModel = UserRequestModel(username: username, password: password) // [1] Create the request model
    loginService.login(user: requestModel) { result in
        switch result {
        case .failure(let error):
            completion(error)
            
        case .success(_):
            completion(nil)
        }
    }
}

func performRegister(withUsername username: String, andPassword password: String, completion: @escaping (Error?) -> ()) {
    guard let hashedPasssword = Crypto.hash(message: password) else { // [1] Make sure we crash in case there are invalid passwords that could not be hashed
        fatalError("Unable to hash password")
    }
    
    let requestModel = UserRequestModel(username: username, password: hashedPasssword) // [2] Create the request model (same model as we have for Login)
    usersService.create(requestModel) { result in
        switch result {
        case .failure(let error):
            completion(error)
            
        case .success(let resourceId):
            print("Created user: \(resourceId)")
            completion(nil)
        }
    }
}

보시다시피 코드가 View Controller 에서 Model 이 분리되어 훨씬 깔끔해 보입니다. 이제 View/View Controller 는 무엇이 필요한지 View Model 에 묻습니다.

PlayerListViewControllerLoginViewController 보다 더 크고 리팩토링과 비지니스 로직을 추출하기에 더 어렵습니다.

먼저, 아웃렛 (outlet) 과 이 클래스에서 필요한 모든 UIView 객체만 남겨두고 싶습니다.

viewDidLoad 에서 view 의 초기상태의 설정을 하고 view model delegate 를 설정하고 view model 을 통해 선수 로드를 트리거 합니다.

선수 로딩:

private func loadPlayers() {
    view.isUserInteractionEnabled = false
    
    viewModel.fetchPlayers { [weak self] error in // [1] Pass the responsibility tot the ViewModel
        DispatchQueue.main.async {
            self?.view.isUserInteractionEnabled = true
            
            if let error = error { // [2] Handle the response
                self?.handleServiceFailures(withError: error)
            } else {
                self?.handleLoadPlayersSuccessfulResponse()
            }
        }
    }
}

응답 처리는 LoginViewController 와 비슷합니다:

private func handleServiceFailures(withError error: Error) {
    AlertHelper.present(in: self, title: "Error", message: String(describing: error)) // [1] Present an alert to the user
}

private func handleLoadPlayersSuccessfulResponse() {
    if viewModel.playersCollectionIsEmpty {
        showEmptyView() // [2] The players array is empty
    } else {
        hideEmptyView() // [3] No need to display the emptyView
    }
    
    playerTableView.reloadData() // [4] Reload the players table view
}

table view 의 셀에서 model 프로퍼티를 보여주고 구성하려면 기본요소를 얻기 위해 ViewModel 에 요청한 다음에 셀의 프로퍼티에 설정합니다:

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return viewModel.numberOfRows // [1] The number of players in the array
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let cell: PlayerTableViewCell = tableView.dequeueReusableCell(withIdentifier: "PlayerTableViewCell") as? PlayerTableViewCell else {
        return UITableViewCell()
    }
    
    if viewModel.isInListViewMode { // [2] When we are in the default view mode, showing the players
        viewModel.clearSelectedPlayerIfNeeded(at: indexPath)
        cell.setupDefaultView()
    } else {
        cell.setupSelectionView() // [3] When we are in the view mode for selecting the players for the gather
    }
    
    // [4] Display the model properties in the cell’s properties
    cell.nameLabel.text = viewModel.playerNameDescription(at: indexPath)
    cell.positionLabel.text = viewModel.playerPositionDescription(at: indexPath)
    cell.skillLabel.text = viewModel.playerSkillDescription(at: indexPath)
    cell.playerIsSelected = viewModel.playerIsSelected(at: indexPath)
    
    return cell
}

선수를 삭제하려면 다음과 같습니다:

func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
    return viewModel.isInListViewMode // [1] Only in list view mode we can edit rows
}

// [2] Present a confirmation alert
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
    guard editingStyle == .delete else { return }
    
    let alertController = UIAlertController(title: "Delete player", message: "Are you sure you want to delete the selected player?", preferredStyle: .alert)
    let confirmAction = UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
        self?.handleDeletePlayerConfirmation(forRowAt: indexPath)
    }
    alertController.addAction(confirmAction)
    
    let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
    alertController.addAction(cancelAction)
    
    present(alertController, animated: true, completion: nil)
}

private func handleDeletePlayerConfirmation(forRowAt indexPath: IndexPath) {
    requestDeletePlayer(at: indexPath) { [weak self] result in // [3] Perform the server call
        guard result, let self = self else { return }
        
        self.playerTableView.beginUpdates() // [4] In case the service succeeded, delete locally the player
        self.viewModel.deleteLocallyPlayer(at: indexPath)
        self.playerTableView.deleteRows(at: [indexPath], with: .fade)
        self.playerTableView.endUpdates()
        
        if self.viewModel.playersCollectionIsEmpty {
            self.showEmptyView() // [5] Check if we need to display the empty view in case we haven’t any players left
        }
    }
}

private func requestDeletePlayer(at indexPath: IndexPath, completion: @escaping (Bool) -> Void) {
    viewModel.requestDeletePlayer(at: indexPath) { [weak self] error in // [6] Tells the ViewModel to perform the API call for deleting the player
        DispatchQueue.main.async {
            self?.hideLoadingView()
            
            if let error = error {
                self?.handleServiceFailures(withError: error)
                completion(false)
            } else {
                completion(true)
            }
        }
    }
}

Confirm/Detail 그리고 Add 화면으로의 이동은 performSegue 를 통해 수행됩니다. 다음 화면의 view model 을 생성하고 prepareForSegue 에 주입하기 위해 PlayerListViewModel 을 선택합니다. 이것은 SRP 원칙을 위반하므로 가장 좋은 방법이 아닙지만 이 문제를 어떻게 해결하는지는 Coordinator 글에서 확인해 보겠습니다.

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    switch segue.identifier {
    case SegueIdentifier.confirmPlayers.rawValue:
        if let confirmPlayersViewController = segue.destination as? ConfirmPlayersViewController {
            confirmPlayersViewController.viewModel = viewModel.makeConfirmPlayersViewModel()
        }
        
    case SegueIdentifier.playerDetails.rawValue:
        if let playerDetailsViewController = segue.destination as? PlayerDetailViewController, let player = viewModel.selectedPlayerForDetails {
            playerDetailsViewController.delegate = self
            playerDetailsViewController.viewModel = PlayerDetailViewModel(player: player)
        }
        
    case SegueIdentifier.addPlayer.rawValue:
        (segue.destination as? PlayerAddViewController)?.delegate = self
        
    default:
        break
    }
}

PlayerListViewModel 는 크고 View 에 노출되는 많은 프로퍼티와 메서드를 포함하고 모두 필수항목 입니다. 데모를 위해 그대로 두고 독자의 연습문제 리팩토링을 위해 남겨 두겠습니다. 다음을 수행합니다:

  • 여러 View Controller / View Model 에서 PlayerListViewController 을 분리하고 부모 또는 컨테이너 view controller 에 의해 처리합니다.
  • 수정/리스트 함수, 서비스 컴포넌트, 또는 선수 선택에 따라 다른 컴포넌트로 PlayerListViewModel 을 분리합니다.

View 상태 (선수 선택 그리고 리스트 모드) 는 Factory 패턴을 통해 구현됩니다:

final class PlayerListViewModel {
    
    private var viewState: ViewState
    private var viewStateDetails: LoginViewStateDetails {
        return ViewStateDetailsFactory.makeViewStateDetails(from: viewState)
    }
    
}

extension PlayerListViewModel {
    enum ViewState {
        case list
        case selection
        
        mutating func toggle() {
            self = self == .list ? .selection : .list
        }
    }
}

그리고 리스트와 선택에 대한 클래스:

// [1] Abstractization
protocol LoginViewStateDetails {
    var barButtonItemTitle: String { get }
    var actionButtonIsEnabled: Bool { get }
    var actionButtonTitle: String { get }
    var segueIdentifier: String { get }
}

fileprivate extension PlayerListViewModel {
    
    struct ListViewStateDetails: LoginViewStateDetails {
        var barButtonItemTitle: String {
            return "Select"
        }
        
        var actionButtonIsEnabled: Bool {
            return false
        }
        
        var segueIdentifier: String {
            return SegueIdentifier.addPlayer.rawValue // [2] Binded to the bottom button action
        }
        
        var actionButtonTitle: String {
            return "Add player"
        }
    }
    
    struct SelectionViewStateDetails: LoginViewStateDetails {
        var barButtonItemTitle: String {
            return "Cancel"
        }
        
        var actionButtonIsEnabled: Bool {
            return true
        }
        
        var segueIdentifier: String {
            return SegueIdentifier.confirmPlayers.rawValue
        }
        
        var actionButtonTitle: String {
            return "Confirm players"
        }
    }
    
    enum ViewStateDetailsFactory {
        static func makeViewStateDetails(from viewState: ViewState) -> LoginViewStateDetails {
            switch viewState {
            case .list:
                return ListViewStateDetails()
                
            case .selection:
                return SelectionViewStateDetails()
            }
        }
    }
}

서비스 메서드는 읽기 쉽습니다:

func fetchPlayers(completion: @escaping (Error?) -> ()) {
    playersService.get { [weak self] (result: Result<[PlayerResponseModel], Error>) in
        switch result {
        case .failure(let error):
            completion(error)
            
        case .success(let players):
            self?.players = players
            completion(nil)
        }
    }
}

func requestDeletePlayer(at indexPath: IndexPath, completion: @escaping (Error?) -> Void) {
    let player = players[indexPath.row]
    var service = playersService
    
    service.delete(withID: ResourceID.integer(player.id)) { result in
        switch result {
        case .failure(let error):
            completion(error)
            
        case .success(_):
            completion(nil)
        }
    }
}

PlayerAddViewController 는 선수 추가 화면을 정의합니다 (PlayerAddViewController defines the Add players screen)

선수가 생성되면 delegation 패턴을 사용하여 Player Add 화면에 알리고 view controller 를 pop 합니다. 서비스 호출은 view model 에 있습니다.

@objc private func doneAction(sender: UIBarButtonItem) {
    guard let playerName = playerNameTextField.text else { return }
    
    showLoadingView() // [1] Present the loading indicator
    viewModel.requestCreatePlayer(name: playerName) { [weak self] playerWasCreated in // [2] Check if the service finished sucessfully
        DispatchQueue.main.async {
            self?.hideLoadingView()
            
            if !playerWasCreated {
                self?.handleServiceFailure()
            } else {
                self?.handleServiceSuccess()
            }
        }
    }
}

private func handleServiceFailure() {
    AlertHelper.present(in: self, title: "Error update", message: "Unable to create player. Please try again.")
}

private func handleServiceSuccess() {
    delegate?.playerWasAdded()
    navigationController?.popViewController(animated: true)
}

View Model 엔티티는 다음과 같이 나타냅니다:

struct PlayerAddViewModel {
    private let service: StandardNetworkService // [1] Used to request a player creation
    
    init(service: StandardNetworkService = StandardNetworkService(resourcePath: "/api/players", authenticated: true)) {
        self.service = service
    }
    
    // [2] The title of the view controller
    var title: String {
        return "Add Player"
    }
    
    // [3] Service API call
    func requestCreatePlayer(name: String, completion: @escaping (Bool) -> Void) {
        let player = PlayerCreateModel(name: name)
        service.create(player) { result in
            if case .success(_) = result {
                completion(true)
            } else {
                completion(false)
            }
        }
    }
    
    // [4] Defines the state of the done buttokn
    func doneButtonIsEnabled(forText text: String?) -> Bool {
        return text?.isEmpty == false
    }
}

PlayerDetailViewController 는 상세 화면을 정의합니다 (PlayerDetailViewController defines the Details screen)

view model 은 PlayerListViewController 의 메서드, prepareForSegue 에서 생성되고 전달됩니다. PlayerEditViewController 로 이동할 때도 동일한 방식을 사용합니다:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    guard segue.identifier == SegueIdentifier.editPlayer.rawValue,
          let destinationViewController = segue.destination as? PlayerEditViewController else {
              return
          }
    
    destinationViewController.viewModel = viewModel.makeEditViewModel()
    destinationViewController.delegate = self
}

선수의 세부정보를 표시하는 것은 PlayerList 화면에서 했던 것과 유사합니다: View 는 프로퍼티와 라벨의 문구 설정에 대해 View Model 에 요청합니다.

extension PlayerDetailViewController: UITableViewDelegate, UITableViewDataSource {
    func numberOfSections(in tableView: UITableView) -> Int {
        return viewModel.numberOfSections
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.numberOfRowsInSection(section)
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "PlayerDetailTableViewCell") as? PlayerDetailTableViewCell else {
            return UITableViewCell()
        }
        
        cell.leftLabel.text = viewModel.rowTitleDescription(for: indexPath)
        cell.rightLabel.text = viewModel.rowValueDescription(for: indexPath)
        
        return cell
    }
    
    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        return viewModel.titleForHeaderInSection(section)
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        viewModel.selectPlayerRow(at: indexPath)
        performSegue(withIdentifier: SegueIdentifier.editPlayer.rawValue, sender: nil)
    }
}

사용자가 나타난 화면에서 선수 수정을 마치면 didFinishEditing 이 호출됩니다:

extension PlayerDetailViewController: PlayerEditViewControllerDelegate {
    func didFinishEditing(player: PlayerResponseModel) {
        setupTitle() // [1] If the player’s name changed, reload the title
        viewModel.updatePlayer(player) // [2] Update the player local model
        viewModel.reloadSections() // [3] Reconstruct the sections model
        reloadData() // [4] Reload data from table view
        delegate?.didEdit(player: player) // [5] Tell PlayerList that the player was updated
    }
}

PlayerDetailViewModel 는 다음의 프로퍼티를 가지고 있습니다:

final class PlayerDetailViewModel {
    
    // MARK: - Properties
    private(set) var player: PlayerResponseModel // [1] player that is viewable in the screen
    private lazy var sections = makeSections() // [2] all data is displayed in multiple sections
    private(set) var selectedPlayerRow: PlayerRow? // [3] used for holding the tapped player row information
    
    // MARK: - Public API
    init(player: PlayerResponseModel) {
        self.player = player
    }
}

PlayerEditViewController

Edit 화면을 표시하는 segue 는 PlayerDetails 화면에서 트리거 됩니다. 선수의 상세정보를 수정할 수 있는 화면입니다.

View Model 은 PlayerDetailsViewController 에서 전달됩니다. 동일한 접근방식에 따라 모든 서버 API 상호작용과 model 처리를 View Model 로 옮깁니다.

수정 text field 는 View Model 의 프로퍼티를 기반으로 구성됩니다:

private func setupPlayerEditTextField() {
    playerEditTextField.placeholder = viewModel.playerRowValue
    playerEditTextField.text = viewModel.playerRowValue
    playerEditTextField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
    playerEditTextField.isHidden = viewModel.isSelectionViewType
}

선수의 정보 수정이 끝나면 서버로 업데이트 수행을 위해 view model 에 요청하고 완료된 후에 성공 또는 실패 응답을 처리합니다.

실패하면 사용자에게 알리고 서버 호출이 성공하면 delegate 에 알리고 view controller 스택에서 이 view controller 를 pop 합니다.

@objc private func doneAction(sender: UIBarButtonItem) {
    guard viewModel.shouldUpdatePlayer(inputFieldValue: playerEditTextField.text) else { return }
    
    showLoadingView()
    
    viewModel.updatePlayerBasedOnViewType(inputFieldValue: playerEditTextField.text) { [weak self] updated in
        DispatchQueue.main.async {
            self?.hideLoadingView()
            
            if updated {
                self?.handleSuccessfulPlayerUpdate()
            } else {
                self?.handleServiceError()
            }
        }
    }
}

private func handleSuccessfulPlayerUpdate() {
    delegate?.didFinishEditing(player: viewModel.editablePlayer)
    navigationController?.popViewController(animated: true)
}

private func handleServiceError() {
    AlertHelper.present(in: self, title: "Error update", message: "Unable to update player. Please try again.")
}

PlayerEditViewModel 은 나머지와 유사합니다. 가장 중요한 메서드는 선수 업데이트 방법 입니다:

// [1] Checks if the entered value in the field is different from the old value
func shouldUpdatePlayer(inputFieldValue: String?) -> Bool {
    if isSelectionViewType {
        return newValueIsDifferentFromOldValue(newFieldValue: selectedItemValue)
    }
    
    return newValueIsDifferentFromOldValue(newFieldValue: inputFieldValue)
}

private func newValueIsDifferentFromOldValue(newFieldValue: String?) -> Bool {
    guard let newFieldValue = newFieldValue else { return false }
    
    return playerEditModel.playerRow.value.lowercased() != newFieldValue.lowercased()
}

// [2] There are two different ways to update player information.
// One is through the input / textField where you can type, for example the name or age of the player
// and the other one is through selection where you can choose a different option (applied to player’s position or skill).
private var selectedItemValue: String? {
    guard let playerItemsEditModel = playerItemsEditModel else { return nil}
    
    return playerItemsEditModel.items[playerItemsEditModel.selectedItemIndex]
}

// [3] Decides what needs to be updated (if inputFieldValue is nil, than it will update the player through selection mode).
func updatePlayerBasedOnViewType(inputFieldValue: String?, completion: @escaping (Bool) -> ()) {
    if isSelectionViewType {
        updatePlayer(newFieldValue: selectedItemValue, completion: completion)
    } else {
        updatePlayer(newFieldValue: inputFieldValue, completion: completion)
    }
}

private func updatePlayer(newFieldValue: String?, completion: @escaping (Bool) -> ()) {
    guard let newFieldValue = newFieldValue else {
        completion(false)
        return
    }
    
    playerEditModel.player.update(usingField: playerEditModel.playerRow.editableField, value: newFieldValue)
    requestUpdatePlayer(completion: completion)
}

// [4] Perfoms the player service update call
private func requestUpdatePlayer(completion: @escaping (Bool) -> ()) {
    let player = playerEditModel.player
    service.update(PlayerCreateModel(player), resourceID: ResourceID.integer(player.id)) { [weak self] result in
        if case .success(let updated) = result {
            self?.playerEditModel.player = player
            completion(updated)
        } else {
            completion(false)
        }
    }
}

ConfirmPlayersViewController

수집 화면에 도달하기 전에 선택된 선수를 확인해야 합니다. 이 화면은 ConfirmPlayersViewController 에 의해 정의됩니다.

viewDidLoad 에서 table view 와 Start Gather 버튼을 구성하는 것과 같은 UI 요소를 설정합니다:

func setupViews() {
    playerTableView.isEditing = viewModel.playerTableViewIsEditing
    configureStartGatherButton()
}

서버 API 호출은 다음과 같이 나타냅니다:

@IBAction private func startGather(_ sender: Any) {
    showLoadingView()
    
    viewModel.startGather { [weak self] result in
        DispatchQueue.main.async {
            self?.hideLoadingView()
            
            if !result {
                self?.handleServiceFailure()
            } else {
                self?.performSegue(withIdentifier: SegueIdentifier.gather.rawValue, sender: nil)
            }
        }
    }
}
private func handleServiceFailure() {
    AlertHelper.present(in: self, title: "Error", message: "Unable to create gather.")
}

그리고 Table View Delegate 와 Data Source:

extension ConfirmPlayersViewController: UITableViewDelegate, UITableViewDataSource {
    func numberOfSections(in tableView: UITableView) -> Int {
        return viewModel.numberOfSections
    }
    
    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        return viewModel.titleForHeaderInSection(section)
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.numberOfRowsInSection(section)
    }
    
    func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
        return .none
    }
    
    func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool {
        return false
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "PlayerChooseTableViewCellId") else {
            return UITableViewCell()
        }
        
        cell.textLabel?.text = viewModel.rowTitle(at: indexPath)
        cell.detailTextLabel?.text = viewModel.rowDescription(at: indexPath)
        
        return cell
    }
    
    func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
        viewModel.moveRowAt(sourceIndexPath: sourceIndexPath, to: destinationIndexPath)
        configureStartGatherButton()
    }
}

ConfirmPlayersViewModel 은 선택된 선수와 그들의 팀을 가진 playersDictionary 수집에 선수를 추가하고 수집을 시작하는데 필요한 서비스, 서버에서 수집이 생성된 후에 정의되는 gatherUUID 그리고 여러 서버 호출을 조절하는 dispatchGroup 을 포함합니다.

final class ConfirmPlayersViewModel {
    
    // MARK: - Properties
    private var playersDictionary: [TeamSection: [PlayerResponseModel]]
    private var addPlayerService: AddPlayerToGatherService
    private let gatherService: StandardNetworkService
    
    private let dispatchGroup = DispatchGroup()
    private var gatherUUID: UUID?
    
    // MARK: - Public API
    init(playersDictionary: [TeamSection: [PlayerResponseModel]] = [:],
         addPlayerService: AddPlayerToGatherService = AddPlayerToGatherService(),
         gatherService: StandardNetworkService = StandardNetworkService(resourcePath: "/api/gathers", authenticated: true)) {
        self.playersDictionary = playersDictionary
        self.addPlayerService = addPlayerService
        self.gatherService = gatherService
    }
}

이 클래스에 대해 가장 복잡한 것은 수집을 시작할 때 서버 API 상호작용 입니다:

// [1] Main function for starting a taher
func startGather(completion: @escaping (Bool) -> ()) {
    createGather { [weak self] uuid in
        guard let gatherUUID = uuid else {
            completion(false)
            return
        }
        
        // [2] The gather was created, now is time to put the selected players in it.
        self?.gatherUUID = gatherUUID
        self?.addPlayersToGather(havingUUID: gatherUUID, completion: completion)
    }
}

private func createGather(completion: @escaping (UUID?) -> Void) {
    gatherService.create(GatherCreateModel()) { result in
        if case let .success(ResourceID.uuid(gatherUUID)) = result {
            completion(gatherUUID)
        } else {
            completion(nil)
        }
    }
}

// [3] Use the dispatch group to add the players to the gather
private func addPlayersToGather(havingUUID gatherUUID: UUID, completion: @escaping (Bool) -> ()) {
    var serviceFailed = false
    
    playerTeamArray.forEach { playerTeam in
        dispatchGroup.enter()
        
        self.addPlayer(playerTeam.player, toGatherHavingUUID: gatherUUID, team: playerTeam.team) { [weak self] playerWasAdded in
            if !playerWasAdded {
                serviceFailed = true
            }
            
            self?.dispatchGroup.leave()
        }
    }
    
    // [4] The for loop finished, it’s time to call the completion closure.
    dispatchGroup.notify(queue: DispatchQueue.main) {
        completion(serviceFailed)
    }
}

// [5] Maps the players to the PlayerTeamModel
private var playerTeamArray: [PlayerTeamModel] {
    var players: [PlayerTeamModel] = []
    players += self.playersDictionary
        .filter { $0.key == .teamA }
        .flatMap { $0.value }
        .map { PlayerTeamModel(team: .teamA, player: $0) }
    
    players += self.playersDictionary
        .filter { $0.key == .teamB }
        .flatMap { $0.value }
        .map { PlayerTeamModel(team: .teamB, player: $0) }
    
    return players
}

// [6] This is the service API call to add a player to a gather.
private func addPlayer(_ player: PlayerResponseModel,
                       toGatherHavingUUID gatherUUID: UUID,
                       team: TeamSection,
                       completion: @escaping (Bool) -> Void) {
    addPlayerService.addPlayer(
        havingServerId: player.id,
        toGatherWithId: gatherUUID,
        team: PlayerGatherTeam(team: team.headerTitle)) { result in
            if case let .success(resultValue) = result {
                completion(resultValue)
            } else {
                completion(false)
            }
        }
}

GatherViewController

마지막으로 Football Gather 에서 가장 중요한 화면에 속하는 GatherViewController 을 가지고 있습니다.

프로퍼티를 정리하고 IBOutlet 과 로딩 view 와 view model 은 남겨두었습니다:

final class GatherViewController: UIViewController, Loadable {
    // MARK: - Properties
    @IBOutlet weak var playerTableView: UITableView!
    @IBOutlet weak var scoreLabelView: ScoreLabelView!
    @IBOutlet weak var scoreStepper: ScoreStepper!
    @IBOutlet weak var timerLabel: UILabel!
    @IBOutlet weak var timerView: UIView!
    @IBOutlet weak var timePickerView: UIPickerView!
    @IBOutlet weak var actionTimerButton: UIButton!
    
    lazy var loadingView = LoadingView.initToView(self.view)
    
    var viewModel: GatherViewModel!
}

viewDidLoad 에서 view 를 설정하고 구성합니다:

override func viewDidLoad() {
    super.viewDidLoad()
    
    setupViewModel()
    setupTitle()
    configureSelectedTime()
    hideTimerView()
    configureTimePickerView()
    configureActionTimerButton()
    setupScoreStepper()
    reloadData()
}

private func setupTitle() {
    title = viewModel.title
}

private func setupViewModel() {
    viewModel.delegate = self
}

private func configureSelectedTime() {
    timerLabel?.text = viewModel.formattedCountdownTimerLabelText
}

private func configureActionTimerButton() {
    actionTimerButton.setTitle(viewModel.formattedActionTitleText, for: .normal)
}

private func hideTimerView() {
    timerView.isHidden = true
}

private func showTimerView() {
    timerView.isHidden = false
}

private func setupScoreStepper() {
    scoreStepper.delegate = self
}

private func reloadData() {
    timePickerView.reloadAllComponents()
    playerTableView.reloadData()
}

타이머 관련 함수는 깔끔하게 보입니다:

@IBAction private func setTimer(_ sender: Any) {
    configureTimePickerView()
    showTimerView()
}

@IBAction private func cancelTimer(_ sender: Any) {
    viewModel.stopTimer()
    viewModel.resetTimer()
    configureSelectedTime()
    configureActionTimerButton()
    hideTimerView()
}

@IBAction private func actionTimer(_ sender: Any) {
    viewModel.toggleTimer()
    configureActionTimerButton()
}

@IBAction private func timerCancel(_ sender: Any) {
    hideTimerView()
}

@IBAction private func timerDone(_ sender: Any) {
    viewModel.stopTimer()
    viewModel.setTimerMinutes(selectedMinutesRow)
    viewModel.setTimerSeconds(selectedSecondsRow)
    configureSelectedTime()
    configureActionTimerButton()
    hideTimerView()
}

private var selectedMinutesRow: Int { timePickerView.selectedRow(inComponent: viewModel.minutesComponent) }
private var selectedSecondsRow: Int { timePickerView.selectedRow(inComponent: viewModel.secondsComponent) }

그리고 endGather API 상호작용:

@IBAction private func endGather(_ sender: Any) {
    let alertController = UIAlertController(title: "End Gather", message: "Are you sure you want to end the gather?", preferredStyle: .alert)
    let confirmAction = UIAlertAction(title: "Yes", style: .default) { [weak self] _ in
        self?.endGather()
    }
    alertController.addAction(confirmAction)
    
    let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
    alertController.addAction(cancelAction)
    
    present(alertController, animated: true, completion: nil)
}

private func endGather() {
    guard let scoreTeamAString = scoreLabelView.teamAScoreLabel.text,
          let scoreTeamBString = scoreLabelView.teamBScoreLabel.text else {
              return
          }
    
    showLoadingView()
    
    viewModel.endGather(teamAScoreLabelText: scoreTeamAString, teamBScoreLabelText: scoreTeamBString) { [weak self] updated in
        DispatchQueue.main.async {
            self?.hideLoadingView()
            
            if !updated {
                self?.handleServiceFailure()
            } else {
                self?.handleServiceSuccess()
            }
        }
    }
}

private func handleServiceFailure() {
    AlertHelper.present(in: self, title: "Error update", message: "Unable to update gather. Please try again.")
}

private func handleServiceSuccess() {
    guard let playerListTogglable = navigationController?.viewControllers.first(where: { $0 is PlayerListTogglable }) as? PlayerListTogglable else {
        return
    }
    
    playerListTogglable.toggleViewState()
    
    if let playerListViewController = playerListTogglable as? UIViewController {
        navigationController?.popToViewController(playerListViewController, animated: true)
    }
}

table view 의 Data Source 와 Delegate 는 또한 보기 좋고 깔끔하고 단순합니다:

// MARK: - UITableViewDelegate | UITableViewDataSource
extension GatherViewController: UITableViewDelegate, UITableViewDataSource {
    func numberOfSections(in tableView: UITableView) -> Int {
        viewModel.numberOfSections
    }
    
    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        viewModel.titleForHeaderInSection(section)
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        viewModel.numberOfRowsInSection(section)
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "GatherCellId") else {
            return UITableViewCell()
        }
        
        let rowDescription = viewModel.rowDescription(at: indexPath)
        
        cell.textLabel?.text = rowDescription.title
        cell.detailTextLabel?.text = rowDescription.details
        
        return cell
    }
}

그리고 나머지 메서드:

// MARK: - ScoreStepperDelegate
extension GatherViewController: ScoreStepperDelegate {
    func stepper(_ stepper: UIStepper, didChangeValueForTeam team: TeamSection, newValue: Double) {
        if viewModel.shouldUpdateTeamALabel(section: team) {
            scoreLabelView.teamAScoreLabel.text = viewModel.formatStepperValue(newValue)
        } else if viewModel.shouldUpdateTeamBLabel(section: team) {
            scoreLabelView.teamBScoreLabel.text = viewModel.formatStepperValue(newValue)
        }
    }
}

// MARK: - UIPickerViewDataSource
extension GatherViewController: UIPickerViewDataSource, UIPickerViewDelegate {
    func numberOfComponents(in pickerView: UIPickerView) -> Int {
        viewModel.numberOfPickerComponents
    }
    
    func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
        viewModel.numberOfRowsInPickerComponent(component)
    }
    
    func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
        viewModel.titleForPickerRow(row, forComponent: component)
    }
}

// MARK: - GatherViewModelDelegate
extension GatherViewController: GatherViewModelDelegate {
    func didUpdateGatherTime() {
        configureSelectedTime()
    }
}

View Controller 를 정리하는 것은 View Model 클래스에 대해 몇가지 안좋은 점이 있습니다. 많은 메서드를 가지며 클래스가 큽니다 (거의 200 라인).

GatherTimeHandler 라는 새로운 구조체로 Timer 상호작용을 옮기기로 결정하였습니다. 이 구조체에서 클래스의 외부에서 설정된 selectedTime 을 노출하고 타이머와 멈춤, 실행, 또는 일시정지 될 수 있는 상태 변수인 2개의 변수를 더 가집니다.

public API 는 멈춤, 리셋 그리고 타이머 토글과 같은 메서드와 decrementTime 이 있습니다:

mutating func decrementTime() {
    if selectedTime.seconds == 0 {
        decrementMinutes()
    } else {
        decrementSeconds()
    }
    
    if selectedTimeIsZero {
        stopTimer()
    }
}

private mutating func decrementMinutes() {
    selectedTime.minutes -= 1
    selectedTime.seconds = 59
}

private mutating func decrementSeconds() {
    selectedTime.seconds -= 1
}

private var selectedTimeIsZero: Bool {
    return selectedTime.seconds == 0 && selectedTime.minutes == 0
}

전반적으로 MVC 로 구현한 앱에 비해 더 좋아 보입니다.

비지니스 로직 테스트 (Testing Our Business Logic)

가장 중요한 부분은 View Model 입니다. 여기에서 비지니스 로직을 구현했습니다.

타이틀 테스트:

func testTitle_whenViewModelIsAllocated_isNotEmpty() {
    // given
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    let sut = GatherViewModel(gatherModel: mockGatherModel)
    
    // when
    let title = sut.title
    
    // then
    XCTAssertFalse(title.isEmpty)
}

카운트 다운 라벨 문구 테스트:

func testFormattedCountdownTimerLabelText_whenViewModelIsAllocated_returnsDefaultTime() {
    // given
    let gatherTime = GatherTime.defaultTime
    // Define the expected values, format should be 00:00.
    let expectedFormattedMinutes = gatherTime.minutes < 10 ? "0\(gatherTime.minutes)" : "\(gatherTime.minutes)"
    let expectedFormattedSeconds = gatherTime.seconds < 10 ? "0\(gatherTime.seconds)" : "\(gatherTime.seconds)"
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    let sut = GatherViewModel(gatherModel: mockGatherModel)
    
    // when
    let formattedCountdownTimerLabelText = sut.formattedCountdownTimerLabelText
    
    // then
    XCTAssertEqual(formattedCountdownTimerLabelText, "\(expectedFormattedMinutes):\(expectedFormattedSeconds)")
}

func testFormattedCountdownTimerLabelText_whenTimeIsZero_returnsZeroSecondsZeroMinutes() {
    // given
    let mockGatherTime = GatherTime(minutes: 0, seconds: 0)
    let mockGatherTimeHandler = GatherTimeHandler(selectedTime: mockGatherTime)
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
    
    // when
    let formattedCountdownTimerLabelText = sut.formattedCountdownTimerLabelText
    
    // then
    XCTAssertEqual(formattedCountdownTimerLabelText, "00:00")
}

func testFormattedCountdownTimerLabelText_whenTimeHasMinutesAndZeroSeconds_returnsMinutesAndZeroSeconds() {
    // given
    let mockGatherTime = GatherTime(minutes: 10, seconds: 0)
    let mockGatherTimeHandler = GatherTimeHandler(selectedTime: mockGatherTime)
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
    
    // when
    let formattedCountdownTimerLabelText = sut.formattedCountdownTimerLabelText
    
    // then
    XCTAssertEqual(formattedCountdownTimerLabelText, "10:00")
}

func testFormattedCountdownTimerLabelText_whenTimeHasSecondsAndZeroMinutes_returnsSecondsAndZeroMinutes() {
    // given
    let mockGatherTime = GatherTime(minutes: 0, seconds: 10)
    let mockGatherTimeHandler = GatherTimeHandler(selectedTime: mockGatherTime)
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
    
    // when
    let formattedCountdownTimerLabelText = sut.formattedCountdownTimerLabelText
    
    // then
    XCTAssertEqual(formattedCountdownTimerLabelText, "00:10")
}

Start, Resume, 또는 Pause 에 대한 타이틀 문구 테스트.

// We set the state to be initially .paused
func testFormattedActionTitleText_whenStateIsPaused_returnsResume() {
    // given
    let mockGatherTimeHandler = GatherTimeHandler(state: .paused)
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
    
    // when
    let formattedActionTitleText = sut.formattedActionTitleText
    
    // then
    XCTAssertEqual(formattedActionTitleText, "Resume")
}

Pause 그리고 Start 에 대해 같은 접근방식을 따릅니다:

func testFormattedActionTitleText_whenStateIsRunning_returnsPause() {
    // given
    let mockGatherTimeHandler = GatherTimeHandler(state: .running)
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
    
    // when
    let formattedActionTitleText = sut.formattedActionTitleText
    
    // then
    XCTAssertEqual(formattedActionTitleText, "Pause")
}

func testFormattedActionTitleText_whenStateIsStopped_returnsStart() {
    // given
    let mockGatherTimeHandler = GatherTimeHandler(state: .stopped)
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
    
    // when
    let formattedActionTitleText = sut.formattedActionTitleText
    
    // then
    XCTAssertEqual(formattedActionTitleText, "Start")
}

stopTimer 함수를 테스트하기 위해 시스템을 동작상태로 mock 합니다:

func testStopTimer_whenStateIsRunning_updatesStateToStopped() {
    // given
    let mockGatherTimeHandler = GatherTimeHandler(state: .running)
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
    
    // when
    sut.stopTimer()
    
    // then
    let formattedActionTitleText = sut.formattedActionTitleText
    XCTAssertEqual(formattedActionTitleText, "Start")
}

resetTimer 대해서도 같습니다:

func testResetTimer_whenTimeIsSet_returnsDefaultTime() {
    // given
    let mockMinutes = 12
    let mockSeconds = 13
    let mockGatherTime = GatherTime(minutes: mockMinutes, seconds: mockSeconds)
    let mockGatherTimeHandler = GatherTimeHandler(selectedTime: mockGatherTime)
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
    
    // when
    sut.resetTimer()
    
    // then
    XCTAssertNotEqual(sut.selectedMinutes, mockMinutes)
    XCTAssertNotEqual(sut.selectedSeconds, mockSeconds)
    XCTAssertEqual(sut.selectedMinutes, GatherTime.defaultTime.minutes)
    XCTAssertEqual(sut.selectedSeconds, GatherTime.defaultTime.seconds)
}

pickerViewtableView 의 delegate 는 테스트하기 매우 쉽습니다. 아래는 몇가지 유닛 테스트에 대한 예입니다:

func testNumberOfRowsInSection_whenViewModelHasPlayers_returnsCorrectNumberOfPlayers() {
    // given
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 5)
    let teamAPlayersCount = mockGatherModel.players.filter { $0.team == .teamA}.count
    let teamBPlayersCount = mockGatherModel.players.filter { $0.team == .teamB}.count
    let sut = GatherViewModel(gatherModel: mockGatherModel)
    
    // when
    let numberOfRowsInSection0 = sut.numberOfRowsInSection(0)
    let numberOfRowsInSection1 = sut.numberOfRowsInSection(1)
    
    // then
    XCTAssertEqual(numberOfRowsInSection0, teamAPlayersCount)
    XCTAssertEqual(numberOfRowsInSection1, teamBPlayersCount)
}

func testNumberOfRowsInPickerComponent_whenComponentIsMinutes_returns60() {
    // given
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    let sut = GatherViewModel(gatherModel: mockGatherModel)
    
    // when
    let numberOfRowsInPickerComponent = sut.numberOfRowsInPickerComponent(GatherTimeHandler.Component.minutes.rawValue)
    
    // then
    XCTAssertEqual(numberOfRowsInPickerComponent, 60)
}

수집 종료에 대해 mock 된 엔드포인트와 model 을 사용합니다. 수신된 응답이 true 인지 확인합니다:

func testEndGather_whenScoreIsSet_updatesGather() {
    // given
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    let mockEndpoint = EndpointMockFactory.makeSuccessfulEndpoint(path: resourcePath)
    let mockService = StandardNetworkService(session: session, urlRequest: AuthURLRequestFactory(endpoint: mockEndpoint, keychain: appKeychain))
    let sut = GatherViewModel(gatherModel: mockGatherModel, updateGatherService: mockService)
    let exp = expectation(description: "Update gather expectation")
    
    // when
    sut.endGather(teamAScoreLabelText: "1", teamBScoreLabelText: "1") { gatherUpdated in
        XCTAssertTrue(gatherUpdated)
        exp.fulfill()
    }
    
    // then
    waitForExpectations(timeout: 5, handler: nil)
}

타이머가 토글되었는지 확인하기 위해 MockViewModelDelegate 를 사용합니다:

private extension GatherViewModelTests {
    final class MockViewModelDelegate: GatherViewModelDelegate {
        // [1] Used to check if the delegate was called (didUpdateGatherTime())
        private(set) var gatherTimeWasUpdated = false
        
        // [2] Is fulfilled when the numberOfUpdateCalls is equal to actualUpdateCalls.
        // This means that the selector for the timer was called as many times as we wanted.
        weak var expectation: XCTestExpectation? = nil
        var numberOfUpdateCalls = 1
        private(set) var actualUpdateCalls = 0
        
        func didUpdateGatherTime() {
            gatherTimeWasUpdated = true
            actualUpdateCalls += 1 // [3] Increment the number of calls to this method
            
            if expectation != nil && numberOfUpdateCalls == actualUpdateCalls {
                expectation?.fulfill()
            }
        }
    }
}

그리고 유닛 테스트:

func testToggleTimer_whenSelectedTimeIsValid_updatesTime() {
    // given
    let numberOfUpdateCalls = 2
    let mockGatherTime = GatherTime(minutes: 0, seconds: numberOfUpdateCalls)
    let mockGatherTimeHandler = GatherTimeHandler(selectedTime: mockGatherTime)
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    
    let exp = expectation(description: "Waiting timer expectation")
    let mockDelegate = MockViewModelDelegate()
    mockDelegate.numberOfUpdateCalls = numberOfUpdateCalls
    mockDelegate.expectation = exp
    
    let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
    
    // when
    sut.delegate = mockDelegate
    sut.toggleTimer()
    
    // then
    waitForExpectations(timeout: 5) { _ in
        XCTAssertTrue(mockDelegate.gatherTimeWasUpdated)
        XCTAssertEqual(mockDelegate.actualUpdateCalls, numberOfUpdateCalls)
        sut.stopTimer()
    }
}

MVC 아키텍처에서 View Controller 테스트와 비교하면 ViewModel 레이어를 테스트할 때 더 쉽습니다. 유닛 테스트는 작성하기 쉽고 이해하기 쉽고 더 간단합니다.

주요지표 (Key Metrics)

코드 라인 수 (Lines of code) — view controllers

Untitled

코드 라인 수 (Lines of code) — view models

Untitled

유닛 테스트 (Unit tests)

Untitled

빌드 시간 (Build times)

Untitled

테스트는 iOS 14.3, Xcode 12.4 그리고 i9 MacBook Pro 2019 사양에 iPhone 8 시뮬레이터에서 실행했습니다.

결론 (Conclusion)

애플리케이션은 이제 MVC 에서 MVVM 으로 변환되었습니다. 비지니스 로직을 처리하고 view controller 부터 분리하여 책임을 분리하여 새로운 레이어를 추가했습니다.

MVVM 은 좋은 패턴이고 view controller 의 복잡도를 줄이고 구현을 줄였습니다. 비지니스 로직을 커버하는 유닛 테스트는 작성하기 더 쉽습니다.

그러나 프로젝트에서 UIKit 과 동작하면 MVVM 은 부자연스럽고 적용하기 어렵습니다.

주요지표를 보면 다음과 같은 사항을 알 수 있습니다:

  • view controller 에서 코드 라인 수를 607 라인 줄였습니다.
  • 반면에 view model 을 작성하기 위해 1,113 라인이 추가되었습니다.
  • 총 코드에 506 라인과 7개의 파일이 앱에 추가되었습니다.
  • 평균 유닛 테스트 실행 시간이 5.1 초 증가되어 부정적인 영향을 미쳤습니다.
  • 수집 기능에 적용된 코드 커버리지가 1.6% 증가하여 총 97.3%를 커버하여 기존 로직을 깨뜨리지 않고 앱의 일부를 리팩토링하고 변경 사항을 적용할 때 더 많은 장점을 얻었습니다.
  • MVC 와 비교하면 비지니스 로직을 커버하는 유닛 테스트를 작성하기 더 쉬워졌습니다.

결론적으로 MVVM 은 재밌습니다; 이제는 더 깔끔한 애플리케이션을 가지며 오류가 덜 발생한다고 말할 수 있습니다.

끝까지 읽어 주셔서 감사합니다! 아래 유용한 링크도 참고 바랍니다.

유용한 링크 (Useful Links)