iOS Architecture Patterns 뿌시기: Model-View-ViewModel (MVVM) 보기
Thanks to Radu Dan for allowing the translation.
Reference: Battle of the iOS Architecture Patterns: A Look at Model-View-ViewModel (MVVM)
본 글은 위의 내용에 대한 번역본입니다.
순서는 다음과 같습니다.
iOS Architecture Patterns 뿌시기: Model-View-ViewModel (MVVM) 보기
유명한 아키텍처 패턴을 사용하여 실제 축구선수 iOS 게임을 빌드 해봅시다.
아키텍처 시리즈 — 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” 에 화면 목업
백엔드 (Backend)
이 앱은 Vapor web framework 로 개발된 웹 앱으로 구동됩니다. Vapor 3 initial article 와 article 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
(사용자를 등록하는데 사용하는LoginService
, StandardNetworkService
, 그리고 저장소 관리: 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 에 묻습니다.
PlayerListViewController
는 LoginViewController
보다 더 크고 리팩토링과 비지니스 로직을 추출하기에 더 어렵습니다.
먼저, 아웃렛 (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)
}
pickerView
와 tableView
의 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
코드 라인 수 (Lines of code) — view models
유닛 테스트 (Unit tests)
빌드 시간 (Build times)
테스트는 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)
- The iOS App, Football Gather — GitHub Repo Link
- The web server application made in Vapor — GitHub Repo Link
- Vapor 3 Backend APIs article link
- Migrating to Vapor 4 article link
- Model View Controller (MVC) — GitHub Repo Link and article link
- Model View ViewModel (MVVM) — GitHub Repo Link and article link
- Model View Presenter (MVP) — GitHub Repo link and article link
- Coordinator Pattern — MVP with Coordinators (MVP-C) — GitHub Repo link and article link
- View Interactor Presenter Entity Router (VIPER) — GitHub Repo linkand article link
- View Interactor Presenter (VIP) — GitHub Repo link and article link
- Book about MVVM on raywenderlich.
- Article about MVVM in iOS
- Article “How to not get desperate with MVVM implementation”
- Introduction to Model/View/ViewModel pattern for building WPF apps
- MVVM vs MVC
- Using MVV in iOS
- Practical MVVM + RxSwift
- MVVM with RxSwift
- How to integrate RxSwift in your MVVM architecture
- What Are the Benefits of Model-View-ViewModel
- MVVM Pattern Advantages — Benefits of Using MVVM Model
- Advantages and disadvantages of M-V-VM
- MVVM-1: A General Discussion