iOS Architecture Patterns 뿌시기: Model View Presenter with Coordinators (MVP-C)
Thanks to Radu Dan for allowing the translation.
Reference: Battle of the iOS Architecture Patterns: Model View Presenter with Coordinators (MVP-C)
본 글은 위의 내용에 대한 번역본입니다.
순서는 다음과 같습니다.
iOS Architecture Patterns 뿌시기: Model View Presenter with Coordinators (MVP-C)
아키텍처 시리즈 — Model View Presenter with Coordinators (MVP-C)
동기 (Motivation)
iOS 앱을 개발하기 전에 프로젝트의 구조에 대해 생각해야 합니다. 나중에 앱의 일부분을 다시 볼 때 코드의 조각을 추가하는 방법과 다른 개발자들과 “언어” 라고 알려진 형식을 고려해야 합니다.
다른 글을 놓쳤다면 아래 내용을 확인하거나 이 글 마지막에 링크에서 확인할 수 있습니다.
- Model View Controller (MVC) — link here
- Model View ViewModel (MVVM) — link here
- Model View Presenter (MVP) — link here
코드에 Coordinator 패턴을 도입하여 어떻게 더 간단한 네비게이션을 만들지 살펴 볼 것입니다. 평소와 같이 각 화면에 패턴을 어떻게 적용하는지 보고 실제 구현과 소스코드를 볼 것입니다. 마지막에는 다른 아키텍처 패턴과 비교해서 Coordinator 를 가진 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 에서 앱을 확인할 수 있습니다.
Coordinators 란? (What are Coordinators)
Coordinator 의 컨셉은 view controller 에서 플로우 로직을 처리하기 위한 해결책으로 Soroush Khanlou, in 2015 에 의해 처음 창시되었습니다.
앱이 커지고 복잡해 짐에 따라 새로운 곳에서 view controller 의 일부를 재사용 해야 될 필요성이 있으며 view controller 에서 플로우 로직을 결합하면 이것을 해결하기 어렵습니다. 패턴을 잘 실행하려면 Soroush 가 말했듯이 전체 애플리케이션을 지시하는 최상위 또는 기본 coordinator 가 필요합니다.
coordinator 로 플로우를 추출하는 것에 몇가지 장점이 있습니다:
- View Controller 는 앱에서 사용하는 아키텍처 패턴에 따라 주요 목표에 집중할 수 있습니다 (예를 들어 View 에 Model 바인딩).
- View Controller 의 초기화는 다른 레이어에서 추출됩니다.
Coordinator 로 해결되는 문제:
- 과도한 앱 delegate: 앱 delegate 에 많은 것을 추가하는 경향이 있고 기본 앱 coordinator 를 사용하여 일부 코드를 옮길 수 있습니다.
- 너무 많은 책임: View Controller 는 특히 MVC 아키텍처에서 많은 작업을 수행하는 경향이 있습니다 (model 바인딩, view 처리, 데이터 조회, 데이터 변환 등).
- 부드러운 플로우: 네비게이션 플로우는 이제 View Controller 의 밖으로 이동되고 coordinator 에 추가됩니다.
앱 coordinator 로 시작하여 AppDelegate
에서 수행하는 많은 것의 문제를 해결합니다. 여기서 window 객체를 할당할 수 있고 네비게이션 컨트롤러를 생성하고 첫번째 view controller 를 초기화 합니다. Martin Fowler’s - Patterns of Enterprise Application Architecture 에서는 Application Controller 라고 불립니다.
coordinator 의 규칙은 모든 coordinator 가 자식 coordinator 의 배열을 가지고 있어야 합니다. 이런식으로 자식 coordinator 가 할당 해제되는 것을 방지합니다. 탭바 앱을 가지고 있는 경우 각 네비게이션 컨트롤러는 자체 coordinator 를 가집니다. 각 coordinator 는 부모 coordinator 에 의해 할당됩니다.
플로우 로직 외에도 coordinator 는 view controller 로 부터 model 변경의 책임을 가질 수도 있습니다.
장점 (Advantages)
- 각 view controller 는 이제 완전 분리되었습니다.
- View Controller 는 재사용 가능합니다.
- 앱에 모든 작업과 하위 작업은 캡슐화 할 수 있습니다.
- Coordinator 는 사이드 이펙트로 부터 display-바인딩을 분리합니다.
- Coordinator 는 완전 제어할 수 있는 객체입니다.
심각한 문제 (The back problem)
네비게이션 컨트롤러가 스택에서 뒤로 이동하면 무슨일이 벌어질까요? 특별한 바 버튼 아이템의 경우 많은 제어를 가질 수 없습니다. 자체 사용자 정의 백 버튼을 작성할 수 있지만 사용자가 뒤로가기 위해 오른편으로 스와이프 하면 어떤 일이 벌어질까요?
이 문제를 해결하기 위한 한가지 방법은 view controller 내부에서 coordinator 의 참조를 유지하고 viewDidDisappear
에서 didFinish
메서드를 호출합니다. 간단한 앱에서는 좋은 방법이지만 예를 들어 자식 coordinator 에서 여러 view controller 가 보여지는 경우 문제를 해결할 수 없습니다.
Soroush 가 언급한 대로 이러한 이벤트에 접근하기 위해 UINavigationControllerDelegate
을 구현할 수 있습니다.
- 메인 앱 coordinator 에서
UINavigationControllerDelegate
을 구현합니다. 네비게이션 컨트롤러는 View Controller 의 View 와 네비게이션 아이템 프로퍼티를 표시한 직후에 호출되는 인스턴스 메서드navigationController:didShowViewController:animated:
에 관심을 가집니다. View Controller 가 View 스택에서 팝되는 트리거된 이벤트를 얻으면 관련된 coordinator 를 할당 해제할 수 있습니다. UIViewController
를 하위 클래스로 만들고 이를 플로우의 부분으로 만듭니다. 이 특별한 하위 클래스에는 coordinator 의 항목을 유지하는 딕셔너리를 가집니다:private var viewControllersToChildCoordinators: [UIViewController: Coordinator] = [:]
. 이 클래스에서UINavigationControllerDelegate
을 구현합니다. View Controller 가 팝 되고 딕셔너리의 일부분이면 이것은 삭제되고 할당 해제 됩니다. 이 접근 방식의 단점은UIViewController
인 특수 하위 클래스는 많은 것을 수행한다는 것입니다.
코드에 적용 (Applying to our code)
첫째로 애플리케이션 coordinator 를 정의하는 것으로 시작합니다:
protocol Coordinator: AnyObject {
var childCoordinators: [Coordinator] { get set }
var parent: Coordinator? { get set }
func start()
}
start
함수는 View Controller 를 할당하고 네비게이션 컨트롤러 스택에 푸쉬합니다.
protocol Coordinatable: AnyObject {
var coordinator: Coordinator? { get set }
}
View Controller 가 구현할 Coordinatable
프로젝트를 정의하여 뒤로가기와 같은 coordinator 의 특정 네비게이션 작업을 위임할 수 있습니다.
다음으로 메인 앱 coordinator 를 생성합니다: AppCoordinator
그리고 AppDelegate
내에서 초기화 합니다.
final class AppCoordinator: NSObject, Coordinator {
weak var parent: Coordinator?
var childCoordinators: [Coordinator] = []
private let navController: UINavigationController
private let window: UIWindow
init(navController: UINavigationController = UINavigationController(),
window: UIWindow = UIWindow(frame: UIScreen.main.bounds)) {
self.navController = navController
self.window = window
}
func start() {
navController.delegate = self
window.rootViewController = navController
window.makeKeyAndVisible()
}
}
AppDelegate
는 이제 아래와 같습니다:
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
// We want to initialise the window object after did finish launching with options
private lazy var appCoordinator = AppCoordinator()
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
appCoordinator.start()
return true
}
…
}
첫번째 화면은 Login 입니다. coordinator 를 지원할 수 있도록 다음의 조정을 수행합니다:
final class LoginViewController: UIViewController, Coordinatable {
@IBOutlet weak var loginView: LoginView!
weak var coordinator: Coordinator?
// For convenience we cast the coordinator to the specific screen Coordinator.
private var loginCoordinator: LoginCoordinator? { coordinator as? LoginCoordinator }
…
}
extension LoginViewController: LoginViewDelegate {
…
// Segues are not performed any more, we let this job to be done by the Coordinator.
func didLogin() {
loginCoordinator?.navigateToPlayerList()
}
func didRegister() {
loginCoordinator?.navigateToPlayerList()
}
}
LoginCoordinator
LoginCoordinator
는 아래와 같습니다:
final class LoginCoordinator: Coordinator {
weak var parent: Coordinator?
var childCoordinators: [Coordinator] = []
private let navController: UINavigationController
init(navController: UINavigationController, parent: Coordinator? = nil) {
self.navController = navController
self.parent = parent
}
func start() {
let viewController: LoginViewController = Storyboard.defaultStoryboard.instantiateViewController()
viewController.coordinator = self
navController.pushViewController(viewController, animated: true)
}
func navigateToPlayerList() {
let playerListCoordinator = PlayerListCoordinator(navController: navController, parent: self)
playerListCoordinator.start()
childCoordinators.append(playerListCoordinator)
}
}
Coordinatable
의 대안은 메서드 navigateToPlayerList
을 포함하는 LoginViewControllerDelegate
을 생성하고 LoginCoordinator
을 이 클래스의 delegate 로 만드는 delegation 을 사용하는 것입니다.
그리고 LoginScreen 에 대한 마지막 조각은 storyboard 에 segue 를 삭제하는 것입니다.
Main.storyboard 의 간단 버전
storyboard 에서 모든 View Controller 를 인스턴스화 하기 때문에 이것에 대한 편리한 메서드를 정의해 보겠습니다:
enum Storyboard: String {
case main = "Main"
static var defaultStoryboard: UIStoryboard {
return UIStoryboard(name: Storyboard.main.rawValue, bundle: nil)
}
}
extension UIStoryboard {
func instantiateViewController<T>(withIdentifier identifier: String = String(describing: T.self)) -> T {
return instantiateViewController(withIdentifier: identifier) as! T
}
}
이제 지정된 storyboard 와 사용하는 storyboard 에서 storyboard ID 설정으로 View Controller 를 할당할 수 있습니다:
let viewController: PlayerListViewController = Storyboard.defaultStoryboard.instantiateViewController()
PlayerList 화면은 다음과 같이 조정됩니다:
- 팝 기능은 전적으로 Coordinator 의 책임이므로
PlayerListTogglable
를 삭제합니다. - View Controller 의 Coordinator 를 참조할 수 있으므로
Coordinatable
를 구현합니다. PlayerDetailViewControllerDelegate
에서 delegate 메서드인AddPlayerDelegate
와PlayerListTogglable
을 삭제합니다.- 선수가 편집 (데이터 로드), 추가되고 수집을 마친 후에 요청되는 메서드인 public API 를 향상합니다 (
toggleViewState
).
weak var coordinator: Coordinator?
private var listCoordinator: PlayerListCoordinator? { coordinator as? PlayerListCoordinator }
/// .....
func reloadView() {
playerListView.loadPlayers()
}
func didEdit(player: PlayerResponseModel) {
playerListView.didEdit(player: player)
}
func toggleViewState() {
playerListView.toggleViewState()
}
PlayerList 에서 추가 또는 수정 화면과 같은 다른 화면으로 이동하는 것은 Presenter 에서 적절한 segue 식별자를 생성하고 View 레이어를 사용하여 ViewController 로 전달합니다. 이제 segue 식별자의 사용은 중단되었으므로 모든 라우팅은 Coordinator 를 사용하여 수행될 것입니다. 이제 이 변경사항을 구현해 봅시다:
protocol PlayerListViewDelegate: AnyObject {
func didRequestToChangeTitle(_ title: String)
func addRightBarButtonItem(_ barButtonItem: UIBarButtonItem)
func presentAlert(title: String, message: String)
func didRequestPlayerDeletion()
// New methods defined below
func viewPlayerDetails(_ player: PlayerResponseModel)
func addPlayer()
func confirmPlayers(with playersDictionary: [TeamSection: [PlayerResponseModel]])
}
// Removed func confirmOrAddPlayers(withSegueIdentifier segueIdentifier: String) and func didRequestPlayerDetails()
@IBAction private func confirmOrAddPlayers(_ sender: Any) {
// Checks what action we should perform
if presenter.isInListViewMode {
delegate?.addPlayer()
} else {
delegate?.confirmPlayers(with: presenter.playersDictionary)
}
}
// In didSeletRow method, we retrieve the player model object and pass it to the ViewController, which will pass it to the PlayerListCoordinator.
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard !presenter.playersCollectionIsEmpty else { return }
if presenter.isInListViewMode {
let player = presenter.selectPlayerForDisplayingDetails(at: indexPath)
delegate?.viewPlayerDetails(player)
} else {
toggleCellSelection(at: indexPath)
updateViewForPlayerSelection()
}
}
PlayerListCoordinator
Coordinator 구현은 다음과 같습니다:
final class PlayerListCoordinator: Coordinator {
weak var parent: Coordinator?
var childCoordinators: [Coordinator] = []
private let navController: UINavigationController
private var playerListViewController: PlayerListViewController?
init(navController: UINavigationController, parent: Coordinator? = nil) {
self.navController = navController
self.parent = parent
}
func start() {
let viewController: PlayerListViewController = Storyboard.defaultStoryboard.instantiateViewController()
viewController.coordinator = self
playerListViewController = viewController
navController.pushViewController(viewController, animated: true)
}
// Passes the player created for the row where the user tapped
func navigateToPlayerDetails(player: PlayerResponseModel) {
let playerDetailCoordinator = PlayerDetailCoordinator(navController: navController, parent: self, player: player)
playerDetailCoordinator.delegate = self
playerDetailCoordinator.start()
childCoordinators.append(playerDetailCoordinator)
}
// Go to PlayerDetails screen
func navigateToPlayerAddScreen() {
let playerAddCoordinator = PlayerAddCoordinator(navController: navController, parent: self)
playerAddCoordinator.delegate = self
playerAddCoordinator.start()
childCoordinators.append(playerAddCoordinator)
}
// Next screen in the app flow
func navigateToConfirmPlayersScreen(with playersDictionary: [TeamSection: [PlayerResponseModel]]) {
let confirmPlayersCoordinator = ConfirmPlayersCoordinator(navController: navController, parent: self, playersDictionary: playersDictionary)
confirmPlayersCoordinator.delegate = self
confirmPlayersCoordinator.start()
childCoordinators.append(confirmPlayersCoordinator)
}
}
// We use delegation to listen for child coordinators flow actions
extension PlayerListCoordinator: PlayerAddCoordinatorDelegate {
func playerWasAdded() {
playerListViewController?.reloadView()
}
}
extension PlayerListCoordinator: PlayerDetailCoordinatorDelegate {
func didEdit(player: PlayerResponseModel) {
playerListViewController?.didEdit(player: player)
}
}
extension PlayerListCoordinator: ConfirmPlayersCoordinatorDelegate {
func didEndGather() {
playerListViewController?.toggleViewState()
if let playerListViewController = playerListViewController {
navController.popToViewController(playerListViewController, animated: true)
}
}
}
PlayerEdit 와 PlayerDetail 화면에 몇가지 변경사항이 있습니다.
첫째로 PlayerList 와 마찬가지로 coordinator 에 참조를 가질 수 있으므로 Coordinatable
구현 해야 합니다.
PlayerDetail 에서 선수를 수정하고 이름을 변경할 때 네비게이션 타이틀을 새로고침 할 수 있도록 ViewController 에 변경사항을 전달해야 될 필요성이 있으므로 public 인 setupTitle
메서드를 만들어야 합니다. 타이틀은 실제로 선수 이름입니다.
동일한 방법으로 reloadData()
를 수행하고 선수 변경사항을 View 에 전달하기 위한 새로운 함수 updateData(player)
을 생성합니다.
func reloadData() {
playerDetailView.reloadData()
}
func updateData(player: PlayerResponseModel) {
playerDetailView.updateData(player: player)
}
View 레이어에서 발생한 변경사항을 인지하기 위해 PlayerDetailViewDelegate
을 사용합니다:
extension PlayerDetailViewController: PlayerDetailViewDelegate {
func didRequestEditView(with viewType: PlayerEditViewType,
playerEditModel: PlayerEditModel?,
playerItemsEditModel: PlayerItemsEditModel?) {
detailCoordinator?.navigateToEditScreen(viewType: viewType,
playerEditModel: playerEditModel,
playerItemsEditModel: playerItemsEditModel)
}
}
PlayerDetailViewDelegate
는 이제 didRequestEditView
메서드를 위에처럼 변경하였습니다. 이것은 table view 의 delegate didSelectRow
에서 호출됩니다:
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
presenter.selectPlayerRow(at: indexPath)
delegate?.didRequestEditView(with: presenter.destinationViewType,
playerEditModel: presenter.playerEditModel,
playerItemsEditModel: presenter.playerItemsEditModel)
}
PlayerDetailCoordinator
전체 코드는 아래와 같습니다:
// Communicate up in the view controllers stacks what changes were done
protocol PlayerDetailCoordinatorDelegate: AnyObject {
func didEdit(player: PlayerResponseModel)
}
final class PlayerDetailCoordinator: Coordinator {
weak var parent: Coordinator?
var childCoordinators: [Coordinator] = []
weak var delegate: PlayerDetailCoordinatorDelegate?
private let navController: UINavigationController
private let player: PlayerResponseModel
private var detailViewController: PlayerDetailViewController?
init(navController: UINavigationController, parent: Coordinator? = nil, player: PlayerResponseModel) {
self.navController = navController
self.parent = parent
self.player = player
}
func start() {
let viewController: PlayerDetailViewController = Storyboard.defaultStoryboard.instantiateViewController()
viewController.coordinator = self
viewController.player = player
detailViewController = viewController
navController.pushViewController(viewController, animated: true)
}
func navigateToEditScreen(viewType: PlayerEditViewType,
playerEditModel: PlayerEditModel?,
playerItemsEditModel: PlayerItemsEditModel?) {
let editCoordinator = PlayerEditCoordinator(navController: navController,
viewType: viewType,
playerEditModel: playerEditModel,
playerItemsEditModel: playerItemsEditModel)
editCoordinator.delegate = self
editCoordinator.start()
childCoordinators.append(editCoordinator)
}
}
extension PlayerDetailCoordinator: PlayerEditCoordinatorDelegate {
func didFinishEditing(player: PlayerResponseModel) {
detailViewController?.setupTitle()
detailViewController?.updateData(player: player)
detailViewController?.reloadData()
delegate?.didEdit(player: player)
}
}
PlayerEditCoordinator
구현은 매우 간단합니다:
// Communicate up in the view controllers stacks what changes were done
protocol PlayerEditCoordinatorDelegate: AnyObject {
func didFinishEditing(player: PlayerResponseModel)
}
final class PlayerEditCoordinator: Coordinator {
weak var parent: Coordinator?
var childCoordinators: [Coordinator] = []
weak var delegate: PlayerEditCoordinatorDelegate?
private let navController: UINavigationController
private let viewType: PlayerEditViewType
private let playerEditModel: PlayerEditModel?
private let playerItemsEditModel: PlayerItemsEditModel?
init(navController: UINavigationController,
parent: Coordinator? = nil,
viewType: PlayerEditViewType,
playerEditModel: PlayerEditModel?,
playerItemsEditModel: PlayerItemsEditModel?) {
self.navController = navController
self.parent = parent
self.viewType = viewType
self.playerEditModel = playerEditModel
self.playerItemsEditModel = playerItemsEditModel
}
func start() {
let viewController: PlayerEditViewController = Storyboard.defaultStoryboard.instantiateViewController()
viewController.coordinator = self
viewController.viewType = viewType
viewController.playerEditModel = playerEditModel
viewController.playerItemsEditModel = playerItemsEditModel
navController.pushViewController(viewController, animated: true)
}
// Called from the ViewController
func didFinishEditingPlayer(_ player: PlayerResponseModel) {
delegate?.didFinishEditing(player: player)
navController.popViewController(animated: true)
}
}
PlayerAddCoordinator
선수 추가 기능은 매우 간단하므로 약간의 영향을 받습니다. coordinator 는 다음과 같습니다:
protocol PlayerAddCoordinatorDelegate: AnyObject {
func playerWasAdded()
}
final class PlayerAddCoordinator: Coordinator {
weak var parent: Coordinator?
var childCoordinators: [Coordinator] = []
weak var delegate: PlayerAddCoordinatorDelegate?
private let navController: UINavigationController
init(navController: UINavigationController, parent: Coordinator? = nil) {
self.navController = navController
self.parent = parent
}
func start() {
let viewController: PlayerAddViewController = Storyboard.defaultStoryboard.instantiateViewController()
viewController.coordinator = self
navController.pushViewController(viewController, animated: true)
}
func playerWasAdded() {
delegate?.playerWasAdded()
navController.popViewController(animated: true)
}
}
PlayerAddViewController
에서 아래와 같이 View 레이어에서 호출되는 didAddPlayer
를 수정합니다:
func didAddPlayer() {
// The delegate is now the addCoordinator.
// We remove navigationController?.popViewController(animated: true), because we handle this in addCoordinator.
addCoordinator?.playerWasAdded()
}
ConfirmPlayersCoordinator
ConfirmPlayers 에서 선택된 선수 딕셔너리에서 가져와 팀을 선택하고 마지막으로 수집을 시작합니다.
ConfirmPlayersCoordinator
다음과 같습니다:
// Notify PlayerListCoordinator that we finished the gather and is time to toggle the view state
protocol ConfirmPlayersCoordinatorDelegate: AnyObject {
func didEndGather()
}
final class ConfirmPlayersCoordinator: Coordinator {
weak var parent: Coordinator?
var childCoordinators: [Coordinator] = []
weak var delegate: ConfirmPlayersCoordinatorDelegate?
private let navController: UINavigationController
private let playersDictionary: [TeamSection: [PlayerResponseModel]]
init(navController: UINavigationController, parent: Coordinator? = nil, playersDictionary: [TeamSection: [PlayerResponseModel]] = [:]) {
self.navController = navController
self.parent = parent
self.playersDictionary = playersDictionary
}
func start() {
let viewController: ConfirmPlayersViewController = Storyboard.defaultStoryboard.instantiateViewController()
viewController.coordinator = self
viewController.playersDictionary = playersDictionary
navController.pushViewController(viewController, animated: true)
}
func navigateToGatherScreen(with gatherModel: GatherModel) {
let gatherCoordinator = GatherCoordinator(navController: navController, parent: self, gather: gatherModel)
gatherCoordinator.delegate = self
gatherCoordinator.start()
childCoordinators.append(gatherCoordinator)
}
}
extension ConfirmPlayersCoordinator: GatherCoordinatorDelegate {
func didEndGather() {
delegate?.didEndGather()
}
}
ConfirmPlayersView
에서 didStartGather()
메서드를 변경하고 파라미터 목록으로 GatherModel
을 전달합니다: func didStartGather(_ gather: GatherModel)
.
GatherCoordinator
마지막으로 GatherCoordinator
는 다음에 자세히 나와 있습니다:
// Notifies Confirmation screen that a gather has ended.
protocol GatherCoordinatorDelegate: AnyObject {
func didEndGather()
}
final class GatherCoordinator: Coordinator {
weak var parent: Coordinator?
var childCoordinators: [Coordinator] = []
weak var delegate: GatherCoordinatorDelegate?
private let navController: UINavigationController
private let gather: GatherModel
init(navController: UINavigationController, parent: Coordinator? = nil, gather: GatherModel) {
self.navController = navController
self.parent = parent
self.gather = gather
}
func start() {
let viewController: GatherViewController = Storyboard.defaultStoryboard.instantiateViewController()
viewController.coordinator = self
viewController.gatherModel = gather
navController.pushViewController(viewController, animated: true)
}
// Called from the ViewController
func didEndGather() {
delegate?.didEndGather()
}
}
GatherViewController
의 didEndGather
메서드는 다음과 같이 줄어들었습니다:
func didEndGather() {
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)
}
}
이렇게 줄어듭니다:
func didEndGather() {
gatherCoordinator?.didEndGather()
}
주요지표 (Key Metrics)
코드 라인 수 (Lines of code) — Coordinators
코드 라인 수 (Lines of code) — View Controllers
코드 라인 수 (Lines of code) — Views
코드 라인 수 (Lines of code) — Presenters
코드 라인 수 (Lines of code) — Local Models
유닛 테스트 (Unit Tests)
빌드 시간 (Build Times)
테스트는 iOS 14.4, Xcode 12.5.1 그리고 i9 MacBook Pro 2019 사양에 iPhone 8 시뮬레이터에서 실행했습니다.
결론 (Conclusion)
너무 힘들군요. 축하합니다! 또 하나의 아키텍처 시리즈 (Architecture Series) 글을 완성하였습니다.
기존 애플리케이션에 Coordinator 패턴을 구현하여 View Controller 를 간소화하는 방법을 발견하였습니다.
첫째로 storyboard 에서 모든 segue 를 제거하고 몇개의 화면은 남겨두어야 했습니다. 다시 Main.storyboard
을 열면 화면이 어떻게 연결되어 있는지 알 수 없습니다. View Controller 의 위치로 어느정도 알 수 있지만 모든 케이스에 대해 알 수 없습니다.
그런 다음 메인 coordinator 를 생성하기 위해 Application 레벨에서 새로운 클래스를 도입합니다.
다음으로 모듈별로 가져와 새로운 패턴을 적용하고 화면 사이에 전달되는 방식과 다음 단계의 시작을 단순화 하였습니다. 더이상 segue 를 수행할 필요가 없으며 Model 의 Presenter 에서 참조를 유지하고 View Controller 가 Presenter 로 돌아가기 위해 segue 를 수행할 준비가 되거나 ViewController 에서 참조를 유지하면 다음 화면에 필요한 Model 을 검색합니다.
마지막으로 자식 coordinator 에서 부모 coordinator 로 통신하기 위해 Delegation 패턴을 구현합니다 (예를 들어 화면을 새로 나타내기 위해 선수 목록으로 다시 선수 추가 또는 수정을 전달).
제가 생각할 땐 아주 좋은 패턴이며 segue 와 Storyboard 로 부터 벗어나고 싶은 모든 앱에 사용될 수 있습니다.
코드 라인 수로 보면 348 의 새로운 라인이 추가되었습니다.
그러나 이제 View Controller 에서 64 라인 더 적은 코드를 가집니다.
LoginViewController
에서 보았듯이 3 라인이 증가되었습니다. 이건 대게 일반적이지 않은 겁니다. 왜 이럴까요?
아마도 View Controller 는 간단하고 segue 를 수행할 때 한 줄만 추가됩니다. Coordinator 패턴이 적용될 때 2개의 새로운 변수가 도입됩니다:
weak var coordinator: Coordinator?
private var listCoordinator: PlayerListCoordinator? {
coordinator as? PlayerListCoordinator
}
View 와 Presenter 는 거의 동일한 코드 라인 수를 유지합니다. PlayerDetail 모듈에서 약간의 차이점은 didSelectRowAt
메서드에서 보면 Edit 화면으로 전달되기 위한 3개의 새로운 변수를 도입하기 때문에 PlayerDetailView
에서 3 라인이 추가됩니다. 그러나 PlayerListPresenter
에서 7 라인을 줄였습니다.
예상대로 이 패턴의 가장 이점은 View Controller 입니다.
새로운 파일을 추가하고 컴파일러는 더 많은 처리가 필요로 하므로 빌드 시간은 약간 증가되었습니다. 클린 빌드와 Derived Data 폴더를 지우고 수행한 각 시간은 Coordinator 가 없는 MVP 와 비교하면 거의 2초가 느리고 앱이 MVC 를 사용할 때 5초 이상 느립니다.
보통 CI 솔루션을 사용하고 모든 테스트를 통과하기 위해 기다릴 필요가 없으므로 이것은 치명적이지 않습니다.
끝났습니다!
유용한 링크 (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