[디자인 패턴] Coordinator Pattern

전성훈·2023년 11월 3일
0

DesignPattern

목록 보기
10/18
post-thumbnail

주제: 뷰 컨트롤러 간의 네비게이션 로직을 캡슐화 하기


iOS 앱의 구조를 개선하고, 뷰 컨트롤러 간의 결합도를 낮추며, 코드의 재사용성과 테스트 용이성을 높이는데 도움을 줄 수 있는 패턴이다.

Coordinator 패턴이란?

  • Coordinator 패턴은 iOS 앱 개발에서 뷰 컨트롤러 간의 네비게이션 로직을 캡슐화하고, 뷰 컨트롤러들을 서로 분리하여 더욱 깔끔하고 관리하기 쉬운 구조를 만들기 위해 사용되는 디자인 패턴이다. 이 패턴을 사용하면 각 뷰 컨트롤러는 자신의 표시 로직과 사용자 인터페이스에만 집중할 수 있게 되며, 네비게이션 로직은 별도의 객체(Coordinator)에 의해 처리된다.

패턴의 구조

Coordinator

  • Coordinator

    앱의 네비게이션 흐름을 관리하는 객체이다. 새로운 뷰 컨트롤러를 표시하거나, 다른 코디네이터를 시작하는 역할을 한다.

  • ViewController

    사용자 인터페이스를 담당하며, 사용자의 액션에 반응한다. 뷰 컨트롤러는 코디네이터에게 이벤트를 전달하여 네비게이션을 요청할 수 있다.

  • ViewModel

    (선택적) 뷰 컨트롤러의 로직을 분리하여 담당하는 객체이다. 뷰모델은 뷰 컨트롤러의 상태를 관리하고, 뷰 컨트롤러에게 UI 업데이트를 알려준다.

작동 방식

  1. 코디네이터 시작
    • 앱이 시작될 때 루트 코티네이터(AppCoordinator)가 시작되고, 초기 뷰 컨트롤러를 표시한다.
  2. 이벤트 처리
    • 사용자가 뷰 컨트롤러에서 어떤 액션을 취하면, 해당 이벤트는 코디네이터로 전달된다.
  3. 네비게이션 결정
    • 코디네이터는 전달받은 이벤트를 바탕으로 어떤 뷰 컨트롤러를 표시하거나, 어떤 액션을 취할지 결정한다.
  4. 새로운 코디네이터 시작
    • 필요에 따라 현재 코디네이터는 새로운 코디네이터를 시작할 수 있다. 예를 들어, 사용자가 로그인 버튼을 클릭하면, AppCoordinatorLoginCoordinator를 시작할 수 있다.
  5. 뒤로 이동 또는 종료
    • 사용자가 뒤로 이동하거나 어떤 플로우를 완료하면, 코디네이터는 자신이 시작한 뷰 컨트롤러나 하위 코디네이터를 종료할 수 있다.

패턴의 장단점

장점

  • 재사용성

    뷰 컨트롤러가 네비게이션 로직에서 분리되어 있어, 다른 곳에서 쉽게 재사용할 수 있다.

  • 테스트 용의성

    네비게이션 로직이 분리되어 있어, 뷰 컨트롤러와 코디네이터를 별도로 테스트하기 쉽다.

  • 의존성 주입

    코디네이터를 통해 뷰 컨트롤러에 필요한 의존성을 주입할 수 있어, 느슨한 결합과 높은 응집력을 유지할 수 있다.

  • 관심사 분리

    뷰 컨트롤러는 UI와 사용자 상호 작용에만 집중하고, 네비게이션 로직은 코디네이터에 의해 처리된다.

단점

  • 추가적인 복잡성
    • 코디네이터 패턴을 도입하면 앱의 구조가 복잡해질 수 있으며, 새로운 개발자가 코드베이스를 이해하기 어려워질 수 있다.
  • 관리 포인트 증가
    • 코디네이터, 뷰 컨트롤러, 뷰 모델 등 여러 객체들을 관리해야 하므로, 코드베이스가 커질수록 관리 포인트가 증가한다.
  • 개발 코드 증가
    • 간단한 앱의 코디네이터 패턴을 도입하면 오히려 개발 속도가 느려지고, 코드가 불필요하게 복잡해질 수 있다.

구현

Coordinator protocol

import UIKit

// MARK: Coordinator
protocol Coordinator: AnyObject {
    // 자신을 완료했다고 부모 코디네이터에게 알리기 위한 델리게이트
    var finishDelegate: CoordinatorFinishDelegate? { get set }
    // 각 코디네이터에게 할당된 하나의 navigation controller
    var navigationController: UINavigationController { get set }
    // 모든 자식 코디네이터를 추적하기 위한 배열, 대부분의 경우 이 배열은 하나의 자식 코디네이터만 포함
    var childCoordinators: [Coordinator] { get set }
    var type: CoordinatorType { get }
    // 플로우를 시작하기 위한 로직을 넣는 곳
    func start()
    // 플로우를 마치기 위한 로직을 넣는 곳, 모든 자식 코디네이터를 정리하고, 자신이 deallocate 될 준비가 되었다는 것을 부모에게 알리는 곳
    func finish()
    
    init(_ navigationController: UINavigationController)
}

extension Coordinator {
    func finish() {
        // 모든 자식 코디네이터를 제거
        childCoordinators.removeAll()
        // 부모 코디네이터에게 자신이 완료되었음을 알림
        finishDelegate?.coordinatorDidFinish(childCoordinator: self)
    }
}

// 자식 코디네이터가 완료되어 제거될 준비가 되었음을 부모 코디네이터에게 알리기 위한 델리게이트 프로토콜
protocol CoordinatorFinishDelegate {
    func coordinatorDidFinish(childCoordinator: Coordinator)
}

// 플로우 타입 정의
enum CoordinatorType {
    case app, login, tab
}

활용법 (App Coordinator 만들기 & Scene Deleagate 수정)

  • 모든 앱은 하나의 메인 코디네이터를 가져야 하며, 이는 AppCoordinator이다.
import UIKit

// 해당 coordinator에서 어떤 flow에서 시작할지 결정한다.
protocol AppCoordinatorProtocol: Coordinator {
    func showLoginFlow()
    func showMainFlow()
}

// AppCoordinator는 App life cycle동안 단 하나만 존재한다.
final class AppCoordinator: AppCoordinatorProtocol {
	// App Coordinator는 최상위 Coordinator이므로 finishDelegate는 nil
    weak var finishDelegate: CoordinatorFinishDelegate? = nil
    var navigationController: UINavigationController
    var childCoordinators = [Coordinator]() 
    var type: CoordinatorType { .app }
    
    private var isLogin: Bool {
        get {
            return UserDefaults.standard.bool(forKey: "isLogin")
        }
        set {
            UserDefaults.standard.set(newValue, forKey: "isLogin")
        }
    }

    required init(_ navigationController: UINavigationController) {
        self.navigationController = navigationController
        navigationController.setNavigationBarHidden(true, animated: true)
    }
    
    deinit {
        print("AppCoordinator deinit")
    }
    
    func start() {
        if isLogin {
            showMainFlow()
        } else {
            showLoginFlow()
        }

    }
    
    func showLoginFlow() {
        // LoginFlow 구현
        let loginCoordinator = LoginCoordinator(navigationController)
        
        loginCoordinator.finishDelegate = self
        loginCoordinator.start()
        childCoordinators.append(loginCoordinator)
    }
    
    func showMainFlow() {
        // MainFlow 구현
        let tabCoordinator = TabCoordinator(navigationController)
        
        tabCoordinator.finishDelegate = self
        tabCoordinator.start()
        childCoordinators.append(tabCoordinator)
    }
}

extension AppCoordinator: CoordinatorFinishDelegate {
	// 해당 coordinator에서 finish가 호출될 때 동일한 child coordinator를 제거하며, navigationController를 초기화 하고 새롭게 coordinator를 설정한다. 
    func coordinatorDidFinish(childCoordinator: Coordinator) {
        childCoordinators = childCoordinators.filter({ $0.type != childCoordinator.type })
        
        switch childCoordinator.type {
        case .tab:
            isLogin = false
            navigationController.viewControllers.removeAll()
            
            showLoginFlow()
        case .login:
            isLogin = true
            navigationController.viewControllers.removeAll()
            
            showMainFlow()
        default:
            break
        }
    }
}
  • Scene Delegate
import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?
    var coordinator: AppCoordinator!

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }
        
        window = UIWindow(windowScene: windowScene)
        
        let navigationController = UINavigationController()
        
        window?.rootViewController = navigationController

        coordinator = AppCoordinator(navigationController)
        coordinator.start()
        
        window?.makeKeyAndVisible()
    }
}

첫 번째 flow 설정하기

  • LoginViewController
import UIKit

final class LoginViewController: UIViewController {

    var didSendEventClosure: ((LoginViewController.Event) -> Void)?
    
    private lazy var loginButton: UIButton = {
        let btn = UIButton()
        
        btn.setTitle("로그인", for: .normal)
        btn.setTitleColor(.white, for: .normal)
        btn.backgroundColor = .systemBlue
        btn.layer.cornerRadius = 8.0
        btn.addTarget(self, action: #selector(didTapLoginButton(_:)), for: .touchUpInside)
        
        return btn
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupView()
    }
    
    deinit {
        print("LoginViewController deinit")
    }

    private func setupView() {
        view.backgroundColor = .white
        
        [
            loginButton
        ].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview($0)
        }
        
        NSLayoutConstraint.activate([
            loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            loginButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            loginButton.widthAnchor.constraint(equalToConstant: 200),
            loginButton.heightAnchor.constraint(equalToConstant: 50)
        ])
    }
    
    @objc func didTapLoginButton(_ sender: UIButton) {
        didSendEventClosure?(.login)
    }

}

extension LoginViewController {
    enum Event {
        case login
    }
}
  • LoginCoordinator
import UIKit

protocol LoginCoordinatorProtocol: Coordinator {
    func showLoginViewController()
}

final class LoginCoordinator: LoginCoordinatorProtocol {
    weak var finishDelegate: CoordinatorFinishDelegate?
    
    var navigationController: UINavigationController
    
    var childCoordinators: [Coordinator] = []
    
    var type: CoordinatorType { .login }
    
    required init(_ navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    func start() {
        showLoginViewController()
    }
    
    deinit {
        print("LoginCoordinator deinit")
    }
    
    func showLoginViewController() {
        let loginVC = LoginViewController()
        loginVC.didSendEventClosure = { [weak self] event in
            self?.finish()
        }
        
        navigationController.pushViewController(loginVC, animated: true)
    }
}
  • 'Login Button'이 탭되었을 때, coordinator는 해당 event를 받으며 finish fuction을 호출 한다.

Main Flow 설정하기

  • Tab Bar Page Enum 만들기
import Foundation

enum TabBarPage {
    case home
    case plus
    case myPage
    
    init?(index: Int) {
        switch index {
        case 0:
            self = .home
        case 1:
            self = .plus
        case 2:
            self = .myPage
        default:
            return nil
        }
    }
    
    func pageTitleValue() -> String {
        switch self {
        case .home:
            return "Home"
        case .plus:
            return "Plus"
        case .myPage:
            return "MyPage"
        }
    }
    
    func pageOrderNumber() -> Int {
        switch self {
        case .home:
            return 0
        case .plus:
            return 1
        case .myPage:
            return 2
        }
    }
}
  • TabCoordinator
import UIKit

protocol TabCoordinatorProtocol: Coordinator {
    var tabBarController: UITabBarController { get set }
    func selectPage(_ page: TabBarPage)
    func setSelectedInedx(_ index: Int)
    func currentPage() -> TabBarPage?
}

final class TabCoordinator: NSObject, TabCoordinatorProtocol {
    weak var finishDelegate: CoordinatorFinishDelegate?
    
    var childCoordinators: [Coordinator] = []
    var navigationController: UINavigationController
    var tabBarController: UITabBarController
    var type: CoordinatorType { .tab }
    
    required init(_ navigationController: UINavigationController) {
        self.navigationController = navigationController
        self.tabBarController = UITabBarController()
    }
    
    func start() {
        let pages: [TabBarPage] = [.home, .plus, .myPage].sorted(by: { $0.pageOrderNumber() < $1.pageOrderNumber() })
        
        let controllers: [UINavigationController] = pages.map({ getTabController($0) })
        
        prepareTabBarController(withTabControllers: controllers)
    }
    
    private func getTabController(_ page: TabBarPage) -> UINavigationController {
        let navController = UINavigationController()
        
        navController.setNavigationBarHidden(false, animated: false)
        
        navController.tabBarItem = UITabBarItem(title: page.pageTitleValue(), image: nil, tag: page.pageOrderNumber())
        
        switch page {
        case .home:
            let homeVC = HomeViewController()
            
            homeVC.didSendEventClosure = { [weak self] event in
                switch event {
                case .home:
                    self?.selectPage(.plus)
                }
            }
            
            navController.pushViewController(homeVC, animated: true)
        case .plus:
            let plusVC = PlusViewController()
            
            plusVC.didSendEventClosure = { [weak self] event in
                switch event {
                case .plus:
                    self?.selectPage(.myPage)
                }
            }
            
            navController.pushViewController(plusVC, animated: true)
        case .myPage:
            let myPageVC = MyPageViewController()
            
            myPageVC.didSendEventClosure = { [weak self] event in
                switch event {
                case .myPage:
                    self?.finish()
                }
            }
            
            navController.pushViewController(myPageVC, animated: true)
        }
        
        return navController
    }
    
    private func prepareTabBarController(withTabControllers tabControllers: [UINavigationController]) {
        tabBarController.delegate = self
        tabBarController.setViewControllers(tabControllers, animated: true)
        tabBarController.selectedIndex = TabBarPage.home.pageOrderNumber()
        tabBarController.tabBar.isTranslucent = false
        
        navigationController.viewControllers = [tabBarController]
    }
    
    func currentPage() -> TabBarPage? {
        TabBarPage.init(index: tabBarController.selectedIndex)
    }
    
    func selectPage(_ page: TabBarPage) {
        tabBarController.selectedIndex = page.pageOrderNumber()
    }
    
    func setSelectedInedx(_ index: Int) {
        guard let page = TabBarPage.init(index: index) else { return }
        
        tabBarController.selectedIndex = page.pageOrderNumber()
    }
}

extension TabCoordinator: UITabBarControllerDelegate {
    func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {

    }
}
  • LoginViewController
import UIKit

final class LoginViewController: UIViewController {

    var didSendEventClosure: ((LoginViewController.Event) -> Void)?
    
    private lazy var loginButton: UIButton = {
        let btn = UIButton()
        
        btn.setTitle("로그인", for: .normal)
        btn.setTitleColor(.white, for: .normal)
        btn.backgroundColor = .systemBlue
        btn.layer.cornerRadius = 8.0
        btn.addTarget(self, action: #selector(didTapLoginButton(_:)), for: .touchUpInside)
        
        return btn
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupView()
    }
    
    deinit {
        print("LoginViewController deinit")
    }

    private func setupView() {
        view.backgroundColor = .white
        
        [
            loginButton
        ].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview($0)
        }
        
        NSLayoutConstraint.activate([
            loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            loginButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            loginButton.widthAnchor.constraint(equalToConstant: 200),
            loginButton.heightAnchor.constraint(equalToConstant: 50)
        ])
    }
    
    @objc func didTapLoginButton(_ sender: UIButton) {
        didSendEventClosure?(.login)
    }

}

extension LoginViewController {
    enum Event {
        case login
    }
}
  • HomeViewController
import UIKit

final class HomeViewController: UIViewController {

    var didSendEventClosure: ((HomeViewController.Event) -> Void)?
    
    private lazy var loginButton: UIButton = {
        let btn = UIButton()
        
        btn.setTitle("Home", for: .normal)
        btn.setTitleColor(.white, for: .normal)
        btn.backgroundColor = .systemBlue
        btn.layer.cornerRadius = 8.0
        btn.addTarget(self, action: #selector(didTapLoginButton(_:)), for: .touchUpInside)
        
        return btn
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupView()
    }
    
    deinit {
        print("HomePageViewController deinit")
    }

    private func setupView() {
        view.backgroundColor = .white
        
        [
            loginButton
        ].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview($0)
        }
        
        NSLayoutConstraint.activate([
            loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            loginButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            loginButton.widthAnchor.constraint(equalToConstant: 200),
            loginButton.heightAnchor.constraint(equalToConstant: 50)
        ])
    }
    
    @objc func didTapLoginButton(_ sender: UIButton) {
        didSendEventClosure?(.home)
    }
}

extension HomeViewController {
    enum Event {
        case home
    }
}
  • PlusViewController
import UIKit

final class PlusViewController: UIViewController {

    var didSendEventClosure: ((PlusViewController.Event) -> Void)?
    
    private lazy var loginButton: UIButton = {
        let btn = UIButton()
        
        btn.setTitle("Push", for: .normal)
        btn.setTitleColor(.white, for: .normal)
        btn.backgroundColor = .systemBlue
        btn.layer.cornerRadius = 8.0
        btn.addTarget(self, action: #selector(didTapLoginButton(_:)), for: .touchUpInside)
        
        return btn
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupView()
    }
    
    deinit {
        print("PlusViewController deinit")
    }

    private func setupView() {
        view.backgroundColor = .white
        
        [
            loginButton
        ].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview($0)
        }
        
        NSLayoutConstraint.activate([
            loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            loginButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            loginButton.widthAnchor.constraint(equalToConstant: 200),
            loginButton.heightAnchor.constraint(equalToConstant: 50)
        ])
    }
    
    @objc func didTapLoginButton(_ sender: UIButton) {
        didSendEventClosure?(.plus)
    }
}

extension PlusViewController {
    enum Event {
        case plus
    }
    
}
  • MyPageViewController
import UIKit

final class MyPageViewController: UIViewController {
    
    var didSendEventClosure: ((MyPageViewController.Event) -> Void)?
    
    private lazy var loginButton: UIButton = {
        let btn = UIButton()
        
        btn.setTitle("MyPage", for: .normal)
        btn.setTitleColor(.white, for: .normal)
        btn.backgroundColor = .systemBlue
        btn.layer.cornerRadius = 8.0
        btn.addTarget(self, action: #selector(didTapLoginButton(_:)), for: .touchUpInside)
        
        return btn
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupView()
    }
    
    deinit {
        print("MyPageViewController deinit")
    }

    private func setupView() {
        view.backgroundColor = .white
        
        [
            loginButton
        ].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview($0)
        }
        
        NSLayoutConstraint.activate([
            loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            loginButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            loginButton.widthAnchor.constraint(equalToConstant: 200),
            loginButton.heightAnchor.constraint(equalToConstant: 50)
        ])
    }
    
    @objc func didTapLoginButton(_ sender: UIButton) {
        didSendEventClosure?(.myPage)
    }
}
 
extension MyPageViewController {
    enum Event {
        case myPage
    }
}

출처(참고문헌)

해당 코드

https://github.com/Jeon0976/ToyProject/tree/main/Coordinator/Coordinator

제가 학습한 내용을 요약하여 정리한 것입니다. 내용에 오류가 있을 수 있으며, 어떠한 피드백도 감사히 받겠습니다.

감사합니다.

0개의 댓글