-
RIBs) tutorial3iOS 2021. 6. 26. 17:01728x90
uber 공식 튜토리얼을 따라가면서 제 나름대로 정리한 내용입니다.
보다 정확한 내용은 원문을 참고해주시고 오류나 수정사항이 있으면 알려주시면 감사하겠습니다. 🙇🏻♂️
전체 코드를 보시려면 여기로 ;)⚠️ 우버 튜토리얼의 순서를 그대로 따라가지 않습니다.
이전 튜토리얼에서 부터 만들어나갑니다.목표
이번엔 각 플레이어의 스코어 확인 화면, 무승부 처리 같은 추가 기능들을 추가합니다. 이건 부수적인거고
핵심적으로
- build 메소드를 통해 자식 RIB에 동적으로 의존성을 주입하는 방법
- DI tree를 사용해 정적으로 의존성을 주입하는 방법
- RIBs LifeCycle을 사용해 Rx Stream을 관리하는 방법에 대해 배워봅니다.
이번 튜토리얼부터는 Rx 개념이 조금 들어갑니다.
Dynamic dependencies
앞선 튜토리얼에서는 LoggedOut에서 player1, player2의 이름을 넘겨받았지만 LoggedIn 부터는 사용하지 않고 있었는데요,
이 플레이어들의 이름을 넘겨주는 부분을 구현해보겠습니다.먼저 LoggedIn을 build할 때 플레이어들의 이름을 받을 수 있도록 선언해줍니다.
protocol LoggedInBuildable: Buildable { func build(withListener listener: LoggedInListener, player1Name: String, player2Name: String) -> LoggedInRouting }
그리고 LoggedIn dependency의 구현체인 component에 이를 넘겨줘야 합니다.
func build(withListener listener: LoggedInListener, player1Name: String, player2Name: String) -> LoggedInRouting { let component = LoggedInComponent(dependency: dependency, player1Name: player1Name, player2Name: player2Name)
넘겨받을 수 있도록 LoggedInComponent를 수정해줍니다.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) } }
route할 때 넘겨주도록 합니다.
func routeToLoggedIn(withPlayer1Name player1Name: String, player2Name: String) { ... let loggedIn = loggedInBuilder.build(withListener: interactor, player1Name: player1Name, player2Name: player2Name) attachChild(loggedIn) }
Dynamic dependencies vs static dependencies
위 과정으로 LoggedIn RIB이 생길 때 동적으로 플레이어들의 이름을 주입시켜줄 수 있었습니다.
LoggedInComponent라는 구체적인 객체 클래스에는 플레이어들의 이름이 있지만 Dependency에 정의되어 있지는 않습니다.
protocol LoggedInDependency: Dependency { var loggedInViewController: LoggedInViewControllable { get } } 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) } }
이렇게 하지 않고 아예 LoggedInDependency에 추가해줄 수도 있는데요 이러한 방식을 static dependency라고 합니다.
protocol LoggedInDependency: Dependency { var loggedInViewController: LoggedInViewControllable { get } // 아예 의존성으로 들고있기 var player1Name: String { get } var player2Name: String { get } }
하지만 이렇게 하면 LoggedInDependency를 따르고 있는 RootComponent에서도 플레이어들의 이름을 가지고 있어야 합니다. 아직 플레이어들의 이름이 결정되기도 전인데 말이죠. 따라서 이때는 옵셔널로 처리해줄 수 밖에 없습니다. 따라서 불필요한 옵셔널 처리를 또 해줘야 하는 상황이 발생합니다.
동적 의존성을 사용해 이렇게 불합리한 부분을 효율적으로 처리해줄 수 있으니 상황에 맞게 적절하게 사용해야겠습니다.
RIB's Dependencies and Components
앞선 튜토리얼을 따라오시면서 어느정도 이해가 되셨을텐데요
Dependency는 말 그대로 부모에서 RIB을 만들 때 넣어줘야 하는 의존성들을 나열한 프로토콜 이고
Component는 구체적인 구현체입니다.추가적으로 Component는 자기 자신과 하위 RIB의 종속성을 소유하는 책임을 가집니다.
LoggedInComponent에서 하위 RIB인 TicTacToe과 OffGame 의 의존성을 채택해서 소유하고 있었죠.이렇게 함으로써 build에서 하위 RIB을 생성할 때 component를 넘겨줘서 생성할 수 있었습니다.
의존성에 포함되어 있는 녀석들은 주로 DI Tree 아래로 전달될 어떤 상태를 포함하고 있는 경우가 많은데요,
비용이나 성능상 이유로 RIB 간에 공유된다고 합니다.DI Tree를 사용해 OffGame에 플레이어 이름 전달하기
LoggedIn에서 OffGame에게 플레이어들의 이름을 내려주고 이를 표시해보려고 합니다.
먼저 OffGame의 의존성에 플레이어들의 이름을 선언해줍니다.
protocol OffGameDependency: Dependency { var player1Name: String { get } var player2Name: String { get } }
이렇게 함으로써 반드시 OffGame을 생성할 때는 반드시 플레이어들의 이름을 넘겨주도록 강제했습니다.
이를 따르는 Component에 구현해줍니다.
final class OffGameComponent: Component<OffGameDependency> { fileprivate var player1Name: String { return dependency.player1Name } fileprivate var player2Name: String { return dependency.player2Name } }
이때 fileprivate을 접근 제한자를 사용했는데요, 이렇게 함으로써 OffGame 하위 범위에는 노출되지 않고 OffGame 내에서만 사용되도록 제한할 수 있습니다.
이제 build 할 때 view에 주입시켜줍니다.
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) } }
뷰컨에서 주입받을 수 있도록 수정해줍니다.
... 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) } ...
주입받은 걸 화면에 표시해줘야 할텐데요. 이건 역시 친절히 제공해주시는 코드를 복붙합니다 🙃
import RIBs import SnapKit import UIKit protocol OffGamePresentableListener: class { func startGame() } final class OffGameViewController: UIViewController, OffGamePresentable, OffGameViewControllable { var uiviewController: UIViewController { return self } weak var listener: OffGamePresentableListener? init(player1Name: String, player2Name: String) { self.player1Name = player1Name self.player2Name = player2Name super.init(nibName: nil, bundle: nil) } required init?(coder aDecoder: NSCoder) { fatalError("Method is not supported") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor.yellow buildStartButton() buildPlayerLabels() } private func buildPlayerLabels() { let labelBuilder: (UIColor, String) -> UILabel = { (color: UIColor, text: String) in let label = UILabel() label.font = UIFont.boldSystemFont(ofSize: 35) label.backgroundColor = UIColor.clear label.textColor = color label.textAlignment = .center label.text = text return label } let player1Label = labelBuilder(UIColor.blue, player1Name) view.addSubview(player1Label) player1Label.snp.makeConstraints { (maker: ConstraintMaker) in maker.top.equalTo(self.view).offset(70) maker.leading.trailing.equalTo(self.view).inset(20) maker.height.equalTo(40) } let vsLabel = UILabel() vsLabel.font = UIFont.systemFont(ofSize: 25) vsLabel.backgroundColor = UIColor.clear vsLabel.textColor = UIColor.darkGray vsLabel.textAlignment = .center vsLabel.text = "vs" view.addSubview(vsLabel) vsLabel.snp.makeConstraints { (maker: ConstraintMaker) in maker.top.equalTo(player1Label.snp.bottom).offset(10) maker.leading.trailing.equalTo(player1Label) maker.height.equalTo(20) } let player2Label = labelBuilder(UIColor.red, player2Name) view.addSubview(player2Label) player2Label.snp.makeConstraints { (maker: ConstraintMaker) in maker.top.equalTo(vsLabel.snp.bottom).offset(10) maker.height.leading.trailing.equalTo(player1Label) } } // MARK: - Private private let player1Name: String private let player2Name: String private func buildStartButton() { let startButton = UIButton() view.addSubview(startButton) startButton.snp.makeConstraints { (maker: ConstraintMaker) in maker.center.equalTo(self.view.snp.center) maker.leading.trailing.equalTo(self.view).inset(40) maker.height.equalTo(100) } startButton.setTitle("Start Game", for: .normal) startButton.setTitleColor(UIColor.white, for: .normal) startButton.backgroundColor = UIColor.black startButton.addTarget(self, action: #selector(didTapStartButton), for: .touchUpInside) } @objc private func didTapStartButton() { listener?.startGame() } }
Reactive Stream 을 사용해 score 추적하기
이제 두 명의 플레이어가 표시됐는데요, 둘이서 게임을 여러 번 할텐데 누가 몇 번을 이겼는지 승부를 가려야합니다.
이를 위해 Score가 필요하고 부모 자식간에 공유하며 관리해줘야 하는데 여기에 Rx개념이 적용됩니다.
먼저 주어진 코드로 ScoreStream을 만들어줍니다.
추가하면 빨간불이 막 생기실텐데요! 기존에 PlayerType이 red, blue로 되어있었던 걸 player1, player2로 수정해줍니다.
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: AnyObject { 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)) }
이제 하위 RIB들이 공유할 수 있도록 LoggedInComponent에 stream을 만들어줍니다.
var mutableScoreStream: MutableScoreStream { return shared { ScoreStreamImpl() } }
이걸 intreractor에 주입해줘야 하므로 LoggedInInteractor가 받을 수 있도록 수정해줍니다.
그리고 build에서 주입해줍니다.private let mutableScoreStream: MutableScoreStream init(mutableScoreStream: MutableScoreStream) { self.mutableScoreStream = mutableScoreStream }
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)
read-only ScoreStream 을 OffGame에 전달하기
LoggedIn 은 ScoreStream을 가지게 되었습니다. 혼자 가지고 있는 건 의미가 없으므로 OffGame에 전달해주도록 하겠습니다.
계속 비슷한 과정의 반복인데요, 의존성을 주입시켜줘야 하니 주입 받을 대상의 Dependency를 고쳐주고, 이에 따라 자연스럽게 바꿔줘야 할 대상들을 바꿔주면 됩니다.
OffGameDependency를 수정합니다.
그리고 Component에도 추가해줍니다.
build에서 주입해줍니다,
주입받을 수 있도록 수정도 해줘야겠죠.protocol OffGameDependency: Dependency { var player1Name: String { get } var player2Name: String { get } var scoreStream: ScoreStream { get } }
// OffGameComponent fileprivate var scoreStream: ScoreStream { return dependency.scoreStream }
// OffGameBuilder 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)
// OffGameInteractor ... private let scoreStream: ScoreStream init(presenter: OffGamePresentable, scoreStream: ScoreStream) { self.scoreStream = scoreStream super.init(presenter: presenter) presenter.listener = self } ...
여기까지 하고 나면 마지막으로 LoggedInComponent 에서 확장한 OffGameDependency 부분을 수정해줍니다
extension LoggedInComponent: OffGameDependency { var scoreStream: ScoreStream { return mutableScoreStream } }
stream 구독하고 화면에 보여주기
OffGame Interactor에 score를 처리해줄 함수를 하나 먼저 선언하겠습니다.
protocol OffGamePresentable: Presentable { var listener: OffGamePresentableListener? { get set } func set(score: Score) }
OffGame이 active되면 stream을 새로 업데이트 시켜주려고 합니다. interactor에서는 이런 RIB 라이프사이클 관련 메소드들을 제공합니다.
updateScore 함수를 만들어서 구독하고, 만약 이 RIB이 deactive 되면 dispose시켜주도록 하겠습니다.
RIB 에서 다음과 같이 disposeOnDeactive(interactor:) 메소드를 제공합니다 :)override func didBecomeActive() { super.didBecomeActive() updateScore() } private func updateScore() { scoreStream.score .subscribe( onNext: { (score: Score) in self.presenter.set(score: score) } ) .disposeOnDeactivate(interactor: self) }
이제 OffGame이 active 되면 scoreStream 을 구독하게 되고 이걸 사용해 뷰를 업데이트 시켜줄 수 있습니다.
관련 코드는 제공되는 코드를 복붙합니다.
게임이 끝나면 scoreStream 업데이트 시키기
이제 게임이 끝났을 때 이 scoreStream을 업데이트 시켜주도록 하겠습니다.
TicTacToe의 기존 endGame 메소드에 승자를 넘겨받아서 업데이트 시켜줄 수 있도록 선언해줍니다.
protocol TicTacToeListener: class { func gameDidEnd(withWinner winner: PlayerType?) }
presentableListener로 가서 기존의 closeGame()을 제거하고 announce에서 핸들러를 통해 처리할 수 있도록 수정해줍니다.
protocol TicTacToePresentableListener: class { func placeCurrentPlayerMark(atRow row: Int, col: Int) }
// OffGamePresentable protocol TicTacToePresentable: Presentable { ... func announce(winner: PlayerType?, withCompletionHandler handler: @escaping () -> ()) } // OffGameViewController func announce(winner: PlayerType?, withCompletionHandler handler: @escaping () -> Void) { 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 호출 부분도 처리해줍니다.
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) } } }
이제 gameDidEnd를 수정해줍니다 :)
func gameDidEnd(withWinner winner: PlayerType?) { if let winner = winner { mutableScoreStream.updateScore(withWinner: winner) } router?.routeToOffGame() }
이제 게임 결과가 스코어에 반영됩니다 !!
다음 튜토리얼로 돌아오겠습니다.
감사합니다.
'iOS' 카테고리의 다른 글
UITextField clear button custom (1) 2021.10.25 Playground using Algorithms Pakage (0) 2021.10.24 RIBs) tutorial2-2 (0) 2021.06.26 RIBs) tutorial2 (1) 2021.06.26 RIBs) tutorial1 (0) 2021.06.25