UIKit에 RIBs 아키텍처 적용하기

RIBs 유래

VIPER

uikit-ribs
  • VIPER는 View, Interactor, Presenter, Entity, Router로 구성
  • VIPER는 View Driven이기 때문에 비즈니스 로직만 있는 VIPER 노드 구현에 어려움이 있음

RIBs

uikit-ribs
  • RIBs는 Router, Interactor, Builder의 약자
  • 하나의 RIB은 Router, Interactor, Builder는 필수적으로 구성된다
  • 필요에 따라 Presenter와 View까지 추가적으로 구성한다
  • VIPER와 달리 View가 포함되지 않고 비즈니스 로직으로만 구성된 Viewless RIB 생성이 가능하다

RIBs 구성

Builder

  • RIBs의 모든 구성 요소를 생성
  • Router, Interactor, Presenter, View, Component 모두 생성

Dependency

  • Dependency는 RIB이 올바르게 초기화 되기 위해 부모로부터 필요로 하는 의존성을 나열하는 프로토콜

Component

  • Component는 Dependency 프로토콜을 구현하는 클래스
  • Component는 RIB의 Builder에 부모 의존성을 제공하는 것 외에도, RIB이 자체적으로 생성하거나 자식 RIB을 위해 생성하는 의존성을 소유하는 역할도 담당

Router

  • 자식 RIB을 attach 혹은 detach하여 RIBs의 논리적 트리 구조 형성

Interactor

  • 비즈니스 로직을 수행하여 Router로 라우팅 호출
  • RIBs의 attach와 detach를 요청
  • Presenter로 data model을 전달

View

  • UI를 생성하고 구성
  • UI 이벤트를 Presenter로 전달
  • ViewModel을 받아서 UI를 업데이트

Presenter

  • Interactor와 View간의 통신을 담당
  • Business Model을 ViewModel로 변환하는 역할
  • 상태를 갖고 있지 않은 클래스
  • Presenter를 생략하는 경우 ViewModel 변환은 View 또는 Interactor가 함

RIBs 트리

uikit-ribs
  • RIBs는 트리 구조를 형성하여, 부모 RIB과 자식 RIB 간 통신을 함
  • RIBs간의 통신은 각 RIB의 Interactor가 담당
  • 부모에서 자식으로의 통신은 Stream을 넘겨주어서 데이터를 전달
  • 자식에서 부모로의 통신은 자식 Interactor에서 부모 Interactor를 Listener로 접근

RIBs 예시

Board RIB

  • 게시판의 게시물이 리스트 형태로 표시되는 화면
uikit-ribs

PostDetail RIB

  • 게시물의 상세 내용을 표시하는 화면
uikit-ribs

파일 구조

  • Board, PostDetail RIBs의 파일 구조는 다음과 같다
uikit-ribs

Board RIB

BoardBuilder

protocol BoardDependency: Dependency {
    ...
}

final class BoardComponent: Component<BoardDependency>, PostDetailDependency {
    let postsStream: BehaviorRelay<(posts: [Post], initial: Bool, reload: Bool)> = .init(value: (posts: [], initial: true, reload: true))
}

protocol BoardBuildable: Buildable {
    func build(withListener listener: BoardListener) -> ViewableRouting
}

final class BoardBuilder: Builder<BoardDependency>, BoardBuildable {
    func build(withListener listener: BoardListener) -> ViewableRouting {
        let component = BoardComponent(dependency: dependency)
        let viewController = BoardViewController()
        let interactor = BoardInteractor(
            presenter: viewController,
            postsStream: component.postsStream
        )
        
        interactor.listener = listener
        
        let postDetailBuilder = PostDetailBuilder(dependency: component)
        
        return BoardRouter(
            interactor: interactor,
            viewController: viewController,
            postDetailBuilder: postDetailBuilder
        )
    }
}
  • BoardBuilder는 RIB의 모든 구성요소를 생성하고 의존성을 주입하는 역할
  • postsStream을 통해 게시물 데이터를 관리하고 필요하다면 자식 RIB과 공유
  • 자식 RIB인 PostDetailBuilder를 생성하는 역할까지 함

BoardInteractor

protocol BoardRouting: ViewableRouting {
    func routeToPostDetail(post: PostEntity)
    func detachPostDetail()
}

protocol BoardPresentable: Presentable {
    var listener: BoardPresentableListener? { get set }
    func update(posts: [Post], initial: Bool, reload: Bool)
}

protocol BoardListener: AnyObject {
    ...
}

final class BoardInteractor: PresentableInteractor<BoardPresentable>, BoardInteractable {
    weak var router: BoardRouting?
    weak var listener: BoardListener?
    
    private let disposeBag = DisposeBag()
    private let postsStream: BehaviorRelay<(posts: [Post], initial: Bool, reload: Bool)>
    
    private var selectedBoardId: String?

    ...

    init(
        presenter: BoardPresentable,
        postsStream: BehaviorRelay<(posts: [Post], initial: Bool, reload: Bool)>
    ) {
        self.postsStream = postsStream
        super.init(presenter: presenter)
        presenter.listener = self
    }
    
    override func didBecomeActive() {
        super.didBecomeActive()
        
        Task {
            do {
                try await fetchPosts(initial: true)
            } catch {
                logError("Error fetching initial data: \(error)")
            }
        }
        
        postsStream
            .subscribe(onNext: { [weak self] posts in
                self?.presenter.update(posts: posts.posts, initial: posts.initial, reload: posts.reload)
            })
            .disposed(by: disposeBag)
    }
}

extension BoardInteractor {
    private func fetchPosts(initial: Bool = false, reload: Bool = true) async throws {
        let endpoint = PostEndpoint.fetchPosts(boardId: selectedBoardId, reload: reload)
        let response: FetchPostsResponse = try await Network.shared.request(endpoint: endpoint)
        let posts = response.data
        
        self.postsStream.accept((posts: posts, initial: initial, reload: reload))
    }
}

extension BoardInteractor: BoardPresentableListener {
    func postDetailButtonDidTap(post: PostEntity) {
        router?.routeToPostDetail(post: post)
    }
}

extension BoardInteractor: PostDetailListener {
    func detachPostDetail() {
        router?.detachPostDetail()
    }
}
  • 게시판의 비즈니스 로직을 담당
  • 게시물 목록을 fetch 하는 네트워크 요청 처리
  • postsStream을 구독하여 데이터 변경 시 View 업데이트
  • PostDetail RIB 라우팅 호출

BoardViewController

protocol BoardPresentableListener: AnyObject {
    func postDetailButtonDidTap(post: PostEntity)
}

final class BoardViewController: UIViewController {
    weak var listener: BoardPresentableListener?
 
    private var posts = [PostEntity]()
    
    ...
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setup()
    }

    ...
}

extension BoardViewController: BoardPresentable {
    func update(posts: [Post], initial: Bool, reload: Bool) {
        Task {
            self.posts = try await withThrowingTaskGroup(of: PostEntity.self) { group in
                ...
            }
            
            // UI 업데이트
            await MainActor.run {
                ...
            }
        }
    }
}

extension BoardViewController: BoardViewControllable {
    func push(_ viewControllable: ViewControllable, animated: Bool) {
        DispatchQueue.main.async {
            self.navigationController?.pushViewController(viewControllable.uiviewController, animated: animated)
        }
    }
    
    func pop(_ viewControllable: ViewControllable, animated: Bool) {
        DispatchQueue.main.async {
            viewControllable.uiviewController.navigationController?.popViewController(animated: animated)
        }
    }
}

extension BoardViewController: UITableViewDelegate, UITableViewDataSource {
    ...

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        ...

        let post = posts[indexPath.row]
        listener?.postDetailButtonDidTap(post: post)
    }
}
  • 게시판 UI를 담당하며, UITableView를 통해 게시물 목록 표시
  • NavigationController를 통해 화면 이동 기능 구현 (push / pop)
  • Interactor와 통신하여 사용자 이벤트 전달

BoardRouter

protocol BoardInteractable: Interactable, PostDetailListener {
    var router: BoardRouting? { get set }
    var listener: BoardListener? { get set }
}

protocol BoardViewControllable: ViewControllable {
    func push(_ viewControllable: ViewControllable, animated: Bool)
    func pop(_ viewControllable: ViewControllable, animated: Bool)
}

final class BoardRouter: ViewableRouter<BoardInteractable, BoardViewControllable> {
    private let postDetailBuilder: PostDetailBuildable
    private var postDetailRouter: PostDetailRouting?
    
    init(
        interactor: BoardInteractable,
        viewController: BoardViewControllable,
        postDetailBuilder: PostDetailBuildable
    ) {
        self.postDetailBuilder = postDetailBuilder
        super.init(interactor: interactor, viewController: viewController)
        interactor.router = self
    }
}

extension BoardRouter: BoardRouting {
    func routeToPostDetail(post: PostEntity) {
        let router = postDetailBuilder.build(withListener: interactor, post: post)
        postDetailRouter = router
        attachChild(router)
        viewController.push(router.viewControllable, animated: true)
    }
    
    func detachPostDetail() {
        guard let router = postDetailRouter else { return }
        viewController.pop(router.viewControllable, animated: true)
        detachChild(router)
        postDetailRouter = nil
    }
}
  • Board RIB과 PostDetail RIB 간의 라우팅 담당
  • PostDetail RIB의 attach 및 detach 관리

PostDetail RIB

PostDetailBuilder

protocol PostDetailDependency: Dependency {
    ...
}

final class PostDetailComponent: Component<PostDetailDependency> {
    ...
}

protocol PostDetailBuildable: Buildable {
    func build(withListener listener: PostDetailListener, post: PostEntity) -> PostDetailRouting
}

final class PostDetailBuilder: Builder<PostDetailDependency>, PostDetailBuildable {
    override init(dependency: PostDetailDependency) {
        super.init(dependency: dependency)
    }
    
    func build(withListener listener: PostDetailListener, post: PostEntity) -> PostDetailRouting {
        let component = PostDetailComponent(dependency: dependency)
        let viewController = PostDetailViewController()
        let interactor = PostDetailInteractor(presenter: viewController, post: post)
        
        interactor.listener = listener
        
        return PostDetailRouter(interactor: interactor, viewController: viewController)
    }
} 
  • PostDetail RIB의 구성요소 생성

PostDetailInteractor

protocol PostDetailRouting: ViewableRouting {
    ...
}

protocol PostDetailPresentable: Presentable {
    var listener: PostDetailPresentableListener? { get set }
    func update(post: PostEntity)
}

protocol PostDetailListener: AnyObject {
    func detachPostDetail()
}

final class PostDetailInteractor: PresentableInteractor<PostDetailPresentable>, PostDetailInteractable {
    weak var router: PostDetailRouting?
    weak var listener: PostDetailListener?
    
    private let post: PostEntity
    
    init(presenter: PostDetailPresentable, post: PostEntity) {
        self.post = post
        super.init(presenter: presenter)
        presenter.listener = self
    }
    
    override func didBecomeActive() {
        super.didBecomeActive()
        presenter.update(post: post)
    }
}

extension PostDetailInteractor: PostDetailPresentableListener {
    func backButtonDidTap() {
        listener?.detachPostDetail()
    }
}
  • 게시물 상세 화면의 비즈니스 로직 담당
  • 게시물 데이터를 View에 전달
  • PostDetail RIB 라우팅을 부모 RIB에 호출

PostDetailViewController

protocol PostDetailPresentableListener: AnyObject {
    func backButtonDidTap()
}

final class PostDetailViewController: UIViewController {
    weak var listener: PostDetailPresentableListener?
    
    ...
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setup()
    }
}

extension PostDetailViewController {
    ...

    @objc private func backButtonTapped() {
        listener?.backButtonDidTap()
    }
}

extension PostDetailViewController: PostDetailViewControllable {
    ...
}

extension PostDetailViewController: PostDetailPresentable {
    func update(post: PostEntity) {
        // UI 업데이트
    }
}
  • 게시물 상세 화면 UI 담당하여, 게시물 정보 표시
  • 뒤로가기 버튼 등 UI 이벤트 처리

PostDetailRouter

protocol PostDetailInteractable: Interactable {
    var router: PostDetailRouting? { get set }
    var listener: PostDetailListener? { get set }
}

protocol PostDetailViewControllable: ViewControllable {
    ...
}

final class PostDetailRouter: ViewableRouter<PostDetailInteractable, PostDetailViewControllable>, PostDetailRouting {
    override init(interactor: PostDetailInteractable, viewController: PostDetailViewControllable) {
        super.init(interactor: interactor, viewController: viewController)
        interactor.router = self
    }
} 
  • PostDetail RIB의 라우팅 담당
  • 필요한 경우 추가적인 자식 RIB 연결 가능

참고