본문 바로가기

IOS

내가 원하는 개발 패턴 만들어보기 PART1

안녕하세요. JHM 입니다.

이번에 개인 프로젝트를 준비하면서 활용해보고 싶은 iOS 개발 패턴을 찾아보던 중 Uber의 RIBs Pattern을 보게 되었습니다. 하지만, 아래 3가지 이유로 인해 RIBs는 잠시 뒤로 미뤄두게 되었습니다.

  1. Business Logic을 기반으로 움직이는 Pattern이지만, 확실하게 어떤 이점이 있는지 감을 잡지 못한 점
  2. RIBs 예제는 Present 예시에만 취중 되어있어, UINavigationController을 적용했어도 맞게 활용하고 있는지 알 수 없어 자신감 지수가 낮았음
  3. 자식 화면을 modalPresentationStyle =. fullScreen로 노출하였을 때 Memory Leak(RIBS는 Memory Leak이 발생하면 Build를 Fail 시킵니다.)이 발생하여 전체 화면 Modal을 할 수 없음

위 같은 이유로 저는 RIBs 활용은 잠시 미뤄두게 되었고, 대신에 원하는 Point들을 만족시킬 수 있는 Pattern을 직접 만들어 활용해보기로 했습니다.

 

제가 원하는 Point는 아래와 같습니다.

  1. 다른 화면을 생성하고 전환하는 기능이 UIViewController에 있지 않다.
  2. MVVM + RxSwift를 주로 사용하는데, 사용자의 입력과 관련된 Input들을 깔끔하게 ViewModel로 전달하여 Input과 Output을 명확하게 하고 싶다.
  3. 부모/자식 간의 통신 Pattern이 존재해야 한다.

오늘의 글은 그중 처음인 다른 화면을 생성하고 전환하는 기능이 UIViewController에 있지 않다. 를 어떻게 구성했는지에 대한 내용입니다.

다른 화면을 생성하고 전환하는 기능이 UIViewController에 있지 않다.

이를 만족시키기 위해 제가 선택한 것은 Coordinator 입니다. 해당 블로그는 부모 Coordinator 안에 자식 Coordinator를 놓는 방식을 설명해줍니다. 하지만, 저는 모든 Coordinator가 독립적으로 움직이길 원해서 다른 방향으로 개발하였습니다.

AppDelegate 부분을 시작으로 하나씩 살펴보겠습니다. 저는 AppDelegate에서 초기 화면(이하 Root)을 론칭하는 RootLauncher Class를 만들었습니다. (Launcher라는 개념은 RIBS 패턴을 공부할 때 마음에 들어서 가져왔습니다. 🙂)

class RootLauncher {
    weak var window: UIWindow?
    
    init(window: UIWindow?) {
        self.window = window
    }
    
    func launch() {
        let coordinator = RootCoordinator()
        let viewController = RootViewController(coordinator: coordinator, viewModel: RootViewModel())
        coordinator.viewController = viewController
        
        let nav = UINavigationController(rootViewController: viewController)
        window?.rootViewController = nav
        window?.makeKeyAndVisible()
    }
}

launch 함수에서 Root를 구성하는 code를 보며 제가 정의한 Coordinator/ViewController/ViewModel 의 역할을 보겠습니다.

let coordinator = RootCoordinator()
let viewController = RootViewController(coordinator: coordinator, viewModel: RootViewModel())
coordinator.viewController = viewController

// Coordinator <------- ViewController
coordinator.viewController = viewController
// RootViewController에서 발생하는 화면전환의 책임을 Coordinator에게 전달

// ViewController <-------> ViewModel
RootViewController(coordinator: coordinator, viewModel: RootViewModel())
// ViewController에 표현할 데이터를 만드는 역할을 ViewModel에게 위임
// ViewModel 안에 Model도 추가하여 BusinessLogic 담당

AppDelegate에서는 다음과 같이 Launcher를 호출하면 화면을 그리게 됩니다.

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Override point for customization after application launch.

    let window = UIWindow(frame: UIScreen.main.bounds)
    self.window = window
    
    let launcher = RootLauncher(window: window)
    launcher.launch()
    
    return true
}

이제 본격적으로 RootCoordinator의 구현체를 보겠습니다. 제가 "다른 화면을 생성하고 전환하는 기능이 UIViewController에 있지 않다."를 목적으로 한 이유는 Coordinator를 만들지 않으면, ViewController의 코드가 너무 비대해지기 때문입니다.

보통의 경우라면 사용자의 입력을 BusinessLogic과 연결하는 역할, 다른 화면 생성 및 전환의 역할, 화면을 그리는 역할을 모두 ViewController에서 하게 됩니다. 3가지 역할을 모두 ViewController에 담다 보면 무거워지는 것은 당연하고 추후 관리하기도 어려워집니다.

protocol RootCoordinatorType {
    func pushChild()
    func presentChild()
    func dismissChild()
}

class RootCoordinator: RootCoordinatorType {
    weak var viewController: RootViewController?
    
    func pushChild() {
        let childCoordinator = ChildCoordinator()
        let childViewModel = ChildViewModel(listener: viewController?.viewModel)
        
        let childViewController = ChildViewController(coordinator: childCoordinator, viewModel: childViewModel)
        childCoordinator.viewController = childViewController
        
        self.viewController?.navigationController?.pushViewController(childViewController, animated: true)
    }
    
    func presentChild() {
        let childCoordinator = ChildCoordinator()
        let childViewController = ChildViewController(coordinator: childCoordinator, viewModel: ChildViewModel(listener: viewController?.viewModel))
        
        childCoordinator.viewController = childViewController
        self.viewController?.present(childViewController, animated: true)
    }
    
    func dismissChild() {
        if let _ = viewController?.presentedViewController {
            viewController?.presentedViewController?.dismiss(animated: true, completion: nil)
        } else {
            viewController?.navigationController?.popToRootViewController(animated: true)
        }
    }
}

위와 같이 Coordinator에 Root 위에 그려질 화면 생성 및 전환의 역할을 위임하게 되면서, ViewController는 View를 그리는 코드와 사용자의 입력을 Business Logic과 연결하는 코드만 가지게 되었습니다.

class RootViewController: UIViewController {
    var coordinator: RootCoordinatorType?
    var viewModel: RootViewModel?
    let disposeBag = DisposeBag()
    
    let pushButton = UIButton()
    let presentButton = UIButton()
    
    init(coordinator: RootCoordinatorType, viewModel: RootViewModel) {
        self.coordinator = coordinator
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        attribute()
        bindViewModel()
    }
    
    private func attribute() {
        view.backgroundColor = .white
        
        pushButton.setTitle("Push", for: .normal)
        pushButton.setTitleColor(.systemRed, for: .normal)
        pushButton.isUserInteractionEnabled = true
        view.addSubview(pushButton)
        
        pushButton.snp.makeConstraints {
            $0.centerX.equalToSuperview()
            $0.centerY.equalToSuperview().offset(15)
        }
        
        presentButton.setTitle("Present", for: .normal)
        presentButton.setTitleColor(.systemRed, for: .normal)
        presentButton.isUserInteractionEnabled = true
        view.addSubview(presentButton)
        
        presentButton.snp.makeConstraints {
            $0.centerX.equalToSuperview()
            $0.centerY.equalToSuperview().offset(-15)
        }
    }
    
    deinit {
        print("\(Self.description()) deinit")
    }
}

extension RootViewController: ViewControllerProtocol {
    internal func bindInputToViewModel() {
        viewModel?.getInput(
            input:(
                buttonTapped: pushButton.rx.tap.asObservable(),
                viewDidAppear: Observable.just(Void())
            )
        )
    }
    
    internal func subscribeOutputToViewModel() {
        viewModel?.buttonTapped?
            .subscribe(onNext: { str in
                print(str)
            })
            .disposed(by: disposeBag)
        
        // 부모 자식 통신 예제
        viewModel?.childDismissEventOccured
            .subscribe(onNext: { [weak self]  _ in
                self?.coordinator?.dismissChild()
            })
            .disposed(by: disposeBag)
    }
    
    internal func userAction() {
        pushButton.rx.tap
            .subscribe { [weak self] _ in
                self?.coordinator?.pushChild()
            }
            .disposed(by: disposeBag)
        
        presentButton.rx.tap
            .subscribe { [weak self] _ in
                self?.coordinator?.presentChild()
            }
            .disposed(by: disposeBag)
    }
}

위와 같이 코드를 구성하고 자식 화면을 부르는 모습입니다.

미리보는 구현 결과

다음에는 "MVVM + RxSwift를 주로 사용하는데, 사용자의 입력과 관련된 Input들을 깔끔하게 ViewModel로 전달하여 Input과 Output을 명확하게 하고 싶다. 을 어떻게 했는지 설명해보겠습니다.

'IOS' 카테고리의 다른 글

UICollectionViewCell Dynamic Frame 다루기  (0) 2021.08.08
Jenkins PipeLine 적용기  (0) 2021.06.01