"Coordinator패턴"은 ViewController로부터 화면 전환의 부담을 줄여주기 위한 패턴이다.
이를 통해ViewController 간의 결합도를 낮춰주게 된다.
만약 ViewController에서 다음과 같이 화면 전환 로직을 담당하게 되면,
ViewController는 자신의 이후에 올 ViewController가 무엇인지 알아야 한다.
@objc didTapButton() {
let vc = ViewController()
self.pushViewController(vc, animated: true)
}
이는, ViewController에서 다음 ViewController를 위한 의존성 생성도 담당해야 하는데,
특히나, MVVM 아키텍처 채턴의 경우, ViewController는 다음과 같이 ViewModel에 대한 의존성이 필요하다.
final class ViewController: UIViewController {
let viewModel: ViewModel
...
init(viewModel: ViewModel) {
self.viewModel = viewModel
...
}
}
이러한 환경에서 ViewController가 화면전환을 담당하기 위해서는 다음 ViewController를 위한 ViewModel도 생성해야 한다.
만약 ViewModel이 추가적으로 UseCase 혹은 Repository와 같은 추가적인 의존성이 필요한 경우, ViewController는 너무나 많은 의존성을 들고 있어야 한다.
즉, "Coordinator"는 ViewController의 화면 전환 로직을 담당하게 되어, ViewController간의 결합도를 낮춰주게 된다.
이는, ViewController에 필요한 의존성들을 Coordinator가 들고 있게 되면서, ViewController의 부담을 줄여주게 된다.
Coordinator
final class Coordinator {
private var navigationController: UINavigationController?
private let viewModel = ViewModel()
private let viewController = ViewController()
func start(to navigationController: UINavigationController?) {
self.navigationController = navigationController
navigationController?.pushViewController(viewController, animated: true)
}
// 화면 전환
func pushAViewController() {
let coordinator = ACoordinator()
coordinator.start(to: navigationController)
}
}
다음과 같이 Coordinator가 화면 전환들을 담당하며, 화면 전환에 필요한 ViewController 및 ViewModel 등등을 들고 있는다.
Coordinator의 트리구조
이러한 Coordinator 패턴은 다음과 같이 트리구조를 이루게 할 수 있다.
이러한 트리 구조는 각 Coordinator를 원하는 시점에 탈부착이 용이해 재사용성에 장점이 있다.
이를 위해선 다음과 같이 Coordinator에는 자식 Coordinator를 저장하는 child를 선언한다.
/// 화면 전환 로직 및, `ViewController`와 `ViewModel`생성을 담당하는 객체입니다.
public protocol Coordinating: AnyObject {
var navigationController: UINavigationController? { get set }
var children: [Coordinating] { get }
func start(at navigationController: UINavigationController?)
func stop()
func addChild(_ coordinator: Coordinating)
func removeChild(_ coordinator: Coordinating)
}
open class Coordinator: Coordinating {
public var navigationController: UINavigationController?
public final var children: [Coordinating] = []
public init() { }
public init(_ navigationController: UINavigationController?) {
self.navigationController = navigationController
}
open func start(at navigationController: UINavigationController?) {
self.navigationController = navigationController
}
/// deinit 직전에 원하는 동작이 있을 때, 해당 메서드에 구현하면 됩니다.
open func stop() {
self.navigationController = nil
}
public final func addChild(_ coordinator: Coordinating) {
guard !children.contains(where: { $0 === coordinator }) else { return }
children.append(coordinator)
}
public final func removeChild(_ coordinator: Coordinating) {
guard let index = children.firstIndex(where: { $0 === coordinator }) else { return }
children.remove(at: index)
}
private func removeAllChild() {
children.forEach { removeChild($0) }
}
deinit {
self.stop()
if !children.isEmpty {
self.removeAllChild()
}
}
}
실제 사용하는 Coordinator에서는 위에 구현한 Coordinator를 상속받으면 된다.
사용 예시
App Coordinator자식으로 Home Coordinator를 구현하는 경우를 살펴보자.
child
우선 자식인 HomeCoordinator를 살펴보면 다음과 같다.
protocol HomeListener: AnyObject {
func homeDidTapBackButton()
}
final class HomeCoordinator: Coordinator, HomeCoordinatable {
weak var listener: HomeListener?
private let viewController: HomeViewController
private let viewModel: HomeViewModel
init(viewModel: HomeViewModel) {
self.viewModel = viewModel
self.viewController = HomeViewController(viewModel: viewModel)
super.init()
viewModel.coordinator = self
}
override func start(at navigationController: UINavigationController?) {
super.start(at: navigationController)
navigationController?.pushViewController(viewController, animated: true)
}
func didTapBackButton() {
listener?.homeDidTapBackButton()
}
}
init단계에서 필요한 의존성들을 생성해주고, 부모에게 전달하고픈 이벤트를 listener를 통해 부모 Coordinator로 전달한다.
parent
부모 Coordinator는 다음과 같이 자식 Coordinator에 필요한 의존성을 들고 있는다.
final class AppCoordinator: Coordinator {
private let homeViewModel = HomeViewModel()
private var homeCoordinator: HomeCoordinator?
...
}
이후, 자식을 attach하고 detach하는 로직을 구현해준다.
func attachHomeCoordinator() {
guard homeCoordinator == nil else { return }
let coordinator = HomeCoordinator(viewModel: homeViewModel)
coordinator.listener = self
self.addChild(coordinator)
coordinator.start(at: navigationController)
self.homeCoordinator = coordinator
}
func detachHomeCoordinator() {
guard let coordinator = homeCoordinator else { return }
self.removeChild(coordinator)
navigationController?.popViewController(animated: false)
self.homeCoordinator = nil
}
마지막으로 자식의 listener를 자신으로 지정해 자식 Coordinator에서 전달하는 이벤트를 전달받는다.
extension AppCoordinator: HomeListener {
func homeDidTapBackButton() {
detachHomeCoordinator()
}
}
Coordinator 개선
개인적으로 다음과 같은 이유로 인터넷에 있는 Coordinator가 단점으로 여겨졌으며, 이를 개선해보았다.
Coordinator가 비대해진다.
위의 구조에서 AppCoordinator 아래 Home, Profile, Setting 등 여러 자식 Coordinator가 있는 경우,
AppCoordinator는 다시 비대해질 것이다.
특히나, ViewModel 혹은 UseCase와 같은 추가적인 의존성까지 생성해야 하는 경우, 비대해질 것이다.
느슨한 결합이 어렵다.
만약, 자식 Coordinator에게 특정 이벤트를 전달하고 싶은 경우, 다음과 같이 start(at:_) 호출 시점에 진행하거나, 추가적인 프로퍼티로 전달해주어야 한다.
func attachPostCoordinator(postId: Int) {
guard postCoordinator == nil else { return }
let coordinator = PostCoordinator(viewModel: postViewModel)
self.addChild(coordinator)
coordinator.start(at: navigationController, postId: postId)
self.postCoordinator = coordinator
}
하지만, 이렇게 진행하는 경우 다음과 같이 컴파일 타임 의존성을 가지게 되어 느슨한 결합이 되지 못한다.
private var postCoordinator: PostCoordinater?
Container
이를 개선하기 위해 Coordinator에 필요한 의존성을 들고 있는 Container을 추가했다.
/// 부모에게 요구하는 의존성들입니다.
public protocol Dependency { }
public protocol Containable: AnyObject {}
/// `Coordinator`, `ViewController`, `ViewModel`에서 필요한 의존성을 들고 있으며, `Coordinator`생성을 담당하는 객체입니다.
open class Container<DependencyType> {
public let dependency: DependencyType
public init(dependency: DependencyType) {
self.dependency = dependency
}
}
Child
protocol PostContainable: Containable {
func coordinator(listener: PostListener, postId: Int) -> Coordinating
}
final class PostContainer: Container<PostDependency>, PostContainable {
var useCase: PostUseCase { dependency.postUseCase }
func coordinator(listener: PostListener, postId: Int) -> Coordinating {
let viewModel = PostViewModel(useCase: useCase, postId: postId)
let coordinator = PostCoordinator(viewModel: viewModel)
coordinator.listener = listener
return coordinator
}
}
Container의 경우에는 의존성 및 coordinator 생성을 담당한다.
Dependency는 부모에게 요구하는 의존성이다.
protocol PostDependency: Dependency {
var postUseCase: PostUseCase { get }
}
final class PostContainer: Container<PostDependency>, PostContainable {
var useCase: PostUseCase { dependency.postUseCase }
...
}
이러한 dependency는 다음과 같이 런타임에 인스턴스화되기 때문에,
컴파일 타임 의존성이 아닌 런타임 의존성을 통해 느슨하게 결합이 가능하다.
open class Container<DependencyType> {
public let dependency: DependencyType
public init(dependency: DependencyType) {
self.dependency = dependency
}
...
}
Parent
우선 부모의 경우 다음과 같이 Composition Root가 되어 자식의 의존성을 주입시켜줄 수도 있지만,
final class AppContainer:
Container<AppDependency>,
HomeDependency { // 자식의 Dependency를 채택한다.
// 자식에 필요한 Depdency의 생성을 담당한다.
var useCase: HomeUseCase {
HomeUseCase()
}
func coordinator() -> Coordinating {
let coordinator = AppCoordinator()
return coordinator
}
}
부모 역시 Dependency를 통해 상위 부모에게 의존성을 요구할 수도 있다.
이후, Coordinator에선 다음과 같이 사용한다.
// HomeCoordinator.swift
func attachHomeCoordinator() {
guard homeCoordinator == nil else { return }
let coordinator = homeContainable.coordinator(listener: self)
self.addChild(coordinator)
coordinator.start(at: navigationController)
self.homeCoordinator = coordinator
}
func detachHomeCoordinator() {
guard let coordinator = homeCoordinator else { return }
self.removeChild(coordinator)
navigationController?.popViewController(animated: false)
self.homeCoordinator = nil
}
이때, 부모 Coordinator는 다음과 같이 컴파일 타임에는 Interface에 의존하게 되어 느슨하게 결합이 가능해진다.
private let homeContainable: HomeContainable
private var homeCoordinator: Coordinating?
장점 및 느낀점
Container를 통해 Coordinator는 의존성을 담당하지 않게 되면서 Routing 로직만 처리하게 되었다.
Coordinator가 가벼워질 뿐만이 아닌, 객체간에 DIP를 적용해 느슨하게 결합이 가능해진다.
또한, 이는 객체간 뿐만이 아닌 모듈간에도 느슨하게 결합이 편리했다.
모듈화 작업을 진행할 때, Listener와 Containable만 interface 부분으로 분리 시켜주면 된다.
// homeInterface.swift
public protocol HomeContainable: Containable {
func coordinator(listener: HomeListener) -> Coordinating
}
public protocol HomeListener: AnyObject {
func homeDidSelectItem(id: Int)
}
Dependency와 Container 객체는 Compositional Root에서 생성하기 때문에, 이 역시 public으로 선언해주면 모듈화 작업이 완료된다.
public protocol HomeDependency: Dependency { ... }
public final class HomeContainer: Container<HomeDependency>, HomeContainable {
...
}
하지만, 계속 역할을 분리하다 보니 RIBs 아키텍쳐 패턴과 점점 닮아간다는 느낌을 받게 되었다. 이런 점에서 RIBs가 정말 역할 분리가 잘 되어 있는 것 같다.
해당 포스팅에서 사용한 예시는 여기서 확인해볼 수 있다.
https://github.com/jungseokyoung-cloud/Architecture-Pattern/tree/main/CoordinatorPattern
References
https://khanlou.com/2015/01/the-coordinator/
'iOS > Pattern' 카테고리의 다른 글
[Architecture Pattern] MVVM (0) | 2023.05.27 |
---|---|
[Architecture Pattern] SoftWare Architecture (0) | 2023.05.24 |
[Design Pattern] DI(Dependency Injection) (0) | 2023.05.08 |
[Architecture Pattern] MVP (0) | 2023.03.24 |
[Architecture Pattern] MVC (0) | 2023.03.22 |