개발/iOS

RIBs iOS Tutorial 3

빙수킹 2021. 12. 21. 19:31

iOS Tutorial 3


https://github.com/uber/RIBs/wiki/iOS-Tutorial-3

예전 튜토리얼에서 만들어놓은 구조

이번 튜토리얼에서는, 새로운 RIB을 만들지 않고 기존것을 변형시킨다.

 

목표


게임의 시작 screen에 몇가지 추가할 것이 있다.

  • 참가자들의 이름을 display할것이다.
  • 참가자들이 여러번 연속 플레이를 하면 점수를 추적해서 시작 화면에서 보여줄 것이다.

이번 튜토의 목표는 다음과같다.

  • child RIB에 dynamic(동적) dependency를 Builder의 build 메소드가 불릴 때 넘긴다.
  • static(정적) dependency를 Dependency Injection tree를 사용해서 넘긴다.
    • Swift의 Extension 기반 종속성 준수
  • RIB lifecycle을 사용한 Rx stream lifecycle 관리

 

Dynamic dependencies


튜토1에서 LoggedOut RIB에서 player1, 2의 이름들을 넘겼는데 사용하지는 않았다. 이것들을 Offgame, TicTacToe RIB으로 전달해보자. 이 이름들을 dynamic dependency로 전달하자.

LoggedInBuildable의 build메소드에 파라미터를 추가하자.

protocol LoggedInBuildable: Buildable {
    func build(
        withListener listener: LoggedInListener,
        player1Name: String,
        player2Name: String
    ) -> LoggedInRouting
}

그리고 메소드 실제 구현도 업데이트하자.

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

        let offGameBuilder = OffGameBuilder(dependency: component)
        let ticTacToeBuilder = TicTacToeBuilder(dependency: component)
        return LoggedInRouter(interactor: interactor,
                              viewController: component.loggedInViewController,
                              offGameBuilder: offGameBuilder,
                              ticTacToeBuilder: ticTacToeBuilder)
    }

 

근데 이 때 어떻게 OffGameBuilder에 LoggedInComponent가 들어갈 수 있는가?

LoggedInComponent+OffGame 파일에 보면 LoggedInComponent가 extension으로 OffGameDependency를 준수하고 있다. 이런식으로 전달하고, 만약 자식 RIB에서 그 종속성 항목을 사용하고 싶으면 자식 dependency protocol에 추가하면 된다.

routeToLoggedIn 메소드에서 build하는부분 전달인자 추가

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

LoggedInComponent에 player1Name, player2Name 인자 추가하자. dependency에 안넣는 이유는 RootBuilder.build에서 넣어줄 수 없기때문에.

final class LoggedInComponent: Component<LoggedInDependency> {

    let player1Name: String
    let player2Name: String

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

    fileprivate var loggedInViewController: LoggedInViewControllable {
        return dependency.loggedInViewController
    }
}

이 과정은 LoggedIn의 부모가 제공하는 동적 종속성을 LoggedIn의 자식에게 정적 종속성으로 전달할 수 있도록 변환한다.

 

Dynamic dependencies vs static dependencies


우리는 LoggedIn RIB에 플레이어 이름을 RIB를 build할 때 동적으로 주입하기로 결정했다. (dependency는 init할 때 부모에서 넣어주는데, 이렇게 하는 대신 build할 때 넣으면 이걸 동적주입이라고 하나보다.) 우리는 LoggedIn RIB을 RIB 트리 아래로 전달하여 정적으로 종속성을 넣어줄 수도 있었다. (dependency로 RootBuiler에서 build할 때 넣어줄 수도 있었다.) 하지만 이 경우 플레이어의 이름을 Optional로 받아야 한다. 왜냐면 RootBuilder가 만들어지는 시점에서는 플레이어 이름이 없기 때문이다.

만약 우리가 Optional로 다루기로 결정했다면, RIB코드에 추가적인 복잡성(complexity. 문맥상 optional chaining 같은 것?)을 넣었을것이다. LoggedIn RIB와 그 자식들에서 nil 처리를 해야 한다. 적절하게 범위가 지정된(scoped) 종속성은 우리가 불변 가정을 하게 하고, 이를통해 불합리하고 불안정한 코드를 제거할 수 있다.

그러니까 static으로 종속성을 넣어주면 자식에서 Optional value에 대한 불필요한 nil처리같은걸 해줘야 한다. 그래서 적당히 scope를 지정하고 dynamic으로 종속성을 지정해주는 것도 좋다. 이런 말인듯!!

 

RIB's Dependencies and Components


그래서 Dependency와 Component가 뭔데??

dependencies와 component에 대해 알아보자. RIB 용어로, Dependency는 RIB가 제대로 인스턴스화되기 위해 부모로부터 필요한 종속성을 나열하는 프로토콜이다. Component는 Dependency 프로토콜의 구현이다. RIB의 Builder에 부모의 종속성을 제공하는 것 외에도, Component는 RIB가 자신과 자식을 위해 만드는 Dependency를 소유할 책임이 있다.

보통, 부모 RIB가 자식 RIB를 인스턴스화할 때, 생성자 종속성으로 자식의 빌더에 자신의 Component를 주입한다. 주입된 각 Component는 자식에게 어떤 종속성을 스스로 노출할지 결정합니다.

Component에 포함된 종속성은 일반적으로 DI 트리를 전달해야 하는 일부 상태를 유지하거나, 구성 비용이 많이 들고 성능상의 이유로 RIB 간에 공유됩니다.

사족) Dependency는 프로토콜이고, AComponent는 ADependency를 프로퍼티로 가진다. AComponent는 Dependency를 준수한다. 하지만 ADependency는 준수하지 않는다.! component에서 dependency 프로퍼티에 접근해서, 그 안에 저장된 값을 가져올 수 있다.

 

DI tree를 이용해서 OffGame scope에 player 이름들을 전달하기


DI 트리를 통해 player 이름들을 OffGame RIB에 안전하게 전달하여 "게임 시작" 버튼과 함께 표시할 수 있다. 이를 위해, 우리는 OffGameDependency 프로토콜에서 플레이어 이름을 종속성으로 선언하자.

protocol OffGameDependency: Dependency {
    var player1Name: String { get }
    var player2Name: String { get }
}
final class OffGameComponent: Component<OffGameDependency> {
    // TODO: Declare 'fileprivate' dependencies that are only used by this RIB.
    fileprivate var player1Name: String {
        return dependency.player1Name
    }

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

자식 scope에 노출시키지 않기 위해 fileprivate로 사용한다. LoggedInComponent에서는 자식에게 제공하기 때문에 fileprivate를 사용하지 않았다.

이제 dependency를 OffGameVC에 전달해서 표시해보자. interactor에 전달하고 presentable의 메소드를 호출할 수 있지만, 추가 처리가 필요하지 않기 때문에 바로 vc에 전달해도 된다.

vc의 생성자에 추가하자.

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)
    }

이제 전달받은 값으로 view를 업데이트 하면 된다. 제공하는 코드를 붙여넣자.

https://raw.githubusercontent.com/uber/ribs/assets/tutorial_assets/ios/tutorial3-rib-di-and-communication/source/source1.swift

 

결과

이름으로 입력한 234234, 2222가 보인다.

 

ReactiveX stream을 사용해서 점수를 추적하기


점수를 업데이트하고 시작 화면에 표시하자. reactive stream을 만들고 구독해보자.

반응형 프로그래밍 기술은 RIB 아키텍처에서 널리 사용된다. 그들에게 가장 일반적인 용도 중 하나는 RIB 간의 의사소통을 촉진하는 것이다. 자식 RIB가 부모로부터 동적 데이터를 받아야 할 때, 데이터를 생산자 측의 observable 스트림으로 래핑하고 소비자 측에서 이 스트림을 구독하는 것이 일반적인 관행이다. Reactive X를 알고 있어야 한다.

우리의 경우, TicTacToe RIB이 현재 게임의 상태를 제어하기 때문에 게임 점수는 TicTacToe RIB에 의해 업데이트되어야 한다. 이 점수는 OffGame RIB가 소유한 화면에 표시되므로 OffGame RIB가 읽어야 한다. TicTacToe와 OffGame은 서로에 대해 알지 못하며 데이터를 직접 교환할 수 없다. 그러나, 둘 다 같은 부모를 가지고 있다 - LoggedIn RIB. 우리는 두 자식 모두에게 스트림에 대한 액세스를 제공하기 위해 이 RIB에서 점수 스트림을 구현해야 할 것이다.

LoggedIn 그룹에서 ScoreStream이라는 새 Swift 파일을 만들어 TicTacToe target에 추가하고 구현된 stream 코드를 복붙하자.

https://raw.githubusercontent.com/uber/ribs/assets/tutorial_assets/ios/tutorial3-rib-di-and-communication/source/source2.swift

제공된 코드에 보면 Score Stream 프로토콜의 두 가지 버전을 제공한다. ScoreStream이라는 읽기 전용 버전과 MutableScoreStream이라는 가변 버전이 선언되어 있다.

ScoreStream 내부 구조

  • Score은 점수 2개를 보관하는 struct이다.
  • ScoreStream은 Score을 Observable형태로 프로퍼티로 가지고있는 protocol이다. 
  • MutableScoreStream은 ScoreStream을 준수하는 protocol이다. 여기서는 score을 업데이트하는 기능이 들어가야 하므로 updateScore()이라는 함수가 추가된다.
  • ScoreStreamImpl은 MutableScoreStream을 구현한 class이다. 

 

그런데 주어진 코드를 복붙하면 컴파일 에러가 난다. Rx에서 Variable이 deprecated되었기 때문인데, 이건 BehaviorRelay로 대체해주면 된다. 다음과 같이 2줄을 바꿔준다. import RxRelay도 해줘야 한다.

// MARK: - Private

    private let variable = BehaviorRelay<Score>(value: Score(player1Score: 0, player2Score: 0))
//    private let variable = Variable<Score>(Score(player1Score: 0, player2Score: 0))
variable.accept(newScore)
//        variable.value = newScore

 

LoggedInComponent에서 공유 ScoreStream 인스턴스를 만들자. TicTacToe, OffGame 모두에 전달할 것이므로 class LoggedInComponent에 만들어준다.

var mutalbeScoreStream: MutableScoreStream {
    return shared { ScoreStreamImpl() }
}

 

그런데 왜 shared로 만드는가? shared의 내부 구성?

Component의 기본 구현을 보면 다음과 같은 shared라는 함수가 만들어져 있다. 여기서 __function은 shared를 부른 주체인 mutalbeScoreStream이다. 기본 Component 클래스에는 sharedInstance라는 프로퍼티가 있는데  이 프로퍼티에 [키:밸류] 형태로 전달인자를 저장해놓는다. 만약 해당 키로 저장된게 있으면 그걸 사용하고, 없으면 새로 저장한다. component 내에서 공유되는 dependency는 shared에 저장한다.

public final func shared<T>(__function: String = #function, _ factory: () -> T) -> T {
    lock.lock()
    defer {
        lock.unlock()
    }

    if let instance = (sharedInstances[__function] as? T?) ?? nil {
        return instance
    }

    let instance = factory()
    sharedInstances[__function] = instance

    return instance
}

// MARK: - Private

private var sharedInstances = [String: Any]()
private let lock = NSRecursiveLock()

 

shared 인스턴스는 주어진 scope에 대해 생성된 싱글톤을 의미한다. (우리의 경우, scope는 LoggedIn RIB와 모든 자식을 포함한다). 스트림은 대부분의 상태 저장 객체와 마찬가지로 일반적으로 범위가 있는 싱글톤이다. 그러나 대부분의 다른 dependency는 stateless해야 하며, 따라서 공유되지 않아야 한다. 

LoggedInComponent에서 mutableScoreStream 프로퍼티는 fileprivate가 아니라 internal로 생성되었다는 것을 알아두자. LoggedIn의 children이 접근할 수 있어야 하기 때문에 이 프로퍼티를 파일 외부에 노출해야 한다. 이 요구 사항이 유지되지 않으면, stream을 파일 내에 캡슐화하는 것이 바람직하다.

그리고, RIB에서 직접 사용되는 dependency만 component의 기본 구현에 배치되어야 하며, 플레이어 이름과 같은 동적 종속성에서 주입되는 stored property는 예외다. 그러니까 mutableScoreStream같은건 LoggedInInteractor(LoggedInRIB)에서 바로 쓰이므로 LoggedInComponent에다가 넣은 것이고, 만약에 어디에 전달되는 용도의 dependency였다면 해당 전달child를 구현하는 부분의 Extension에 넣어야 한다. 예를 들어 OffGame에 전달하는 경우에, LoggedInComponent+OffGame 파일에 넣는것이좋다.

 

 

이제 mutableScoreStream을 interactor에 전달하자.

private let mutableScoreStream: MutableScoreStream

    init(mutableScoreStream: MutableScoreStream) {
        self.mutableScoreStream = mutableScoreStream
    }
let interactor = LoggedInInteractor(mutableScoreStream: component.mutalbeScoreStream)

 

display를 위해 읽기전용 ScoreStream을 Offgame scope로 pass down하자.

OffGameDependency 프로토콜에 read-only ScoreStream 추가하고 Component에서는 그 값을 가져오자. OffGame component의 stream변수는 fileprivate하게 만든다. 자식에게 줄 필요가 없기 때문에.

protocol OffGameDependency: Dependency {
    // TODO: Declare the set of dependencies required by this RIB, but cannot be
    // created by this RIB.
    var player1Name: String { get }
    var player2Name: String { get }
    var scoreStream: ScoreStream { get }
}

final class OffGameComponent: Component<OffGameDependency> {
    // TODO: Declare 'fileprivate' dependencies that are only used by this RIB.
    fileprivate var player1Name: String {
        return dependency.player1Name
    }

    fileprivate var player2Name: String {
        return dependency.player2Name
    }

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

그리고 LoggedInComponent에 OffGameDependency를 구현한 부분에 가서 scoreStream 내용을 구현해주자. 읽기 전용 scoreStream은 OffGame 범위에서만 필요하며 LoggedIn RIB에서 사용되지 않기 때문에, 이 Dependency는 LoggedInComponent+OffGame Extension에 넣는다.

extension LoggedInComponent: OffGameDependency {
    var scoreStream: ScoreStream {
        return mutableScoreStream
    }
}

OffGameBuilder에서 build할 때 interactor에 scoreStream을 전달해주자.

let interactor = OffGameInteractor(scoreStream: component.scoreStream, presenter: viewController)
private let scoreStream: ScoreStream
init(scoreStream: ScoreStream, presenter: OffGamePresentable) {
    self.scoreStream = scoreStream
    super.init(presenter: presenter)
    presenter.listener = self
}

 

score stream을 구독해서 점수를 보여주자


이제 scoreStream을 구독해서 뷰를 업데이트하자.

protocol OffGamePresentable: Presentable {
    weak var listener: OffGamePresentableListener? { get set }
    func set(score: Score)
}
func updateScore() {
    scoreStream.score.subscribe(onNext: { [weak self] score in
        self?.presenter.set(score: score)
    }).disposeOnDeactivate(interactor: self)
}

 

disposeOnDeactivate extension?

: Interactor의 생명주기를 기반으로 구독을 삭제한다. 이 메소드가 호출되었을 떄 interactor가 inactivate상태이면 즉시 구독을 취소한다. 이 경우에는 OffGameInteractor가 deactivated될 때 구독이 자동으로 폐기된다. 우리는 거의 항상 이러한 Rx 수명 주기 관리 유틸리티를 활용하기 위해 interactor 또는 worker 클래스에서 Rx 구독을 해야한다.

그리고 OffGameInteractor의 didBecomeActive에서 updateScore을 호출하자. 그러면 OffGameInteractor가 활성화될 때마다 새 구독을 만들 수 있으며, 이는 disposeOnDeactivate의 사용과 관련이 있다.

override func didBecomeActive() {
    super.didBecomeActive()
    // TODO: Implement business logic here.
    updateScore()
}

마지막으로, 우리는 점수를 표시하기 위해 OffGameViewController UI를 구현해야 한다. 샘플코드를 복붙하자.

https://raw.githubusercontent.com/uber/ribs/assets/tutorial_assets/ios/tutorial3-rib-di-and-communication/source/source3.swift

하지만 아직 게임이 끝났을 때 바뀐 점수가 화면에 표시되지 않는다.

 

게임이 끝났을 때 score stream을 update하자


gameDidEnd()에서 score stream의 값을 업데이트 해야 한다. 인자를 추가하자.

protocol TicTacToeListener: class {
    func gameDidEnd(withWinner winner: PlayerType?)
}

그리고 closure을 사용해서 업데이트하자. 이전에 있던 closeGame()을 지우자. 그리고 누군가 이겼을 때 호출되는 VC의 announce메소드에서 completeHandler을 인자로 넣어주자.

protocol TicTacToePresentableListener: class {
    func placeCurrentPlayerMark(atRow row: Int, col: Int)
}
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)
}

announce함수 호출하는곳에 가서 콜백 넣어주고, gameDidEnd도 updateScore이 호출되도록 수정해주자.

if let winner = checkWinner() {
    presenter.announce(winner: winner) {
        self.listener?.gameDidEnd(withWinner: winner)
    }
}
func gameDidEnd(withWinner winner: PlayerType?) {
    if let winner = winner {
        mutableScoreStream.updateScore(withWinner: winner)
    }
    router?.routeToOffGame()
}

 

결과

이긴쪽이 1이 된다.

 

추가 Exercises


앱을 개선하고 RIB 간의 통신을 더 잘 이해하기 위해 해야 할 두 가지가 더 있다.

  1. 게임 끝나고 이긴사람 이름을 전달해서 보여주기.
  2. 무승부 처리

 

Alert에 이름을 전달하기

첫 번째는 게임이 끝난 후 표시된 alert를 개선하는 것이다. 현재, 우리는 우승한 플레이어의 이름을 보여주는 대신 하드코딩된 이름 "레드"와 "블루"를 사용함. LoggedIn scope에서 TicTacToe scope로 플레이어 이름을 전달하고 대신 경고에 표시할 수 있다.

player name TicTacToeViewController에 추가

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)
}

build메소드에서 vc에 playername 주입

let component = TicTacToeComponent(dependency: dependency)
let viewController = TicTacToeViewController(
    player1Name: component.player1Name, player2Name: component.player2Name
)

Dependency, Component에 추가

protocol TicTacToeDependency: Dependency {
    // TODO: Declare the set of dependencies required by this RIB, but cannot be
    // created by this RIB.
    var player1Name: String { get }
    var player2Name: String { get }
}

final class TicTacToeComponent: Component<TicTacToeDependency> {
    fileprivate var player1Name: String {
        return dependency.player1Name
    }

    fileprivate var player2Name: String {
        return dependency.player2Name
    }
    // TODO: Declare 'fileprivate' dependencies that are only used by this RIB.
}

결과

 

무승부 처리하기

또 다른 좋은 개선은 무승부를 다루는 것이다. 현재, 게임은 모든 게임 필드가 표시된 무승부로 끝날 때 갇힌다. 이 경우를 처리하기 위해 게임 논리, 사용자 인터페이스 및 점수 계산을 업데이트할 수 있습니다.

winner이 비어있으면서 시도를 9번 했을 때 무승부로 처리한다. 시도한 횟수를 interactor에 변수로 가지고있는다.

private var selectedCount: Int = 0 // 칠해진 갯수

시도 한번 할 때 selectedCount를 증가시키고, 이 횟수가 9회가 되었을 때 게임을 중지한다. 이 때 winner에 nil을 전달한다.

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)
    selectedCount += 1

    if let winner = checkWinner() {
        presenter.announce(winner: winner) {
            self.listener?.gameDidEnd(withWinner: winner)
        }
    } else if selectedCount == GameConstants.colCount * GameConstants.rowCount {
        presenter.announce(winner: nil) {
            self.listener?.gameDidEnd(withWinner: nil)
        }
    }
}

나는 무승부시 1점씩 둘다 증가시킬 것이다. gameDidEnd 함수의 파라미터를 옵셔널로 변경하고, 무조건 updateScore하게 변경한다.

func gameDidEnd(withWinner winner: PlayerType?) {
    mutableScoreStream.updateScore(withWinner: winner)
    router?.routeToOffGame()
}

그리고 마지막으로 updateScore함수에 winner이 비었을 때 guard문을 추가한다.

func updateScore(withWinner winner: PlayerType?) {
    let newScore: Score = {
        let currentScore = variable.value
        guard let winner = winner else {
            return Score(player1Score: currentScore.player1Score + 1, player2Score: currentScore.player2Score + 1)
        }

        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)
//        variable.value = newScore
}

결과

점수는 둘다 1점씩 증가시켰다.

 

마무리


  • dynamic dependency는 build 메소드를 통해 직접 넣는것을 의미하고, static dependency는 init 시점에 부모에서 dependency의 전달인자로 넣는다. 
  • 부모 -> 자식 소통할 때 Rx stream을 사용한다.