Tutorial3에서는 TicTacToe 게임을 발전시켜본다. 게임에 플레이어 이름을 표시하고, 게임을 연속으로 하는 경우, 게임 점수를 추적하여서 시작화면에 점수도 표시한다.
LoggedInBuilder의 build 메소드를 통해 플레이어 이름을 Root RIB에서 LoggedIn RIB으로 dynamic dependencies로 전달하려고 한다.
이를 위해 LoggedInBuildable 프로토콜을 아래와 같이 업데이트하여, 기존 listener 종속성 외에 두 플레이어 이름을 포함하도록 한다.
protocol LoggedInBuildable: Buildable {
func build(withListener listener: LoggedInListener,
player1Name: String,
player2Name: String) -> LoggedInRouting
}
final class LoggedInBuilder: Builder<LoggedInDependency>, LoggedInBuildable {
override init(dependency: LoggedInDependency) {
super.init(dependency: dependency)
}
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)
}
}
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
}
}
위와 같이 하면, 동적 종속성에서 정적 종속성으로 변경된다. (🤔...무슨소리지...?!?!)
다음으로 RootRouter 클래스를 업데이트하여, LoggedInBuildable의 빌드 메소드에 플레이어 이름을 전달한다.
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)
}
이렇게 하면, LoggedIn RIB과 그 하위의 RIB 모두 플레이어 이름을 사용할 수 있게 된다.
위에서 살펴본 방식은 RIB을 생성할때, 플레이어 이름을 동적으로 LoggedIn RIB에 주입하는 방식이다.
이 방식과 달리, RIB 트리를 전달하는 정적 방식을 사용해서 LoggedIn RIB을 구성할 수도 있다. 그러나 정적 방식의 경우 Root RIB이 생성될때 플레이어 이름을 초기화할 수 없기 때문에 플레이어 이름을 optional로 설정해야한다.
RIB에서 Dependency란, RIB이 부모로부터 인스턴스화되기 위해 필요한 종속성을 나열하는 프로토콜이다. Component란, Dependency 프로토콜을 구현한것이다.
Component는 RIB의 builder에 parent dependency를 제공해주고, 자기 자신과 자식을 위한 종속성을 소유할 필요도 있다.
일반적으로는 부모 RIB이 자식 RIB을 인스턴스화하면, 부모의 component를 자식의 Builder에 dependency로 주입한다.
DI 트리를 OffGame RIB으로 전달하여, Start Game 버튼과 함께 플레이어 이름을 표시하려고 한다.
OffGameDependency 프로토콜에서 플레이어 이름을 종속성으로 선언한다.
protocol OffGameDependency: Dependency {
var player1Name: String { get }
var player2Name: String { get }
}
이런 정적 종속성은 상위 RIB이 초기화되는 동안, OffGame RIB으로 전달되야한다.
OffGame Component에 정의된 component를 사용해서 OffGame 스코프 내에서 종속성을 사용할 수 있도록 할 것이다.
final class OffGameComponent: Component<OffGameDependency> {
fileprivate var player1Name: String {
return dependency.player1Name
}
fileprivate var player2Name: String {
return dependency.player2Name
}
}
OffGame Component를 fileprivate으로 표시해서, LoggedInComponent에서는 접근 불가능하도록 한다. 이전 단계에서 이미 LoggedInComponent은 추가했기 때문에, LoggedIn 스코프를 만들기 위한 단계는 필요하지 않는다.
종속성을 생성자 주입을 통해 OffGameViewController에 전달한다.
(종속성을 OffGameInteractor에 전달하고 interactor가 offGamePresentable 메소드를 호출하여 이 정보를 표시할 수도 있지만, 플레이어 이름을 표시하는데 별도의 처리가 필요하지 않기 때문에 직접 컨트롤러에 전달해도 된다.)
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)
}
}
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)
}
...
Reactive Programming은 RIBs에서 널리 사용되고 있다.
현재 이 게임에서는 TicTacToe RIB이 현재 게임의 상태를 제어하기 때문에 TicTacToe RIB이 게임 점수를 업데이트 해야한다. 그 뒤, OffGame RIB이 소유한 화면에서 점수가 표시되기 때문에 OffGame RIB이 점수를 읽어야한다.
하지만, TicTacToe와 OffGame RIB은 서로를 알지 못하며, 직접 데이터를 교환할 수 없다. 하지만 둘은 같은 부모를 가진다. 우리는, LoggedIn RIB에 점수 스트림을 구현해서 TicTacToe, OffGame RIB이 점수 스트림에 액세스 할 수 있도록 구현해야한다.
LoggedIn 그룹에 ScoreStream이라는 파일을 생성한다.
import RxSwift
import RxRelay
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))
}
LoggedInComponent에 ScoreStream 공유 인스턴스(해당 범위, 위 예제에서는 LoggedIn과 하위 RIB, 내 싱글톤을 의미)를 생성한다. 스트림은 대부분의 상태 저장 객체와 마찬가지로 일반적으로 범위가 있는 싱글톤이다. 그러나 대부분의 다른 dependency는 stateless해야 하며, 따라서 공유되지 않아야 한다. (뭔소리냐...)
var mutableScoreStream: MutableScoreStream {
return shared { ScoreStreamImpl() }
}
mutableScoreStream은 fileprivate이 아닌 internal 접근자로 선언되었는데, LoggedIn 하위의 RIB도 접근가능해야하기 때문에 internal로 선언하였다. 하지만, 하위 RIB이 접근 가능하지 않아도 된다면 프로퍼티는 fileprivate으로 선언하는 것이 좋다.
또한, RIB에서 직접 사용되는 종속성만 component의 기본 구현에 배치해야한다. (dynamic dependencies로부터 주입된 stored property(ex. 플레이어 이름)의 경우는 예외이다.)
즉, LoogedInInteractor에서 mutableScoreStream을 직접 사용하기 때문에 기본 구현에 배치해야하며, 그렇지 않은 경우는 extension dependency (ex. LoggedInComponent+OffGame)에 배치해야한다.
mutableScoreStream을 LoggedIninteractor에 전달하여 나중에 점수를 업데이트할 수 있도록 한다.
...
private let mutableScoreStream: MutableScoreStream
init(mutableScoreStream: MutableScoreStream) {
self.mutableScoreStream = mutableScoreStream
}
...
LoggedInBuilder도 아래와 같이 업데이트한다.
final class LoggedInBuilder: Builder<LoggedInDependency>, LoggedInBuildable {
override init(dependency: LoggedInDependency) {
super.init(dependency: dependency)
}
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)
interactor.listener = listener
let offGameBuilder = OffGameBuilder(dependency: component)
let ticTacToeBuilder = TicTacToeBuilder(dependency: component)
return LoggedInRouter(interactor: interactor,
viewController: component.loggedInViewController,
offGameBuilder: offGameBuilder,
ticTacToeBuilder: ticTacToeBuilder)
}
}
이제 scoreStream을 OffGame RIB에 전달하여, 게임 종료 후에 플레이어의 스코어를 표시하도록 한다. scoreStream은 OffGame RIB의 스코프임을OffGameDependency 프로토콜에서 선언한다.
protocol OffGameDependency: Dependency {
var player1Name: String { get }
var player2Name: String { get }
var scoreStream: ScoreStream { get }
}
이후, 우리는 OffGameComponent에서 현재 범위에 대한 종속성을 제공한다.
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
}
}
위 코드를 보면 LoggedIn Component와 다르게 scoreStream을 fileprivate으로 정의했다. (LoggedIn Component는 하위 RIB도 접근가능해야 했지만, OffGame의 경우 그럴 필요가 없어서!)
OffGame Builder는 stream을 나중에 사용하기 위해 OffGameInteractor에 주입한다.
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
}
...
stream은 OffGame 스코프 내에서만 필요하고, LoggedIn RIB에서는 사용되지 않기 때문에 이 종속성은 LoggedInComponent+OffGame에 확장 배치한다.
이제, OffGame RIB은 score stream을 subscribe해야한다. 이벤트가 방출되면 OffGamePresentable은 이벤트를 viewController에 전달한다.
점수값을 설정할 수 있도록 OffGamePresentable 프로토콜을 업데이트하자. OffGamePresentable 프로토콜은 interactor에서 뷰로 소통할 때 사용하는 프로토콜이다.
protocol OffGamePresentable: Presentable {
weak var listener: OffGamePresentableListener? { get set }
func set(score: Score)
}
OffGameInteractor에 subscription을 만들어서, stream에서 값이 방출되면 OffGamePresentable에 전달되도록 한다.
private func updateScore() {
scoreStream.score
.subscribe(
onNext: { (score: Score) in
self.presenter.set(score: score)
}
)
.disposeOnDeactivate(interactor: self)
}
우리는 disposeOnDeactivate를 사용하여 Rx 구독 lifecycle을 관리한다. disposeOnDeactivate을 통해 Interactor가 비활성화 되면 subscription이 자동으로 폐기된다.
OffGameInterator의 didBecomeActive 메소드에서 updateSocre 메소드가 호출되도록 한다. OffGameInteractor가 활성화되면 subscription이 생성된다.
override func didBecomeActive() {
super.didBecomeActive()
updateScore()
}
게임이 종료된 후, TicTacToe RIB은 listener를 이용해 게임의 승리자를 공유하고, score stream을 업데이트할 것이다.
listener에게 정보를 공유하기 위해 TicTacToeListener 프로토콜을 업데이트한다.
protocol TicTacToeListener: class {
func gameDidEnd(withWinner winner: PlayerType?)
}
이제, TicTacToeInteractor를 구현해보자. RIBs에서는 여러 방법을 소개하고 있는데, 그중 클로저를 사용하는 방식만 작성하도록 하겠다.
TicTacToePresentableListener에서 closeGame 메소드를 제거한다.
protocol TicTacToePresentableListener: AnyObject {
func placeCurrentPlayerMark(atRow row: Int, col: Int)
}
TicTacToeViewController에서 announce(winner:) 메소드를 수정하여 사용자가 alert을 닫으면, completion handler를 호출하도록 수정하자. announce(winner:) 메소드는 특정 플레이어가 이기면, interactor에 의해서 호출된다.
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)
}
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 () -> ())
}
// LoggedInInteractor
func gameDidEnd(withWinner winner: PlayerType?) {
if let winner = winner {
mutableScoreStream.updateScore(withWinner: winner)
}
router?.routeToOffGame()
}