[iOS] RIBs 튜토리얼2
https://github.com/uber/RIBs/wiki/iOS-Tutorial-2
Composing RIBs
Goals
로그인 이후 게임 필드 표시
- 자식 RIB가 부모 RIB과 통신하기
- 부모 인터렉터가 결정하면 RIB을 붙이거나 분리하기
- 뷰 없는 RIB
- 뷰 없는 RIB이 분리되면 뷰 수정사항 cleaning up
- 부모 RIB 처음 로드될 때 자식 RIB 붙이기
- RIB 라이프사이클 이해
- RIB 유닛테스트
Project structure
LoggedIn, OffGame, TicTacToe추가하기
여기서 LoggedIn 은 뷰 없는 RIB입니다.
TicTacToe와 OffGame 를 스위치하는 역할만 합니다.
다른 모든 RIBs 은 자기 뷰컨트롤러를 가지고 스크린에 뷰를 표시합니다.
OffGame RIB 은 플레이어가 새로운 게임을 시작하게하고 "Start Game" button인터페이스가 있습니다.
TicTacToe RIB 은 게임 필드를 보여주고 플레이어가 움직일수 있게해줍니다.
Communicating with a parent RIB
유저이름 입력하고 "Login" button누르면 "Start game" view가 포워딩 돼야합니다.
액티브LoggedOut RIB 은 Root RIB 에 로그인 액션을 알려야합니다.
그 후에 루트 라우터에서 LoggedOut RIB 을 LoggedIn RIB으로 바꿔야합니다.
여기서 뷰 없는 LoggedIn RIB이 OffGame RIB 을 로드하고 뷰컨트롤러를 스크린에 띄웁니다.
Root RIB 이 LoggedOut RIB의 부모이므로 LoggedOut's interactor의 리스너로 구성되어있습니다.
이 리스너 인터페이스로 LoggedOut RIB으로부터 로그인 이벤트를 Root RIB에 전달해야합니다.
First, update the LoggedOutListener to add a method that allows the LoggedOut RIB to inform the Root RIB that the players have logged in.
protocol LoggedOutListener: class {
func didLogin(withPlayer1Name player1Name: String, player2Name: String)
}
이렇게 하면 LoggedOut RIB 의 부모 RIB이 didLogin function을 구현하도록 강제할 수 있습니다.
그리고 컴파일러가 부무와 자식 사이의 contract 를 강제하는 것을 확신하게 합니다.
Change the implementation of loginfunction inside the LoggedOutInteractor to add a newly declared listener call.
func login(withPlayer1Name player1Name: String?, player2Name: String?) {
let player1NameWithDefault = playerName(player1Name, withDefaultName: "Player 1")
let player2NameWithDefault = playerName(player2Name, withDefaultName: "Player 2")
listener?.didLogin(withPlayer1Name: player1NameWithDefault, player2Name: player2NameWithDefault)
}
LoggedOut RIB의 리스너는 로그인 버튼이 탭 된 것을 알 수 있습니다.
Routing to LoggedIn RIB
위의 다이어그램에서 보듯 유저가 로그인 후 루트는 로그아웃에서 로그인으로 립을 전환해야 합니다.
Update RootRouting protocol to add a method to route to the LoggedIn RIB.
protocol RootRouting: ViewableRouting {
func routeToLoggedIn(withPlayer1Name player1Name: String, player2Name: String)
}
RootInteractor와 RootRouter 의 계약을 만듭니다.
LoggedOutListenerprotocol로 구현된 LoggedIn RIB을 라우팅하기 위해 RootInteractor 의 RootRouting 를 호출합니다.
LoggedOut RIB의 부모가 되기 위해, Root RIB 에 리스터 인터페이스를 구현합니다.
// MARK: - LoggedOutListener
func didLogin(withPlayer1Name player1Name: String, player2Name: String) {
router?.routeToLoggedIn(withPlayer1Name: player1Name, player2Name: player2Name)
}
유저가 로그인하면 Root RIB 이 LoggedIn RIB 으로 라우팅 되도록 해줍니다.
하지만 아직 우리는 구현된 LoggedIn RIB 이 없고 Root 에서 전환할 수 없습니다.
LoggedIn에 DELETE\\_ME.swif 를 지웁니다.
"Owns corresponding view”를 체크하지 않고 로그인 RIB을 만듭니다.
Attaching a viewless LoggedIn RIB and detaching LoggedOut RIB when the users log in
새로 생성된 RIB을 붙이기 위해서 루트 라우터가 그것을 빌드할 수 있어야합니다.
RootRouter 에 LoggedInBuildable protocol 를 통해 생성자 주입으로 가능합니다.
Modify the constructor of the RootRouterto look like this:
init(interactor: RootInteractable,
viewController: RootViewControllable,
loggedOutBuilder: LoggedOutBuildable,
loggedInBuilder: LoggedInBuildable) {
self.loggedOutBuilder = loggedOutBuilder
self.loggedInBuilder = loggedInBuilder
super.init(interactor: interactor, viewController: viewController)
interactor.router = self
}
You'll also need to add a private loggedInBuilder constant for the RootRouter:
// MARK: - Private
private let loggedInBuilder: LoggedInBuildable
...
LoggedInBuilder 를 인스턴스화하기 위해 RootBuilder 를 업데이트합니다. 그리고 RootRouter에 주입합니다.
Modify the build function of the RootBuilder like so:
func build() -> LaunchRouting {
let viewController = RootViewController()
let component = RootComponent(dependency: dependency,
rootViewController: viewController)
let interactor = RootInteractor(presenter: viewController)
let loggedOutBuilder = LoggedOutBuilder(dependency: component)
let loggedInBuilder = LoggedInBuilder(dependency: component)
return RootRouter(interactor: interactor,
viewController: viewController,
loggedOutBuilder: loggedOutBuilder,
loggedInBuilder: loggedInBuilder)
}
LoggedInBuilder 에 생성자 주입을 통해 RootComponent를 DI합니다.
RootRouter는 구체적인 LoggedInBuilder 클래스 대신 LoggedInBuildable 프로토콜에 의존합니다.
이를 통해 RootRouter를 테스트할 때 LoggedInBuildable에 대한 mock 테스트를 할 수 있습니다.
이것은 스위프트의 제약인데, 스위프트는 swizzling-based mocking할 수 없습니다.
동시에, 이것은 또한 프로토콜 기반 프로그래밍 원칙을 따르므로 RootRouter와 LoggedInBuilder가 밀접하게 결합되지 않도록 보장합니다.
우리는 Loggedin RIB에 대한 모든 boilerplate code를 만들었고 Root RIB가 이를 인스턴스화할 수 있도록 했습니다.
이제 routeToLogged 메서드를 RootRouter에 구현할 수 있습니다.
A good place to add it is just before the // MARK: - Private section.
// MARK: - RootRouting
func routeToLoggedIn(withPlayer1Name player1Name: String, player2Name: String) {
// Detach LoggedOut RIB.
if let loggedOut = self.loggedOut {
detachChild(loggedOut)
viewController.dismiss(viewController: loggedOut.viewControllable)
self.loggedOut = nil
}
let loggedIn = loggedInBuilder.build(withListener: interactor)
attachChild(loggedIn)
}
위의 코드 스니펫에서 볼 수 있듯이, 제어를 자식 RIB로 전환하려면 부모 RIB가 기존 자식 RIB를 분리하여 분리된 자식 RIB 대신 새 자식 RIB를 만들어야 합니다.
RIBs 아키텍처에서 부모 라우터는 항상 자식 라우터를 붙입니다.
또한 RIB와 뷰 계층 간의 일관성을 유지하는 것은 부모 RIB의 책임입니다.
자식 RIB에 뷰 컨트롤러가 있는 경우 부모 RIB는 자식 RIB를 분리하거나 부착할 때 자식 뷰 컨트롤러를 dismiss하거나 present해야 합니다.
routeToLoggedOut 메서드를 통해 뷰컨트롤러를 가지는 RIB을 연결하는 방법을 이해하세요.
새로 생성된 LoggedIn RIB로부터 이벤트를 수신할 수 있도록 하기 위해, Root RIB는 해당 인터랙터를 LoggedInRIB의 Listener로 구성합니다.
이것은 Root RIB가 위의 코드에서 자식 RIB를 만들 때 발생합니다.
그러나 이 시점에서 Root RIB는 LoggedIn RIB의 요청에 응답할 수 있는 프로토콜을 아직 구현하지 않습니다.
RIBs are unforgiving when it comes to conforming to listener interfaces as they are protocol-based.
우리는 다른 암시적인 관측 메서드대신 프로토콜을 사용하는데, 그러므로써 컴파일러가 런타임에 실패하는 대신 어떤 부모가 자식의 모든 이벤트를 모두 소비하지 않앗을 때 오류를 내보낼 수 있게 합니다.
이제 LoggedInBuilder 빌드 메서드의 리스너로써 RootInteractable을 전달하므로 RootInteractable은 LoggedInListener를 준수해야 합니다.
Let's add this conformance to the RootInteractable:
protocol RootInteractable: Interactable, LoggedOutListener, LoggedInListener {
weak var router: RootRouting? { get set }
weak var listener: RootListener? { get set }
}
LoggedOut RIB 을 떨어트리기 위해서 또한 뷰를 dismiss하기 위해, 새로운 dismiss 메서드를 RootViewControllableprotocol에 작성해야합니다.
Modify the protocol to look like this:
protocol RootViewControllable: ViewControllable {
func present(viewController: ViewControllable)
func dismiss(viewController: ViewControllable)
}
dismiss 메서드를 프로토콜에 추가했으니 RootViewController 에 구현해야합니다.
func dismiss(viewController: ViewControllable) {
if presentedViewController === viewController.uiviewController {
dismiss(animated: true, completion: nil)
}
}
이제 RootRouter 는 LoggedOut RIB을 떼어낼 수 있습니다. 그리고 routeToLoggedIn 메서드를 사용해 LoggedInRIB으로 라우팅할 때 뷰컨트롤러를 dismiss할 수 있습니다.
Pass in LoggedInViewControllable instead of creating it
LoggedIn RIB이 뷰가 없으나 여전히 자식 RIBs의 뷰를 보여줄수 있어야 하기 때문에, LoggedIn RIB은 그 조상의 뷰에 접근할 수 있어합니다. 우리 경우에는, 이 뷰는 LoggedIn RIB의 부모인 Root RIB으로부터 제공받습니다.
Update RootViewController to conform to LoggedInViewControllable by adding an extension to the end of the file:
// MARK: LoggedInViewControllable
extension RootViewController: LoggedInViewControllable {
}
LoggedIn RIB에 LoggedInViewControllable 인스턴스를 주입해야합니다.(3편에서)
For now, just override the content of LoggedInBuilder.swiftwith this code.
LoggedIn RIB은 Root RIB에 구현된 LoggedInViewControllable 메서드를 호출해서 자식 RIBs를 보여주고 가릴 수 있습니다.
Attaching the OffGame RIB when the LoggedIn RIB loads
LoggedIn RIB은 뷰가 없어서 오직 자식 RIBs사이에서만 전환이 가능합니다.
먼저 첫 번째 자식RIB인 OffGame 를 만들건데, "Start Game" button이 있습니다.
"OffGame"그룹에 새로운 RIB을 뷰와함께 만듭니다.
OffGameViewController UI는 the provided implementation 참고
OffGame와 LoggedIn를 연결해봅시다.
LoggedIn RIB는 OffGame RIB 을 빌드할 수 있어야하고 자식을 붙일 수 있어야합니다.
OffGameBuildable instance 의존성을 선언하기 위해 LoggedInRouter 생성자를 바꿔줍니다.
To do so, modify its constructor as suggested below:
init(interactor: LoggedInInteractable,
viewController: LoggedInViewControllable,
offGameBuilder: OffGameBuildable) {
self.viewController = viewController
self.offGameBuilder = offGameBuilder
super.init(interactor: interactor)
interactor.router = self
}
We'll also have to declare a new private constant to hold a reference to the offGameBuilder :
// MARK: - Private
...
private let offGameBuilder: OffGameBuildable
OffGameBuilder 클래스를 인스턴스화 하고 LoggedInRouter에 주입하기 위해 LoggedInBuilder를 수정합니다.
Modify the build function like so:
func build(withListener listener: LoggedInListener) -> LoggedInRouting {
let component = LoggedInComponent(dependency: dependency)
let interactor = LoggedInInteractor()
interactor.listener = listener
let offGameBuilder = OffGameBuilder(dependency: component)
return LoggedInRouter(interactor: interactor,
viewController: component.loggedInViewController,
offGameBuilder: offGameBuilder)
}
OffGameBuilder 의존성을 충족하기 위해, LoggedInComponent 클래스를 OffGameComponent를 따르도록 수정합니다.
final class LoggedInComponent: Component<LoggedInDependency>, OffGameDependency {
fileprivate var loggedInViewController: LoggedInViewControllable {
return dependency.loggedInViewController
}
}
유저 로그인 후에는 OffGame RIB을 즉시 스크린 켜지면 보여주고 싶습니다.
이것은 LoggedIn RIB가 로드되자마자 OffGame RIB 를 붙여야 한다는 것입니다.
LoggedInRouter의 didLoad 메서드를 override해서 OffGame RIB 를 로드하게 합니다.
override func didLoad() {
super.didLoad()
attachOffGame()
}
attachOffGame 은 LoggedInRouter 클래스에서 빌드하고 OffGame RIB을 붙이고 뷰컨트롤러를 present하기 위한 private 메서드입니다.
Add the implementation of this method to the end of LoggedInRouter class.
attachOffGame메서드 안에 OffGameBuilder를 인스턴스화 하기 위해, LoggedInInteractable인스턴스를 주입해야 합니다.
이 인터랙터는 부모가 자식RIB으로부터 전달된 이벤트를 받고 해석할 수 있는 OffGame's 리스너 인터페이스 역할을 합니다.
OffGame RIB 이벤트를 받기 위해 LoggedInInteractable는 OffGameListener프로토콜을 준수해야합니다.
Let's add the protocol conformance to it.
protocol LoggedInInteractable: Interactable, OffGameListener {
weak var router: LoggedInRouting? { get set }
weak var listener: LoggedInListener? { get set }
}
이제 로딩 후 LoggedIn RIB 은 OffGame RIB을 붙입니다. 그리고 이벤트를 받을 수 있습니다.
Cleaning up the attached views when the LoggedIn RIB is detached
LoggedIn RIB이 자신의 뷰가 없어 부모 뷰 계층을 수정하기 때문에, Root RIB는 LoggedIn RIB가 수정한 뷰를 자동으로 제거할 수 없습니다.
뷰 없는 LoggedIn RIB은 이미 LoggedIn RIB가 떼어질 때 뷰 수정사항을 clean up할 수 있는 hook을 제공합니다.
Declare present and dismiss methods in LoggedInViewControllable protocol:
protocol LoggedInViewControllable: ViewControllable {
func present(viewController: ViewControllable)
func dismiss(viewController: ViewControllable)
}
다른 프로토콜 처럼 LoggedIn RIB ViewControllable 를 dismiss하는 기능이 필요하다고 선언합니다.
Then we'll update the cleanupViews method of the LoggedInRouter to dismiss the view controller of the current child RIB:
func cleanupViews() {
if let currentChild = currentChild {
viewController.dismiss(viewController: currentChild.viewControllable)
}
}
cleanupViews 메서드는 부모 RIB이 LoggedIn RIB을 제거하려 할 때, LoggedInInteractor 에 의해호출될 것입니다.
cleanupViews에 presented 뷰컨트롤러를 dismiss함으로써, LoggedIn RIB가 분리된 후 부모RIB의 뷰 계층에 뷰를 남기지 않도록 보장합니다.
Switching to TicTacToe RIB on tapping "Start Game" button
LoggedIn RIB는 OffGame and TicTacToe RIBs 전환이 가능해야합니다.
OffGame에 대해 구현하고 로그인 후 LoggedIn RIB이 컨트롤할 수 있께 했습니다.
TicTacToe RIB를 추가해봅시다.
로그인 버튼을 눌렀을 떄, LoggedIn RIB 을 붙이고 LoggedOut RIB를 제거하는 것과 비슷합니다.
TicTacToe 는 프로젝트에 구현되어있습니다.
TicTacToe를 라우트 하기 위해, routeToTicTacToe메서드를 LoggedInRouter클래스에 구현하고 OffGameViewController의 탭 이벤트를 OffGameInteractor와 LoggedInInteractor에 연결해야합니다.
코드를 구현하고 Start Game을 탭하면 게임 필드가 보일겁니다.
OffGameListener's 메서드는 startTicTacToe 로 만드세요 유닛테스트에 이미 그렇게 했으니까.
Attaching the OffGame RIB and detaching the TicTacToe RIB when we have a winner
게임 종료 후TicTacToe RIB 에서 OffGame RIB로 돌아가고 싶습니다.
동일한 listener-based 라우팅패턴을 사용할 것입니다.
TicTacToe RIB 은 이미 리스너 set up이 있습니다.
우리는 LoggedInInteractor안에 LoggedIn RIB가 TicTacToe이벤트를 받게 구현하면 됩니다.
Declare the routeToOffGame method in the LoggedInRouting protocol.
protocol LoggedInRouting: Routing {
func routeToTicTacToe()
func routeToOffGame()
func cleanupViews()
}
Implement the gameDidEnd method in the LoggedInInteractor class:
// MARK: - TicTacToeListener
func gameDidEnd() {
router?.routeToOffGame()
}
Then, implement the routeToOffGame in the LoggedInRouter class.
func routeToOffGame() {
detachCurrentChild()
attachOffGame()
}
Add the private helper method somewhere in your private section:
private func detachCurrentChild() {
if let currentChild = currentChild {
detachChild(currentChild)
viewController.dismiss(viewController: currentChild.viewControllable)
}
}
Now, the app will switch from the game screen to start screen after one of the players wins the game.
Unit Testing
앱의 유닛 테스트 작성 방법을 시연하겠습니다.
RootRouter 클래스를 테스트해 봅시다.
동일한 원리를 RIB의 다른 부분에도 적용할 수 있으며, RIB에 대한 모든 장치 테스트를 생성하는 툴링 템플릿도 있습니다.
TicTacToeTests/Root 그룹에 새 swift 파일을 만들고 RootRouterTests라고 합니다.
routeToLoggedIn의 메서드를 확인하는 테스트를 작성합시다.
이 메서드가 호출되면 RootRouter는 LoggedInBuildable 프로토콜의 build 메서드를 호출하고 반환된 라우터를 연결해야 합니다.
here에서 사용할 수 있는 이 테스트의 구현을 이미 준비했습니다.
코드를 RootRouterTests에 복사하고 테스트를 컴파일하여 통과했는지 확인하세요.
방금 추가한 테스트의 구조를 살펴보겠습니다.
RootRouter를 테스트하는 동안 이를 인스턴스화해야 합니다.
라우터는 mocks와 함께 인스턴스화된 많은 프로토콜 기반 의존성을 가지고 있습니다.
필요한 모든 mock은 TicTacToeMocks.swift 파일에 이미 제공되어 있습니다.
다른 RIB에 대한 유닛 테스트를 작성할 때, 직접 그들을 위한 mock을 만들어야 합니다.
routeToLoggedIn을 호출할 때, 우리의 루트 라우터의 구현은 라우터를 인스턴스화하기 위해 LoggedIn RIB의 build 메소드를 호출해야 합니다.
builder 로직을 mocks에 복사하고 싶지 않기 때문에 대신 Closure를 통과하여 예상 LoggedInRouting 인터페이스를 구현하는 라우터 mock을 반환합니다.
이 클로저는 테스트를 실행하기 전에 구성됩니다.
핸들러 클로져로 작업하는 것은 유닛 테스트 중에 많이 사용하는 일반적인 개발 패턴입니다.
또 다른 패턴은 메서드 호출의 수를 계산하는 것입니다.
예를 들어 routeToLoggedIn 구현에서 테스트 중인 메서드에서 LoggedInBuildable의 빌드 메서드를 정확히 한 번 호출해야 한다는 것을 알고 있으므로 테스트 대상 메서드를 호출하기 전과 후에 각각의 모의 호출 카운드를 확인합니다.