iOS Architecture Patterns 뿌시기: Model View Presenter with Coordinators (MVP-C)

15 분 소요

Thanks to Radu Dan for allowing the translation.

Reference: Battle of the iOS Architecture Patterns: Model View Presenter with Coordinators (MVP-C)

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

순서는 다음과 같습니다.

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

iOS Architecture Patterns 뿌시기: Model View Presenter with Coordinators (MVP-C)

아키텍처 시리즈 — Model View Presenter with Coordinators (MVP-C)

아키텍처 시리즈 — Model View Presenter with Coordinators (MVP-C)

동기 (Motivation)

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

다른 글을 놓쳤다면 아래 내용을 확인하거나 이 글 마지막에 링크에서 확인할 수 있습니다.

코드에 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" 에 화면 목업

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

백엔드 (Backend)

이 앱은 Vapor web framework 로 개발된 웹 앱으로 구동됩니다. Vapor 3 initial articlearticle 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 을 구현할 수 있습니다.

  1. 메인 앱 coordinator 에서 UINavigationControllerDelegate 을 구현합니다. 네비게이션 컨트롤러는 View Controller 의 View 와 네비게이션 아이템 프로퍼티를 표시한 직후에 호출되는 인스턴스 메서드 navigationController:didShowViewController:animated: 에 관심을 가집니다. View Controller 가 View 스택에서 팝되는 트리거된 이벤트를 얻으면 관련된 coordinator 를 할당 해제할 수 있습니다.
  2. 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** 의 간단 버전

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 메서드인 AddPlayerDelegatePlayerListTogglable 을 삭제합니다.
  • 선수가 편집 (데이터 로드), 추가되고 수집을 마친 후에 요청되는 메서드인 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)
        }
    }
}

PlayerEditPlayerDetail 화면에 몇가지 변경사항이 있습니다.

첫째로 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()
    }
    
}

GatherViewControllerdidEndGather 메서드는 다음과 같이 줄어들었습니다:

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

Untitled

코드 라인 수 (Lines of code) — View Controllers

Untitled

코드 라인 수 (Lines of code) — Views

Untitled

코드 라인 수 (Lines of code) — Presenters

Untitled

코드 라인 수 (Lines of code) — Local Models

Untitled

유닛 테스트 (Unit Tests)

Untitled

빌드 시간 (Build Times)

Untitled

테스트는 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 를 수행할 필요가 없으며 ModelPresenter 에서 참조를 유지하고 View ControllerPresenter 로 돌아가기 위해 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
}

ViewPresenter 는 거의 동일한 코드 라인 수를 유지합니다. PlayerDetail 모듈에서 약간의 차이점은 didSelectRowAt 메서드에서 보면 Edit 화면으로 전달되기 위한 3개의 새로운 변수를 도입하기 때문에 PlayerDetailView 에서 3 라인이 추가됩니다. 그러나 PlayerListPresenter 에서 7 라인을 줄였습니다.

예상대로 이 패턴의 가장 이점은 View Controller 입니다.

새로운 파일을 추가하고 컴파일러는 더 많은 처리가 필요로 하므로 빌드 시간은 약간 증가되었습니다. 클린 빌드와 Derived Data 폴더를 지우고 수행한 각 시간은 Coordinator 가 없는 MVP 와 비교하면 거의 2초가 느리고 앱이 MVC 를 사용할 때 5초 이상 느립니다.

보통 CI 솔루션을 사용하고 모든 테스트를 통과하기 위해 기다릴 필요가 없으므로 이것은 치명적이지 않습니다.

끝났습니다!

유용한 링크 (Useful Links)