관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] 4-5) RIBs 튜토리얼 (Dependency Injection, Rx Stream) 본문

Architecture (swift)/RIBs

[iOS - swift] 4-5) RIBs 튜토리얼 (Dependency Injection, Rx Stream)

jake-kim 2021. 4. 22. 23:44

RIB 트리

Root -> LoggedIn 플레이어 이름 DI

  • LoggedInBuildable에 받을 데이터 기입 (이름정보)
// LoggedInBuilder.swift

protocol LoggedInBuildable: Buildable {
    func build(withListener listener: LoggedInListener, 
               player1Name: String, 
               player2Name: String) -> LoggedInRouting
}
  • 변경내용 반영
// LoggedInBuilder.swift

func build(withListener listener: LoggedInListener, player1Name: String, player2Name: String) -> LoggedInRouting {
    let component = LoggedInComponent(dependency: dependency,
                                      player1Name: player1Name,
                                      player2Name: player2Name)

  • LoggedInComponent의 init, 프로퍼티 추가
// LoggedBuilder.swift

final class LoggedInComponent: Component<LoggedInDependency> {

    fileprivate var loggedInViewController: LoggedInViewControllable {
        return dependency.loggedInViewController
    }

    let player1Name: String
    let player2Name: String

    init(dependency: LoggedInDependency, player1Name: String, player2Name: String) {
        self.player1Name = player1Name
        self.player2Name = player2Name
        super.init(dependency: dependency)
    }
}

  • RootRouter에 dependency내용의 build초기화 반영
// RootRouter.swift

    func routeToLoggedIn(withPlayer1Name player1Name: String, player2Name: String) {
        // Detach logged out.
        if let loggedOut = self.loggedOut {
            detachChild(loggedOut)
            viewController.dismiss(viewController: loggedOut.viewControllable)
            self.loggedOut = nil
        }

        let loggedIn = loggedInBuilder.build(withListener: interactor, player1Name: player1Name, player2Name: player2Name)
        attachChild(loggedIn)
    }

Component를 사용한 DI

Component란

  • Dependency 프로토콜의 구현체
  • 부모로 부터 필요한 종속성을 나열 (파일: ParentComponent+ChildComponent)

Component에 종속성 정의

  • Dependency 프로토콜 정의: 
// OffGameBuilder.swift

protocol OffGameDependency: Dependency {
    var player1Name: String { get }
    var player2Name: String { get }
}
  • Component정의 (Dependency의 구현체)
    - component의 접근 범위는 fileprivate: child scopes에 노출되지 않도록 하기 위함
    - 단, LoggedInComponent에서는 OffGame 자식에게 데이터를 넘기기 위해 fileprivate 사용 x
// OffGameBuilder.swift

final class OffGameComponent: Component<OffGameDependency> {
    fileprivate var player1Name: String {
        return dependency.player1Name
    }

    fileprivate var player2Name: String {
        return dependency.player2Name
    }
}

OffGameViewController로 이름 넘기기

OffGameController에 이름 띄우기

  • OffGameBuilder 생성자에서 DI: OffGameViewController를 만드는 build에 Component 적용
// OffGameBuilder.swift

final class OffGameBuilder: Builder<OffGameDependency>, OffGameBuildable {
    override init(dependency: OffGameDependency) {
        super.init(dependency: dependency)
    }

    func build(withListener listener: OffGameListener) -> OffGameRouting {
        let component = OffGameComponent(dependency: dependency)
        let viewController = OffGameViewController(player1Name: component.player1Name,
                                                   player2Name: component.player2Name)
        let interactor = OffGameInteractor(presenter: viewController)
        interactor.listener = listener
        return OffGameRouter(interactor: interactor, viewController: viewController)
    }
}

  • 초기화 적용
// OffGameViewController.swift

...

private let player1Name: String
private let player2Name: String

init(player1Name: String, player2Name: String) {
    self.player1Name = player1Name
    self.player2Name = player2Name
    super.init(nibName: nil, bundle: nil)
}

...

Rx stream

부모 -> 자식 하향 데이터 전달 시나리오

  • 사용처: 게임이 끝난 경우 시작화면으로 이동
  • TicTacToe에서 점수를 LoggedIn과 OffGame에서 받아서 데이터를 UI에 표출해야 하는 상황
  • Listener interface로 부모에게 전달: TicTacToe -> LoggedIn
  • Rx stream로 자식에게 전달: LoggedIn -> OffGame
  • score stream을 생성하여 해결

구현

  • LoggedIn 그룹에 ScoreStream.swift 추가
import RxSwift
import RxCocoa

struct Score {
    let player1Score: Int
    let player2Score: Int

    static func equals(lhs: Score, rhs: Score) -> Bool {
        return lhs.player1Score == rhs.player1Score && lhs.player2Score == rhs.player2Score
    }
}

protocol ScoreStream: class {
    var score: Observable<Score> { get }
}

protocol MutableScoreStream: ScoreStream {
    func updateScore(withWinner winner: PlayerType)
}

class ScoreStreamImpl: MutableScoreStream {

    var score: Observable<Score> {
        return variable
            .asObservable()
            .distinctUntilChanged { (lhs: Score, rhs: Score) -> Bool in
                Score.equals(lhs: lhs, rhs: rhs)
            }
    }

    func updateScore(withWinner winner: PlayerType) {
        let newScore: Score = {
            let currentScore = variable.value
            switch winner {
            case .player1:
                return Score(player1Score: currentScore.player1Score + 1, player2Score: currentScore.player2Score)
            case .player2:
                return Score(player1Score: currentScore.player1Score, player2Score: currentScore.player2Score + 1)
            }
        }()
        variable.accept(newScore)
    }

    // MARK: - Private

    private let variable = BehaviorRelay<Score>(value: Score(player1Score: 0, player2Score: 0))
}

  • Stream 버전
    • Read-only 버전: ScoreStream
    • 변경할 수 있는 버전: MutableScoreStream
  • mutableScoreStream 추가
    - shared: 싱글톤
    - stream들은 일반적으로 싱글톤
    - 접근은 fileprivate이 아닌 internal: LoggedIn의 child에서 접근이 가능해야 하므로 internal
    (Component는 대부분 fileprivate, stream은 대부분 internal)
// LoggedInBuilder.swift

    var mutableScoreStream: MutableScoreStream {
        return shared { ScoreStreamImpl() }
    }
  • LoggedInBuilder에서 LoggedInInteractor를 만들때 mutableScoreStream을 넘기는 코드 적용
// LoggedInInteractor.swift

private let mutableScoreStream: MutableScoreStream

init(mutableScoreStream: MutableScoreStream) {
    self.mutableScoreStream = mutableScoreStream
}
  • builder에서 Interactor를 만드는 코드 적용
// LoggedInBuilder.swift

func build(withListener listener: LoggedInListener, player1Name: String, player2Name: String) -> LoggedInRouting {
    let component = LoggedInComponent(dependency: dependency,
                                      player1Name: player1Name,
                                      player2Name: player2Name)
    let interactor = LoggedInInteractor(mutableScoreStream: component.mutableScoreStream)

OffGame에 scoreStream 전달

  • OffGameBuilder를 만들 때 scoreStream을 넘기도록 적용
// OffGameBuilder.swift

protocol OffGameDependency: Dependency {
    var player1Name: String { get }
    var player2Name: String { get }
    var scoreStream: ScoreStream { get }
}

final class OffGameComponent: Component<OffGameDependency> {
    fileprivate var player1Name: String {
        return dependency.player1Name
    }

    fileprivate var player2Name: String {
        return dependency.player2Name
    }

    fileprivate var scoreStream: ScoreStream {
        return dependency.scoreStream
    }
}

LoggedInComponent+OffGame.swift

  • LoggedIn -> OffGame 의존성에 scoreStream 적용
    - OffGameBuilder가 아닌 LoggedInComponent+OffGame.swift파일에 위치시키는 이유: LoggedIn RIB에서는 사용되지 않고 OffGame RIB에서만 사용되게 하기 위함
// LoggedInComponent+OffGame.swift

extension LoggedInComponent: OffGameDependency {
    var scoreStream: ScoreStream {
        return mutableScoreStream
    }
}
  • OffGameInteractor에 scoreStream 프로퍼티 적용
// OffGameInteractor.swift

private let scoreStream: ScoreStream

    init(presenter: OffGamePresentable,
                  scoreStream: ScoreStream) {
        self.scoreStream = scoreStream
        super.init(presenter: presenter)
        presenter.listener = self
    }

  • Builder에서, OffGameInteractor 초기화 적용
// OffGameBuilder.swift

final class OffGameBuilder: Builder<OffGameDependency>, OffGameBuildable {
    override init(dependency: OffGameDependency) {
        super.init(dependency: dependency)
    }

    func build(withListener listener: OffGameListener) -> OffGameRouting {
        let component = OffGameComponent(dependency: dependency)
        let viewController = OffGameViewController(player1Name: component.player1Name,
                                                   player2Name: component.player2Name)
        let interactor = OffGameInteractor(presenter: viewController, scoreStream: component.scoreStream) // <- scoreStream 초기화 추가
        interactor.listener = listener
        return OffGameRouter(interactor: interactor, viewController: viewController)
    }
}

subscribe Rx Stream

  • OffGame의 Interactor -> ViewController 데이터 전송
  • OffGameInteractor -> OffGamePresentable
// OffGameInteractor.swift

protocol OffGamePresentable: Presentable {
    weak var listener: OffGamePresentableListener? { get set }
    func set(score: Score) // <- 추가
}
  • subscribe
    - disposeOnDeactivate(interactor:) 의미: 
// OffGameInteractor.swift

override func didBecomeActive() {
    super.didBecomeActive()

    updateScore() // <- 추가
}

private func updateScore() {
    scoreStream.score
        .subscribe(
            onNext: { (score: Score) in
                self.presenter.set(score: score)
            }
        )
        .disposeOnDeactivate(interactor: self)
}
  • offGamePresentableListener UI: 코드

게임이 끝난 경우 부모에게 전달

  • 게임이 끝난 경우, 정의 TicTacToe -> LoggedIn
// TicTacToeInteractor.swift

protocol TicTacToeListener: class {
    func gameDidEnd(withWinner winner: PlayerType?)
}
  • LoggedInInteractor에 해당 함수 구현
    // LoggedInInteractor.swift
    func gameDidEnd(withWinner winner: PlayerType?) {
        if let winner = winner {
            mutableScoreStream.updateScore(withWinner: winner)
        }
        router?.routeToOffGame()
    }
  • listener수정: func closeGame() 삭제
protocol TicTacToePresentableListener: class {
    func placeCurrentPlayerMark(atRow row: Int, col: Int)
}

TicTacToeInteractor.swift의 closeGame() 함수 삭제

  • 게임이 끝난 경우 Interactor에서 이동하는 로직을 넣기 위해서 completionHandler로 구현
    - 이유:  만약 ViewController에 화면전환 로직이 들어간다면, 비지니스 로직이 ViewController에 종속되는 문제가 발생하므로
// TicTacToeInteractor.swift

protocol TicTacToePresentable: Presentable {
    var listener: TicTacToePresentableListener? { get set }
    func setCell(atRow row: Int, col: Int, withPlayerType playerType: PlayerType)
    func announce(winner: PlayerType?, withCompletionHandler handler: @escaping () -> ())
}
// TicTacToeViewController.swift

func announce(winner: PlayerType?, withCompletionHandler handler: @escaping () -> ()) {
    let winnerString: String = {
        if let winner = winner {
            switch winner {
            case .player1:
                return "Red won!"
            case .player2:
                return "Blue won!"
            }
        } else {
            return "It's a draw!"
        }
    }()
    let alert = UIAlertController(title: winnerString, message: nil, preferredStyle: .alert)
    let closeAction = UIAlertAction(title: "Close Game", style: UIAlertActionStyle.default) { _ in
        handler()
    }
    alert.addAction(closeAction)
    present(alert, animated: true, completion: nil)
}
  • completion handler로 화면 전환 적용
// TicTacToeInteractor.swift

    func placeCurrentPlayerMark(atRow row: Int, col: Int) {
        guard board[row][col] == nil else {
            return
        }

        let currentPlayer = getAndFlipCurrentPlayer()
        board[row][col] = currentPlayer
        presenter.setCell(atRow: row, col: col, withPlayerType: currentPlayer)

        if let winner = checkWinner() {
            presenter.announce(winner: winner) {
                self.listener?.gameDidEnd(withWinner: winner)
            }
        }
    }

 

* 참고: github.com/uber/RIBs/wiki/iOS-Tutorial-3

Comments