[회고] 신입 iOS 개발자가 되기까지 feat. 카카오 자세히보기

🍎 Apple/Patterns

[iOS Design Pattern] Coordinator

inu 2022. 4. 12. 22:05
반응형

Coordinator Pattern

Coordinator 패턴은 ViewController가 보유하던 책임 중 Navigation과 관련된 부분을 다른 인스턴스에서 책임지도록 하는 패턴입니다.

기존의 ViewController에서 직접적으로 화면전환을 시행하는 방식은 다음에 띄워질 다른 ViewController에 대해 기존 ViewController가 알고 있어야 하는 구조입니다. 이는 ViewController 인스턴스 간에 심한 커플링을 발생시킵니다.

이를 해결한 것이 Coordinator 패턴입니다. 모든 ViewController는 Coordinator 인스턴스만 보유할뿐, 다른 ViewController 인스턴스를 직접적으로 보유하지 않습니다. 그저 Coordinator에게 요청할 뿐입니다.

 

이런 컨셉을 프로그래밍적으로 구현해내는 모든 것이 Coordinator 패턴이 될 수 있습니다. 

구현방식은 당연히 다양할 수 있겠죠?

 

저는 그 중에서도 Navigation Controller를 활용한 가장 기본적인 사용 튜토리얼을 찾아 정리해봤습니다. 


기본적인 튜토리얼

1. Coordinator 프로토콜 설정

일단 Coordinator의 역할을 프로토콜을 통해 정의해줍니다. 일반적으로 아래와 같이 구성됩니다. (세부사항은 변경될 수 있습니다.)

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

    func start()
}
  • child coordinator를 관리하는 배열을 보유해야합니다. (var childCoordinators)
  • viewController들을 push&pop할 수 있는 Navigation Controller를 보유해야 합니다. (var navigationController)
  • 앱을 관리할 준비가 되었을 때 호출하는 start 메서드를 보유해야합니다. (func start)

2. 프로토콜에 대한 Coordinator 구현체 구현

이제 이 프로토콜에 대한 구체타입을 구현합니다.

class MainCoordinator: 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: false)
    }
}
  • start는 띄워줄 viewController를 생성하고 해당 vc의 coordinator로 자신을 할당합니다. (3번째 단계에서 vc에 coordinator property를 추가할 것입니다.)
  • 그리고 navigationController를 활용해 해당 vc를 push 합니다.
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    let navController = UINavigationController()

    coordinator = MainCoordinator(navigationController: navController)
    coordinator?.start()

    window = UIWindow(frame: UIScreen.main.bounds)
    window?.rootViewController = navController
    window?.makeKeyAndVisible()

    return true
}
  • 이제 구체 타입 설정이 끝났으니 AppDelegate(혹은 SceneDelegate)에서도 이를 활용해 rootViewController를 설정해줍니다.

3. ViewController에서 Coordinator 사용

이제 viewController 쪽에서 Coordinator를 보유하도록 하고 coordinator 내부에 필요한 동작을 정의하면 됩니다.

class MainCoordinator: Coordinator {
    ...
    func buySubscription() {
        let vc = BuyViewController.instantiate()
        vc.coordinator = self
        navigationController.pushViewController(vc, animated: true)
    }

    func createAccount() {
        let vc = CreateAccountViewController.instantiate()
        vc.coordinator = self
        navigationController.pushViewController(vc, animated: true)
    }
}
class ViewControlelr: UIViewContoller {
    var coordinator: Coordinator?
    ...
    @IBAction func buyTapped(_ sender: Any) {
        coordinator?.buySubscription()
    }

    @IBAction func createAccount(_ sender: Any) {
        coordinator?.createAccount()
    }
    ...
}
  • 특정 동작이 필요할 때 Coordinator에 특정 명령 보내 처리하도록 합니다.

여기까지 가장 기본적인 Coordinator를 생성하고 사용하는 과정이었습니다.


결과

  • 더 이상 view controller들의 flow를 하드 코딩하지 않아도 됩니다.
  • view controller 간의 확실한 격리 처리가 가능해졌습니다.
  • 훨씬 DRY 한 코드가 작성 가능하며, SOLID의 원칙 중 SRP(단일 책임 원칙)도 잘 지키게 되었습니다.

하지만 우리에겐 여러 의문점들이 남아있습니다.

  • childCoordinator는 언제 쓰는 건데요?
  • TabBarController 있는 경우에는 어떻게 하나요?
  • ViewController 간 데이터 전달은요?

이제 이들을 하나씩 파헤쳐봅시다.


Child Coordinator

앞서 살펴본 방식처럼 하나의 Coordinator에서 child Coordinator 없이 모든 화면을 관리하다면 각 ViewController들은 불필요한 다른 화면 전환의 메서드까지 갖게됩니다. 이는 상당히 비효율적이겠죠? 그래서 사용하는 것이 child Coordinator입니다. 각 Coordinator는 현재 필요로 하는 화면전환 메서드만 보유합니다.

 

먼저 child Coordinator가 되어줄 Coordinator를 먼저 생성해봅시다.

class BuyCoordinator: Coordinator {
    var childCoordinators = [Coordinator]()
    var navigationController: UINavigationController
    weak var parentCoordinator: MainCoordinator?

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

    func start() {
        let vc = BuyViewController.instantiate()
        vc.coordinator = self
        navigationController.pushViewController(vc, animated: true)
    }
}
  • parent Coordinator와 동일한 구조를 가지고 있습니다.
  • 추가적으로 parent Coordinator에 대한 약한 참조를 가지고 있습니다.
  • 외부에서 주입받아 할당되는 navigationController는 parent Coordinator와 동일한 것입니다. (부모로부터 주입받습니다.)
class MainCoordinator: Coordinator {
...
    func buySubscription() {
        let child = BuyCoordinator(navigationController: navigationController)
        child.parentCoordinator = self
        childCoordinators.append(child)
        child.start()
    }
...
}
  • parent coordinator는 child coordinator를 생성해 자신을 parentCoordinator로 할당한 다음 childCoordinators에 추가합니다.
  • start 메서드를 호출해 화면을 띄워줍니다.
  • 앞서 살펴본 방식과는 다르게 화면 전환에서 coordinator만을 사용하고 있네요!

이제 child Coordinator를 통해 화면을 전환하는 것까지는 성공했습니다. 하지만 이대로라면 child Coordinator가 담당하는 화면이 사라진 뒤에도 childCoordinators에 child Coordinator가 남아있을 것입니다. 이를 처리해봅시다.

class BuyViewController: UIViewController {
    weak var coordinator: BuyCoordinator?
    ...
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        coordinator?.didFinishBuying()
    }
    ...
}
  • 해당 child coordinator를 활용 중인 ViewController의 viewDidDisappear에서 coordinator의 didFinishBuying이라는 메서드를 호출하도록 합니다.
class BuyCoordinator: Coordinator {
    weak var parentCoordinator: MainCoordinator?
...
    func didFinishBuying() {
        parentCoordinator?.childDidFinish(self)
    }
...
}
  • didFinishBuying 메서드는 parentCoordinator의 childDidFinish를 호출합니다.
class MainCoordinator: Coordinator {
...
    func childDidFinish(_ child: Coordinator?) {
        for (index, coordinator) in childCoordinators.enumerated() {
            if coordinator === child {
                childCoordinators.remove(at: index)
                break
            }
        }
    }
...
}
  • childDidFinish는 Coordinator를 파라미터로 받아 이를 child Coordinator 배열에서 삭제하는 메서드입니다.
  • 다만 화면이 여러 개일 경우 viewDidDisappear가 이르게 호출되는 경우도 있기 때문에 주의해서 사용해야 합니다.
  • cf. UINavigationControllerDelegate의 delegate method를 활용하는 방법도 있습니다. 이는 화면 전환이 발생할 때마다 delegate method를 통해 이를 확인하고 childDidFinish 메서드를 실행시키는 구조입니다.

Coordinator with Tab Bar Controllers

이 경우 각각의 탭을 각각의 Coordinator가 관리하고 있다고 생각하면 됩니다.
해당 예시에서는 하나의 탭만 있다고 가정하고 설명하겠습니다.

class MainTabBarController: UITabBarController {
    let main = MainCoordinator(navigationController: UINavigationController)

    override func viewDidLoad() {
        super.viewDidLoad()
        main.start()
        viewControllers = [main.navigationController]
    }
}
  • 내부에 coordinator를 들고 이를 직접 넣어줍니다.
  • 물론 AppDelegate나 SceneDelegate에서 window의 rootViewController를 MainTabBarController로 설정해주는 과정이 필요합니다.
class MainCoordinator: Coordinator {
...
    func start() {
        let vc = ViewController.instantiate()
        vc.tabBarItem = UITabBarItem(tabBarSystemItem: .favorites, tag: 0)
        vc.coordinator = self
        navigationController.pushViewController(vc, animated: false)
    }
...
}
  • start의 과정에서 tabBarItem을 설정하는 과정을 하면 끝입니다.

View Controller 간 데이터 전달

이건 정말 간단합니다.

class MainCoordinator: Coordinator {
...
    func buySubscription(_ number: Int) {
        let child = BuyCoordinator(navigationController: navigationController)
        child.parentCoordinator = self
        child.number = number
        childCoordinators.append(child)
        child.start()
    }
...
}

이렇게 단순히 coordinator의 메서드에서 전달이 필요한 데이터를 입력받고 전달하면 됩니다.


+Tip

protocol Coordinating {
    var coordinator: Coordinator? { get set }
}
  • coordinator 프로퍼티를 가지도록 하는 coordinating이라는 protocol을 관리하면 좀 더 쉽게 프로퍼티를 할당할 수 있습니다.
  • coordinator를 활용할 ViewController가 이를 채택하도록 하면 좀 더 확실한 관리가 가능해지겠죠?

여기까지 coodinator 패턴에 대한 정리였습니다.

 

저도 제대로 공부해본건 처음인데, 확실히 잘 활용하면 화면 전환 책임 분리에 매우 매우 유용하게 사용할 수 있을 것 같네요!

긴 글 읽어주셔서 감사합니다!!


참고

반응형