iOS Architecture Patterns 뿌시기: Model View Presenter (MVP)
Thanks to Radu Dan for allowing the translation.
Reference: Battle of the iOS Architecture Patterns: Model View Presenter (MVP)
본 글은 위의 내용에 대한 번역본입니다.
순서는 다음과 같습니다.
iOS Architecture Patterns 뿌시기: Model View Presenter (MVP)
아키텍처 시리즈 — Model View Presenter (MVP)
동기 (Motivation)
iOS 앱을 개발하기 전에 프로젝트의 구조에 대해 생각해야 합니다. 나중에 앱의 일부분을 다시 볼 때 코드의 조각을 추가하는 방법과 다른 개발자들과 “언어” 라고 알려진 형식을 고려해야 합니다.
시리즈의 세번째 글에서 MVVM 앱을 MVP 로 변환할 것입니다. 평소와 같이 각 화면에 패턴을 어떻게 적용하는지 보고 실제 구현과 소스코드를 볼 것입니다. 마지막에는 다른 아키텍처 패턴과 비교해서 MVP 에 대한 빌드시간과 주요 관찰사항을 살펴볼 것입니다.
코드를 보기 원한다면 이 글을 건너뛰어도 됩니다. 코드는 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 에서 앱을 확인할 수 있습니다.
MVP 란? (What is MVP)
MVP 는 MVVM 과 약간 비슷하지만 몇가지 주요사항이 있습니다:
- 이제 presenter 레이어가 있습니다.
- presenter 레이어로 view 를 제어할 수 있습니다.
Model
- Model 레이어는 다른 아키텍처와 동일하고 비지니스 데이터를 캡슐화 하는데 사용됩니다.
- 도메인 데이터를 담당하는 인터페이스 입니다.
통신 (Communication)
- 예를 들어 사용자가 액션을 시작하는 것과 같이 view 레이어에서 어떠한 것이 발생하면 Presenter 를 통해 model 로 전달됩니다.
- 예를 들어 새로운 데이터를 사용할 수 있고 UI 를 업데이트 해야하는 경우와 같이 model 이 변경되면 Presenter 는 View 를 업데이트 합니다.
View
- View 레이어는 MVVM 에서와 같지만 View 는 이제 상태 업데이트에 대해 책임이 없습니다. presenter 는 View 를 소유합니다.
통신 (Communication)
- View 는 Model 과 직접적으로 통신할 수 없고 모든 것은 Presenter 를 통해서 수행됩니다.
Presenter
- view 에서 오는 이벤트를 처리하고 Model 과 같이 적절한 이벤트를 트리거하는 책임이 있습니다.
- View 와 Model 을 연결하지만 View 에 어떠한 로직을 추가하지 않습니다.
- View 와 1:1 매핑을 가집니다.
통신 (Communication)
- Model 과 View/View Controller 의 레이어 모두와 통신이 가능합니다.
- view 업데이트는 Presenter 를 통해 수행됩니다.
- 데이터가 변경되면 해당 변경사항이 사용자 인터페이스로 전달되어 View 를 업데이트 되는지 확인합니다.
언제 MVP 를 사용할까 (When to use MVP)
MVC 와 MVVM 이 use case 에 대해 잘 동작하지 않는다고 느낄 때 MVP 를 사용합니다. 앱을 더 모듈화 하고 코드 커버리지를 늘리기 원할 것입니다. 초보자 이거나 iOS 개발에 많은 경험이 없으면 사용하지 마시기 바랍니다. 더 많은 코드를 작성할 준비를 해야 합니다.
예제 앱에서 View 레이어를 두개의 컴포넌트로 분리합니다: ViewController 와 실제 View. ViewController 는 Coordinator / Router 로 역할을 하고 일반적으로 IBOutlet
으로 설정된 View 에 대한 참조를 보유합니다.
장점 (Advantages)
- 다른 패턴에 비해 레이어 분리가 더 좋습니다.
- 비지니스 로직에 대해 더 많은 테스트를 할 수 있습니다.
단점 (Disadvantages)
- 조립 문제 (assembly problem) 은 MVP 에서 더 두드러지게 나타납니다. 대부분 네비게이션과 모듈 조립 (module assembly) 를 다루기 위해 Router 또는 Coordinator 를 도입해야 합니다.
- 이것은 Presenter 에 더 많은 책임을 가지기 때문에 비대한 클래스가 될 수 있는 리스크가 있습니다.
코드에 적용 (Applying to our code)
두가지 중요한 단계가 있습니다:
- ViewModel 을 하나씩 Presenter 로 변환합니다.
- ViewController 로 부터 View 를 분리합니다.
MVP 패턴 적용은 아래 자세히 나와있습니다:
final class FooViewController: UIViewController {
@IBOutlet weak var fooView: FooView!
override func viewDidLoad() {
super.viewDidLoad()
setupView()
}
private func setupView() {
let presenter = FooPresenter(view: fooView)
fooView.delegate = self
fooView.presenter = presenter
fooView.setupView()
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
}
}
extension FooViewController: FooViewDelegate {
func didRequestToNavigateToFooDetail() {
// perform segue
}
}
protocol FooViewDelegate: AnyObject {
func didRequestToNavigateToFooDetail()
}
protocol FooViewProtocol: AnyObject {
func setupView()
}
final class FooView: UIView, FooViewProtocol {
var presenter: FooPresenterProtocol = FooPresenter()
weak var delegate: FooViewDelegate?
func setupView() {
}
func loadData() {
}
}
protocol FooPresenterProtocol: AnyObject {
func loadData()
}
final class FooPresenter: FooPresenterProtocol {
private(set) weak var view: FooViewProtocol?
// model
init(view: FooViewProtocol? = nil) {
self.view = view
}
func loadData() {
}
}
LoginPresenter
LoginPresenter
가 어떻게 보이는지 확인해 봅시다:
// Defines the public API
protocol LoginPresenterProtocol: AnyObject {
var rememberUsername: Bool { get }
var username: String? { get }
func setRememberUsername(_ value: Bool)
func setUsername(_ username: String?)
func performLogin(withUsername username: String?, andPassword password: String?)
func performRegister(withUsername username: String?, andPassword password: String?)
}
모든 파라미터는 초기화 구문을 통해 주입됩니다.
final class LoginPresenter: LoginPresenterProtocol {
private weak var view: LoginViewProtocol?
private let loginService: LoginService
private let usersService: StandardNetworkService
private let userDefaults: FootballGatherUserDefaults
private let keychain: FootbalGatherKeychain
init(view: LoginViewProtocol? = nil,
loginService: LoginService = LoginService(),
usersService: StandardNetworkService = StandardNetworkService(resourcePath: "/api/users"),
userDefaults: FootballGatherUserDefaults = .shared,
keychain: FootbalGatherKeychain = .shared) {
self.view = view
self.loginService = loginService
self.usersService = usersService
self.userDefaults = userDefaults
self.keychain = keychain
}
}
Keychain 상호작용은 아래와 같이 정의됩니다:
var rememberUsername: Bool {
return userDefaults.rememberUsername ?? true
}
var username: String? {
return keychain.username
}
func setRememberUsername(_ value: Bool) {
userDefaults.rememberUsername = value
}
func setUsername(_ username: String?) {
keychain.username = username
}
그리고 두개의 서비스를 가집니다:
func performLogin(withUsername username: String?, andPassword password: String?) {
guard let userText = username, userText.isEmpty == false,
let passwordText = password, passwordText.isEmpty == false else {
// Key difference between MVVM and MVP, the presenter now tells the view what should do.
view?.handleError(title: "Error", message: "Both fields are mandatory.")
return
}
// Presenter tells the view to present a loading indicator.
view?.showLoadingView()
let requestModel = UserRequestModel(username: userText, password: passwordText)
loginService.login(user: requestModel) { [weak self] result in
DispatchQueue.main.async {
self?.view?.hideLoadingView()
switch result {
case .failure(let error):
self?.view?.handleError(title: "Error", message: String(describing: error))
case .success(_):
// Go to next screen
self?.view?.handleLoginSuccessful()
}
}
}
}
등록 함수는 기본적으로 로그인 함수와 동일합니다:
func performRegister(withUsername username: String?, andPassword password: String?) {
guard let userText = username, userText.isEmpty == false,
let passwordText = password, passwordText.isEmpty == false else {
view?.handleError(title: "Error", message: "Both fields are mandatory.")
return
}
guard let hashedPasssword = Crypto.hash(message: passwordText) else {
fatalError("Unable to hash password")
}
view?.showLoadingView()
let requestModel = UserRequestModel(username: userText, password: hashedPasssword)
usersService.create(requestModel) { [weak self] result in
DispatchQueue.main.async {
self?.view?.hideLoadingView()
switch result {
case .failure(let error):
self?.view?.handleError(title: "Error", message: String(describing: error))
case .success(let resourceId):
print("Created user: \(resourceId)")
self?.view?.handleRegisterSuccessful()
}
}
}
}
LoginView
는 다음의 프로토콜을 가집니다:
// MARK: - LoginViewDelegate
protocol LoginViewDelegate: AnyObject { // This is how it communicates with the ViewController
func presentAlert(title: String, message: String)
func didLogin()
func didRegister()
}
// MARK: - LoginViewProtocol
protocol LoginViewProtocol: AnyObject { // The Public API
func setupView()
func showLoadingView()
func hideLoadingView()
func handleError(title: String, message: String)
func handleLoginSuccessful()
func handleRegisterSuccessful()
}
ViewController 로직의 대부분은 이제 View 안에 있습니다.
// MARK: - LoginView
final class LoginView: UIView, Loadable {
// MARK: - Properties
@IBOutlet weak var usernameTextField: UITextField!
@IBOutlet weak var passwordTextField: UITextField!
@IBOutlet weak var rememberMeSwitch: UISwitch!
lazy var loadingView = LoadingView.initToView(self)
weak var delegate: LoginViewDelegate?
var presenter: LoginPresenterProtocol = LoginPresenter()
private func configureRememberMe() {
rememberMeSwitch.isOn = presenter.rememberUsername
if presenter.rememberUsername {
usernameTextField.text = presenter.username
}
}
// [1] Convenient function to store the username and remember name values.
private func storeUsernameAndRememberMe() {
presenter.setRememberUsername(rememberMeSwitch.isOn)
if rememberMeSwitch.isOn {
presenter.setUsername(usernameTextField.text)
} else {
presenter.setUsername(nil)
}
}
// [2] The service call and the show/hide loading indicator has now moved into the responsibility of the Presenter.
@IBAction private func login(_ sender: Any) {
presenter.performLogin(withUsername: usernameTextField.text, andPassword: passwordTextField.text)
}
// [3] Same for registration.
@IBAction private func register(_ sender: Any) {
presenter.performRegister(withUsername: usernameTextField.text, andPassword: passwordTextField.text)
}
}
extension LoginView: LoginViewProtocol {
func setupView() {
configureRememberMe()
}
func handleError(title: String, message: String) {
delegate?.presentAlert(title: title, message: message)
}
// [4] A more MVP way would have been to just leave delegate?.didLogin in this function.
// The store username and remember me logic should have been done in the Presenter and expose
// whatever needed from the View in the LoginViewProtocol.
func handleLoginSuccessful() {
storeUsernameAndRememberMe()
delegate?.didLogin()
}
func handleRegisterSuccessful() {
storeUsernameAndRememberMe()
delegate?.didRegister()
}
}
마지막으로 ViewController:
// MARK: - LoginViewController
final class LoginViewController: UIViewController {
// [1] Another way would have been to cast self.view to a LoginViewProtocol and extract it to a variable.
@IBOutlet weak var loginView: LoginView!
override func viewDidLoad() {
super.viewDidLoad()
setupView()
}
private func setupView() {
let presenter = LoginPresenter(view: loginView)
loginView.delegate = self
loginView.presenter = presenter
loginView.setupView()
}
}
// MARK: - LoginViewDelegate
extension LoginViewController: LoginViewDelegate {
func presentAlert(title: String, message: String) {
// [2] Show the alert.
AlertHelper.present(in: self, title: title, message: message)
}
// [3] Navigate to player list screen.
func didLogin() {
performSegue(withIdentifier: SegueIdentifier.playerList.rawValue, sender: nil)
}
func didRegister() {
performSegue(withIdentifier: SegueIdentifier.playerList.rawValue, sender: nil)
}
}
화면을 하나씩 가져와 MVVM 아키텍처를 MVP 로 변환합니다.
PlayerListPresenter
다음 화면은 PlayerList 이고 웹 API 호출부터 시작합니다: **
func performPlayerDeleteRequest() {
guard let indexPath = indexPathForDeletion else { return }
view?.showLoadingView()
requestDeletePlayer(at: indexPath) { [weak self] result in
if result {
self?.view?.handlePlayerDeletion(forRowAt: indexPath)
}
}
}
선수 삭제에 대한 확인은 View/ViewController 가 아니라 Presenter 에서 이루어집니다.
private func requestDeletePlayer(at indexPath: IndexPath, completion: @escaping (Bool) -> Void) {
let player = players[indexPath.row]
var service = playersService
service.delete(withID: ResourceID.integer(player.id)) { [weak self] result in
DispatchQueue.main.async {
// [1] We tell the view to hide the spinner view.
self?.view?.hideLoadingView()
switch result {
case .failure(let error):
self?.view?.handleError(title: "Error", message: String(describing: error))
completion(false)
case .success(_):
// [2] Player was deleted.
completion(true)
}
}
}
}
table view 의 data source 메서드에서 PlayerListView
를 보면 Presenter 가 정확하게 ViewModel 과 일치하게 동작한다는 것을 알 수 있습니다:
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return presenter.numberOfRows
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell: PlayerTableViewCell = tableView.dequeueReusableCell(withIdentifier: "PlayerTableViewCell") as? PlayerTableViewCell else {
return UITableViewCell()
}
if presenter.isInListViewMode {
presenter.clearSelectedPlayerIfNeeded(at: indexPath)
cell.setupDefaultView()
} else {
cell.setupSelectionView()
}
cell.nameLabel.text = presenter.playerNameDescription(at: indexPath)
cell.positionLabel.text = presenter.playerPositionDescription(at: indexPath)
cell.skillLabel.text = presenter.playerSkillDescription(at: indexPath)
cell.playerIsSelected = presenter.playerIsSelected(at: indexPath)
return cell
}
PlayerListViewController
는 이제 수정 (Edit), 확인 (Confirm) 그리고 추가 (Add) 화면 사이에서 router 역할을 합니다.
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
switch segue.identifier {
case SegueIdentifier.confirmPlayers.rawValue:
// [1] Compose the selected players that will be added in the ConfirmPlayersPresenter.
if let confirmPlayersViewController = segue.destination as? ConfirmPlayersViewController {
confirmPlayersViewController.playersDictionary = playerListView.presenter.playersDictionary
}
// [2] Set the player that we want to show the details.
case SegueIdentifier.playerDetails.rawValue:
if let playerDetailsViewController = segue.destination as? PlayerDetailViewController,
let player = playerListView.presenter.selectedPlayerForDetails {
// [3] From Details screen we can edit a player. Using delegation, we listen for
// such modifications and refresh this view presenting the player with the updated details.
playerDetailsViewController.delegate = self
playerDetailsViewController.player = player
}
case SegueIdentifier.addPlayer.rawValue:
(segue.destination as? PlayerAddViewController)?.delegate = self
default:
break
}
}
책임을 나누어 PlayerList 모듈은 다음의 컴포넌트가 있습니다.
PlayerListViewController
책임:
- 수집이 완료될 때마다 (
GatherViewController
로 부터 호출됨)listView
모드 상태로 돌아가기 위해PlayerListTogglable
프로토콜을 구현합니다. PlayerListView
에IBOutlet
를 가집니다.- presenter, view delegate 를 설정하고 view 에 설정하도록 요청합니다.
- 네비게이션 로직을 처리하고 수정 (Edit), 추가 (Add) 그리고 확인 (Confirm) 화면에 대한 model 을 구성합니다.
PlayerListViewDelegate
구현하고 다음의 동작을 수행합니다:- view 는 타이틀을 변경하기 위해 호출하면 (
func didRequestToChangeTitle(_ title: String)
) 타이틀을 변경합니다. - 네비게이션 오른편에 bar 버튼 아이템을 추가합니다 (선수 선택을 Select 또는 Cancel)
- Presenter 에서 구성된 identifier 를 사용하여 적절한 segue 를 수행합니다.
- 예를 들어 서비스 실패 시 타이틀과 메세지를 가진 alert 를 나타냅니다.
- 삭제 확인 alert 을 나타냅니다.
PlayerDetailViewControllerDelegate
을 구현하면 선수가 수정될 때 View 를 새로고침 하기위해 요청합니다.AddPlayerDelegate
의 경우와 비슷하며 여기에서는 View 가 선수의 리스트를 다시 로드하도록 요청합니다.
PlayerListView
책임:
- public API,
PlayerListViewProtocol
을 노출합니다. 이 레이어는 가능한 단순하고 복잡하지 않아야 합니다.
PlayerListPresenter
책임:
PlayerListPresenterProtocol
을 보면 많은 일을 한다는 것을 알 수 있습니다.barButtonItemTitle
,barButtonItemIsEnabled
등과 같이 View 에 대한 필요한 메서드를 노출합니다.
PlayerListViewState
책임:
PlayerListView
의 다른 상태를 할당하기 위해 Factory Method 패턴을 사용하여 MVVM 에서와 동일한 기능을 유지하며ViewState
를 새로운 파일로 추출하기로 결정했습니다.
PlayerDetail screen
계속해서 PlayerDetail 화면에서 View 와 ViewController 를 분리합니다.
// MARK: - PlayerDetailViewController
final class PlayerDetailViewController: UIViewController {
// MARK: - Properties
@IBOutlet weak var playerDetailView: PlayerDetailView!
weak var delegate: PlayerDetailViewControllerDelegate?
var player: PlayerResponseModel?
// .. other methods
}
동일한 패턴에 따라 수정 화면에 대한 네비게이션은 delegation 을 통해 수행됩니다:
- 사용자가 선수 프로퍼티에 해당하는 행 중 하나를 탭합니다. View 는 수정하기 위해 해당 필드에 요청하고 ViewController 는 올바른 segue 를 수행합니다.
prepare:for:segue
메서드에서 선수를 수정하는데 필요한 프로퍼티를 할당합니다.
extension PlayerDetailViewController: PlayerDetailViewDelegate {
func didRequestEditView() {
performSegue(withIdentifier: SegueIdentifier.editPlayer.rawValue, sender: nil)
}
}
PlayerDetailViewController
내부:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard segue.identifier == SegueIdentifier.editPlayer.rawValue,
let destinationViewController = segue.destination as? PlayerEditViewController else {
return
}
let presenter = playerDetailView.presenter
// [1] Show the textfield or the picker for editing a player field
destinationViewController.viewType = presenter?.destinationViewType ?? .text
// [2] The edit model
destinationViewController.playerEditModel = presenter?.playerEditModel
// [3] In case we are in picker mode, we need to specify the data source.
destinationViewController.playerItemsEditModel = presenter?.playerItemsEditModel
destinationViewController.delegate = self
}
PlayerDetailView
는 아래와 같이 나타냅니다:
final class PlayerDetailView: UIView, PlayerDetailViewProtocol {
// MARK: - Properties
@IBOutlet weak var playerDetailTableView: UITableView!
weak var delegate: PlayerDetailViewDelegate?
var presenter: PlayerDetailPresenterProtocol!
// MARK: - Public API
var title: String {
return presenter.title
}
func reloadData() {
playerDetailTableView.reloadData()
}
func updateData(player: PlayerResponseModel) {
presenter.updatePlayer(player)
presenter.reloadSections()
}
}
그리고 table view delegate 와 data source 를 구현합니다:
extension PlayerDetailView: UITableViewDelegate, UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
return presenter.numberOfSections
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return presenter.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 = presenter.rowTitleDescription(for: indexPath)
cell.rightLabel.text = presenter.rowValueDescription(for: indexPath)
return cell
}
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
return presenter.titleForHeaderInSection(section)
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
presenter.selectPlayerRow(at: indexPath)
delegate?.didRequestEditView()
}
}
PlayerDetailPresenter
:
final class PlayerDetailPresenter: PlayerDetailPresenterProtocol {
// MARK: - Properties
private(set) var player: PlayerResponseModel
private lazy var sections = makeSections()
private(set) var selectedPlayerRow: PlayerRow?
// MARK: - Public API
init(player: PlayerResponseModel) {
self.player = player
}
// other methods
}
수정 화면 (Edit Screen)
앱의 나머지 화면에 대해 동일한 접근방식을 따릅니다. PlayerEdit
기능이 아래 예제에 있습니다. PlayerEditView
클래스는 기본적으로 새로운 ViewController 입니다.
final class PlayerEditView: UIView, Loadable {
// MARK: - Properties
@IBOutlet weak var playerEditTextField: UITextField!
@IBOutlet weak var playerTableView: UITableView!
private lazy var doneButton = UIBarButtonItem(title: "Done", style: .done, target: self, action: #selector(doneAction))
lazy var loadingView = LoadingView.initToView(self)
weak var delegate: PlayerEditViewDelegate?
var presenter: PlayerEditPresenterProtocol!
// other methods
}
selector 는 간단합니다:
// MARK: - Selectors
@objc private func textFieldDidChange(textField: UITextField) {
doneButton.isEnabled = presenter.doneButtonIsEnabled(newValue: playerEditTextField.text)
}
@objc private func doneAction(sender: UIBarButtonItem) {
presenter.updatePlayerBasedOnViewType(inputFieldValue: playerEditTextField.text)
}
그리고 Public API:
extension PlayerEditView: PlayerEditViewProtocol {
var title: String {
return presenter.title
}
func setupView() {
setupNavigationItems()
setupPlayerEditTextField()
setupTableView()
}
func handleError(title: String, message: String) {
delegate?.presentAlert(title: title, message: message)
}
func handleSuccessfulPlayerUpdate() {
delegate?.didFinishEditingPlayer()
}
}
마지막으로,
UITableViewDataSource
와 UITableViewDelegate
메서드:
extension PlayerEditView: UITableViewDelegate, UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return presenter.numberOfRows
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "ItemSelectionCellIdentifier") else {
return UITableViewCell()
}
cell.textLabel?.text = presenter.itemRowTextDescription(indexPath: indexPath)
cell.accessoryType = presenter.isSelectedIndexPath(indexPath) ? .checkmark : .none
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if let selectedItemIndex = presenter.selectedItemIndex {
clearAccessoryType(forSelectedIndex: selectedItemIndex)
}
presenter.updateSelectedItemIndex(indexPath.row)
tableView.cellForRow(at: indexPath)?.accessoryType = .checkmark
doneButton.isEnabled = presenter.doneButtonIsEnabled(selectedIndexPath: indexPath)
}
private func clearAccessoryType(forSelectedIndex selectedItemIndex: Int) {
let indexPath = IndexPath(row: selectedItemIndex, section: 0)
playerTableView.cellForRow(at: indexPath)?.accessoryType = .none
}
func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
tableView.cellForRow(at: indexPath)?.accessoryType = .none
}
}
PlayerEditPresenter
는 비지니스 로직을 처리하고 UI 요소 업데이트를 위한 프로퍼티를 노출합니다.
final class PlayerEditPresenter: PlayerEditPresenterProtocol {
// MARK: - Properties
private weak var view: PlayerEditViewProtocol?
private var playerEditModel: PlayerEditModel
private var viewType: PlayerEditViewType
private var playerItemsEditModel: PlayerItemsEditModel?
private var service: StandardNetworkService
// MARK: - Public API
init(view: PlayerEditViewProtocol? = nil,
viewType: PlayerEditViewType = .text,
playerEditModel: PlayerEditModel,
playerItemsEditModel: PlayerItemsEditModel? = nil,
service: StandardNetworkService = StandardNetworkService(resourcePath: "/api/players", authenticated: true)) {
self.view = view
self.viewType = viewType
self.playerEditModel = playerEditModel
self.playerItemsEditModel = playerItemsEditModel
self.service = service
}
// other methods
}
API 호출은 아래에 자세히 나와있습니다:
func updatePlayerBasedOnViewType(inputFieldValue: String?) {
// [1] Check if we updated something.
guard shouldUpdatePlayer(inputFieldValue: inputFieldValue) else { return }
// [2] Present a loading indicator.
view?.showLoadingView()
let fieldValue = isSelectionViewType ? selectedItemValue : inputFieldValue
// [3] Make the Network call.
updatePlayer(newFieldValue: fieldValue) { [weak self] updated in
DispatchQueue.main.async {
self?.view?.hideLoadingView()
self?.handleUpdatedPlayerResult(updated)
}
}
}
PlayerAdd, Confirm 그리고 Gather 화면은 동일한 접근방식을 따릅니다.
비지니스 로직 테스트 (Testing our business logic)
테스트 접근방식은 MVVM 에 대해 수행한 것과 90% 동일합니다.
또한 view 를 mock 하고 적절한 메서드가 호출되었는지 확인해야 합니다. 예를 들어 서비스 API 가 호출 시 view 가 상태를 다시 로드하거나 실패 케이스에서 에러를 처리하는지 확인합니다.
GatherPresenter
의 유닛 테스트:
// [1] Basic setup
final class GatherPresenterTests: XCTestCase {
// [2] Define the Mocked network classes.
private let session = URLSessionMockFactory.makeSession()
private let resourcePath = "/api/gathers"
private let appKeychain = AppKeychainMockFactory.makeKeychain()
// [3] Setup and clear the Keychain variables.
override func setUp() {
super.setUp()
appKeychain.token = ModelsMock.token
}
override func tearDown() {
appKeychain.storage.removeAll()
super.tearDown()
}
}
countdownTimerLabelText
테스트:
func testFormattedCountdownTimerLabelText_whenViewModelIsAllocated_returnsDefaultTime() {
// given
let gatherTime = GatherTime.defaultTime
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 = GatherPresenter(gatherModel: mockGatherModel)
// when
let formattedCountdownTimerLabelText = sut.formattedCountdownTimerLabelText
// then
XCTAssertEqual(formattedCountdownTimerLabelText, "\(expectedFormattedMinutes):\(expectedFormattedSeconds)")
}
func testFormattedCountdownTimerLabelText_whenPresenterIsAllocated_returnsDefaultTime() {
// given
let gatherTime = GatherTime.defaultTime
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 = GatherPresenter(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 = GatherPresenter(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 = GatherPresenter(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 = GatherPresenter(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
// when
let formattedCountdownTimerLabelText = sut.formattedCountdownTimerLabelText
// then
XCTAssertEqual(formattedCountdownTimerLabelText, "00:10")
}
토클 타이머는 더 흥미롭습니다:
func testToggleTimer_whenSelectedTimeIsNotValid_returns() {
// given
let mockGatherTime = GatherTime(minutes: -1, seconds: -1)
let mockGatherTimeHandler = GatherTimeHandler(selectedTime: mockGatherTime)
let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
// [1] Allocate the mock view.
let mockView = MockView()
let sut = GatherPresenter(view: mockView, gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
// when
sut.toggleTimer()
// then
// [2] configureSelectedTime() was not called.
XCTAssertFalse(mockView.selectedTimeWasConfigured)
}
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)
// [1] Configure the mock view parameters
let exp = expectation(description: "Waiting timer expectation")
let mockView = MockView()
mockView.numberOfUpdateCalls = numberOfUpdateCalls
mockView.expectation = exp
let sut = GatherPresenter(view: mockView, gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
// when
sut.toggleTimer()
// [2] Selector should be called two times.
// then
waitForExpectations(timeout: 5) { _ in
XCTAssertTrue(mockView.selectedTimeWasConfigured)
XCTAssertEqual(mockView.actualUpdateCalls, numberOfUpdateCalls)
sut.stopTimer()
}
}
그리고 아래는 mock view 입니다:
private extension GatherPresenterTests {
final class MockView: GatherViewProtocol {
private(set) var selectedTimeWasConfigured = false
weak var expectation: XCTestExpectation? = nil
var numberOfUpdateCalls = 1
private(set) var actualUpdateCalls = 0
func configureSelectedTime() {
selectedTimeWasConfigured = true
actualUpdateCalls += 1
if expectation != nil && numberOfUpdateCalls == actualUpdateCalls {
expectation?.fulfill()
}
}
func handleSuccessfulEndGather() {
expectation?.fulfill()
}
func setupView() {}
func showLoadingView() {}
func hideLoadingView() {}
func handleError(title: String, message: String) {}
func confirmEndGather() {}
}
}
presenter 를 테스트하는 것은 매우 좋습니다. 마법같은 걸 할 필요도 없고 메서드는 크기가 작아 도움이 됩니다. 복잡한 것은 View 레이어를 mock 하고 일부 파라미터가 적절하게 변경되는지 확인해야 합니다.
주요지표 (Key Metrics)
코드 라인 수 (Lines of code) — View Controllers
코드 라인 수 (Lines of code) — Views
코드 라인 수 (Lines of code) — Presenters
코드 라인 수 (Lines of code) — Local Models
유닛 테스트 (Unit Tests)
빌드 시간 (Build Times)
테스트는 iOS 14.3, Xcode 12.4 그리고 i9 MacBook Pro 2019 사양에 iPhone 8 시뮬레이터에서 실행했습니다.
결론 (Conclusion)
이제 애플리케이션은 MVVM 에서 MVP 로 재작성 되었습니다. 이 접근방식은 간단하고 ViewModel 마다 Presenter 레이어로 변경했습니다.
추가적으로 관계를 더 구분하기 위해 ViewController 에서 View 라는 새로운 레이어를 생성합니다. Single Responsibility Principle 을 수용하여 코드는 깔끔해 보이고 view controller 는 더 다이어트 되고 클래스와 함수가 작고 한가지에 집중합니다.
개인적으로 UIKit
을 염두에 두고 앱을 개발할 때 MVVM 보다 이 패턴을 더 선호합니다. MVVM 보다 더 자연스럽습니다.
주요지표를 보면 다음을 알 수 있습니다:
- View Controller 는 더 다이어트 되고 전체적으로 코드의 라인 수를 1,000 라인 이상 줄였습니다.
- 그러나 UI 업데이트를 위해 View 라는 새로운 레이어를 도입합니다.
- view 를 관리하기 위해 추가 책임이 늘었으므로 Presenter 는 View Model 보다 큽니다.
- 유닛 테스트 작성은 97.2% 코드 커버리지와 동일함을 포함하여 MVVM 과 유사합니다.
- 더 많은 파일과 클래스를 가지므로 빌드 시간에 약간의 영향을 끼치며 MVVM 보다 530 ms 그리고 MVC 보다 400 ms 증가됩니다.
- 놀랍게도 평균 유닛 테스트 실행 시간은 MVVM 보다 1.36 초 빨라졌습니다.
- MVC 패턴과 비교하여 비지니스 로직을 커버하는 유닛 테스트는 작성하기 더 쉽습니다.
MVVM 으로 작성한 앱을 MVP 와 같은 다른 패턴으로 변환하는 방법을 보았습니다. 내 관점에서 보면 ViewController 로 부터 View 를 분리하는 MVP 는 MVVM 보다 좋습니다. 레이어에 더 큰 힘을 가져다 주고 서로 분리하고 의존성 주입 (dependency injection) 사용이 더 쉽습니다.
끝까지 읽어 주셔서 감사합니다! 아래 유용한 링크도 참고 바랍니다.
유용한 링크 (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