[iOS] Coordinator 문제점과 느낀점..

kimdocs...📄·2022년 3월 23일
3

iOS

목록 보기
8/22
post-thumbnail

Coordinator

0. Coordinator의 수행 기능

1) 화면 전환에 필요한 인스턴스 생성 (UIViewController, ViewModel 등)

2) 생성한 인스턴스의 종속성 주입 (DI)

3) 생성된 UIViewController의 화면 전환 (push or present)

1. Coordinator Protocol 구현

import UIKit

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

    func start()
}

: 화면 전환에 필요한 UINavigationController

navigationController.pushViewController(vc, animated: true) // push
navigationController.present(vc, animated: true, completion: nil) //present

chlidCoordinators

: 화면 전환시 생성될 하위 Coordinator를 저장할 때 사용합니다.

coordinator 생성 후 저장하지 않으면, 메모리에서 제거되기 때문에 꼭 저장해야합니다.

start

: 컨트롤러 생성, 화면 전환 및 종속성 주입의 역할을 합니다.

Coordinator + Extension

extension Coordinator {
    public func addChildCoordinator(_ childCoordinator: Coordinator) { // 코디네이터 추가
        self.childCoordinators.append(childCoordinator)
    }

    public func removeChildCoordinator(_ childCoordinator: Coordinator) { // 코디네이터 삭제, 네비게이션 스택에 코디네이터가 필요하지 않은 경우에만 삭제합니다
        self.childCoordinators = self.childCoordinators.filter { $0 !== childCoordinator }
    }
    
    public func removeChildCoordinators() { // 코디네이터 전체 삭제
        childCoordinators.forEach { $0.removeChildCoordinators() }
        childCoordinators.removeAll()
    }
}

BaseCoordinator

중복되는 프로퍼티들이 많기 때문에 BaseCoordinator를 만들어 상속받아 사용합니다.

import UIKit

class BaseCoordinator: Coordinator {
    var childCoordinators: [Coordinator] = []
    var navigationController: UINavigationController
   

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

    func start() {
        fatalError("Start method must be implemented")
    }
}

2. AppCoordinator

import UIKit

public enum AppFlow {
    case login
    case main
}

final class AppCoordinator: Coordinator {
    var navigationController: UINavigationController
    var childCoordinators: [Coordinator]
    
    let window: UIWindow
    let flow: AppFlow

    init(window: UIWindow) {
        navigationController = UINavigationController()
        self.window = window
        self.window.backgroundColor = .white
        self.window.rootViewController = navigationController
        childCoordinators = []
        flow = .login
    }
    
    func start() {
        switch flow {
        case .login:
            let loginCoordinator = LoginCoordinator(navigationController: self.navigationController, dependencies: self)
            loginCoordinator.start()
            addChildCoordinator(loginCoordinator)
        case .main:
            let mainCoordinator = MainCoordinator(navigationController: self.navigationController)
            mainCoordinator.start()
            addChildCoordinator(mainCoordinator)
        }
        window.makeKeyAndVisible()
    }
}

extension AppCoordinator: LoginCoordinatorDependencies {
    func makeMainTabBarViewController(_ loginCoordinator: LoginCoordinator) {
        window.rootViewController = navigationController
        removeChildCoordinator(loginCoordinator)
        let mainCoordinator = MainCoordinator(navigationController: self.navigationController)
        mainCoordinator.start()
        addChildCoordinator(mainCoordinator)
        window.makeKeyAndVisible()
    }
}

AppCoordinator는 앱 시작시 초기 컨트롤러를 연결하기 위한 coordinator입니다.

AppDelegate에서 전달받은 window의 rootViewController와 Main또는 Login을 연결하기 위한 내용입니다.

  • window.rootViewController = navigationController 초기화 메서드에서 생성한 UINavigationController를 rootViewController로 설정
  • let loginCoordinator = LoginCoordinator(navigationController: self.navigationController, dependencies: self) rootViewController로 설정된 navigationController를 LoginCoordinator에 생성자 파라미터로 전달하여, LoginViewController와 연결하고,
  • LoginCoordinator 에 자신을 전달( LoginCoordinator의 부모 코디네이터가 AppCoordinator임을 알리기 위함 → Delegate 대신, appCoordinator(부모 코디네이터, parentCoordinator)를 자식 코디네이터 생성시에 전달하는 경우도 있습니다.
  • childCoordinators.append(coordinator) coordinator가 메모리에서 사라지지 않기 위해서 인스턴스를 저장해야한다.
  • coordinator.start() start()를 호출하여 LoginVC 생성

3. LoginCoordinator

import UIKi

enum LoginFlow {
    case main
    case yellow
}

protocol LoginCoordinatorDependencies: AnyObject {
    func makeMainTabBarViewController(_ loginCoordinator: LoginCoordinator)
}

final class LoginCoordinator: BaseCoordinator {
    weak var dependencies: LoginCoordinatorDependencies?
    
    init(navigationController: UINavigationController, dependencies: LoginCoordinatorDependencies) {
        super.init(navigationController: navigationController)
        self.dependencies = dependencies
    }
    
    override func start() {
        let viewModel = LoginViewModel(loginControllable: self) // 코디네이터에서 ViewModel을 생성후
        let login = LoginViewController(loginViewModel: viewModel) // LoginVC에 VM을 주입해줍니다.
        login.title = "로그인"
        
        self.navigationController.pushViewController(login, animated: true)
    }
}

extension LoginCoordinator: LoginViewControllable {
    func performTransition(_ loginViewModel: LoginViewModel, to transition: LoginFlow) {
        switch transition {
        case .main:
            dependencies?.makeMainTabBarViewController(self)
        case .yellow:
            let yellow = YellowCoordinator(navigationController: navigationController)
            yellow.start()
            yellow.dependencies = self
            addChildCoordinator(yellow)
        }
    }
}

extension LoginCoordinator: YellowCoordinatorDependencies {
    func performTransition(_ yellowCoordinator: YellowCoordinator, to transition: YellowFlow) {
        switch transition {
        case .main:
            dependencies?.makeMainTabBarViewController(self)
        case .pop:
              removeChildCoordinator(yellowCoordinator)
              navigationController.popViewController(animated: true)
        case .red:
            let red = RedCoordinator(navigationController: navigationController)
            red.start()
            addChildCoordinator(red)
        }
    }
}

4. LoginViewController

import UIKit

import RxCocoa
import RxSwift

final class LoginViewController: UIViewController {
   
    private let viewModel: LoginViewModel
    
    private let button: UIButton = {
        let btn = UIButton()
        btn.backgroundColor = .black
        btn.setTitle("메인으로 이동", for: .normal)
        btn.translatesAutoresizingMaskIntoConstraints = false
        return btn
    }()
    
    private let button2: UIButton = {
        let btn = UIButton()
        btn.backgroundColor = .black
        btn.setTitle("노랑으로 이동", for: .normal)
        btn.translatesAutoresizingMaskIntoConstraints = false
        return btn
    }()
    
    init(loginViewModel: LoginViewModel) {
        self.viewModel = loginViewModel
        super.init(nibName: nil, bundle: nil)
        
        bind()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setUpButton()
    }
    
    private func bind() {
        let input = LoginViewModel.Input(
            buttonDidTapped: button.rx.controlEvent(.touchUpInside).asSignal(), // tap 이벤트를 ViewModel로 전달합니다
            button2DidTapped: button2.rx.controlEvent(.touchUpInside).asSignal()
        )
        
        _ = viewModel.transform(input: input)
    }
    
    private func setUpButton() {
        view.addSubview(button)
        button.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor, constant: 0).isActive = true
        button.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor, constant: 0).isActive = true
        button.widthAnchor.constraint(equalToConstant: 117).isActive = true
        button.heightAnchor.constraint(equalToConstant: 40).isActive = true
        
        view.addSubview(button2)
        button2.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor, constant: -30).isActive = true
        button2.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor, constant: 0).isActive = true
        button2.widthAnchor.constraint(equalToConstant: 117).isActive = true
        button2.heightAnchor.constraint(equalToConstant: 40).isActive = true
    }
}

5. LoginViewModel

import RxSwift
import RxCocoa

protocol LoginViewControllable: AnyObject {
    func performTransition(_ loginViewModel: LoginViewModel, to transition: LoginFlow)
}

final class LoginViewModel {
    private let disposeBag = DisposeBag()
    
    weak var controllable: LoginViewControllable?
    
    struct Input {
        let buttonDidTapped: Signal<Void>
        let button2DidTapped: Signal<Void>
    }
    
    struct Output {
        
    }
    
    init(loginControllable: LoginViewControllable) {
        self.controllable = loginControllable
    }
    
    func transform(input: Input) -> Output {
        input.buttonDidTapped
            .withUnretained(self)
            .emit{ owner, _ in
                owner.controllable?.performTransition(owner, to: .main)
            }
            .disposed(by: disposeBag)
        
        input.button2DidTapped
            .withUnretained(self)
            .emit{ owner, _ in
                owner.controllable?.performTransition(self, to: .yellow)
            }
            .disposed(by: disposeBag)
            
        
        return Output()
    }
}

6. 궁금증

코디네이터와 VC 는 무조건 1:1 인가?

: 자료들을 찾다보면 1:N인 경우도, 1:1인 경우도 있습니다.

코디네이터와 VC를 1:1로 하는 이유?

: 코디네이터는 VC를 생성해주고 ViewModel을 만들어 주입하기 때문입니다.

코디네이터와 VC는 무조건 1:1로 하되 부모 코디네이터와 자식 코디네이터를 명확하게 구분해주세요!

코디네이터의 장점

  • 화면 전환만 담당하는 역할로 분리가 가능하다.
  • ViewModel에서 다음 VC를 띄울 수 있다. → ViewModel1 - VC1 - VC2 - ViewModel2 의 데이터 흐름이아닌 → ViewModel1 - 코디네이터 - ViewModel2로 데이터 전달이 가능

코디네이터의 단점

  • 개념자체는 간단하지만 복잡하다. (화면 이동 3줄이면 하는데 코디네이터를 사용하게 되면 생성되는 파일만 몇갠지..)
  • Delegate 패턴을 많이 사용하므로 한번 화면이 꼬이기 시작하면 끝도없다...
  • 레퍼런스마다 방식이 달라서 어렵다.

7. 문제점🌟🌟🌟

구글링하며 찾은 레퍼런스들은 모두 버튼에 대한 동작에 대해서만 코디네이터를 작동시키고 있었다.

하지만 “숨쉴때” (숭실대학교 커뮤니티 앱)에서는 기본 네비바를 사용하고 있었기 때문에 제스쳐에 대한 dimsiss나 pop과 네비게이션 컨트롤러에서 만들어주는 백버튼을 눌렀을때 pop되는 부분은 처리하지 못했다.

viewDidDisappear를 살펴보자!

viewController는 자체적으로 dismiss 되는지 또는 pop되는지에 대해 알 수 있다.

override func viewDidDisappear(_ animated: Bool) {
				super.viewDidDisappear(animated)
        if navigationController?.isBeingDismissed ?? false {
            print("Red: navigationController isBeingDismissed")
        }
        
        if navigationController?.isMovingFromParent ?? false {
            print("Red: navigationController isMovingFromParent")
        }
        
        if isBeingDismissed {
            print("isBeingDismissed")
        }
        
        if isMovingFromParent {
            print("isMovingFromParent")
        }
 }

isBeingDismissed

isMovingFromParent

두 가지를 사용하면 pop과 dismiss 되는 경우를 알아낼 수 있다.

네비게이션 자체를 present , push 해주는 경우도 있으므로 경우의 수는 4가지이다.

이를 토대로 Rx+UIViewController extension을 작성해보자

       var isDismissing: Observable<Void> {
            return base.rx
                .methodInvoked(#selector(Base.viewDidDisappear(_:)))
                .map { _ in }
                .filter { [weak base] in
                    guard let base = base else { return false}
                    return base.isBeingDismissed
                }
        }
        
        var isPopping: Observable<Void> {
            return base.rx
                .methodInvoked(#selector(Base.viewDidDisappear(_:)))
                .map { _ in }
                .filter { [weak base] in
                    guard let base = base else { return false}
                    return base.isMovingFromParent
                }
        }
        
        var isDismissingWithNavigationController: Observable<Void> {
            return base.rx
                .methodInvoked(#selector(Base.viewDidDisappear(_:)))
                .map { _ in }
                .filter { [weak base] in
                    guard let base = base else { return false}
                    guard let navigationController = base.navigationController else { return false }
                    return navigationController.isBeingDismissed
                }
        }
        
        var isPoppingWithNavigationController: Observable<Void> {
            return base.rx
                .methodInvoked(#selector(Base.viewDidDisappear(_:)))
                .map { _ in }
                .filter { [weak base] in
                    guard let base = base else { return false}
                    guard let navigationController = base.navigationController else { return false }
                    return navigationController.isMovingFromParent
                }
        }

ViewController에서

rx.isPopping
		.map { _ in (print("finish")) }
    .bind(to: viewModel.finish)
    .disposed(by: disposeBag)

이런식으로 바인딩 해준뒤에

ViewModel에서

finish
	.withUnretained(self)
  .subscribe { owner, _ in
			owner.controllable?.finish()
	}
	.disposed(by: disposeBag)

이런식으로 viewController가 종료되었다는 것을 알려주면 된다.

8. 느낀점

사실 화면전환을 하기 위해 이렇게나 많은 클래스와 파일을 생성해야한다는데 가장 큰 단점인 것 같다. 또한 생명주기에 바인딩하는 것도 비효율적이라 생각하기도하고... 경우에 따라서는 코디네이터를 사용하지 않는 것이 더 좋을 수도 있다고 생각한다.. 더 좋은 구현방법이 있으면... 알려주세용..
그래도 ViewController가 아닌 ViewModel에서 Coordinator에게 알려 화면전환을 한다는 점은 큰 장점으로 작용하는 것 같다..! 코디네이터를 통해서 역할분리가 완전 되는 느낌이랄까..

profile
👩‍🌾 GitHub: ezidayzi / 📂 Contact: ezidayzi@gmail.com

1개의 댓글

comment-user-thumbnail
2024년 4월 11일

잘 보고 갑니당~

답글 달기