[새싹 iOS] 25주차_Coordinator Pattern 적용기

임승섭·2024년 1월 8일
0

새싹 iOS

목록 보기
41/44

Sooda

1. 개요

1. 선택한 이유

  • VC에서 화면 전환에 대한 로직을 떼어내기 위해
  • 보다 독립적으로 VC 인스턴스 관리 (이전/다음 화면을 알지 못하게 함)

2. 과정

  • 여러 레퍼런스에서 대부분 비슷한 Coordinator 클래스를 사용하고 있어서 처음 학습하는 데 큰 어려움은 없었다.
  • 다만, 탭바 코디네이터 관련해서는 구조가 헷갈리는 부분이 있어서 Coordinator pattern with Tab Bar Controller 이 글의 도움을 많이 받았다
  • 코디네이터 클래스 자체에 대한 이해보다는 화면 전환 플로우 방식을 이해하는 데 시간을 많이 소요했다.
  • 코디네이터 패턴을 구현하면서 내가 필요하다고 느끼는 부분에 대해서는 코드를 추가했다.

2. 코드

1. protocol Coordinator

  1. 가장 최상위 코디네이터인 AppCoordinator 를 제외하고 모두 부모 코디네이터를 갖는다
  2. 각 코디네이터는 하나의 navigationController 를 갖는다.
    • 두 개 이상을 갖지 못하도록 스스로(?) 규칙을 정했다.
  3. 현재 살아있는(?) 자식 코디네이터들을 저장한 배열이 있다.
    • 탭바 코디네이터를 제외하고는 대부분 단 하나의 자식 코디네이터만 살아있다.
  4. 모든 코디네이터의 타입은 enum CoordinatorType 의 case에 존재하고, 각 코디네이터마다 본인의 타입을 저장하는 프로퍼티가 있다.
    • 코디네이터가 종료될 때, 부모 코디네이터에서 자식 코디네이터 배열을 초기화할 때 사용한다.
  5. 새로운 코디네이터가 생성된다는 건 결국 새로운 화면이 나타난다는 뜻이기 때문에, 첫 번째로 어떤 화면 또는 어떤 플로우가 실행될지 정의해주어야 한다.
  6. 현재 코디네이터가 종료될 때 다음 타겟 코디네이터를 정하고 종료되어야 한다.
    • 모든 코디네이터에 대해 동일하기 때문에, extension Coordinator 에서 메서드를 선언한다.
protocol Coordinator: AnyObject {

    // 1. 부모 코디네이터
    var finishDelegate: CoordinatorFinishDelegate? { get set }

    // 2. 각 코디네이터는 하나의 nav를 갖는다
    var navigationController: UINavigationController { get set }
    init(_ navigationController: UINavigationController)

    // 3. 현재 살아있는 자식 코디네이터 배열.
    var childCoordinators: [Coordinator] { get set }

    // 4. Flow 타입
    var type: CoordinatorType { get }

    // 5. Flow 시작 시점 로직
    func start()

    // 6. Flow 종료 시점 로직. (extension에서 선언)
    func finish(_ nextFlow: ChildCoordinatorTypeProtocol?)
}

extension Coordinator {
    func finish(_ nextFlow: ChildCoordinatorTypeProtocol?) {
        // 1. 자식 코디 다 지우기
        childCoordinators.removeAll()

        // 2. 부모 코디에게 알리기
        finishDelegate?.coordinatorDidFinish(
            childCoordinator: self,
            nextFlow: nextFlow  
        )
    }
}

2. protocol CoordinatorFinishDelegate: AnyObject

  • 자식 코디네이터가 종료된 경우, 부모 코디네이터가 이를 알아야 하기 때문에 delegate pattern 을 이용해서 알려준다.
  • 만약 자식 코디네이터가 없는 경우, 이 프로토콜을 채택하지 않는다.
  • 알려줄 때, 2가지 정보를 전달한다
    1. 종료된 자식 코디네이터의 타입
    2. 다음에 실행되어야 할 코디네이터의 타입
protocol CoordinatorFinishDelegate: AnyObject {
    func coordinatorDidFinish(childCoordinator: Coordinator, nextFlow: ChildCoordinatorTypeProtocol?)
}


// ex.
extension AppCoordinator: CoordinatorFinishDelegate {

    func coordinatorDidFinish(
        childCoordinator: Coordinator,
        nextFlow: ChildCoordinatorTypeProtocol?
    ) {
    	// 1. 자식 코디 배열 초기화 (사실상 하나밖에 없었기 때문에 빈 배열이 된다)
        childCoordinators = childCoordinators.filter { $0.type != childCoordinator.type }
        // 2. nav에 연결된 VC 모두 제거
        navigationController.viewControllers.removeAll()
		
        // 3. 다음 타겟 코디 실행
        // 3 - 0
        if nextFlow == nil { }
        
        // 3 - 1. Child 코디인 경우
        else if let nextFlow = nextFlow as? ChildCoordinatorType {
        	/* ... */
        }
        
        // 3 - 2. Child 코디보다 더 하위 코디인 경우 -> 직접 특정해야 한다 
        // ex). 탭바의 자식 코디인 경우
        else if let nextFlow = nextFlow as? TabBarCoordinator.ChildCoordinatorType {
        	/* ... */
        }
        
        // 3 - 3. 부모 코디를 타고 가야 하는 경우
        else {
        	self.finish(nextFlow)
        }
}

3. protocol ChildCoordinatorTypeProtocol

  • 모든 부모 코디네이터는 자식 코디네이터의 정보를 enum 으로 저장한다.
  • 코디네이터가 종료되는 시점에서, target 코디네이터의 정보를 전달할 때 이 enum 으로 다음 코디네이터를 특정한다.
  • 따라서 coordinatorDidFinish 메서드에서는 모든 자식 코디네이터를 매개변수로 받을 수 있어야 하고, 이를 위해 이 프로토콜을 선언한다.
  • 모든 부모 코디네이터에서 저장하는 자식 코디네이터 enum 은 이 프로토콜을 채택한다
protocol ChildCoordinatorTypeProtocol {
}


// ex.
// AppCoordinator
extension AppCoordinator {
    enum ChildCoordinatorType: ChildCoordinatorTypeProtocol {
        case splash
        case loginScene
        case tabBarScene
        case homeEmptyScene
    }
}

4. CoordinatorType

  • 앱 내에 있는 모든 코디네이터의 종류
  • 각 코디네이터별로 프로퍼티로 어떤 종류인지 저장하고 있다
enum CoordinatorType {
    case app
    case splash, loginScene, homeEmptyScene, tabBarScene
    
    /* ... */
}

3. 내가 적용한 코디네이터 패턴

1. 새로운 코디네이터를 만드는 기준

  • 결론부터 말하면, 내가 정한 새로운 코디네이터를 만드는 기준은 새로운 navigationController가 필요한 경우 이다.

트러블 슈팅

  • 이 기준이 없을 때는, 대강 하나의 플로우는 하나의 코디로 관리하면 되겠지~ 라고 생각하고 구현을 시작했다.
  • 이렇게 시작하고, 바로 멈추게 되는 지점이 있었는데, 연속으로 화면 present가 실행되는 경우 이다.
    (이 때, present를 실행하는 객체는 코디의 navigationController 이다)
    1. Onboarding View를 관리하는 코디에서 SelectAuth View present
    2. SelectAuth View를 관리하는 코디에서 EmailLogin View present
  • 위 과정은 하나의 Coordinator에서 진행할 수 없다.
    • 하나의 VC는 반드시 하나의 VC만 present 할 수 있다 (multiple 불가능)
    • 즉, 코디는 nav가 하나만 있기 때문에 present로 화면 전환을 할 수 있는 건 단 한 번 뿐이다.
  • 결국, 위와 같은 플로우에서는 추가적인 코디가 필요할 수밖에 없다...
    • 새로운 VC를 present시켜줄 코디의 nav가 필요하기 때문이다
  • 그래서 1번 과정에서는 새로운 코디를 생성하고, 그 코디의 nav를 present하는 방식으로 구현했다.
    새로운 navigationController가 필요한 경우
  • 다만 2번 과정에서는 새로운 코디를 생성할 필요가 없다. EmailLogin VC의 navigationController는 따로 하는 일이 없기 때문이다.
    즉, 새로운 navigationController가 필요 없다
  • 이게 내가 세운 새로운 코디네이터를 만드는 기준 이다.
    만약 EmailLoginView의 nav에서 push나 present 등 추가로 해야 하는 일이 있다면, 이 역시 새로운 코디를 생성해야 할 것이다.

코드 정리

// LoginSceneCoordinator (Onboarding View)
class LoginSceneCoordinator: LoginSceneCoordinatorProtocol {

	/* ... */
    
    func showSelectAuthFlow() {
        // TODO: selectAuthFlow 시작. present로 진행. 별도의 navigationController 주입
        
        // 새로 만드는 nav는 어차피 여기서 관리하지는 않을거고, 새로운 코디에서 관리할 예정이기 때문에 현재 코디에서 따로 변수로 가지고 있을 필요는 없다
        // 단지, 현재 코디와 다른 nav를 가질 수 있도록 주입해주는 것 뿐임
        let nav = UINavigationController()
        let selectAuthCoordinator = SelectAuthCoordinator(nav)
        selectAuthCoordinator.finishDelegate = self
        childCoordinators.append(selectAuthCoordinator)
        selectAuthCoordinator.start()   // 코디의 첫 화면 세팅
        
        // 여기서 직접 present 실행
        navigationController.present(selectAuthCoordinator.navigationController, animated: true)
    }
}


// SelectAuthCoordinator (SelectAuth View)
class SelectAuthCoordinator: SelectAuthCoordinatorProtocol {

	/* ... */
    
    func start() {
        showSelectAuthView()
    }
    
    // 초기 화면
    func showSelectAuthView() {
        let selectAuthVM = /* ... */
        let selectAuthVC = /* ... */
        selectAuthVM.didSendEventClosure = /* ... */
       
        navigationController.pushViewController(selectAuthVC, animated: false)
    }
    
    // present로 EmailLoginView (추가적인 nav가 필요 없기 때문에 여기서 present 실행)
    func showEmailLoginView() {
        let emailLoginVM = /* ... */
        let emailLoginVC = /* ... */
        emailLoginVM.didSendEventClosure = /* ... */
        
        // nav는 화면에 보이는 것 외에 다른 기능은 없다
        let nav = UINavigationController(rootViewController: emailLoginVC)
        navigationController.present(nav, animated: true)
    }
}

2. 코디네이터 종료 시, 다음 타겟 코디네이터 지정

  • 내가 참고한 코디네이터 레퍼런스에서는
    코디네이터가 finish로 종료되면,
    부모 코디에서 그 코디의 타입에 따라 다음 코디를 정하고, 이를 실행시켰다.

  • 하지만 하나의 코디가 종료될 때 상태에 따라 서로 다른 코디가 실행될 가능성이 있기 때문에 추가적인 코드의 필요성을 느꼈다.


1. Child : 타겟 지정 후 finish

  • 모든 코디네이터에 대해 finish 메서드는 extension Coordinator에서 미리 선언이 되어있다.
    여기서 delegate pattern을 이용해 다음 타겟 코디를 매개변수로 넣어서 메서드를 실행한다
  • 이 때, 매개변수로 넣는 타겟 코디는
    타겟코디의_부모코디.ChildCoordinatorType.타겟코디 형식으로 넣어준다.

    • 모든 부모코디는 ChildCoordinatorType 으로 본인의 자식 코디 종류를 저장하고 있다.

      extension TabBarCoordinator {
      	enum ChildCoordinatorType: ChildCoordinatorTypeProtocol {
          case homeDefaultScene(workspaceId: Int)
          case dmScene(workspaceID: Int)
          case searchScene, settingScene
      }   
    • 즉,다음 타겟 코디를 넘겨줄 때는, 그 부모 코디가 누구인지도 알아야 한다.

      self?.finish(
          TabBarCoordinator.ChildCoordinatorType.homeDefaultScene(workSpaceId: workSpaceId)
      )
    • 모든 코디의 finish 메서드는 동일

      extension Coordinator {
          func finish(_ nextFlow: ChildCoordinatorTypeProtocol?) {
              // 1. 자식 코디 다 지우기
              childCoordinators.removeAll()
      
              // 2. 부모 코디에게 알리기
              finishDelegate?.coordinatorDidFinish(
                  childCoordinator: self,
                  nextFlow: nextFlow  // 다음 타겟 코디
              )
          }
      }

2. Parent : didFinsih로 종료된 코디와 다음 코디 확인

  • 결과적으로 자식 코디가 종료될 때, 부모 코디에서 전달받는 값은
    자식 코디의 타입 (CoordinatorType)
    다음 타겟 코디 (ChildCoordinatorTypeProtocol) 이다.
  • 자식 코디의 타입은 코디의 자식 코디 배열을 초기화하는 용도로 사용한다.
    childCoordinators = childCoordinators.filter { $0.type != childCoordinator.type }
  • 다음 타겟 코디는 분기 처리가 필요하다
    • 위 구조에서, A 코디네이터가 종료된 상황을 생각해보자
    • 그럼 P2 코디에서 didFinish 가 실행될 것이고, 다음 타겟 코디를 실행시켜주어야 한다.
    • 분기 케이스는 총 4개이다
      1. 타겟 코디가 없는 경우
      2. P2의 Child 코디인 경우 (ex. C)
      3. P2의 Child 코디보다 하위 코디인 경우 (ex. D)
      4. P2의 Parent 코디를 타고 가야 하는 경우 (ex. B, P3)

1. 타겟 코디가 없는 경우

  • nextFlow가 nil인 경우. 따로 작업할 내용 없다
    if nextFlow == nil { }

2. Child 코디인 경우

  • 해당 플로우를 시작하는 메서드 실행
    else if nextFlow = nextFlow as? ChildCoordinatorType {
        /* ... */
    }

3. Child 코디보다 더 하위 코디인 경우

  • 직접 특정해서 정확히 누구인지 알아야 한다
    ex). 탭바코디의 자식코디
    else if let nextFlow = nextFlow as? TabBarCoordinator.ChildCoordinatorType {
        /* ... */
    }

4. Parent 코디를 타고 가야 하는 경우

  • 현재 코디네이터도 종료시키고, 타겟 코디를 그대로 부모에게 전달한다
    else {
        self.finish(nextFlow)
    }

3. 커스텀 얼럿 VC을 띄우는 위치

  • 프로젝트에서 자주 사용되는 커스텀 얼럿을 ViewController로 구현하였다.
  • 처음에는 위 얼럿도 하나의 VC이기 때문에 코디네이터에서 띄워주어야 한다고 생각했다.
  • 그래서 코디네이터에서 얼럿 창을 띄우는 메서드를 구현했다.
  • 그럼 얼럿 창의 버튼 클릭 시 네트워크 통신이 필요한 경우,
    코디네이터에서 네트워크 통신을 진행해야 한다....
    • 물론 버튼 클릭 후 화면 전환을 바로 진행할 수 있는 건 좋지만,
    • 화면 전환 로직이 목적인 코디에서 네트워크 콜을 하고 있는 건 절대 좋지 않다.
  • 그래서 이 이슈 이후에는 얼럿 창 정도는 VC에서 띄워주기로 결정했다. 버튼 클릭 시 네트워크 통신까지는 해당 VM의 역할이라고 판단했다. 이후 화면 전환이 필요한 경우에만 Coordinator에게 event로 알려준다.

4. Coordinator와 VM의 통신

  • 화면 전환이 필요한 시점에, VM은 Coordinator에게 이 사실을 알려주어야 한다.
  • 생각나는 방법은 두 가지였다. 클로저delegate

delegate를 통해 이벤트 전달

  • 이를 구현하기 위해서는 프로토콜을 통해 VM에서 코디네이터를 알고 있어야 한다
  • 결국, 해당 VM에서는 무조건 하나의 코디네이터를 특정해서 알고 있어야 하고, 해당 코디에서만 실행되는 이벤트를 전달해야 한다. 중심이 코디
  • 하지만, 어떤 View는 여러 코디네이터에서 사용되기도 한다. 이 때, VM이 하나의 코디만 알고 있게 구현한다면 다른 코디에서는 VM에게 이벤트를 전달받지 못하게 된다.
  • 물론, 여러 코디를 알고 있는 VM에게 이벤트를 받기 위해 한 번 더 프로토콜을 추상화할 수는 있지만, 너무 비효율적이고 유지보수에 좋아 보이지는 않는다.

closure를 통해 이벤트를 전달

  • 클로저를 통해 이벤트를 전달하면, 중심이 VM 이 된다.
  • VM은 본인이 실행할 이벤트를 실행하면 되고, 해당 VM에 연결된 코디에서 각 이벤트별로 화면 전환을 실행한다.
  • 이를 위해 모든 VM에는 어떤 화면으로 전환될지에 대한 enum Event 가 존재한다.
// HomeDefaultVM
extension HomeDefaultViewModel {
    enum Event {
        case presentWorkSpaceListView(workSpaceId: Int)
        case presentInviteMemberView
        case presentMakeChannelView
        case goExploreChannelFlow        
        case goChannelChatting(workSpaceId: Int, channelId: Int, channelName: String)
        case goBackOnboarding
    }
}

// HomeDefaultCoordinator - showHomeDefaultView
class HomeDefaultSceneCoordinator: HomeDefaultSceneCoordinatorProtocol {
	
    /* ... */

    func showHomeDefaultView(_ workSpaceId: Int) {
        let homeDefaultVM = /* ... */
        homeDefaultVM.didSendEventClosure = { [weak self] event in
            switch event {
            case .presentWorkSpaceListView(let workSpaceId):
                self?.showWorkSpaceListFlow(workSpaceId: workSpaceId)
                
            case .presentInviteMemberView:
                self?.showInviteMemberView()
                
            case .presentMakeChannelView:
                self?.showMakeChannelView()
                
            case .goExploreChannelFlow:
                self?.showExploreChannelFlow(workSpaceId: (self?.workSpaceId)!)
                
            case .goChannelChatting(let workSpaceId, let channelId, let channelName):
                self?.showChannelChattingView(
                    workSpaceId: workSpaceId,
                    channelId: channelId,
                    channelName: channelName
                )
                
            case .goBackOnboarding:  
                self?.finish(AppCoordinator.ChildCoordinatorType.loginScene)
            }
        }
        let vc = HomeDefaultViewController.create(with: homeDefaultVM)
        navigationController.pushViewController(vc, animated: true)
    }
}

5. 특정 화면으로 즉시 이동하기 (Push Notification 클릭)

Push Notification 설정


4. 후기

1. 장점

VM에서 VC로 굳이 화면 전환 시점을 알려줄 필요 없음

  • 기존에는 VC에서 화면 전환을 담당했기 때문에
    VM에서 특정 시점이 되면 VC로 결과를 알려주어야 했다. (Output)

  • 코디네이터를 쓴 이후에는 이 과정 자체가 필요가 없다. VC는 더 이상 화면 전환을 담당하지 않기 때문에 VM에서는 VC에게 시점을 알리지 않고, 바로 클로저를 통해 코디에게 시점을 알려준다.

  • 단순히 VC의 코드가 줄었다고만 생각했는데, Output으로 VC에게 이벤트를 전달하는 코드도 줄어들었다.

VC의 독립성

  • 물론 대부분의 화면은 이전/다음 화면이 명확하기 때문에 VC에서 이 흐름을 알고 있더라도 큰 문제는 없다.
  • 그래도 코디네이터를 이용하면 각 VC는 이전 화면이 뭐였는지, 다음 화면은 뭐가 될 지 전혀 알지 못하기 때문에 보다 독립적인 인스턴스가 될 수 있다.

2. 단점

  • 만약 연속된 present가 많고, present로 올라온 뷰에서도 계속 화면 전환이 필요하다면, 그만큼 코디네이터가 또 필요하게 된다. (코디네이터를 새로 만드는 기준)
  • 이러면 하나의 코디네이터가 거의 하나의 View만 관리하게 되기 때문에 굳이 코디네이터를 쓸 필요가 없다고 생각했다.

  • 물론 화면 전환에 대한 로직을 분리시킬 수는 있지만, 이 정도를 위해 매번 코디네이터를 새로 만드는 건 리소스 낭비라고 생각이 든다.

0개의 댓글