[iOS] RIBs 패턴으로 서비스 출시하기

영모·2022년 8월 5일
1

개인 프로젝트

목록 보기
2/2
post-thumbnail

서비스 소개

🎉 앱스토어 🎉 여기에서 다운 받으실 수 있습니다.
한줄 요약 하자면, 취업 후기를 기록하는 서비스입니다.
🍺 깃허브 코드가 난잡하지만, 공개 레포 입니다.

들어가기 앞서

아직 리펙토링 전이어서 코드가 난잡하고, 정리가 되어있지 않은 점 미리 양해를 구합니다. 🥲 그래도 코드를 확인하고 싶으시다면 깃허브 사이트를 방문해주세요 ! 🥺

종속성

iOS 버전 13.0을 지원 합니다. 사용한 종속성을 요약하자면 다음과 같습니다.
RIBs (0.9.3)
RxSwift (5.0.0)
ReactorKit (2.1.1)
Alamofire (5.6.1)
SnapKit (5.6.0)

구조

  • Source
    • Extension (Extension 관련)
    • Util (Helper 함수들, ex 파싱 관련)
    • Domain
      • Model (모델)
      • Repository (네트워크 통신 관련)
      • Service (Repository와 뷰컨에서 쓰일 추가적인 함수들 결합)
    • RIBs (RIBs 관련)
    • View (View 관련)

RIBs 패턴

제가 개발을 겪은 시행착오들을 핵심만 뽑아내서 정리해보겠습니다. 😀

부모 RIB이 자식 RIB을 떼고 붙이고

부모 RIB이 자식 RIB을 떼었다(detach) 붙였다(attach) 하는게 핵심입니다. 떼고 붙이는 것을 잘 알고 있어야 나중에 헷갈리지 않습니다.

SignUpSecond RIB에서 LoggedIn RIB으로 넘어가야 하는 상황은 부모 RIB이 LoggedOut RIB을 떼어내고 LoggedIn RIB을 붙이도록 해야합니다. LoggedOut RIB을 떼어냈는데, SignUpFirst RIB을 또 다시 떼어내려고 하면 메모리 릭이 발생하고 프로세스가 종료 합니다. LoggedOutRIB을 부모로부터 떼어내면 자식 RIB들은 모두 떼어내집니다.
이 과정을 요약하면 이렇습니다.

  1. SignUpSecond RIB 에서 회원가입이 끝난 시점에서 Root RIB에게 그 사실을 알린다.
    이 상황에서는 SignUpSecond는 바로 Root RIB으로 알릴 수 없기 때문에 SignUpSecond -> SignUpFirst -> LoggedOut -> Root 이렇게 거쳐서 Root RIB으로 알립니다.
  2. Root RIB 에서 LoggedOut RIB을 떼어낸다.
    보통은 child라는 변수를 두어서 현재 attach된 RIB을 넣어놓고 detach를 하는식으로도 많이 쓰는데, 결국 핵심은 LoggedOut RIB이 attach된 상태이니깐 detach 해주어야 합니다.
  3. Root RIB 에서 LoggedIn RIB을 붙인다.
    detach를 잘 마친 후에, LoggedIn RIB을 생성해서 attach 해주면 LoggedOut RIB을 떼고, LoggedIn RIB을 붙이는 과정이 끝납니다.

탭바 컨트롤러에서는 RIB을 재사용 해야한다

RIB에서 탭바를 사용하려면 추가적인 설정이 필요합니다. 제 나름대로 해석하고 RIBs 패턴을 적용해 보았습니다.

// router
func attachApplyDetailRIB(apply: Apply) {
	if let child = child {
    	detachChild(child)
    }
	let applyDetail = applyDetailBuilder.build(withListener: interactor, apply: apply)
        
	self.applyDetail = applyDetail
	attachChild(applyDetail)
	viewController.present(applyDetail.viewControllable, isTabBarShow: false)
        
	child = applyDetail
}

자식 RIB으로 넘어갈때 일반적으로 다음과 같은 코드를 사용합니다.

let applyDetail = applyDetailBuilder.build(withListener: interactor, apply: apply)

이 부분에서 자식 RIB을 재생성하는 부분 입니다. build를 attach 할 때마다 하게 된다면 새로운 RIB이 계속 생성되서 탭바 상황과 맞지 않습니다.

// interactor
// init 부분
blog = blogBuilder.build(withListener: interactor)
apply = applyBuilder.build(withListener: interactor)
myPage = myPageBuilder.build(withListener: interactor)
schedule = scheduleBuilder.build(withListener: interactor)

func attachApplyRIB() {
	if let child = child {
    	detachChild(child)
    }
    attachChild(apply)
    child = apply
}

init 부분에서 RIB을 미리 생성하고, attach할때 재사용해주는 코드를 작성하였습니다.

init 에서 RIB을 생성하지 않고 처음 attach할때 RIB을 생성하고 다음부터는 재사용하도록만 쓰면 되지 않을까 했었습니다. 하지만, TabBarController을 구성하려면 ViewController가 필요한데, 미리 ViewController을 세팅해주어야 하는 문제가 있어서 init에서 미리 RIB들을 생성해주고 TabBarController의 ViewControllers에 넣어주었습니다.

// viewcontroller
func setupTabBar(blogViewController: UIViewController, applyViewController: UIViewController, myPageViewController: UIViewController, scheduleViewController: UIViewController) {
    
    blogViewController.tabBarItem = UITabBarItem(title: "블로그", image: UIImage(named: ImageName.search)?.withTintColor(.white), tag: 0)
    applyViewController.tabBarItem = UITabBarItem(title: "홈", image: UIImage(named: ImageName.home)?.withTintColor(.white), tag: 1)
    myPageViewController.tabBarItem = UITabBarItem(title: "마이", image: UIImage(named: ImageName.user)?.withTintColor(.white), tag: 2)
    
    // FIXME: 다음 버전 출시
//        scheduleViewController.tabBarItem = UITabBarItem(title: "스케쥴", image: UIImage(named: ImageName.user)?.withTintColor(.white), tag: 3)
    
    self.viewControllers = [UINavigationController(rootViewController: blogViewController), UINavigationController(rootViewController: applyViewController), UINavigationController(rootViewController: myPageViewController)]
    
    tapTabBarSubject.onNext(1)
    selectedIndex = 1
}

override func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
    tapTabBarSubject.onNext(item.tag)
}

// interactor
func tabTabBar(index: Int) {
    Log("[D] \(index)")
    switch index {
    case 0:
        router?.attachBlogRIB()
    case 1:
        router?.attachApplyRIB()
    case 2:
        router?.attachMyPageRIB()
//        case 3:
//            router?.attachScheduleRIB()
    default:
        return
    }
}

탭바 컨트롤러에 뷰컨트롤러를 넣어주는 과정은 이와 같고, tabBar의 메소드를 오버라이딩하여 바인딩 처리를 한 후에 interactor에서 routing 처리를 해주었습니다.

수평 / 수직으로 RIB 트리 설계 과정

RIB 트리를 설계할때 고민을 많이 했던 지점이고, 수평으로 RIB을 넓혀야 할때와 수직으로 RIB을 깊어져야 할때의 시행착오를 정리해보겠습니다.

하나의 Flow가 하나의 ViewController에서 끝나지 않는 경우가 많습니다. 예를 들면, 회원가입Flow에서. 닉네임을 입력해 주세요 -> 성별을 선택해 주세요 -> 생년월일을 입력해주세요 -> 이제 확인 버튼을 클릭하면 회원가입이 완료됩니다. -> 축하합니다! 회원가입이 완료되었습니다.

이렇게 수평적으로 설계할 수도 있습니다. SignUp에서 자식 RIB들을 모두 관리해야합니다. 고려 해야 할점은 BirthDay에서 NickName으로 갈때 Sex를 거쳐서 가야 하는것에 관리 책임이 SignUp에게 있습니다. 왜냐하면 이렇게 설계되어 있다면, BirthDay에서 Nickname으로 곧바로 갈 수 있기 때문입니다.

이렇게 수직적으로 설계할 수 있습니다. BirthDay에서 Nickname으로 가려면 반드시 Sex를 거쳐야하는 구조입니다. 부모 RIB은 각각 하나의 자식을 갖고 있으므로 다른 RIB으로 갈 가능성이 애초에 존재하지 않습니다.

Mypage에서 Blog로 Blog에서 Apply이렇게 자유롭게 왔다갔다 하는 상황에서는 수평적으로 설계하는 것이 올바르다고 생각합니다.

요약하자면 특정 RIB으로 이동하는 방법을 트리 구조로 제한할 수 있습니다. 트리가 펼쳐져 있다면 이는 어느 RIB에서도 접근이 가능하게 설계하는 것이고, 수직으로 내려간다면 특정 RIB에서만 자식 RIB으로 이동할 수 있는 것을 강제할 수 있습니다. RIB 트리를 설계하는 것은 중요하고, 이는 RIBs 패턴의 장점을 활용하는 방식입니다.

첫번째 사진과 같이 수평적으로 설계할 수 있지만, 이는 MVC 패턴과 MVVM 패턴이 갖고 있는 문제점인 뷰컨에서 다른 뷰컨으로 제한 없이 이동할 수 있는 문제를 해결합니다.

인터렉터와 뷰컨 간의 바인딩 3가지 방법

제가 적용해 본 방식은 총 3가지이고, 함수 전달 방식, Action-Hanlder 바인딩, Reactorkit을 모두 살짝 사용해보았고, 현재 프로젝트에서 세가지 코드가 모두 섞여있는 상태입니다 (ㅋㅋㅋ.. ㅠ)

// Interactor
protocol SchedulePresentable: Presentable {
    var listener: SchedulePresentableListener? { get set }
    // TODO: Declare methods the interactor can invoke the presenter to present data.
    func updateCalendarCell(prevDate: Date, currentDate: Date, nextDate: Date)
    func updateNavigationTitle(title: String)
    func switchBottomEditButton() -> Bool
    func updateBottomSheet(date: Date)
}

func tapBottomViewEditButton() {
    presenter.switchBottomEditButton()
}

// ViewController
protocol SchedulePresentableListener: AnyObject {
    // TODO: Declare properties and methods that the view controller can invoke to perform
    // business logic, such as signIn(). This protocol is implemented by the corresponding
    // interactor class.
    func scrollCalendarPrev()
    func scrollCalendarNext()
    func tapBottomViewEditButton()
    func tapCalendarView(date: Date)
}

func switchBottomEditButton() -> Bool {
    let bool = !selfView.bottomEditButton.isSelected
    
    selfView.bottomEditButton.isSelected = bool
    canEdit = bool
    selectedCellIndexPath = nil
    selfView.bottomTableView.performBatchUpdates(nil, completion: { [weak self] _ in
        self?.selfView.bottomTableView.reloadData()
    })
    
    return bool
}

selfView.bottomEditButton.rx.tap
    .bind { [weak self] _ in
        self?.listener?.tapBottomViewEditButton()
    }
    .disposed(by: disposeBag)
    

첫번째 함수 전달 방식입니다. 함수에 인자를 넣고 뷰컨에서 이를 사용하는 방식입니다. selfView의 bottomEditButton이 클릭 되면 interactor의 tapBottomViewEditButton()이 작동되고 interactor의 함수인 tapBottomViewEditButton에서는 viewController의 switchBottomEditButton()의 함수를 실행 시킵니다. interacotr에서 데이터 처리를 완료한 후에 viewController의 함수를 실행시켜주는 방식입니다.

// Interactor
protocol ApplyPresentable: Presentable {
    var listener: ApplyPresentableListener? { get set }
    var action: ApplyPresentableAction? { get }
    var handler: ApplyPresentableHandler? { get set }
}

func setupBind() {
    guard let action = presenter.action else { return }
    
    action.tapApplyTVC
        .bind { [weak self] apply in
            self?.router?.attachApplyDetailRIB(apply: apply)
        }
        .disposeOnDeactivate(interactor: self)
    
    action.tapPlusButton
        .bind { [weak self] _ in
            self?.router?.attachWriteApplyOverallRIB()
        }
        .disposeOnDeactivate(interactor: self)
}

// ViewController
protocol ApplyPresentableAction: AnyObject {
    var tapPlusButton: Observable<Void> { get }
    var tapApplyTVC: Observable<Apply> { get }
}

protocol ApplyPresentableHandler: AnyObject {
    var applies: Observable<[Apply]> { get }
    var myPage: Observable<MyPageResponse> { get }
}

override func setupBind() {
    super.setupBind()
    
    guard let action = action else { return }
    guard let handler = handler else { return }
    
    handler.applies
        .bind { [weak self] applies in
            self?.reloadApplyTableView(applies: applies)
        }
        .disposed(by: disposeBag)
    
    handler.myPage
        .bind { [weak self] myPage in
            self?.updateUserInfoView(myPage: myPage)
        }
        .disposed(by: disposeBag)
}

두번째로 바인딩 처리입니다. MVVM 패턴에서 사용하는 입력값 따로 변화값 따로 프로토콜을 뷰컨에서 선언해주고 이를 interactor에서 가져와서 연결지어줍니다. 단순 설명하면, Rx값들을 선언해서 인터렉터와 뷰컨간 소통을 하는 방식입니다.

// Interactor
enum Mutation {
    case myPageResponse(MyPageResponse)
    case detach
}

func mutate(action: Action) -> Observable<Mutation> {
    switch action {
    case .viewWillAppear:
        return Observable<Mutation>.create { emitter in
            self.userService.myPage { result in
                switch result {
                case .success(let data): emitter.onNext(.myPageResponse(data))
                case .failure: break
                }
            }
            return Disposables.create()
        }
    case .tapLogOutButton:
        authService.logOut()
        return .just(.detach)
    case .tapSignOutButton:
        return Observable<Mutation>.create { emitter in
            self.authService.signOut { result in
                switch result {
                case .success: emitter.onNext(.detach)
                case .failure: break
                }
            }
            return Disposables.create()
        }
    }
}

func transform(mutation: Observable<Mutation>) -> Observable<Mutation> {
    return mutation
        .flatMap { [weak self] mutation -> Observable<Mutation> in
            guard let this = self else { return .empty() }
            switch mutation {
            case .detach:
            return this.detachSignInRIBTransform()
        default:
            return .just(mutation)
        }
        }
    }

func reduce(state: State, mutation: Mutation) -> MyPagePresentableState {
    var newState = state
    switch mutation {
    case .myPageResponse(let myPageResponse):
        newState.myPageResponse = myPageResponse
    default:
        break
    }
    return newState
}

private func detachSignInRIBTransform() -> Observable<Mutation> {
    listener?.detachSignInRIB()
    return .empty()
}

// ViewController
enum MyPagePresentableAction {
    case viewWillAppear
    case tapLogOutButton
    case tapSignOutButton
}

protocol MyPagePresentableListener: AnyObject {
    typealias Action = MyPagePresentableAction
    typealias State = MyPagePresentableState

    var action: ActionSubject<Action> { get }
    var state: Observable<State> { get }
    var currentState: State { get }
}

extension MyPageViewController {
    func bindActions(to listner: MyPagePresentableListener) {
        bindViewWillAppear(to: listner)
        bindTapLogOutButton(to: listner)
        bindTapSignOutButton(to: listner)
    }

    func bindViewWillAppear(to listner: MyPagePresentableListener) {
        rx.viewWillAppear
            .map { _ in () }
            .map { .viewWillAppear }
            .bind(to: listner.action)
            .disposed(by: disposeBag)
    }

    func bindTapLogOutButton(to listner: MyPagePresentableListener) {
        selfView.logOutButton.rx.tap
            .map { .tapLogOutButton }
            .bind(to: listner.action)
            .disposed(by: disposeBag)
    }

    func bindTapSignOutButton(to listner: MyPagePresentableListener) {
        selfView.signOutButton.rx.tap
            .map { .tapSignOutButton }
            .bind(to: listner.action)
            .disposed(by: disposeBag)
    }
}

extension MyPageViewController {
    func bindStates(from listner: MyPagePresentableListener) {
        bindMyPageResponseState(from: listner)
    }

    func bindMyPageResponseState(from listner: MyPagePresentableListener) {
        listner.state
            .map { $0.myPageResponse }
            .bind { [weak self] myPageResonse in
                if let myPageResponse = myPageResonse {
                    self?.didUpdateMyPage(myPage: myPageResponse)
                }
            }
            .disposed(by: disposeBag)
    }
}

세번째로 리엑토 킷을 활용하는 것이었는데, 이 방식이 제일 나았습니다. Action-Hanlder 하는 방식은 Action과 Handler가 어쩔수 없이 겹치게 되는 부분이 나와서 불편하였습니다. 그리고 이러한 불편 때문에 나온 방식이 리엑토 킷이니깐, 이를 해결하였습니다. 코드를 방출해놓고 설명이 없어서 죄송하지만, 리엑토 킷을 사용하는 것을 추천합니다. 액션 따로 스테이트 따로 값을 받아놓으니깐 가독성이 훨씬 높아졌습니다.

자주 사용하는 뷰컨 관련 함수는 묶어두기

protocol NavigationViewControllable: ViewControllable {
    func present(_ viewController: ViewControllable, isTabBarShow: Bool)
    func dismiss(_ rootViewController: ViewControllable?, isTabBarShow: Bool)
}

이런식으로 뷰컨 관련 함수를 모아놓은 ViewControllable을 선언해서 사용하였습니다. 근데 이부분은 하다보면 어쩔수없이 필요해집니다. 저는 처음에 일일이 뷰컨에다가 화면전환 함수를 넣었는데, 거의 대부분의 RIB에서 다음의 코드가 필요했고 이를 묶어두고 사용하면 편리하였습니다.

그외 짧은 것들

RIBs가 Rxswift에 의존하고 있는데, Rxswift의 예전 버전을 참조하고 있었습니다. Rxswift의 예전 버전을 사용했을때의 문제점은 아주 오래된 웹뷰를 사용하는데 이 코드가 파일에 섞여있으면 앱스토어 커넥트에 올라가지 않고 메일로 경고 메세지가 옵니다. 직접 Rxswift 파일에서 해당 오래된 웹뷰를 주석처리 해주어야 했습니다.

파일이 너무 많이 생성되는 단점이 있는데, 이부분은 어쩔수 없는 거여서 처음에는 비슷한 뷰컨을 하나의 뷰컨으로 묶을 생각을 해보았지만, 파일이 많아지는 이유 때문에 걱정이라면 그런 걱정은 하나도 안해도 됩니다.

처음에는 재사용성을 높이려고 달력 RIB에서 생성한 달력을 다른 RIB에서 달력부분 View만 떼어내서 가져오는 코드를 작성했는데, 일단 동작하는 것은 확인했는데, 이렇게 활용하는건 좋은 방식이라는 느낌을 못받았습니다. 컴포넌트를 분리하는 것은 좋지만, 한 화면에 대한 책임은 한 화면에서 지는 방식이 좋다고 생각이 들었고, 재사용하고 싶은 부분이 있다면 차라리 1번 뷰컨 위에 2번 뷰컨을 띄우는 식이 좋을 것 같습니다. 1번 뷰컨에 있는 달력 뷰만 2번 뷰컨에서 사용하는 방식은 피했습니다.

저는 detach를 매번 해주어야 하는줄 알았는데, 최종 부모가 가지고 있는 RIB을 detach해주면 그 아래 자식들은 알아서 detach 되는 줄 모르고 자식들도 모두 detach를 해주었다가 메모리 릭이 발생 했었습니다. 이유는 이미 detach가 되었는데 또 detach를 하려고 접근하려다 보니 메모리 상에서 없어진 애를 참조하는 문제였었습니다.

처음에 탭바 컨트롤러를 사용했을때 전혀 감이 잡히지 않아서, 탭바립스 관련 깃허브 이 레포를 참고하다가 심각한 문제가 있었습니다. 여기에서는 ViewController을 단순 사용하고 ViewController의 View를 갈아끼워 넣는 방식으로 화면 전환을 구현한 방식인데, 나중에 RIBs를 이해하고선 왜 이렇게 작성했지 싶었습니다. 아마도 탭바 관련해서 참고할만한 깃허브가 많지 않아서 조금 헤멨습니다.

립스 리엑토킷 깃허브 여기 깃허브가 참고용으로 최고인 것 같습니다. 리엑토킷을 어떻게 적용시킬지 고민이었는데, 구글링 했을때 한국어로 나오는 유일한 깃허브 인것 같습니다. 많이 배웠습니다. 감사드립니다.

profile
iOS Developer

1개의 댓글

comment-user-thumbnail
2022년 8월 16일

세상에서 제일 많이 배워가요 너무 유익해요 !

답글 달기