RxFlow 파트 2 : 실전
2018.11.14
#RxSwift • #RxFlow • #Architecture
by Thibault Wittemberg, translated by pilgwon
지난 글에선 RxFlow라는 iOS 프레임워크에 대해서 알아보았습니다. 저는 이 프레임워크를 몇 달 동안 작업중이었고 이젠 세상에 내놓을 준비가 됐다고 생각합니다. 아직 안 읽어보셨다면 꼭 읽어보는 것을 제안드립니다.
RxFlow는 다음과 같은 목표를 가지고 있습니다.
- 네비게이션을 논리적인 부분으로 나누는 작업을 더 쉽게 만들어줍니다
- View Controller에서 네비게이션 코드를 삭제합니다
- View Controller의 재사용성을 증가시킵니다
- 반응형 프로그래밍을 촉진시킵니다
- 의존성 주입을 촉진시킵니다
용어 정리를 빠르게 다시 한 번 하겠습니다.
- Flow: Flow는 어플리케이션 내부의 네비게이션 공간을 의미합니다.
- Step: Step은 어플리케이션 내부의 네비게이션 상태를 의미합니다.
- Stepper: Step을 발생할 수 있다면 어떤 것이든 될 수 있습니다. Stepper는 Flow 내부의 모든 네비게이션 행동들을 트리거할 수 있습니다.
- Presentable: 표현될 수 있는 무언가를 추상화한 것을 의미합니다. 기본적으로 UIViewController와 Flow가 Presentable 입니다.
- NextFlowItem: 반응형 매커니즘에서 새로운 Step을 발생시킬 다음 무언가에 대해 Coordinator에게 얘기해주는 역할을 합니다.
그리고 또한 RxFlow 는 프로토콜 기반 프로그래밍을 사용해서 상속된 계층으로 인해 고통받지 않아도 됩니다.
RxFlow 저장소안에는 데모 어플리케이션이 있습니다. 이 예제에선 꽤 많은 종류의 네비게이션을 보여줍니다.
- Navigation stack
- Tab bar
- Master / detail
- Modal popup
모든 것은 상태(State)로 통합니다
RxFlow 는 네비게이션 상태 변경을 반응형스러운 방법으로 조절하는 일을 주로 합니다. 여러 컨텍스트에서 재사용되기 위해선 이러한 상태들은 사용자가 보고 있는 현재 네비게이션 Flow를 잊어야 합니다. “나는 이 화면으로 갈거야” 대신에, 상태는 “누군가 또는 무언가가 이 행동을 했어”와 같이 표시해줘야 합니다. 그리고 RxFlow 는 현재 네비게이션 Flow의 값을 보고 올바른 화면을 고를 것입니다. RxFlow 에선 이러한 네비게이션 상태를 Step 이라고 합니다.
Step 을 정의할 때 Enums만한 것이 없습니다.
- 사용하기에 쉽고
- 값은 처음 딱 한 번만 정의될 수 있으며 (그래서 상태값이 유니크합니다)
- Swift는 switch 문에서 모든 가능한 값에 대한 경우를 구현하려고 할거기때문에 안전하며
- 다른 화면으로 넘어갈 수 있는 값을 딱 하나만 가져갈 수 있고
- 값을 표현한 것이기 때문에 컨트롤되지 않는 공통의 레퍼런스가 없습니다
예를 들어, 데모 어플리케이션에선 아래처럼 가능한 모든 네비게이션들의 목록을 보여줍니다.
import RxFlow
enum DemoStep: Step {
case apiKey
case apiKeyIsComplete
case movieList
case moviePicked (withMovieId: Int)
case castPicked (withCastId: Int)
case settings
case settingsDone
case about
}
Flow와 함께 사용하기
RxFlow 와 함께라면 모든 네비게이션 코드(present, push 등)는 Flow 로 정의될 수 있습니다. Flow 는 어플리케이션의 논리적인 네비게이션 섹션을 표현합니다. 그리고 특정 Step 과 합쳐질 경우 어떠한 네비게이션 행동을 일으킵니다.
그러기 위해선 먼저 Flow 가 구현돼야 합니다.
- “navigate(to:)” 함수는 Flow와 Step에 의해 네비게이션 행동을 일으킵니다.
- “root” UIViewController는 이 Flow 의 네비게이션에 기반을 두고 있습니다.
다음은 UINavigationController와 그것의 네비게이션 스택을 조절하는 Flow 의 예제입니다. 이 Flow 에선 세 가지 네비게이션 행동이 가능합니다.
import RxFlow
import UIKit
class WatchedFlow: Flow {
var root: UIViewController {
return self.rootViewController
}
private let rootViewController = UINavigationController()
private let service: MoviesService
init(withService service: MoviesService) {
self.service = service
}
func navigate(to step: Step) -> [NextFlowItem] {
guard let step = step as? DemoStep else { return NextFlowItem.noNavigation }
switch step {
case .movieList:
return navigateToMovieListScreen()
case .moviePicked(let movieId):
return navigateToMovieDetailScreen(with: movieId)
case .castPicked(let castId):
return navigateToCastDetailScreen(with: castId)
default:
return NextFlowItem.noNavigation
}
}
private func navigateToMovieListScreen () -> [NextFlowItem] {
let viewModel = WatchedViewModel(with: self.service)
let viewController = WatchedViewController.instantiate(with: viewModel)
viewController.title = "Watched"
self.rootViewController.pushViewController(viewController, animated: true)
return [NextFlowItem(nextPresentable: viewController, nextStepper: viewModel)]
}
private func navigateToMovieDetailScreen (with movieId: Int) -> [NextFlowItem] {
let viewModel = MovieDetailViewModel(withService: self.service,
andMovieId: movieId)
let viewController = MovieDetailViewController.instantiate(with: viewModel)
viewController.title = viewModel.title
self.rootViewController.pushViewController(viewController, animated: true)
return [NextFlowItem(nextPresentable: viewController, nextStepper: viewModel)]
}
private func navigateToCastDetailScreen (with castId: Int) -> [NextFlowItem] {
let viewModel = CastDetailViewModel(withService: self.service,
andCastId: castId)
let viewController = CastDetailViewController.instantiate(with: viewModel)
viewController.title = viewModel.name
self.rootViewController.pushViewController(viewController, animated: true)
return NextFlowItem.noNavigation
}
}
네비게이션은 사이드 이펙트입니다
함수형 반응형 프로그래밍(Functional Reactive Programming)을 배웠다면, 종종 사이드 이펙트 에 대한 내용을 보셨을 것입니다. FRP의 목표는 이벤트를 전파하고 그 과정에서 모든 함수를 적용하는 것입니다. 이러한 함수는 그 이벤트들을 변형하고 결국엔 우리가 원하는 모양으로 코드를 실행합니다. (네트워킹을 하거나 파일을 저장하고 경고를 띄우는 등) 이런 것들이 사이드 이펙트 입니다.
RxFlow 는 반응형 프로그래밍에 의존하고 있기 때문에 내재돼있는 개념을 쉽게 이해할 수 있습니다.
- events: 발생(emit)된 Step 입니다
- function: “navigate(to:)” 함수입니다
- transformation: “navigate(to:)” 함수가 Step 을 NextFlowItem 으로 변형합니다
- side effect: “navigate(to:)” 에서 실행되는 네비게이션 행동들입니다 (예를 들어, “navigateToMovieListScreen()” 함수는 새로운 UIViewController를 네비게이션 스택에 쌓습니다)
네비게이팅은 NextFlowItem을 만드는 것으로 구성됩니다
기본적으로 NextFlowItem 은 Presentable 과 Stepper 를 가지고 있는 간단한 데이터 구조입니다.
Presentable 은 Coordinator 에게 다음에 보여줄 것이 무엇인지 알려주고 Stepper 는 Coordinator 에게 Step 을 발생하기 위해 해야 할 다음 일을 알려줍니다.
모든 종류의 UIViewController는 Presentable 입니다. 또한 언젠가는 Flow 에 설명된 대로 완전히 새로운 네비게이션 공간을 만들고 싶을 수도 있으니 Flow 도 Presentable 이라고 할 수 있습니다.
그러면 왜 Coordinator 가 Presentable 을 알아야 할까요?
Presentable 은 표현(present)될 수 있는 무언가를 추상화한 것입니다. Step 은 연결된 Presentable 이 보여지기 전까지는 발생될 수 없으며 Presentable 은 Coordinator 가 구독할 반응형 옵저버블(reactive observable)을 제공합니다. (그러니 Presentable 의 프레젠테이션 상태를 알게될 것입니다) 그래서 Presentable 이 완전하게 보여지지 않는 동안은 Step 이 발생될 때의 리스크는 없을 것입니다.
Stepper 는 무엇이든 될 수 있습니다. 커스텀 UIViewController, 뷰모델, Presenter 등등… Coordinator 에 한 번 등록되면 Stepper 는 내부의 “step” 속성을 통해 Step 을 발생시킬 수 있습니다. Coordinator는 이러한 Step 을 듣고 Flow의 “navigate(to:)” 함수를 호출할 것입니다.
다음은 데모 앱의 Stepper 에 대한 예제입니다.
import RxFlow
import RxSwift
class WatchedViewModel: Stepper {
let movies: [MovieViewModel]
init(with service: MoviesService) {
// we can do some data refactoring in order to display
// things exactly the way we want (this is the aim of a ViewModel)
self.movies = service.watchedMovies().map({ (movie) -> MovieViewModel in
return MovieViewModel(id: movie.id,
title: movie.title,
image: movie.image)
})
}
public func pick (movieId: Int) {
self.step.onNext(DemoStep.moviePicked(withMovieId: movieId))
}
}
이 예제의 경우엔 pick 함수는 사용자가 목록의 영화를 선택(pick)했을 때 호출됩니다. 이 함수는 “self.step” Rx 스트림에 새로운 값을 발생시킵니다.
네비게이션 과정을 정리하자면,
- navigate(to:) 함수는 Step 을 파라미터로 받아서 호출됩니다
- 이 Step 에 따라서 어떠한 네비게이션 코드가 호출됩니다 (사이드 이펙트)
- 또한 이 Step 에 따라서 NextFlowItem 이 생성됩니다. 그리고 Presentable 과 Stepper 가 Coordinator 에 등록됩니다.
- Stepper 는 새로운 Step 을 발생시킵니다.
하나의 Flow, Step 조합에 여러개의 NextFlowItem을 생성하는 것이 왜 좋은가요?
왜냐하면 어플리케이션이 한 번에 여러 네비게이션을 가지는 것을 금지되지 않았기 때문입니다. 예를 들어 탭바의 각 아이템은 각자의 네비게이션 스택을 가질 수 있습니다. UITabBarController를 보여주도록 트리거하는 Step 은 네비게이션 스택 각각 NextFlowItem 을 가지게 될 것입니다.
이 개념을 이해하기 위한 데모 앱도 존재합니다. 다음은 우리가 UITabBarController에 2개의 Flow 를 넣는 코드입니다. (각 Flow 는 각 탭바 아이템과 관련된 네비게이션 스택을 의미합니다)
private func navigationToDashboardScreen () -> [NextFlowItem] {
let tabbarController = UITabBarController()
let wishlistStepper = WishlistStepper()
let wishListFlow = WishlistWarp(withService: self.service,
andStepper: wishlistStepper)
let watchedFlow = WatchedFlow(withService: self.service)
Flows.whenReady(flow1: wishListFlow, flow2: watchedFlow, block: { [unowned self]
(root1: UINavigationController, root2: UINavigationController) in
let tabBarItem1 = UITabBarItem(title: "Wishlist",
image: UIImage(named: "wishlist"),
selectedImage: nil)
let tabBarItem2 = UITabBarItem(title: "Watched",
image: UIImage(named: "watched"),
selectedImage: nil)
root1.tabBarItem = tabBarItem1
root1.title = "Wishlist"
root2.tabBarItem = tabBarItem2
root2.title = "Watched"
tabbarController.setViewControllers([root1, root2], animated: false)
self.rootViewController.pushViewController(tabbarController, animated: true)
})
return ([NextFlowItem(nextPresentable: wishListFlow,
nextStepper: wishlistStepper),
NextFlowItem(nextPresentable: watchedFlow,
nextStepper: OneStepper(withSingleStep: DemoStep.movieList))])
}
static 함수인 “Flows.whenReady()” 는 실행할 Flow 와 이 Flow 가 보여질 준비가 다 되었을 때 호출될 클로져를 받습니다. (예. Flow의 첫 번째 화면이 선택되었을 때)
Flow, Step 조합에 NextFlowItem 을 발생시키지 않아도 되는 이유는 무엇일까요?
네비게이션 Flow에는 마지막이 있을 수 밖에 없기 때문입니다! 예를 들어 네비게이션 스택의 마지막 화면은 네비게이션이 작동되지 않고 UINavigationController 자체가 조절하는 뒤로가기 액션만 작동합니다. 이러한 경우 navigate(to:) 함수는 NextFlowItem.noNavigation 을 반환합니다. Because a navigation Flow has to have an end ! For instance the last screen of a navigation stack will not allow further navigation but only a back action handled by the UINavigationController itself. In this case, the navigate(to:) function will return NextFlowItem.noNavigation.
Flow 안의 Flow에는 어떤 일이 일어날까요!
앞에서 보았듯이, 같은 타이밍에 여러 Flow가 네비게이팅될 가능성이 있습니다. 예를 들어 네비게이션 스택안의 화면은 또 다른 네비게이션 스택을 가진 팝업을 띄울 수도 있습니다. UIKit의 관점에서 UIViewController의 계층은 매우 중요하고 Coordinator 에선 어지를 수도 없습니다.
이게 바로 Flow 가 아직 보여지지 않았을 때(위 예제의 경우엔 팝업 뒤에 첫번째 네비게이션 스택이 숨어있을 때), 발생되는 Step 은 Coordinator 에 의해서 무시될 수도 있습니다.
더 일반적인 관점에서 본다면 Flow 의 컨텍스트에서 발생된 Step 은 그 컨텍스트와 Flow 에서만 해석될 것입니다. (다른데서는 작동하지 않습니다)
의존성 주입이 쉬워집니다
의존성 주입(Dependency Injection, DI)은 RxFlow 의 주요 목표 중 하나입니다. 기본적으로 의존성 주입은 무언가(service, manager 등)의 구현을 이니셜라이저나 메소드에 파라미터로 전달하는 것으로 완료될 수 있습니다. (속성을 통해 완료될 수도 있습니다)
RxFlow 에서는 개발자가 UIViewController, ViewModels, Presenters 등을 인스턴스화 할 때 필요한 모든 것을 주입 할 수있는 좋은 기회입니다. 다음은 ViewModel에서 의존성 주입을 하는 예제입니다.
import RxFlow
import UIKit
class WatchedFlow: Flow {
...
private let service: MoviesService
init(withService service: MoviesService) {
self.service = service
}
...
private func navigateToMovieListScreen () -> [NextFlowItem] {
// inject Service into ViewModel
let viewModel = WatchedViewModel(with: self.service)
// injecy ViewMNodel into UIViewController
let viewController = WatchedViewController.instantiate(with: viewModel)
viewController.title = "Watched"
self.rootViewController.pushViewController(viewController, animated: true)
return [NextFlowItem(nextPresentable: viewController, nextStepper: viewModel)]
}
...
}
네비게이션 프로세스 미리 불러오기
이제 Flow 와 Step 을 섞고, 네비게이션 액션을 트리거하고 NextFlowItem 을 생성하는 법까지 익혔으니 남은 것은 딱 하나입니다. 어플리케이션이 시작할 때 네비게이션 프로세스를 미리 불러오는 것이죠.
모든 것은 AppDelegate 에서 일어나고 복잡하지도 않습니다.
- Coordinator 를 생성합니다
- 첫 번째로 보여줄 Flow 를 생성합니다
- Coordinator 에게 이 Flow 를 Step 과 합쳐달라고 요청합니다
- 첫 번째 Flow 가 준비되면 루트를 Window의 rootViewController로 설정합니다
다음은 데모 앱 예제입니다.
import UIKit
import RxFlow
import RxSwift
import RxCocoa
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
let disposeBag = DisposeBag()
var window: UIWindow?
var coordinator = Coordinator()
let movieService = MoviesService()
lazy var mainFlow = {
return MainFlow(with: self.movieService)
}()
func application(_ application: UIApplication,
didFinishWithOptions options: [UIApplicationLaunchOptionsKey: Any]?)
-> Bool {
guard let window = self.window else { return false }
Flows.whenReady(flow: mainFlow, block: { [unowned window] (root) in
window.rootViewController = root
})
coordinator.coordinate(flow: mainFlow,
withStepper: OneStepper(withSingleStep: DemoStep.apiKey))
return true
}
}
보너스
Coordinator: willNavigate 와 didNavigate 에겐 리액티브 익스텐션이 존재합니다. 예를 들면 이 둘을 AppDelegate에서 구독할 수 있습니다.
coordinator.rx.didNavigate.subscribe(onNext: { (flow, step) in
print ("did navigate to flow=\(flow) and step=\(step)")
}).disposed(by: self.disposeBag)
위의 코드는 다음과 같은 결과를 보여줄 것입니다.
did navigate flow=RxFlowDemo.MainFlow step=apiKeyIsComplete
did navigate flow=RxFlowDemo.WishlistFlow step=movieList
did navigate flow=RxFlowDemo.WatchedFlow step=movieList
did navigate flow=RxFlowDemo.WishlistFlow step=moviePicked(23452)
did navigate flow=RxFlowDemo.WishlistFlow step=castPicked(2)
did navigate flow=RxFlowDemo.WatchedFlow step=moviePicked(55423)
did navigate flow=RxFlowDemo.WatchedFlow step=castPicked(5)
did navigate flow=RxFlowDemo.WishlistFlow step=settings
did navigate flow=RxFlowDemo.SettingsFlow step=settings
did navigate flow=RxFlowDemo.SettingsFlow step=apiKey
did navigate flow=RxFlowDemo.SettingsFlow step=about
did navigate flow=RxFlowDemo.SettingsFlow step=apiKey
did navigate flow=RxFlowDemo.SettingsFlow step=settingsDone
이는 분석이나 디버깅할 때 아주 도움이 될 것입니다.
저는 여러분이 이 Reactive Flow Coordinator 패턴의 흥미로운 점을 알아보셨으면 좋겠습니다. 그리고 기여와 챌린지는 언제나 환영입니다. RxFlow on GitHub
세 번째이자 마지막 글이 될 RxFlow 에 관한 다음 글은 리액티브 매커니즘을 구현하는 데에 사용하는 팁과 요령에 대해서 얘기해보겠습니다.
채널 고정!