본문 바로가기
Design Pattern

[Design Pattern] Coordinator Pattern

by Jiseong 2022. 7. 29.

기존에 화면전환 로직을 분리하기 위해서 Coordinator Pattern을 사용한 적이 있는데, 자세히 알아보지 않고 사용해본 것 같아서 정리해보려한다.

Coordinator? 

Coordinator Pattern을 만든 khanlou의 에 따르면..

What is a coordinator?
So what is a coordinator? A coordinator is an object that bosses one or more view controllers around. Taking all of the driving logic out of your view controllers, and moving that stuff one layer up is gonna make your life a lot more awesome.

 

하나 이상의 ViewController를 지시하는 객체라고 설명하고있다.

 

뒷 문단에서 말하는 driving logic화면 전환에 대한 로직을 말하는 것 같고, 해당 로직을 한 계층 위로 올림으로써 효과를 본단다.

 

그냥 뭐 뷰컨에서 화면 전환하면 되지 굳이 이걸 왜 쓰느냐?

이 패턴을 적용했던 이유는.. 

보통의 화면 전환 로직은 다음과 같다.

@objc private func didTapPushVC2Button() {
        let vc2 = ViewController2()
        navigationController?.pushViewController(vc2, animated: true)
}

버튼을 하나 만들고, 그 버튼을 누르면 특정 뷰컨이 네비게이션 스택에 푸시되는 로직이다.

 

이렇게 특정 뷰컨트롤러가 다른 뷰컨트롤러를 알아야한다는 것이 첫번째 이유이자 가장 큰 이유였다.

 

두번째 이유로는 뷰컨트롤러가 비대해진다는 것이다.

 

화면 전환 로직이 많아지면 많아질수록 해당 로직을 담당하는 뷰컨트롤러의 역할이 점점 커지는 상황이 발생하기에 이를 분리해주고 싶었다.

 

세번째 이유로는 특정 뷰컨에 여러 화면으로 갈 수 있는 로직이 위치한다면 화면 전환 로직들의 응집도가 떨어져 가독성 또한 떨어졌다.

 

당연히 코드가 많아지면 많아질수록 관리도 귀찮고 힘들겠지 허구언날 FFFFFFF하면서 찾고있고..

 

이런 상황을 방지하기 위해 khanlou는 화면 전환과 화면 전환시 다룰 ViewController를 관리할 상위 객체를 두면 많은 이점이 있을 것이라고 생각하여 Coordinator Pattern을 생각해냈다.

 

SceneDelegate(lower than iOS 13 -> AppDelegate) 는 AppCoordinator(최상위 Coordinator) 를 유지하며, 모든 Coordinator에는 하위(Child)Coordinator가 위치한다.

 

위 문단이 제일 중요함

 

일단 만들어보자

구현

AppCoordinator든 ChildCoordinator든 뭐든 Coordinator이기 때문에 추상화하여 사용한다.

import UIKit

protocol Coordinator {
    var childCoordinators: [Coordinator] { get set }
    var navigationController: UINavigationController { get set }

    func start()
}

다음은 우선 최상위 Coordinator인 AppCoordinator를 생성하여준다.

 

갑자기 저녁약속 생겨서 갔다와서 쓸게요;;;

class AppCoordinator: Coordinator {
    
    var childCoordinators = [Coordinator]()
    var navigationController: UINavigationController

    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }

    func start() {
    	// 앱을 처음 실행했을 때 나오는 화면 Start
    }
}

AppCoordinator는 Cooridnator중 대가리라고 생각하면 된다.

 

보여질 뷰의 Coordinator를 대가리의 부하(ChildCoordinator)로 넣어 계층을 관리하도록 한다.

 

start 메서드 내부에선 처음 보여질 뷰의 Coordinator를 생성하고, 위에서 말했듯이 부하로 들어가게 한다.

final class AppCoordinator: Coordinator {
    var navigationController: UINavigationController
    var childCoordinators: [Coordinator] = []
    
    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    func start() {
        pushViewController1()
    }
    
    func pushViewController1() {
        let vc1Coordinator = VC1Coordinator(
            parentCoordinator: self,
            navigationController: navigationController
        )
        childCoordinators.append(vc1Coordinator)
        vc1Coordinator.start()
    }
 }
 
 // VC1Coordinator
 final class VC1Coordinator: Coordinator {
//    private weak var parentCoordinator: AppCoordinator? // 얘는 필요가 없어보여서 지움, 루트뷰니까
    var childCoordinators: [Coordinator] = []
    var navigationController: UINavigationController
    
    init(
        parentCoordinator: AppCoordinator?,
        navigationController: UINavigationController
    ) {
        self.parentCoordinator = parentCoordinator
        self.navigationController = navigationController
    }
    
    func start() {
        let vc = ViewController1()
        vc.coordinator = self
        navigationController.pushViewController(vc, animated: true)
    }
 }

ParentCoordinator에서 childCoordinator를 소유(강한 참조)하고 있기 때문에 순환참조를 피하기 위해서 weak로 선언해줘야한다.

 

그리고 Coordinator가 뷰를 띄우므로 SceneDelegate에 AppCoordinator를 위치시킨 뒤 NavigationController를 주입시킨다.

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?
    private var appCoordinator: AppCoordinator?

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

기본적인 루트뷰 셋팅은 끝났다.

 

그럼 루트뷰에서 다음 뷰를 띄워보자

final class ViewController1: UIViewController {
    weak var coordinator: VC1Coordinator?
    
    private lazy var pushVC2Button: UIButton = {
        let button = UIButton()
        button.setTitle("PushVC2", for: .normal)
        button.setTitleColor(UIColor.green, for: .normal)
        button.translatesAutoresizingMaskIntoConstraints = false
        button.addTarget(self, action: #selector(didTapPushVC2Button), for: .touchUpInside)
        return button
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        configureUI()
    }
    
    private func configureUI() {
        view.addSubview(pushVC2Button)
        NSLayoutConstraint.activate([
            pushVC2Button.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 50),
            pushVC2Button.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 100),
            pushVC2Button.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -100)
        ])
    }
    
    @objc private func didTapPushVC2Button() {
        coordinator?.pushVC2()
    }
}

 

 ViewController1의 전체 코드는 다음과 같다.

 

단순한 버튼이 선언되어있고, 버튼을 누르면 ViewController2를 push한다.

 

push등의 화면 전환 로직들은 coordinator에게 넘겨줬으므로 VC1에서의 화면 전환을 담당할 VC1Coordinator를 가지도록 한다.

 

VC2는 VC1의 자식이므로 Child로 등록후 인스턴스 생성 및 뷰 렌더링을 진행한다.

// VC1Coordinator
func pushVC2() {
   let vc2Coordinator = VC2Coordinator(
       parentCoordinator: self,
       navigationController: navigationController
   )
    
    childCoordinators.append(vc2Coordinator)
    vc2Coordinator.start()
}

 // VC2Coordinator
func start() {
    let vc = ViewController2()
    vc.coordinator = self
    navigationController.pushViewController(vc, animated: true)
}

메서드 내부엔 보여질 뷰의 Coordinator를 생성하고, 이 역시 start를 호출하여 ViewController를 생성한 뒤 띄워준다.

그림으로 간단히 알아보자면 위의 흐름과 같다.

 

이를 기반으로 화면의 흐름을 따로 관리하여 ViewController의 역할을 줄이는 것이다.

 

화면을 더 타고 타고 구현할 순 있지만, 이 정도 개념만 알아도.. 구현해볼 수 있을 것이다.

 

대신 아주 중요한 것이 있다.

주의할 점

weak로 선언하여 순환참조는 해결하였지만, 또 문제가 있다.

 

ViewController2를 띄우면 당연히 그 이전에 VC2Coordinator 인스턴스가 생성될 것이다.

 

근데 그 화면을 벗어나서 이전의 화면으로 돌아가고, ViewController2 인스턴스가 deinit된다면?

 

VC2Coordinator의 역할, 할 일이 사라졌으므로, 이 놈도 메모리에서 내려가는 것이 맞겠지? 그냥 두면 메모리 릭이 발생한다.

 

쓸데없는 놈이 살아있는거니까 ㅇㅇ

VC1 -> VC2 -> VC1 화면 전환을 3번을 하였더니 VC2Coordinator의 인스턴스가 3개나 메모리에 올라가 있는 모습을 볼 수 있다.

 

그러니 우리가 해줘야할 작업은 메인이 되는 뷰(RootView)가 아닌 하위 뷰들의 인스턴스가 생성되어 뷰에 올라갔다가, 다시 화면전환이 되어 원래의 뷰(RootView)로 갈 때 하위 뷰들의 인스턴스가 갖고 있던 Coordinator 인스턴스까지 전부 내려줘야하는 것이다.

 

어디에서? 하위 뷰(자식)들의 부모 뷰가 가진 Child에 등록되어있으니 거기서 내려줘야겠지

 

Coordinator 프로토콜에 기본 구현으로 정의해줬다.

 extension Coordinator {
    func removeChildCoordinator(_ child: Coordinator) {
        for (index, coordinator) in childCoordinators.enumerated() {
            if coordinator === child {
                childCoordinators.remove(at: index)
                break
            }
        }
    }
}

이후 해당 메서드를 특정 뷰컨이 deinit될 때나 필요가 없어질 때 호출해준다.

final class ViewController2: UIViewController {
    weak var coordinator: VC2Coordinator?

    deinit {
        coordinator?.popVC2()
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .red
    }
}

final class VC2Coordinator: Coordinator {
    private weak var parentCoordinator: Coordinator?

    func popVC2() {
        parentCoordinator?.removeChildCoordinator(self)
    }
}

이래하면..

깔끔해진 메모리의 모습을 볼 수 있다.

 

떠죽겄네 그냥

 

https://khanlou.com/2015/10/coordinators-redux/

 

Khanlou | Coordinators Redux

October 5, 2015 Coordinators Redux I wrote about coordinators at the beginning of the year, but the idea has matured a lot since then, and I’d like to reintroduce the topic with all of my learnings from the last few months. This is adapted from a talk I

khanlou.com

https://github.com/yim2627/Coordinator_Practice

 

GitHub - yim2627/Coordinator_Practice

Contribute to yim2627/Coordinator_Practice development by creating an account on GitHub.

github.com

 

'Design Pattern' 카테고리의 다른 글

[Design Pattern] Singleton Pattern  (0) 2022.03.31
[Design Pattern] Mediator Pattern  (0) 2022.03.20

댓글