Skip to content

화면 전환을 위한 Coordinator

Jerry_hoyoung edited this page Jan 3, 2023 · 3 revisions

🏠 동네 한입팀이 화면전환을 위해 Coordinator 패턴을 설계한 과정을 담아보고자 작성합니다.****

기존의 Coordinator의 불편함

이전에 타 프로젝트를 진행할때 Coordinator를 이용해 화면전환을 했던 경험이 있었습니다

스크린샷 2023-01-03 오전 10 11 30 스크린샷 2023-01-03 오전 10 55 30

childCoordinator, parentCoordinator를 지정하고 finish를 하기 위해 childFinishDelegate를 설정해줘야했습니다.

저는 타 프로젝트를 진행할때 큰 실수를 하였는데요 backButton을 이용해 pop되는 로직이 실행될때는 Coordinator 파일이 메모리에서 해제되지 않는 버그를 발견하기도 했습니다 🥲

이렇게 기존의 Coordinator의 불편함을 느끼고 있었습니다


Coordinator에 대한 나의 견해

Coordinator를 사용해보면서 제가 느꼈던 장점, 단점을 정리해보았습니다

장점

  • 화면 전환이나 View간 데이터 전달을 쉽게 파악할 수 있다

단점

  • ViewModel, ViewController객체를 포함하면서 메모리 관리에 불편함이 있을 수 있다
  • Coordinator를 사용하는 과정에서 휴먼에러가 발생하기 쉽다 ( 보일러 플레이트 또한 발생 )

저희 팀은 Coordinator를 도입할지 고민을 해보았고 위에 적은 단점을 개선한 Coordinator를 설계하기로 결정하였습니다


동네한입 App의 Coordinator

동네 한입 app은 위에 생각했던 단점을 극복하고자 하였습니다

ViewModel, ViewController객체를 포함하면서 메모리 관리에 불편함이 있을 수 있다

→ 자동으로 메모리를 조절할 수 있는 구조로 설계하자!

Coordinator를 사용하는 과정에서 휴먼에러가 발생하기 쉽다 ( 보일러 플레이트 발생 )

→ Enum, protocol을 잘 활용하고 최대한 단순한 구조로 설계하자!

RxFlow와 https://github.com/AlbertMontserrat/CoordinatorsAutoHandled 을 참고하여 저희 Coordinator를 설계하였습니다


구조 설명

스크린샷 2023-01-03 오전 10 34 49

MVVM으로 구성된 하나의 Coordinator으로

  • presenter는 navigationController로 강한 참조로 ViewController를 가지고 있습니다
  • ViewController는 ViewModel을 가지고 있습니다
  • ViewModel에는 강한 참조로 Cooridinator 가져 ViewModelDelegate에 해당하는 이벤트를 전달합니다
  • Coordinator에서 ViewModelDelegate가 위임한 이벤트를 실제로 실행하게 되고 약한 참조로 presenter를 가져 viewController가 해제되는 경우 ViewController -> ViewModel -> Coordinator순으로 메모리가 자동으로 해제 되게 됩니다

Coordinator protocol

Coordinator 프로토콜은 Coordinator의 공통 메서드인 start()를 추상화시켜 채택받게 하도록 구현하였습니다

FlowCoordinator protocol

FlowCoordinator들을 구성하기 위한 프로토콜을 구현하였습니다 구현에 필요한 presentationStyle, navigationController, start, initScene을 작성하였고 start 메서드는 공통적으로 쓰이는 부분이기 때문에 extension을 이용하여 프로토콜 기본구현하였습니다

 public protocol FlowCoordinator: Coordinator {
    var presentationStyle: PresentationStyle { get set }
    var navigationController: UINavigationController? { get set }

    func start()
    func initScene() -> UIViewController
}

public extension FlowCoordinator {
    func start() {
        switch presentationStyle {
        case let .push(navigationController):
            self.navigationController = navigationController
            let initScene = initScene()
            initScene.hidesBottomBarWhenPushed = true
            self.navigationController?.pushViewController(initScene, animated: true)
        case .present(presenter: let presenter, modalPresentationStyle: let modalPresentationStyle):
            let navigationController = BaseNavigationController(rootViewController: initScene())
            self.navigationController = navigationController
            self.navigationController?.modalPresentationStyle = modalPresentationStyle
            presenter.present(navigationController, animated: true, completion: nil)
        case .setViewController(navigationController: let navigationController):
            self.navigationController?.viewControllers = []
            let newNavigation = BaseNavigationController(rootViewController: initScene())
            newNavigation.modalTransitionStyle = .flipHorizontal
            newNavigation.modalPresentationStyle = .overFullScreen
            navigationController.present(newNavigation, animated: true, completion: nil)
        case .none:
            self.navigationController = BaseNavigationController(rootViewController: initScene())
        }
    }
}

PresentationStyle

PresentationStyle은 화면전환을 정리한 열거형으로 push, present, setViewController, none으로 구성되어 있습니다

  • push는 새로운 Coordinator flow를 Push할때 사용합니다
  • present는 새로운 Coordinator flow를 modally하게 띄워줄때 사용합니다 modalPresentationStyle로 overFullScreen, formSheet 등 설정할 수 있고 기본값은 automatic입니다
  • setViewController는 TabCoordinator나 로그아웃하면 Login창을 띄우는 등 ViewHierachy를 초기화 시키려고 만들었습니다
  • none은 TabBar로 구성된 Home, Map, Favorite, Setting Coordinator를 띄우려고 만들었어요 push 할순 없어서...
public enum PresentationStyle {
    case push(navigationController: UINavigationController)
    case present(presenter: UINavigationController, modalPresentationStyle: UIModalPresentationStyle = .automatic)
    case setViewController(navigationController: UINavigationController)
    case none
}

AppCoordinator

AppCoordinator에서는 root Coordinator로 Coordinator flow를 처음 실행하는 로직을 담고 있어요! 현재는 자동로그인 로직 구현을 위해 메서드 껍데기들을 만들어놓았습니다

TabBarCoordinator

TabBarController를 담고있는 coordinator로 Home, Map, Favorite, Setting Coordinator를 생성합니다 지금은 Home이랑 Map만 만들어 두었습니다 :)


Flow 예시

HomeCooridnator

  • FlowCoordinator protocol로 기본구현을 해두어서 initScene메서드만 구현해주면 됩니다!
  • initScene에는 해당 Flow 초기화면인 ViewController를 반환해주어야합니다 ( ViewController가 ViewModel을 가지고 있어야함 )
  • HomeCoordinator는 HomeViewModelDelegate를 채택하고 있어 메서드 내부에 새로운 Coordinator들을 push, present하는 로직을 구성할 수 있습니다!
  • 추가로 ViewController만 push하고 싶다면 secondScene()메서드를 만들어서 push만 하면 됩니다! present도 마찬가지!! navigationController.pushViewController(secondScene(), animated: true)
final class HomeCoordinator: FlowCoordinator {

    var presentationStyle: PresentationStyle
    weak var navigationController: UINavigationController?

    init(presentationStyle: PresentationStyle) {
        self.presentationStyle = presentationStyle
    }

    internal func initScene() -> UIViewController {
        let homeViewModel = HomeViewModel(delegate: self)
        let homeViewController = HomeViewController(viewModel: homeViewModel)
        return homeViewController
    }
}

extension HomeCoordinator: HomeViewModelDelegate {

    func push() {
        guard let navigationController = navigationController else { return }
        MapCoordinator(presentationStyle: .push(navigationController: navigationController)).start()
    }

    func present() {
        guard let navigationController = navigationController else { return }
        HomeCoordinator(presentationStyle: .present(presenter: navigationController)).start()
    }

    func pop() {
        guard let navigationController = navigationController else { return }
        navigationController.popViewController(animated: true)
    }

    func dismiss() {
        guard let navigationController = navigationController else { return }
        navigationController.dismiss(animated: true)
    }

    func setViewController() {
        guard let navigationController = navigationController else { return }
        TabBarCoordinator(presentationStyle: .setViewController(navigationController: navigationController)).start()
    }

}

HomeViewController

HomeViewController는 ViewModel을 가지고 있습니다

class HomeViewController: BaseViewController {

    var viewModel: HomeViewModel?

HomeViewModel

HomeViewmodel은 delegate를 설정하여 실제 실행될 action을 명시하고 action을 coordinator에 위임하여 화면전환 로직을 건네주게 됩니다

  • 추가로 HomeViewModelType은 메서드 일일이 만들기 힘들어서 구현했어요.. 메서드 복붙만 하면 되니까요!
protocol HomeViewModelDelegate {
    func push()
    func present()
    func pop()☺️
    func dismiss()
    func setViewController()
}

protocol HomeViewModelType {
    func push()
    func present()
    func pop()
    func dismiss()
    func setViewController()
}

final class HomeViewModel: BaseViewModel {
    let delegate: HomeViewModelDelegate

    init(delegate: HomeViewModelDelegate) {
        self.delegate = delegate
    }
}


동네한입 App의 Coordinator 사용후기

엄청나게 완벽하다고 박수를 쳤지만 코드 리뷰를 받아보니 끝없는 에러의 향연이 있었습니다.. 😇

첫번째 문제점 : TabBar

로그인 flow에서 TabBar를 띄울 때 문제가 발생합니다.

AppCoordinator에서 navigationController를 가지고 있고

TabBar는 각 VC내 navigationController를 가지고 있어서 navigationBar가 쌓이는 현상이 발생하였습니다

찾아보니 TabBarController는 SceneDelegate내에 있는 window 객체의 rootViewController로 지정하는 경우가 많았습니다

그렇다면 Coordinator를 생성할 때 window객체를 전달해야하는가에 고민이 되었습니다

필요없는 코드가 너무 방대해지고 당장의 flow에서는 TabBar는 한번만 push되기 때문에 navigationBar를 hidden 처리해주어서 해결하였습니다 ☺️

navigationController.isNavigationBarHidden = true
TabBarCoordinator(presentationStyle: .push(navigationController: navigationController)).start()

두번째 문제점 : 러닝커브

정말 열심히 [PR](https://github.com/YAPP-Github/21st-iOS-Team-1-iOS/pull/27)을 남겨서 구조를 설명하려고 애썼으나 직접 개발하지 않으신만큼 러닝커브가 많이 존재하였습니다

그리고 화면 구성을 하는 지금도 PR에 이렇게 작성되어 있습니다…😅 제잘못입니다..

스크린샷 2023-01-03 오전 11 10 30

그래서 협업 측면에서 효율적인지 고민하게 되었고 더 단순하게 할순 없었는지 RxFlow를 사용했었더라면 더 편했을지 생각을 하게 되었습니다

화면전환 설계는 너무나도 어렵네요 😇

감사합니다


[참고]

coordinator-pattern-in-swift-without-child-coordinators