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

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

PostDetail RIB

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

파일 구조

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

Board RIB

BoardBuilder

  • BoardBuilder는 RIB의 모든 구성요소를 생성하고 의존성을 주입하는 역할
  • postsStream을 통해 게시물 데이터를 관리하고 필요하다면 자식 RIB과 공유
  • 자식 RIB인 PostDetailBuilder를 생성하는 역할까지 함
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
        )
    }
}

BoardInteractor

  • 게시판의 비즈니스 로직을 담당
  • 게시물 목록을 fetch 하는 네트워크 요청 처리
  • postsStream을 구독하여 데이터 변경 시 View 업데이트
  • PostDetail RIB 라우팅 호출
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()
    }
}

BoardViewController

  • 게시판 UI를 담당하며, UITableView를 통해 게시물 목록 표시
  • NavigationController를 통해 화면 이동 기능 구현 (push / pop)
  • Interactor와 통신하여 사용자 이벤트 전달
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)
    }
}

BoardRouter

  • Board RIB과 PostDetail RIB 간의 라우팅 담당
  • PostDetail RIB의 attach 및 detach 관리
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
    }
}

PostDetail RIB

PostDetailBuilder

  • PostDetail RIB의 구성요소 생성
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)
    }
} 

PostDetailInteractor

  • 게시물 상세 화면의 비즈니스 로직 담당
  • 게시물 데이터를 View에 전달
  • PostDetail RIB 라우팅을 부모 RIB에 호출
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()
    }
}

PostDetailViewController

  • 게시물 상세 화면 UI 담당하여, 게시물 정보 표시
  • 뒤로가기 버튼 등 UI 이벤트 처리
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 업데이트
    }
}

PostDetailRouter

  • PostDetail RIB의 라우팅 담당
  • 필요한 경우 추가적인 자식 RIB 연결 가능
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
    }
} 

DeepLink란

DeepLink는 앱 내의 특정 화면으로 직접 이동할 수 있게 해주는 링크. 웹처럼 앱에서도 특정 리소스에 대한 고유한 주소를 가질 수 있으며, 다음과 같은 형태로 구성.

  • Custom URL Scheme (앱 고유의 스킴 사용): myapp://product/123
  • Universal Link (앱과 웹 모두에서 동작): https://example.com/product/123

Deep Link 활용 예시

  • 게임 초대: ribs-training://launchGame?gameId=ticTacToe&inviteCode=ABC123
  • 특정 화면 이동: ribs-training://profile?userId=12345
  • 프로모션 연결: ribs-training://promotion?code=SUMMER2024

Workflow란

Workflow는 RIBs 트리에서 비동기 작업의 시퀀스를 나타내는 패턴으로, DeepLink 처리에 이상적인 도구. 콜백 지옥이나 복잡한 상태 관리 코드 없이 복잡한 플로우를 Workflow로 구현할수 있다.

  • RIBs 트리에서 비동기 작업의 시퀀스를 나타내는 패턴
  • 여러 RIB을 거쳐가며 복잡한 네비게이션을 처리
  • Reactive Stream을 사용하여 단계별 진행 관리

Workflow 동작 원리

  • Step 정의: 각 단계에서 수행할 작업을 정의
  • ActionableItem 전달: 각 RIB의 Interactor가 ActionableItem 프로토콜을 구현
  • 비동기 처리: Observable을 반환하여 비동기 작업 지원
  • 상태 전이: ReplaySubject를 사용하여 ActionableItem 전달

RIBs에서의 구현

URLHandler:

  • URL을 처리하는 프로토콜을 정의
  • AppDelegate에서 구현하여 URL 수신 및 라우팅 담당
protocol UrlHandler: AnyObject {
    func handle(_ url: URL)
}

AppDelegate:

  • iOS에서 URL 스킴을 통해 앱이 열릴 때 호출
  • URL을 RootInteractor로 전달하여 처리
class AppDelegate: UIResponder, UIApplicationDelegate {
    private var urlHandler: UrlHandler?
    
    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
        urlHandler?.handle(url)
        return true
    }
}

RootInteractor:

  • 앱의 최상위 RIB의 Interactor로, 앱 전체의 진입점 역할
  • UrlHandler를 통해 DeepLink 처리를 담당
  • Workflow를 시작하고 구독하여 DeepLink 네비게이션 실행
extension RootInteractor: UrlHandler {
    func handle(_ url: URL) {
        let launchGameWorkflow = LaunchGameWorkflow(url: url)
        
        launchGameWorkflow
            .subscribe(self)
            .disposeOnDeactivate(interactor: self)
    }
}

LaunchGameWorkflow:

  • DeepLink를 처리하는 Workflow 클래스로, 복잡한 네비게이션을 단계별로 관리
  • 로그인 상태를 확인하고 URL에서 게임 ID를 추출해서 게임을 실행하는 플로우 구현
  • Observable 체인을 통해 비동기 작업의 순서를 보장
public class LaunchGameWorkflow: Workflow<RootActionableItem> {
    private let url: URL
    
    public init(url: URL) {
        self.url = url
        
        super.init()
        
        self
            .onStep { (rootItem: RootActionableItem) -> Observable<(LoggedInActionableItem, ())> in
                // 1단계: 로그인 대기
                rootItem.waitForLogin()
            }
            .onStep { (loggedInItem: LoggedInActionableItem, _) -> Observable<(LoggedInActionableItem, ())> in
                // 2단계: 게임 ID 파싱 및 게임 실행
                guard let gameId = self.extractGameId(from: self.url) else {
                    return Observable.empty()
                }
                
                return loggedInItem.launchGame(with: gameId)
                    .map { (loggedInItem, ()) }
            }
            .commit()
    }
    
    private func extractGameId(from url: URL) -> String? {
        let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
        return components?.queryItems?.first(where: { $0.name == "gameId" })?.value
    }
}

ActionableItem:

  • Workflow가 각 RIB에서 수행할 수 있는 작업을 정의하는 프로토콜
  • 각 RIB의 Interactor가 이 프로토콜을 구현하여 Workflow와 통신
  • Observable을 반환하여 비동기 작업의 완료를 알리고 다음 단계로 진행
// Root 레벨 ActionableItem
extension RootInteractor: RootActionableItem {
    func waitForLogin() -> Observable<(LoggedInActionableItem, ())> {
        return loggedInActionableItemSubject
            .map { ($0, ()) }
    }
}

// LoggedIn 레벨 ActionableItem  
extension LoggedInInteractor: LoggedInActionableItem {
    func launchGame(with gameId: String) -> Observable<Void> {
        router?.routeToGame(with: gameId)
        return Observable.just(())
    }
}

ActionableItemSubject:

class RootInteractor {
    private let loggedInActionableItemSubject = ReplaySubject<LoggedInActionableItem>.create(bufferSize: 1)
    
    private func routeToLoggedIn() {
        let router = loggedInBuilder.build(withListener: self)
        self.loggedInRouter = router
        attachChild(router)
        
        // Workflow에 ActionableItem 전달
        loggedInActionableItemSubject.onNext(router.interactable)
    }
}

SwiftUI

UIKit 기반의 RIBs 아키텍처에 SwiftUI를 사용하는 방법.

Flow

  • State: Interactor → Presenter → ViewModel → View
  • Action: View → ViewModel → Interactor

State & Action

struct ProfileViewState {
    var member: EntityMember?
    var logoutAlert = false
    var deleteAlert = false
}

enum ProfileViewAction {
    case logout
    case deleteAccount
}

ViewModel

@Observable
final class ProfileViewModel {
    typealias State = ProfileViewState
    typealias Action = ProfileViewAction
    
    var state: State
    
    private weak var listener: ProfilePresentableListener?
    
    init(state: State) {
        self.state = state
    }
    
    func update(state: State) {
        self.state = state
    }
    
    func request(action: Action) {
        listener?.request(action: action)        
    }
}

extension ProfileViewModel {
    func handleLogout() {
        request(action: .logout)
    }
    
    func handleDelete() {
        request(action: .deleteAccount)
    }
}

View

struct ProfileView: View {
    @State var viewModel: ProfileViewModel
    
    init(viewModel: ProfileViewModel) {
        self._viewModel = .init(initialValue: viewModel)
    }

    var body: some View {
        VStack(spacing: .zero) {
            Button {
                viewModel.state.logoutAlert = true
            } label: {
                ...
            }

            ...
            
            Button {
                viewModel.state.deleteAlert = true
            } label: {
                ...
            }
        }
        .alert("정말 로그아웃하시겠습니까?", isPresented: $viewModel.state.logoutAlert) {
            Button("확인", role: .destructive) {
                viewModel.handleLogout()
            }

            Button("취소", role: .cancel) {}
        }
        .alert("정말 탈퇴하시겠습니까?", isPresented: $viewModel.state.deleteAlert) {
            Button("확인", role: .destructive) {
                viewModel.handleDelete()
            }

            Button("취소", role: .cancel) {}
        }
    }
}

Presenter

protocol ProfilePresentable: Presentable {
    var listener: ProfilePresentableListener? { get set }
    func update(state: ProfileViewState)
}

final class ProfileViewController: UIViewController {
    let viewModel: ProfileViewModel
    
    weak var listener: ProfilePresentableListener? {
        didSet {
            viewModel.listener = listener
        }
    }

    private let disposeBag = DisposeBag()
    private let memberStream: BehaviorRelay<EntityMember?>
    
    init(memberStream: BehaviorRelay<EntityMember?>) {
        self.memberStream = memberStream
        self.viewModel = ProfileViewModel(state: .init(myself: memberStream.value))
        
        super.init(nibName: nil, bundle: nil)
    }
    
    ...
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bindMemberStream()
    }
}

extension ProfileViewController {
    private func bindMemberStream() {
        memberStream
            .observe(on: MainScheduler.instance)
            .subscribe(onNext: { [weak self] member in
                guard let self else { return }
                self.update(state: .init(myself: member))
            })
            .disposed(by: disposeBag)
    }
}

extension ProfileViewController: ProfilePresentable {
    func update(state: ProfileViewState) {
        viewModel.update(state: state)
    }
}

Interactor

final class ProfileInteractor: PresentableInteractor<ProfilePresentable>, ProfileInteractable {
    weak var router: ProfileRouting?
    weak var listener: ProfileListener?
    
    override init(presenter: ProfilePresentable) {
        super.init(presenter: presenter)
        presenter.listener = self
    }
}

extension ProfileInteractor: ProfilePresentableListener {
    func request(action: ProfileViewAction) {
        switch action {
        case .logout:
            handleLogout()
        case .deleteAccount:
            handleDeleteAccount()
        }        
    }
    
    private func handleLogout() {
        ...
    }
    
    private func handleDeleteAccount() {
        ...
    }
}

참고