iOS

iOS) RIBs Tutorial 3 - Dependency 활용

snowe 2022. 1. 26. 09:25

RIBs Dependency Injection and Communication

튜토리얼2를 마쳤다는 전제 하에 진행됩니다.

튜토리얼2 까지는 RIBs의 핵심 개념들에 대해서 다루었습니다. 튜토리얼3 에서는 새로운 RIB을 만들지 않고 지금까지 만들었던 RIBs를 수정하는 방식으로 진행됩니다.

지금까지의 RIB Tree

 


Goals

TicTacToe Game의 처음 시작 화면에 몇가지가 추가됩니다.

  • 게임에 어떤 플레이어들이 참가하는지 알고싶기에 플레이어의 이름을 표시해준다.
  • 플레이어들이 게임을 여러번 한다면 그들의 게임 스코어를 추적해서 누적 점수를 시작 화면에 보여준다.

위의 것들을 하기위해서 우리는 다음과 같은 목표를 갖게 됩니다.

  • Builder build 메서드를 통해 동적인 dependency를 하위 RIB에 넘겨주는 것
  • Dependency Injection(DI, 의존성 주입) tree를 사용해 정적인 dependency를 넘겨주는 것
    • Swfitd의 확장 기반의 의존성을 따릅니다.
  • RIB life-cycle을 사용하여 Rx stream의 life-cycle을 관리하는 것

 


 

Dynamic dependencies

튜토리얼 1에서 우리는 로그인 형식을 만들었고 거기서 입력받은 플레이어들의 이름은 LoggedOut RIB 부터 Root RIB까지 전달되었습니다. 하지만 튜토리얼 2에서 우리가 새롭게 만든 뷰에서 그 데이터를 사용하지 않았어요.

이번 튜토리얼에서 RIB tree를 따라서 그 데이터(플레이어 이름)를 하위 RIB인 OffGame, TicTacToe RIB에게 전달할거에요

동적 dependency부터 시작하겠습니다!

0. LoggedInBuildable protocol을 아래와 같이 업데이트해서 두 플레이어의 이름을 가변 dependency로 받을 수 있도록 해주세요.

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

1. 방금 수정한 LoggedInBuildable protocol을 준수하는 LoggedInBuilder의 build메서드도 수정한대로 업데이트 해주어야겠죠?

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

이렇게 함으로써 LoggedIn의 상위 RIB으로 부터 받아온 플레이어의 이름들은 동적 dependency에서 정적 dependency로 효율적으로 변환되고 하위 RIB에서 사용할 수 있게됩니다.

2. 위에서 LoggedIn으로 라우팅될때 플레이어의 이름을 받을 수 있도록 위에서 LoggedInBuilder의 build메서드를 수정했어요
이제 RootRouter 클래스에서 LoggedIn으로 라우팅해줄때 사용되는 routeToLoggedIn 메서드도 플레이어 이름을 받을 수 있게 파라미터를 수정해줄게요.

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


지금까지 진행한 수정사항들을 통해서 사용자에 의해 입력된 플레이어의 이름들은 LoggedOut RIB에 의해 처리되며, 그 결과 LoggedIn RIB, 그리고 그 하위 RIB에서 사용할 수 있게 되었어요


Dynamic dependencies VS Static dependencies

우리는 LoggedIn RIB이 build될 때 플레이어 이름들을 주입하기로 결정했습니다. 이를 동적 주입이라고 합니다.
이렇게 하지 않는다면 RootBuilder에서 build할 때 넣어주는 방법이 있는데요, 이러한 경우에는 RootBuilder가 만들어지는 시점에 플레이어 이름이 없을 수도 있기 때문에 옵셔널로 받아주어야 합니다.

옵셔널이 생기게 되면 옵셔널 체이닝, 혹은 바인딩과 같은 추가적인 코드가 발생하게 되기 때문에 그런 상황에서 추천되지 않는 방식인 것 같다.

따라서 우리가 진행한 것처럼 적절하게 범위가 한정된 종속성은 불변 가정을 할 수 있게 하고 번거로운 옵셔널 처리를 하지 않아도 돼요.

정리하자면 static으로 종속성을 넣어줄 경우 Optional Value에 대한 불필요한, 불합리한 코드를 작성하는 경우가 생긴다면 적당한 Scope를 지정하여 동적으로 종속성을 넣어주는 것도 좋은 방법이 될 수 있습니다.


RIB's Dependencies and Components

Dependency, Component에 대한 개념을 지금까지 다루지 않았는데요.

RIBs에서
Dependency는 RIB이 제대로 인스턴스화 되기 위해 상위 RIB으로부터 필요한 종속성을 나열하는 프로토콜입니다.
ComponentDependency 프로토콜의 수행자입니다. 상위 종속성을 RIB의 빌더에 제공하는것 외에도, Component는 RIB이 자신과 하위 RIB을 위해 만드는 Dependency를 소유할 책임이 있습니다.

보통 상위 RIB이 하위 RIB을 인스턴스화 할 때, 자신의 Component를 하위 Builder에 생성자 dependency로 주입합니다.

func build() -> LaunchRouting { 		
    ...         
    let component = RootComponent(dependency: dependency,rootViewController: viewController)
    ...          
    // 보통 상위 RIB이 하위 RIB을 인스턴스화 할 때, 
    // 자신의 Component를 하위 Builder에 생성자 dependency로 주입합니다.
    let loggedOutBuilder = LoggedOutBuilder(dependency: component) 
    let loggedInBuilder = LoggedInBuilder(dependency: component)  
    
    return RootRouter(
        interactor: interactor, 
        viewController: viewController, 
        loggedOutBuilder: loggedOutBuilder,
        loggedInBuilder: loggedInBuilder)
}

주입된 각각의 Component는 하위 RIB에게 어떤 종속성을 노출시킬지 결정합니다.

Component에 포함되어있는 종속성은 일반적으로 DI(의존성 주입) 트리 아래로 전달되어야 하는 일부 상태를 보유하거나, 구성하는 데 비용이 많이 들고, 성능상의 이유로 RIB간에 공유됩니다.

Passing the player names to the OffGame scope using the DI tree

위에서 공부했듯이 종속성에는 동적인 것과 정적인 것이 존재해요. 정적인 종속성을 주입할 때에는 주입하는 값이 옵셔널일 경우 비효율적인 일이 발생한다는 것까지 배웠습니다.

이제 우리가 종속성을 주입할 친구는 OffGame RIB이고 이 친구는 무조건 플레이어 이름을 가져야만 해요 옵셔널이 아니라는 뜻이죠?

0. 그래서 정적으로 종속성을 주입해줄거에요. OffGameDependency를 다음과 같이 수정해주세요.
이 정적 종속성은 상위 RIB에 의해 OffGame RIB이 이니셜라이징 될 때 무조건 담겨서 와야해요.

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


1. 다음으로 우리는 이 종속성을 OffGame 자신의 Scope 내에서 사용할 수 있도록 해줄거에요.

fileprivate으로 선언해줌으로써 OffGameBuilder.swift 파일 내에서만 사용할 수 있어요
LoggedInComponent에서는 하위 RIB에게 값을 제공해주어야 해서 fileprivate을 사용하지 않았습니다.

OffGameComponent를 다음과 같이 수정해주세요

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

위 코드를 작성함으로써 우리는 이제 상위 RIB으로 부터 player1Name과 player2Name을 꼭 받아야해요. 하지만 아까 LoggedIn RIB의 LoggedInComponent에서 이미 넘겨주도록 구현해놨기 때문에 별도로 진행할 필요는 없습니다.

2. 이 종속성을 OffGameViewController의 생성자를 통해 OffGameViewController로 넘겨줄거에요. 정석대로라면 OffGameInteractor에 이를 넘겨주고 OffGamePresentable을 통해 뷰가 이 정보를 보여줄 수 있도록 메서드를 작성해주어야 하지만 현재 플레이어 이름을 보여주는것에는 별도의 로직이 필요 없기 때문에 바로 ViewController로 넣어줍니다.

view controller에 종속성을 주입하기 위해 OffGameBuilder를 다음과 같이 업데이트 해주세요

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이 플레이어 이름을 이니셜라이징하는 동안 받을 수 있게 수정 해주세요.

...

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

...


이제 데이터는 다 받아왔으니 받아온 데이터를 라벨에 보여줄 수 있도록 UI를 구성해주시면 됩니다!

  • 게임에 어떤 플레이어들이 참가하는지 알고싶기에 플레이어의 이름을 표시해준다.

 


 

Track scores using a Rx stream

지금은 게임이 끝나면 플레이어는 그냥 다시 게임을 시작할 수 있는 화면으로만 이동하게 됩니다.
이제 Rx stream을 사용하여 점수를 관찰해서 시작화면에서 누적점수를 볼 수 있도록 하려고 합니다.

RIBs를 설치하면 그 안에 Rx도 포함되어있을 정도로 Rx가 굉장히 널리 사용되는데요. 하위 RIB이 자신의 상위 RIB으로부터 동적인 데이터를 얻어야 할 때, 데이터 생산자 측에서 Observable로 데이터를 감싸고 데이터를 사용해야하는 쪽에서 이를 subscribe하는 방식으로 자주 사용됩니다.

우리의 경우, 게임 스코어는 게임이 진행되는 TicTacToe RIB에 의해 업데이트 되어야 합니다. 그리고 그 스코어는 스코어를 보여줄 OffGame RIB에 의해 사용될 것입니다. TicTacToeOffGame은 서로의 존재를 모르기 때문에 아무것도 안거치고 직접적으로 데이터를 주고받을 수는 없습니다. 하지만 두 RIB의 부모가 같기때문에 LoggedIn RIB에서 score stream을 구현할것입니다.

우리는 총 두가지(ScoreStream(읽기전용), MutableScoreStream(가변버전))를 Models/ScoreStream에서 만들어놨습니다. 이에 대해서는 아래에서 추가적으로 설명됩니다.

LoggedInComponent에서 공유 ScoreStream 인스턴스를 만들어주세요.

TicTacToe, OffGame 모두에 전달해야하기 때문에 LoggedInComponent에 생성.
MutableScoreStream은 LoggedIn RIB과 그 하위 RIB에서 사용하도록 만든 싱글톤 인스턴스입니다.
var mutableScoreStream: MutableScoreStream {
    return shared { ScoreStreamImpl() }
}


Stream 부분에 대해서 좀 이해가 어려웠는데 잘 설명해주신 글이 있어서 인용해왔습니다.

그런데 왜 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을 LoggedInInteractor로 넘겨준다. 이로써 나중에 스코어를 갱신할 수 있다.

...

private let mutableScoreStream: MutableScoreStream

init(mutableScoreStream: MutableScoreStream) {
    self.mutableScoreStream = mutableScoreStream
}

...
// LoggedInBuilder  
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)

...

 


 

Passing a read-only ScoreStream down to OffGame scope for displaying

이제 우리는 읽기 버전의 ScoreStream을 OffGame RIB에서 게임이 끝나면 점수를 보여주는 데에 사용할 수 있도록(수정은 X) 내려줄거에요. OffGameDependency 프로토콜 내부에 읽기버젼의 ScoreStream을 선언해주세요. OffGame RIB의 범위 내에서만 사용가능한(fileprivate) dependency로써 선언해주셔야합니다.

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

OffGameComponent에서 dependency fileprivate로 선언해주세요.

fileprivate var scoreStream: ScoreStream {
    return dependency.scoreStream
}

OffGame의 builder는 OffGameInteractor가 이 데이터를 사용할 수 있도록 stream을 주입시켜주어야해요

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 의 생성자는 score stream을 받을 수 있도록 변경되었습니다.

...

private let scoreStream: ScoreStream

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

...

LoggedIn RIB에서 한것과 다르게 OffGameComponent에서는 fileprivate으로 stream을 선언해주었는데
LoggedIn RIB에서는 하위 RIB에서 사용이 가능하도록 하기 위해서 fileprivate을 사용하지 않은것이고 OffGame에서는 OffGame의 하위 RIB이 이 dependency를 참조할 수 없게 하기 위해서 fileprivate으로 선언하였습니다.

읽기 전용 score stream은 오로지 OffGame 안에서만 필요하고 그 외에 LoggedIn RIB 등에서는 필요하지 않기 때문에, 제공된 소스코드를 보면 이 dependency를 LoggedInComponent+OffGame extension에 위치시켜놓았습니다.

// LoggedInComponent+OffGame.swift 
extension LoggedInComponent: OffGameDependency {
    var scoreStream: ScoreStream {
        return mutableScoreStream
    }
}

 


 

Display the scores by subscribing to the score stream

이제 OffGame RIB은 score stream을 구독해야합니다. stream에 의해 새로운 점수 값에 대한 노티가 일어나고 나면, OffGamePresentable은 이 값을 view controller에게 전달할거에요. Rx의 subscription을 통해 우리는 저장된 상태를 제거하고 데이터를 반영한 UI를 자동적으로 업데이트 합니다.

점수 값을 셋팅할 수 있게 OffGamePresentable 프로토콜을 업데이트 해주세요. 이 프로토콜(OffGamePresentable)은 우리가 interactor에서 view로 소통하는 경우에 사용합니다!

protocol OffGamePresentable: Presentable {
    weak var listener: OffGamePresentableListener? { get set }
    func set(score: Score)
}


우리는 OffGameInteractor 클래스 내에서 subscription을 만들고 stream이 새로운 값을 내뱉을 때 OffGamePresentable을 통해 새로운 값을 셋팅해줄거에요

override func didBecomeActive() {  
    super.didBecomeActive()    
    updateScore() 
}  

private func updateScore() {    
    scoreStream.score         
    .subscribe(      
        onNext: { (score: Score) in  
            self.presenter.set(score: score)
            }
        )
        .disposeOnDeactivate(interactor: self)
        // OffGameInteractor가 deactivate될때 자동적으로 dispose해준다
}
disposeOnDeactivate?
Interactor의 생명주기를 기반으로 deactivate될 때 자동적으로 구독을 삭제한다. 우리는 거의 항상 이러한 Rx 수명 주기 관리 유틸리티를 활용하기 위해 interactor 또는 worker 클래스에서 Rx 구독을 활용해야한다.

이제 뷰컨에서 점수를 보여주어야하는데 이건 제공해주는 샘플 코드를 복붙하면 된당
아직 점수가 실시간으로 반영되어 화면에 보여지고있지는 않다.


 

Updating the score stream when a game is over

게임이 끝나고나서 TicTacToe RIB은 자신의 listener를 통해 LoggedInInteractor를 호출해주어야한다. 그러기 위해서 메서드를 선언하자.

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

우리가 게임의 승자가 결정되고 이를 뷰컨이 활용할 수 있게 하는 방법은 몇가지가 존재한다.

  1. TicTacToeInteractor가 승자에 대한 데이터를 지역변수로 가지고 있고 전달한다.
  2. TicTacToeViewController가 게임진행 alert창이 닫히면 closeGame 메서드를 통해 승자를 interactor에게 넘긴다.

기술적으로 두 방법 다 괜찮은 방법이다.

1번 방법의 이점은 모든 데이터가 TicTacToeInteractor안에서 캡슐화 되어있다는 것이다. 단점으로는 로컬의 변경 가능한 상태를 유지하고 있어야 한다는 것인데, 우리가 사용중인 RIB의 범위가 넓다는 사실에 의해 조금 상쇄되는 단점입니다. 각 RIB의 로컬 상태는 잘 캡슐화 되어있고 제한되기 때문에 새로운 게임을 시작해서 새롭게 TicTacToe RIB을 만들면 이전거는 새로운 지역 변수로 초기화 됩니다.

두번째 방법을 사용한다면 우리는 1번에서 유의해야 할 단점은 신경쓰지 않아도 되지만 비즈니스 로직을 view controller에서 처리해야합니다.

이번 튜토리얼에서는 두 방법의 이점만을 활용하기 위해서 Swift의 클로져를 활용할거에요. 클로져를 활용하면 Interactor가 view controller에게 게임이 끝났음을 알릴 때, view controller가 상태를 업데이트 한 후 completion handler를 호출하게 할 수 있어요.
이렇게 되면 승자에 대한 데이터가 TicTacToeInteractor 안에서 캡슐화되고 추가적인 상태 값을 저장하지 않을거에요. 또한, closeGame 메서드를 view controller의 리스터안에 불필요하게 만들 필요도 없게됩니다!

TicTacToePresentableListener 에서 closeGame 메서드를 지우고 아래 메서드를 추가해주세요.

protocol TicTacToePresentableListener: class {
    func placeCurrentPlayerMark(atRow row: Int, col: Int)
}


TicTacToeViewController에서 announce 메서드를 수정해주세요. completion handler를 인자로 받고 사용자가 alert창을 끄면 호출할 수 있도록 하는 작업입니다. announce 메서드는 player중 한명이 게임의 승자가 되었을 때 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)
}


TicTacToePresentable 프로토콜에서 announce 메서드가 completion handler를 파라미터로 받을 수 있게 수정해주세요.

protocol TicTacToePresentable: Presentable {
    ...

    func announce(winner: PlayerType?, withCompletionHandler handler: @escaping () -> ())
}


TicTacToeInteractor에서도 수정! 뷰컨에서 리스너를 통해 interactor에게 아래 메서드를 호출시킬거에요.

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


마지막으로 LoggedInInteracor에서 gameDidEnd 메서드의 구현부를 다음과 같이 수정해주세요.
게임의 새로운 승자가 나오면 score stream을 업데이트 해줄거에요.

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

완성