RIBs iOS Tutorial 2
https://github.com/uber/RIBs/wiki/iOS-Tutorial-2
Tutorial2
wiki 설명
목표
로그인 후 게임 필드 보여주기를 할 것임.
- 자식 RIB가 부모 RIB와 소통하게 하기
- 부모 interactor가 원할 때 자식 RIB를 Attach/detach 하기
- view-less RIB 만들기
- view-less RIB이 detach될 때 뷰 수정사항을 cleaning up 하세요
- 부모 RIB가 처음 로드되었을 때 자식 RIB를 attach 해보기
- RIB의 Lifecycle 이해하기
- RIB를 Unit test 해보기
프로젝트 구조
이전 tutorial보다 RIB 3개 추가로 있다. LoggedIn, TicTacToe, OffGame 이렇게.
LoggedIn RIB는 viewless이다. TicTacToe와 Offgame RIB을 전환하는 역할만 수행한다. 얘 빼고 RIB들은 다 view가 있다.
OffGame RIB는 플레이어가 새로운 게임을 시작할 수 있게 해주고 "게임 시작" 버튼이 있다. TicTacToe RIB는 게임 필드를 보여주고 플레이어들이 움직일 수 있도록 한다.
부모 RIB와 소통하기
사용자가 플레이어 이름을 입력하고 Login 버튼을 누르면 게임 시작 뷰로 전달되어야 한다. viewless LoggedIn RIB는 OffGame RIB를 로드하고 화면을 보여줘야 한다.
LoggedOut RIB에서 listener을 통해 부모 RIB와 소통한다. LoggedOutListener에 didLogin 메소드 추가, 버튼 클릭 시 메소드 호출하자.
protocol LoggedOutListener: AnyObject {
// TODO: Declare methods the interactor can invoke to communicate with other RIBs.
func didLogin(with player1Name: String, with player2Name: String)
}
func login(withPlayer1Name player1Name: String?, player2Name: String?) {
let player1NameWithDefault = playerName(player1Name, withDefaultName: "Player 1")
let player2NameWithDefault = playerName(player2Name, withDefaultName: "Player 2")
print("\(player1NameWithDefault) vs \(player2NameWithDefault)")
listener?.didLogin(with: player1NameWithDefault, with: player2NameWithDefault)
}
LoggedIn RID로 routing하기
사용자가 로그인 후, Root RIB에서 LoggedIn RIB으로 라우팅하자. RootRouting protocol에 메소드추가
protocol RootRouting: ViewableRouting {
// TODO: Declare methods the interactor can invoke to manage sub-tree via the router.
func routeToLoggedIn(withPlayer1Name player1Name: String, player2Name: String)
}
메소드 내용 구현하고 didLogin에서 호출
func routeToLoggedIn(with player1Name: String, with player2Name: String) {
// todo: LoggedIn Builder 호출히기
}
// MARK: - LoggedOutListener
func didLogin(with player1Name: String, with player2Name: String) {
router?.routeToLoggedIn(with: player1Name, with: player1Name)
}
자 지금이제 자식RIB에서 부모RIB을 호출했다. 방식은 자식 interactor → 부모 interactor(자식의 listener) → 부모 router
유저가 login 하면 viewless LoggedIn RID를 attaching, LoggedOut RIB를 detaching하기
자 이제 Root RIB에서 attach할 LoggedIn RIB을 만들어야 한다. viewless로 만들기 위해 Owns corresponding view 체크박스를 해제한다. DELETE_ME 파일에 컴파일에러를 방지하기위한 중복 컴포넌트들이 선언되어있으므로 이 파일도 지워주자.
근데 컴파일 에러가 난다.
template에서 자동으로 생성해주는 LoggedInDependency의 loggedInViewController를 lower-camelcase로 바꿔서 컴파일 에러를 일부 없애주자. 나머지는 나중에... viewless임에도 vc를 없애지 않는 이유는 이 다음에 나온다.
override init(interactor: LoggedInInteractable) {
super.init(interactor: interactor)
interactor.router = self
}
RootRouter에서 LoggedIn RIB를 만들려면 loggedInBuilder을 주입받아야 한다. 상수 프로퍼티 추가하고 RootRouter의 생성자에 추가해야함.
init(interactor: RootInteractable,
viewController: RootViewControllable,
loggedOutBuilder: LoggedOutBuildable,
loggedInBuilder: LoggedInBuildable
) {
self.loggedOutBuilder = loggedOutBuilder
self.loggedInBuilder = loggedInBuilder
super.init(interactor: interactor, viewController: viewController)
interactor.router = self
}
private let loggedInBuilder: LoggedInBuildable
init을 부르는 곳에도 가서 인자를 추가해주자.
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)
}
이제 아까 만들어놓은 routeToLoggedIn 메소드 내용을 작성하자. 부모 RIB는 child RIB을 바꾸려면 기존의 child를 detach하고 새로운 자식 RIB을 만들어서 attach 해야한다. RIBs에서 항상 부모 router은 자식 router을 attach한다.
func routeToLoggedIn(with player1Name: String, with player2Name: String) {
if let loggedOut = loggedOut {
detachChild(loggedOut)
viewController.dismiss(viewController: loggedOut.viewControllable)
self.loggedOut = nil
}
let loggedIn = loggedInBuilder.build(withListener: interactor)
attachChild(loggedIn)
// viewless이므로 present는 하지 않음.
}
내용을 작성하다보면, dismiss가 없다. 추가해주자.
protocol RootViewControllable: ViewControllable {
func present(viewController: ViewControllable)
func dismiss(viewController: ViewControllable)
}
또, interactor가 LoggedInListener를 준수하지 않는다. RootRIB이 LoggedIn의 이벤트를 수신할 수 있도록 프로토콜을 준수해주자.
protocol RootInteractable: Interactable, LoggedOutListener, LoggedInListener {
var router: RootRouting? { get set }
var listener: RootListener? { get set }
}
dismiss 메소드 내용을 구현하자.
func dismiss(viewController: ViewControllable) {
if presentedViewController == viewController.uiviewController {
dismiss(animated: true, completion: nil)
}
}
LoggedInViewControllable
LoggedIn RIB는 view가 없지만, 자식 RIB의 뷰를 표시할 수 있어야 한다. 그래서 LoggedIn RIB은 조상의 view에 접근할 수 있어야 한다. (그 위에 자식을 띄우기 위해서) 그러니까 Root RIB의 뷰를 LoggedIn의 뷰로 해주는듯. RootViewController가 LoggedInViewControllable을 준수하도록 하자. 이제 loggedIn RIB은 parent vc의 정보를 가지고, 그 위에 자식 vc를 띄울 수 있다.
extension RootViewController: LoggedInViewControllable {
}
LoggedIn RIB가 로드되었을 떄 OffGame RIB attach
게임시작 버튼 보이는 OffGame RIB을 만들어보자. 뷰가 있는 OffGame RIB을 만들자. 그리고 UI는 이분들이 구현해놓은거 갖다쓰자..
UI는 아래 소스 복붙
이제 OffGame RIB을 LoggedIn RIB에서 attach 해보자. Router의 init에 offGameBuilder을 추가한다.
init(interactor: LoggedInInteractable,
viewController: LoggedInViewControllable,
offGameBuilder: OffGameBuildable
) {
self.viewController = viewController
self.offGameBuilder = offGameBuilder
super.init(interactor: interactor)
interactor.router = self
}
private let offGameBuilder: OffGameBuildable
그리고 LoggedInRouter을 생성하는 곳에 가서 추가한 파라미터를 넣어준다.
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
)
}
}
이 때 component가 OffGameDependency를 준수할 수 있게 해준다.
final class LoggedInComponent: Component<LoggedInDependency>, OffGameDependency
그리고 loggedIn에서 바로 offGame을 attac할 수 있도록 router에 didLoad 메소드를 override한다.
override func didLoad() {
super.didLoad()
attachOffGame()
}
attachOffGame() 메소드를 추가, 구현한다.
protocol LoggedInRouting: Routing {
func cleanupViews()
// TODO: Declare methods the interactor can invoke to manage sub-tree via the router.
func attachOffGame()
}
private var currentChild: ViewableRouting?
func attachOffGame() {
let offGame = offGameBuilder.build(withListener: interactor)
self.currentChild = offGame
attachChild(offGame)
viewController.present(viewController: offGame.viewControllable)
}
그리고 OffGame의 이벤트를 부모가 수신할 수 있도록 interactor가 listener을 준수하게 해준다.
protocol LoggedInInteractable: Interactable, OffGameListener {
var router: LoggedInRouting? { get set }
var listener: LoggedInListener? { get set }
}
LoggedIn RIB가 detach 되었을 때 attach된 뷰 cleaning up 해주기.
LoggedIn RIB는 뷰를 가지고 있지 않고, parent의 뷰를 변형시킨다. Root RIB은 LoggedIn에서 맘대로 뷰를 변형시킨걸 지울 수 없다. 다행히도 템플릿은 LoggedIn RIB이 detach될 때 변형을 clean up할 수 있는 연결고리를 제공한다.
present, dismiss 메소드를 만들자.
protocol LoggedInViewControllable: ViewControllable {
// TODO: Declare methods the router invokes to manipulate the view hierarchy. Since
// this RIB does not own its own view, this protocol is conformed to by one of this
// RIB's ancestor RIBs' view.
func present(viewController: ViewControllable)
func dismiss(viewController: ViewControllable)
}
그리고 위에서 말한 연결고리, cleanupViews() 메소드를 작성해주자. 이걸 구현하므로써 LoggedIn RIB이 없어질 때 부모 뷰에 추가했던 뷰들을 지울 수 있다.
func cleanupViews() {
// TODO: Since this router does not own its view, it needs to cleanup the views
// it may have added to the view hierarchy, when its interactor is deactivated.
if let currentChild = currentChild {
viewController.dismiss(viewController: currentChild.viewControllable)
}
}
Start Game 눌렀을 때 TicTacToe RIB로 바꿔주기.
로그인 RIB에서 TicTacToe와 OffGame을 전환할 수 있도록 해야한다. OffGame RIB에서 게임 시작을 누르면 TicTacToe RIB으로 전환될 수 있도록 구현하자. 위에서 한거랑 똑같이 하면 된다. 다만 OffGameListener의 메소드 이름을 startTicTacToe로 지정하자. (미리 구현된 단위테스트에서 컴파일 에러가 나지 않도록)
일단 startGame 버튼에 tapgesture 추가해준다.
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.rx.tap.subscribe(onNext: { [weak self] in
self?.listener?.startTicTacToe()
}).disposed(by: disposeBag)
}
private let disposeBag = DisposeBag()
vc에서 interactor로 전달할 수 있도록 PresentableListener에 메소드 추가
protocol OffGamePresentableListener: class {
// TODO: Declare properties and methods that the view controller can invoke to perform
// business logic, such as signIn(). This protocol is implemented by the corresponding
// interactor class.
func startTicTacToe()
}
gameInteractor에서 listener로 상위 RIB에 이벤트를 전달한다.
protocol OffGameListener: AnyObject {
// TODO: Declare methods the interactor can invoke to communicate with other RIBs.
func startTicTacToe()
}
func startTicTacToe() {
listener?.startTicTacToe()
}
상위 RIB의 interactor(LoggedInInteractor)에 메소드 구현
func startTicTacToe() {
router?.attachTicTacToe()
}
interactor에서 router로 attach명령을 전달한다.
protocol LoggedInRouting: Routing {
func cleanupViews()
// TODO: Declare methods the interactor can invoke to manage sub-tree via the router.
func attachOffGame()
func attachTicTacToe()
}
func attachTicTacToe() {
if let currentChild = currentChild {
detachChild(currentChild)
viewController.dismiss(viewController: currentChild.viewControllable)
self.currentChild = nil
}
let tictactoe = ticTacToeBuilder.build(withListener: interactor)
self.currentChild = tictactoe
attachChild(tictactoe)
viewController.present(viewController: tictactoe.viewControllable)
}
protocol LoggedInInteractable: Interactable, OffGameListener, TicTacToeListener
init(interactor: LoggedInInteractable,
viewController: LoggedInViewControllable,
offGameBuilder: OffGameBuildable,
ticTacToeBuilder: TicTacToeBuildable
) {
self.viewController = viewController
self.offGameBuilder = offGameBuilder
self.ticTacToeBuilder = ticTacToeBuilder
super.init(interactor: interactor)
interactor.router = self
}
빌더에도 추가
func build(withListener listener: LoggedInListener) -> LoggedInRouting {
let component = LoggedInComponent(dependency: dependency)
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
)
}
그리고 gameDidEnd() 껍질 메소드를 만들어서 컴파일 에러를 없애준다.
func gameDidEnd() {
// todo
}
결과
한명이 이겼을 때 TicTacToe detach, OffGame RIB attach 하기
게임이 끝나고 TicTacToe에서 다시 OffGame RIB으로 전환하자. listener을 사용해서. LoggedInRouting 프로토콜에서 routeToOffGame 메소드를 선언하자.
protocol LoggedInRouting: Routing {
func cleanupViews()
// TODO: Declare methods the interactor can invoke to manage sub-tree via the router.
func attachOffGame()
func attachTicTacToe()
func routeToOffGame()
}
gameDidEnd에서 호출
func gameDidEnd() {
// todo
router?.routeToOffGame()
}
routeToOffGame 구현
func routeToOffGame() {
detachCurrentChild()
attachOffGame()
}
private func detachCurrentChild() {
if let currentChild = currentChild {
detachChild(currentChild)
viewController.dismiss(viewController: currentChild.viewControllable)
self.currentChild = nil
}
}
결과
게임 종료 후 다시 OffGame vc가 보인다.
Unit testing
마지막으로, 단위 테스트를 작성해보자. RootRouter 클래스를 테스트해보자. RIB의 다른 부분을 단위 테스트하는 데 동일한 원칙을 적용할 수 있으며, RIB에 대한 모든 단위 테스트를 만드는 툴링 템플릿도 있다고 함.
TicTacToeTests/Root에 RootRouterTests 파일 추가
음.. 근데 cmd + U 해보니까 컴파일 에러난다.
기존에 만들어진 Mock file에서 내가 만든 메소드와 이름이 맞지 않아 에러남.
Routing protocol 수정. attach~ 메소드 없앰. route로 만듬.
protocol LoggedInRouting: Routing {
func cleanupViews()
// TODO: Declare methods the interactor can invoke to manage sub-tree via the router.
func routeToOffGame()
func routeToTicTacToe()
}
그리고 attachTicTacToe 메소드와 routeToTicTacToe메소드 분리함.
func attachTicTacToe() {
let tictactoe = ticTacToeBuilder.build(withListener: interactor)
self.currentChild = tictactoe
attachChild(tictactoe)
viewController.present(viewController: tictactoe.viewControllable)
}
func routeToOffGame() {
detachCurrentChild()
attachOffGame()
}
func routeToTicTacToe() {
detachCurrentChild()
attachTicTacToe()
}
그리고 이사람들이 만들어놓은 테스트코드 소스 복붙해도 됨.
이건 routeToLoggedIn 메소드의 동작을 확인하는 테스트임. TicTacToeMocks.swift에 필요한 mock 클래스들이 다 만들어져있다.
routeToLoggedIn을 호출할 때, 클로저 형태로 buildHandler을 전달해야 한다. 그렇지 않으면 다음과 같은 에러가 난다. Thread 1: Fatal error: Function build returns a value that can't be handled with a default value and its handler must be set
handler 전달 방식 말고는, 메소드 호출의 수를 세도 된다고 한다. 예를 들어, 테스트 중인 routeToLoggedIn 메소드의 구현에서 LoggedInBuildable의 빌드 메소드를 정확히 한 번 호출해야 한다는 것을 알고 있으므로, 테스트 아래의 메소드를 호출하기 전후에 각 mock의 호출 카운트를 확인해도 된다.
정리
- 자식RIB에서 부모RIB을 부를 때는 interactor에서 listener을 부른다.
- detach하고 attach할 때 뷰도 함꼐 present 해준다.
- 뷰가 없는 RIB은 부모의 뷰를 가지고 있는다.
- 뷰가 없는 RIB은 자신이 없어질 때 router에서 자신의 자식을 모두 clean up 한다.
- Router은 테스트가 가능하고 다른 컴포넌트들도 단위 테스트가 가능하다.