[iOS] Coordinator pattern

Charlie·2022년 10월 3일
0

기존 문제점

ViewController에서 화면 전환을 담당하면 다음과 같은 문제점이 있다.

  • ViewController의 비대화
  • VC가 다른 VC에 대해서 알아야한다
  • 플로우를 전환하기 힘들다
  • 재사용이 안된다
  • 테스트하기 어렵다

Khanlou씨가 말하길

ViewController 기본 클래스는 UI로 시작되며 View객체이고, 사용자 흐름을 처리하는 것은 범위를 벗어난다.
ViewController를 높은 수준의 객체로 관리하면 많은 이점을 얻게 된다.
이 높은 수준의 객체는 모든 ViewController를 모으고 관리하는 역할을 맡는다.
이 친구를 Coordinators 또는 Directors라고 부르기로 한다.

Coordinator 패턴을 제대로 실행하려면 전체 앱을 아우르는 high-level coordinator가 필요하다.

AppDelgate(SceneDelegate)는 AppCoordinator를 유지하며, 모든 Coordinator에는 일련의 하위 Coordinator가 있다.

Coordinator 패턴

화면 전환을 조금 더 유연하게 사용할 수 있도록 만들어주는 패턴이다.
기존 ViewController에서 처리하던 화면 전환 관련 코드들을 Coordinator라는 친구가 다 처리할 수 있도록 한다. Navigation 관련 코드는 Coordinator 구현체 안으로 넣게 되면서 ViewController는 훨씬 가볍고 쉽게 재사용할 수 있도록 한다. ViewController는 그 전에 어떤 ViewController가 왔는지, 어떤 것이 다음에 올지, 어떤 종속성이 전달되어야 하는지 알지 못하고, 자신의 화면과 관련한 레이아웃에 대해서만 처리하면 된다.

예를 들어, 홈에서 사용자가 저장해놓은 맛집 지도를 보여주는 화면으로 전환할 수 있다. 검색을 통해 다른 사용자가 저장해놓은 맛집 지도를 보여주는 화면으로도 갈 수 있고, 또 이상하지만 설정 화면에서도 갈 수 있다고 해보자.

이처럼 여러개의 화면에서 하나의 화면으로 전환해야 하는 경우가 있는데, 이러한 경우에 Coordinator 패턴을 사용하면 좋다.

우선 delegate protocol로 맛집 지도 화면으로 전환하는 coordinator를 만들고, extension에서 화면 전환에 필요한 코드를 구현한다.

protocol MapCoordinator: AnyObject {
	func pushToMap(_ navigationController: UINavigationController, userID: String)
}

extension MapCoordinator {
	func pushToMap(_ navigationController: UINavigationController, userID: String) {
    	let vc = MapViewController()
        vc.userID = userID
        navigationController.pushViewController(vc, animated: true)
    }
}

그리고 지도 화면으로 전환해야 하는 ViewController에 coordinator를 담당하는 delegate을 만들고 화면 전환이 이루어지도록 한다.

final class HomeViewController: UIViewController {
	weak var coordinator: MapCoordinator?
    
	func navigateToMapViewControoler(_ userID: String) {
    	coordinator?.pushToMap(navigationController, userID: userID)
    }
}
final class SearchViewController: UIViewController {
	weak var coordinator: MapCoordinator?
    
	func navigateToMapViewControoler(_ userID: String) {
    	coordinator?.pushToMap(navigationController, userID: userID)
    }
}
final class SettingViewController: UIViewController {
	weak var coordinator: MapCoordinator?
    
	func navigateToMapViewControoler(_ userID: String) {
    	coordinator?.pushToMap(navigationController, userID: userID)
    }
}

이렇게 되면 ViewController는 화면 전환에는 전혀 신경을 쓰지 않아도 되고 그저 coordinator라는 delegate한테 시키기만 하면 된다.

상위 Flow와 하위 Flow

앱이 커진 경우에 Flow도 구분이 될 수 있다. 예를 들어 하위 Coordinator에서 계정 생성과 관련한 흐름을 제어하고, 다른 하위 Coordinator에서는 지도와 관련한 흐름을 제어할 수 있다.
이러한 상황에서 플로우를 구분하고 코디네이터의 계층을 구분하여 사용할 수 있다.
상위 코디네이터와 하위 코디네이터 간의 흐름은 간단하게 다음과 같이 나타낼 수 있다.

  1. 상위 코디네이터 생성
  2. 하위 코디네이터 생성 및 상위 코디네이터에 추가
  3. 하위 코디네이터의 화면 전환 메소드 호출
  4. 하위 코디네이터 제거 메소드 호출
  5. 상위 코디네이터에서 하위 코디네이터 제거

Coordinator Protocol

코디네이터의 계층을 나타내기 위해서는 앞서 구현한 프로토콜을 조금 수정해야한다.

하위 코디네이터는 deallocated 되지 않기 위해서 누군가에 의해 retained 되어야만 한다. 따라서 기존 프로토콜에서 하위 코디네이터에 대한 정보를 가지는 프로퍼티를 추가해준다.

또 화면 전환 시 기준이 되는 Navigation Controller, 하위 코디네이터에 대한 정보, 첫 화면을 띄우기 위한 start() 메소드를 가지도록 한다.

protocol Coordinator: AnyObject {
	var childCoordinators: [Coordinator] { get set }
    var navigationController: UINavigationController { get set }
    
    func start()
}

Parent Coordinator

final class ParentCoordinator: Coordinator {
	var childCoordinators = [Coordinator]()
    var navigationController: UINavigationController
    
    init(navigationController: UINavigationController) {
    	self.navigationController = navigationController
    }
    
    func start() {
		let vc = ViewController.instantiate()
        vc.coordinator = self
        navigationController.pushViewController(vc, animated: true) 
    }
}

Child Coordinator

child가 모든 화면 전환을 다 하고 역할을 다 했다면 parent에 "할 일 다 했으니 지워주세요" 라고 말할 수 있어야 한다. 이를 위해서 child는 parent를 알고있어야 하고, parent coordinator를 프로퍼티로 가져야 한다.
그런데 parent도 child를 알고, child도 parent를 알게 되므로 순환 참조 문제가 일어나게 된다.
따라서 weak을 사용해주어 순환 참조 문제를 해결해야 한다.

즉, 전체적인 모양은 Parent Coordinator와 비슷하지만, parent에 대한 프로퍼티는 weak으로 선언해주면 된다.

final class ChildCoordinator: Coordinator {
	weak var parentCoordinator: Coordinator?
    
    // 나머지는 Parent Coordinator와 동일
}

적용

이제 앞서 말했던 순서대로 Coordinator를 만들어서 테스트해보자.

  1. 상위 코디네이터 생성
  2. 하위 코디네이터 생성 및 상위 코디네이터에 추가
  3. 하위 코디네이터의 화면 전환 메소드 호출
  4. 하위 코디네이터 제거 메소드 호출
  5. 상위 코디네이터에서 하위 코디네이터 제거

1. ParentCoordinator 생성

AppDelegate 또는 SceneDelegate에서 ParentCoordinator를 생성해준다.

coordinator = ParentCoordinator(navigationController: navigationController)
coordinator.start()

2. ChildCoordinator 생성

ParentCoordinator에서 ChildCoordinator를 생성해주고 하위 코디네이터로 추가해준다.

final class ParentCoordinator: Coordinator {
	...
    
    func navigateToMap() {
		let child = ChildCoordinator(navigationController: navigationController)
		child.parentCoordinator = self
        childCoordinators.append(child)
        child.start()
    }
}

3. ChildCoordinator에서 화면 전환

final class ChildCoordinator: Coordinator {
	...
	
    func pushToDetail() {
    	let vc = DetailViewController()
        vc.coordinator = self
        navigationController.pushViewController(vc, animated: true)
    }
}

4. ChildCoordinator 제거 메소드 호출

final class ChildCoordinator: Coordinator {
	...
    
    func didFinish() {
    	parentCoordinator?.childDidFinish(self)
    }
}

5. ParentCoordinator에서 Child 제거

final class ParentCoordinator: Coordinator {
	...
    
    func childDidFinish(_ child: Coordinator?) {
    	for (index, coordinator) in childCoordinators.enumerated() {
        	if coordinator === child {
            	childCoordinators.remove(at: index)
                break
            }
        }
    }
}

다른 fancy한 코드

extension ParentCoordinator {
	func presentChild(_ child: Coordinator) {
    	childCoordinators.append(child)
        child.onDismissed = { [weak self, weak child] in
        	guard let self = self,
            	let child = child else { return }
            self.removeChild(child)
        }
        child.start()
    }
    
    func removeChild(_ child: Coordinator) {
    	guard let index = childCoordinators.firstIndex(where: { $0 === child }) else {
	        return
        }
        childCoordinators.remove(at: index)
    }
}

이러한 방식의 문제점

많은 자료들에서 이렇게 코드를 작성하는 방법에 대해서 알려준다. 큰 문제는 없지만 다음과 같은 부분에서 불편함이 있다.

  • 새로운 플로우를 위해 하위 코디네이터를 생성했을 때 childCoordinators에 append 시켜야한다.
  • 하위 코디네이터가 끝났을 때, childCoordinators에서 해당 코디네이터를 찾아서 삭제해야한다.
  • 뒤로가기 버튼, 스와이프 등과 같이 스크린에서 코디네이터에게 자신을 삭제하라는 신호를 알려주는 무언가가 필요해진다

이와 관련해서는 다음 글에서 다루도록 하겠다. 일단 이번 글에서는 정석?적인 방법으로..

Protocol 사용

프로토콜의 이름은 Coordinator가 하는 역할을 한눈에 알 수 있도록 지으면 좋다.

한 화면에서 여러 화면으로 이동을 해야하는 상황이 있을 수 있다.
예를 들어, 홈 화면에서 지도 화면으로 전환할 수도 있고, 상세 화면으로 전환할 수도 있다고 해보자.

protocol MapCoordinating: AnyObject {
	func pushToMap(_ navigationController: UINavigationController, userID: String)
}
protocol DetailCoordinating: AnyObject {
	func pushToDetail(_ navigationController: UINavigationController, userID: String)
}

홈 화면에서 해당 tab coordinator는 지도 화면으로 이동하는 coordinator, 상세 화면으로 이동하는 coordinator 둘 다 필요하기 때문에 아래와 같이 추가한다.

final class HomeCoordinator: BaseCoordinator, MapCoordinating, DetailCoordinating {
	...
}

그리고 홈 화면에서는 아래와 같이 작성하면 된다.

final class HomeViewController: UIViewController {
	weak var coordinator: (MapCoordinating & DetailCoordinating)?
}

Reference

Coordinator Pattern
[디자인 패턴][swift] Coordinator pattern
화면 전환을 해결해 준 Coordinator 패턴
Coordinator Pattern
Self-deallocated Coordinator pattern in Swift
How to implement flow coordinator pattern

profile
Hello

0개의 댓글