RIBs iOS Tutorial 4
iOS Tutorial 4 - Deeplinking and Workflows
https://github.com/uber/RIBs/wiki/iOS-Tutorial-4
목표
앱에 딥링크 대응을 추가하자.
- RIB workflow와 actionable item에 대한 이해
- safari에서 딥링크(ribs-training://launchGame?gameId=ticTacToe)를 통해 앱으로 이동해보자 (+ 시작화면을 bypass해서 바로 게임을 시작해보자)
URL handler을 구현하기
URL scheme(deeplinking)은 커스텀 URL을 통해 앱끼리의(inter-app) 통신을 가능하게 한다. 특정 URL 스키마로 앱을 등록하면, 유저가 그 URL을 다른앱(ex. 사파리)에서 열었을 때 앱이 시작된다. 열린 앱은 url의 내용을 받아서 쓸 수 있다.
틱택토 앱에다가 URL scheme 등록하자. Info.plist에 추가
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.uber.TicTacToe</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ribs-training</string>
</array>
</dict>
</array>
그리고 AppDelegate에 urlHandler 추가
protocol UrlHandler: class {
func handle(_ url: URL)
}
private var urlHandler: UrlHandler?
딥링크가 보내질 때 실행되는 AppDelegate의 메소드 구현
public func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool {
urlHandler?.handle(url)
return true
}
그리고 RootInteractor을 URLHandler로 만들기 위해, RootInteractor가 RootActionableItem, URLHandler을 준수하도록 한다. RootActionableItem은 지금은 빈 프로토콜이다.
final class RootInteractor: PresentableInteractor<RootPresentable>,
RootInteractable,
RootPresentableListener,
RootActionableItem,
UrlHandler
RootInteractor에 handle 구현을 하자.
// MARK: - UrlHandler
func handle(_ url: URL) {
let launchGameWorkflow = LaunchGameWorkflow(url: url)
launchGameWorkflow
.subscribe(self)
.disposeOnDeactivate(interactor: self)
}
RootBuildable의 build 리턴형에 UrlHandler을 추가하고, 구현하자. 그리고 application didFinishLaunchingWithOptions 메소드에 build의 결과물인 urlHandler을 넣어주자.
우리는 앱에 딥링크 지원을 추가했다. Ribs-training:// 체계가 있는 딥링크를 받은 후, 앱은 RootInteractor에 정의된 Workflow 시작할 것이다. 이미 Workflow의 stub 클래스는 만들어져 있다. LaunchGameWorkflow이다. 그러나, 워크플로우는 스텁일 뿐이므로, 앱을 연 후에는 아무것도 변하지 않을 것이다.
Workflows and actionable items
RIB에서 workflow는 특정 작업을 구성하는 일련의 step들이다. RIB 트리에서 이 작업이 진행됨에 따라 트리를 오르내린다. 보통은 루트에서부터 아래로 내려간다. Workflow의 내부를 보면 ActionableItemType이 제네릭으로 들어가고, 이 ActionableItem을 Observable에 싸서 subject로 가지고있는 식이다.
workflow step은 ActionableItem과 관련 value의 쌍으로 정의할 수 있다. ActionableItem에는 단계 중에 실행해야 하는 로직이 포함되어 있으며, value는 workflow가 진행의 결정을 내리는 데 도움이 되는, 다른 단계 간의 상태를 전달하는 데 사용되는 argument 역할을 한다.
workflow의 actionable item역할을 하고 왔다갔다 하는 로직을 캡슐화하는것은 Interactor의 책임이다.
Workflow 클래스를 보면 subscribe, onStep 과 같은 메소드로 Rx stream이랑 거의 구성이 비슷하다. subscribe하면 내부의 subject를 subscribe하게 된다.
Implementing the workflow
딥링크를 받은 다음에 identifier로 새 게임을 시작할 수 있어야 한다. 그치만 사용자가 로그인하지 않았다면 로그인을 기다렸다가 로그인 후에 게임필드로 redirect 해야한다.
이 과정은 2단계의 workflow로 모델링할 수 있다.
- 플레이어가 로그인했는지 확인하고 필요한 경우 기다린다. 이 과정은 RootRIB에서 일어난다. 플레이어가 준비되면 2번째 step으로 control을 전송한다. (observable stream에서 value를 방출한다.)
- 새로운 게임을 시작하는 trigger을 보낸다. LoggedIn RIB에서 일어나며, LoggedIn router은 routeToGame 메소드를 통해 게임 필드로이동한다.
플레이어가 로그인하기 전에 어떻게해야할까. RootActionableItem에 메소드를 추가한다.
public protocol RootActionableItem: class {
func waitForLogin() -> Observable<(LoggedInActionableItem, ())>
}
(NextActionableItemType, NextValueType) 튜플로 된 Observable형태이다. 현재 step이 끝난 다음에 실행될 다음 workflow step을 만들 수 있게 해준다. observable이 첫 value를 방출할 때 까지, 이 workflow는 block될 것이다. 이 반응형 패턴은 workflow가 async하게 해주고, launch되고나서 반드시 모든 단계를 완료해서는 안된다. (?? 정확히 뭔말인지 모르겠음. 원문은 This reactive pattern allows the workflow to be asynchronous, it shouldn't necessarily complete all its steps immediately after the launch.)
우리는 로그인이 완료될 경우 NextActionableItemType으로 LoggedInActionableItem을 사용해야 한다. 다만 뭔가 extra state를 전달할 필요는 없기 때문에 NextValueType에는 Void가 들어가있다. (로그인을 기다렸다가 게임화면으로 이동할 때 필요한 value가 없어서? 그런 것 같다.)
이제 LoggedInActionableItem을 만든다.
import RxSwift
public protocol LoggedInActionableItem: AnyObject {
func launchGame(with id: String?) -> Observable<(LoggedInActionableItem, ())>
}
launchGame이 완료되면 다른 단계를 트리거할 필요가 없기 때문에, 자기자신을 NextActionableItemType으로 반환한다. 왜 Void를 하지 않고? workflow의 타입 제약을 준수하기 위해 필요하다는데. 아직은 잘 모르겠다.
이제 workflow를 만들 수 있다. Stub.swift를 삭제하고 진짜 LaunchGameWorkflow를 구현하자. 딥링크를 통해 프로모션에 참가할 수 있다고 가정하자. 이 내용의 코드는 튜토리얼에서 제공하므로 복붙하자.
import RIBs
import RxSwift
public class LaunchGameWorkflow: Workflow<RootActionableItem> {
public init(url: URL) {
super.init()
let gameId = parseGameId(from: url)
self
.onStep { (rootItem: RootActionableItem) -> Observable<(LoggedInActionableItem, ())> in
rootItem.waitForLogin()
}
.onStep { (loggedInItem: LoggedInActionableItem, _) -> Observable<(LoggedInActionableItem, ())> in
loggedInItem.launchGame(with: gameId)
}
.commit()
}
private func parseGameId(from url: URL) -> String? {
let components = URLComponents(string: url.absoluteString)
let items = components?.queryItems ?? []
for item in items {
if item.name == "gameId" {
return item.value
}
}
return nil
}
}
코드를 보면, init에서 workflow를 바로 구성한다. 2개의 workflow가 있다. 여기서 onStep 메소드를 통해 다음 단계로 subject에서 방출한 값을 전달한다.
subject는 Workflow 클래스 내부의 private 프로퍼티이다. 내부 구현은 아래와 같은 형태로 되어있다. workflow의 subscribe 메소드가 실행될 때 subject는 인자로 전달된 actionableItem을 방출한다.
private let subject = PublishSubject<(ActionableItemType, ())>()
public final func subscribe(_ actionableItem: ActionableItemType) -> Disposable {
guard compositeDisposable.count > 0 else {
assertionFailure("Attempt to subscribe to \(self) before it is comitted.")
return Disposables.create()
}
subject.onNext((actionableItem, ()))
return compositeDisposable
}
Root scope에서 waitForLogin step을 통합하기
RIB에서 actionable item역할을 하는 것은 인터랙터의 역할이다. waitForLogin 메소드를 구현하자. 로그인을 기다리고 신호를 보낼 수 있게 RootInteractor에 reactive subject를 만들자.
왜 ReplaySubject?????
bufferSize만큼 미리 저장해놓고 구독 시작 시 이벤트를 전달할 수 있어서. BehaviorSubject도 저장이 되지만 init시에 value를 넣어줘야 하기 때문에 사용하지 않은듯.
private let loggedInActionableItemSubject = ReplaySubject<LoggedInActionableItem>.create(bufferSize: 1)
waitForLogin에서 이 subject를 observable로 반환하자.
func waitForLogin() -> Observable<(LoggedInActionableItem, ())> {
return loggedInActionableItemSubject.map {
loggedInItem -> (LoggedInActionableItem, ()) in
(loggedInItem, ())
}
}
그리고 이제 진짜 사람이 로그인했을 때 신호를 주자. 로그인 했을 때 불리는 didLogin 메소드에 방출하는 코드를 추가하자.
func didLogin(withPlayer1Name player1Name: String, player2Name: String) {
let loggedInActionableItem = router?.routeToLoggedIn(withPlayer1Name: player1Name, player2Name: player2Name)
if let loggedInActionableItem = loggedInActionableItem {
// 방출
loggedInActionableItemSubject.onNext(loggedInActionableItem)
}
}
router에서 LoggedInActionableItem 리턴하도록 추가
protocol RootRouting: ViewableRouting {
func routeToLoggedIn(withPlayer1Name player1Name: String, player2Name: String) -> LoggedInActionableItem
}
func routeToLoggedIn(withPlayer1Name player1Name: String, player2Name: String) -> LoggedInActionableItem {
// Detach logged out.
if let loggedOut = self.loggedOut {
detachChild(loggedOut)
viewController.replaceModal(viewController: nil)
self.loggedOut = nil
}
let loggedIn = loggedInBuilder.build(withListener: interactor, player1Name: player1Name, player2Name: player2Name)
attachChild(loggedIn.router)
return loggedIn.actionableItem
}
LoggedInBuilder의 build 메소드가 actionableItem도 함께 반환하도록 수정
func build(withListener listener: LoggedInListener, player1Name: String, player2Name: String) -> (router: LoggedInRouting, actionableItem: LoggedInActionableItem) {
let component = LoggedInComponent(dependency: dependency,
player1Name: player1Name,
player2Name: player2Name)
let interactor = LoggedInInteractor(games: component.games)
interactor.listener = listener
let offGameBuilder = OffGameBuilder(dependency: component)
let router = LoggedInRouter(interactor: interactor,
viewController: component.loggedInViewController,
offGameBuilder: offGameBuilder)
return (router, interactor)
}
이제 interactor가 actionableItem을 준수하도록 수정해야 한다.
LoggedIn scope에서 launchGame 단계 통합하기
LoggedInActionableItem 프로토콜을 준수하도록 LoggedInInteractor를 업데이트하자. 그리고 launchGame을 구현하자.
final class LoggedInInteractor: Interactor, LoggedInInteractable, LoggedInActionableItem
// MARK: - LoggedInActionableItem
func launchGame(with id: String?) -> Observable<(LoggedInActionableItem, ())> {
let game: Game? = games.first { game in
return game.id.lowercased() == id?.lowercased()
}
if let game = game {
router?.routeToGame(with: game.builder)
}
return Observable.just((self, ()))
}
이 메소드에서 반환되는 Observable은 실제로 사용하지 않는다. 형식을 맞추기 위함이다.
Run the workflow
이제 테스트해보자.
앱을 빌드하고 실행한 후, 닫고 Safari를 열어서 ribs-training://launchGame?gameId=ticTacToe
를 입력하고 gogo. 그러면 앱을 열 수 있다.
로그인 화면이 나오고, 로그인 시 시작 화면이 아닌 게임 필드로 이동된다.
만약 로그인을 한 상태라면?
게임에 로그인 한 채로 앱을 밑으로 내려놓고 safari를 통해 url로 이동해보자. 그러면 로그인 하기를 기다리지 않고 게임 필드로 바로 이동함을 확인할 수 있다.