이번 포스팅에서는 RxCommunity 중에서 가장 인기가 많은 RxFlow에 대해서 알아보려고 한다. RxFlow는 Coordinator Pattern에 기반을 두고 있기 때문에 먼저 해당 디자인 패턴을 이해하고 시작하는게 좋다. Coordinator Pattern의 필요와 사용은 Khanlou님의 글과 Paul Hudson님의 글이 있으니 꼭 정독해보길 추천한다.
Coordinator Pattern을 사용할 때 몇 가지 단점이 있다.
매번 새로운 프로젝트를 시작할 때마다 코디네이터 패턴을 새로 짜야 한다.
앱의 화면 전환에 어울리는 코디네이터를 고민하고 짜고 구현하는데 생각보다 시간이 많이 든다.
boilerplate code가 많아진다.
물론 하드 코딩을 피할 수 있지만, 코디네이터를 작성하고 사용하면서 stack이 쌓이게 되면 그만큼 boilerplate code가 많아진다.
RxFlow는 이런 Coodinator Pattern의 단점을 보완하고 Reactive Programming의 장점은 살려, 한번 익혀놓으면 모든 프로젝트에 빠르게 적용가능하고 기술부채도 줄여줄 수 있다. 하지만 선택한 아키텍처에 따라 사용할 이유가 없어지거나 추상화 단계가 높아서 배우는데 시간이 좀 걸린다는 단점이 있다.
(아니다 싶으시면 댓글로 알려주세요...)
Step
Step은 navigation을 lead 할 수 있는 '키'다. RxFlow는 이 키(Step)에 따라 다르게 작동(navigate)되고 이를 적절하게 표현하기 위해 enum으로 정의하고 있다.
Step을 적을 때 주의!
detail1과 detail2는 같은 목적을 가진 상태다. 차라리 associatedValue를 사용해서 detailIsNeeded(with: ID)로 함께 정의해주는 것이 좋다. 같은 목적을 가진 Step을 비슷하게 나누지 말자.
Stepper
Stepper는 Flow에게 step을 방출하는 모든 것(anything)이다. step을 방출한다? stepper! 새로운 상태로의 변화를 알려줘야 하는 ViewController, ViewModel, Custom 구조체, 클래스 등 누구나 이 프로토콜을 채용하면 Stepper가 될 수 있다.
Flow
RxFlow의 핵심. Coodinator의 역할을 한다. Stepper로부터 step을 받아서 새로운 Flow를 생성할 수도, 화면을 전환할 수도 있다.
FlowContributor & FlowContributors
소위 Flow의 결과물이자 새로운 Flow를 시작하는 원인제공자(?)다. 이 두 형제는 enum으로 정의되어 있다. Flow의 결과물을 가지고 새로운 Flow로 가서 새로운 Step으로 새로운 navigate를 하거나, 부모나 앞선 Flow로 가서 동일한 동작을 반복하기도 한다. 이름 그대로 Flow의 생성과 활동에 기여한다.
FlowCoordinator
FlowCoordinator는 RxFlow에서 기본으로 제공하는 요소다. FlowCoordinator의 역할은 앱의 모든 네비게이션을 관리하는 것이다. 일반적으로 SceneDelegate나 AppDelegate에 인스턴스를 생성해서 가장 기반이 되는 Stepper와 Flow를 파라미터로 받는다. 그리고 FlowCoordinator를 통해 하위 Flow에 대한 트리를 형성해 앱 전체를 관장한다.
// SceneDelegate
var window: UIWindow?
var coordinator = FlowCoordinator()
RxFlow의 첫 시작은 FlowCoordinator라는 총사령관을 만드는 것부터 시작한다. 그리고 이 FlowCoordinator는 태초의 공간을 만들고 최초의 상태를 받아서 첫 navigation 실행, 즉 첫 화면을 띄운다. FlowCoordinator는 RxFlow에서 이미 제공하고 있기 때문에 다음과 같이 인스턴스를 생성해주면 된다.
public func coordinate (flow: Flow, with stepper: Stepper = DefaultStepper(), allowStepWhenDismissed: Bool = false)
FlowCoordinator의 핵심은 coordinate라는 메소드다. 이 메소드의 파라미터로 navigation의 root가 되는 Flow와 첫 Step을 방출하는 Stepper를 받아, 첫 navigation을 실행하기 때문입니다. 아무튼 이 첫 실행을 위해서 Step, Flow, Stepper를 차례대로 만들어보자.
enum AppSteps: Step {
case homeIsRequired
case detailIsRequired(Article)
}
Step에 관해서 RxFlow Github에 다음과 같은 조언이 있다.
The idea is to keep the Steps navigation independent as much as possible. For instance, calling a Step showMovieDetail(withId: Int) might be a bad idea since it tightly couples the fact of selecting a movie with the consequence of showing the movie detail screen. It is not up to the emitter of the Step to decide where to navigate, this decision belongs to the Flow.
대략 Step은 독립적으로 유지되어야 한다는 의미다. 이 독립성을 유지하기 위해서 필요를 구체적으로 명시해야 한다. 'showMovieDetail(withId: Int)'와 같은 step은 어떤 특정 영화를 선택하는 행위와 Detail로 가는 행위, 둘 다를 표현하게 된다. 이 경우 사용할 때 모호한 결과나 잘못된 화면 전환을 일으킬 수 있다. 사실 구체적으로 어디로 갈지를 결정하는건 Stepper의 일이 아니라 Flow의 일이다.
class AppStepper: Stepper {
var steps: PublishRelay<Step> = .init()
var initialStep: Step {
return AppSteps.homeIsRequired
}
func readyToEmitSteps() {
}
}
Stepper는 이렇게 Step를 관리할 객체에게 Stepper 프로토콜을 준수하도록 하면 된다. Stepper 프로토콜에서는 steps라는 PublishRelay가 가장 중요하다. 이곳에서 step을 받기도, Flow로 넘기기도 한다. 내부에서 step을 방출하는 메소드를 구현할 수도 있는데, 이건 readyToEmitSteps를 통해서 할 수 있다. initialSteps는 listened되었을 때 처음으로 방출하는 step을 의미한다.
위의 AppStepper를 사용했을 때는 바로 homeIsRequired라는 step이 처음 방출된다. 이후에는 이 Stepper에서 방출하는 step이 없기 때문에 최초의 Flow를 만들 때만 사용될 예정이고, 다른 Flow에서는 다른 Stepper를 사용할 예정이다.
class AppFlow: Flow {
let window: UIWindow!
init(window: UIWindow) {
self.window = window
}
var root: Presentable {
return self.window
}
func navigate(to step: Step) -> FlowContributors {
guard let step = step as? AppSteps else { return FlowContributors.none }
switch step {
case .homeIsRequired :
return navigateToHomeFlow()
case .detailIsRequired :
return navigateToDetailFlow()
}
}
}
이 Flow에서 navigate가 작동해서 일련의 결과(FlowContributors)를 내뱉는다. 먼저, AppFlow 클래스를 만들어 Flow 프로토콜을 채택한다. Flow Protocol을 준수하기 위해서는 navigation의 base가 될 root, adapt라는 기존 step을 새로운 step으로 교체해주는 메소드, 그리고 핵심이 될 navigate라는 실행 메소드를 구현해야 한다.
adapt는 구현할 필요가 없어서 제외(extension에 기본 구현이 되어있음)하고, root를 구현하자. 위와 같이 앱에서 처음으로 실행되기 때문에 window를 root로 설정하고 navigate에는 들어오는 step에 따라 다른 함수가 호출되도록 한다.
// 위의 AppFlow 안에 있는 메서드
private func navigateToHomeFlow() -> FlowContributors {
let viewModel = ArticleViewModel()
let mainFlow = MainFlow(viewModel: viewModel)
Flows.use(mainFlow, when: .created) { root in
self.window.rootViewController = root
}
return .one(flowContributor: .contribute(withNextPresentable: mainFlow, withNextStepper: viewModel))
}
private func navigateToDetailFlow() -> FlowContributors {
return .none
}
일단 step에 따른 실행결과는 navigateToHomeFlow 메소드 하나만 구현했다. 이 메소드는 다음 Flow(mainFlow)를 구현하고 있다. 새로운 Flow로 넘어가려면, 객체를 생성하는 것 말고도 Flows.use()라는 타입 메소드로 root를 교체해주는 것이 필요하다.
이렇게 모든 준비가 끝나면, FlowContributors를 리턴할 준비가 되었다. FlowContributors는 enum으로 5개의 case를 가지고 있다.
none은 Flow가 추가적인 navigation을 하지 않는 것,
one은 Flow가 하나의 Step에 하나의 FlowContributor( s가 붙어있는 것과 다릅니다.) 트리거하는 것,
multiple은 Flow가 하나의 스텝에 여러개의 FlowContributor를 트리거 하는 것,
end는 Flow가 자신을 해지할 FlowContributor를 트리거 하는 것,
triggerParetnFlow는 (deprecated)
이렇게 5개의 선택지 중에 적절한 case를 고르고 FlowContributor를 트리거 하게 된다.
FlowContributor 트리거?
먼저 FlowContributor는 말 그대로 Flow의 활동에 기여한다는 뜻이다. 이 말은 FlowContributor가 contribute 하게 되면 Flow가 활동(navigate가 작동)하게 된다는 의미다. 다시 말해서, stepper에서 step이 방출되고, Flow에서 이를 받아서 navigate 함수를 호출한다는 뜻이다.
그렇기 때문에 FlowContributor에는 다음 navigation에 활동할 Flow와 Stepper를 파라미터로 넣어줘야 한다. 우리는 이미 AppFlow가 아닌 MainFlow에서, AppStepper가 아닌 ArticleViewModel에서 하기로 했었고 인스턴스를 생성했었기 때문에 위와 같이 .contribute의 파라미터에 mainflow와 viewModel을 넣어준 것!
FlowContributor도 enum이다.
이렇게 구현하면, return 되는 FlowContributors에 의해 바로 다음 navigation이 action됩니다. 하지만 아직 어떤 화면도 띄우지 않은 상태... 그저 App을 시작했을 뿐이다. 이제 FlowContributors에 의해서 넘어온 새로운 Flow와 Stepper를 살펴보고, Scene을 띄우는 걸 해보자.
class MainFlow: Flow {
let navigationController = UINavigationController()
let viewModel: ArticleViewModel
init(viewModel: ArticleViewModel) {
self.viewModel = viewModel
}
var root: Presentable {
return self.navigationController
}
func navigate(to step: Step) -> FlowContributors {
guard let step = step as? AppSteps else { return .none }
switch step {
case .homeIsRequired:
return homeVCPresent()
case .detailIsRequired(let article):
return detailVCPresent(article: article)
}
}
}
우리가 새롭게 도착한 mainFlow에서는 root를 UINavigationController를 가지고 있다. 그렇다는건 앞으로 이 Flow는 navigationBar와 push, pop로 화면 전환을 하게 된다는 의미. 나머지는 똑같다! DetailViewController에 article을 전달하는 방식으로 구현했기 때문에 assciatedValue로 Article을 주었다.
private func homeVCPresent() -> FlowContributors {
var vc = MainViewController.instantiate()
vc.bind(viewModel: viewModel)
self.navigationController.pushViewController(vc, animated: true)
return .none
}
private func detailVCPresent(article: Article) -> FlowContributors {
var vc = DetailViewController.instantiate()
let detailViewModel = DetailViewModel(title: article.title, content: article.content, thumbnailImageURL: article.thumbnailImageURL)
vc.bind(viewModel: detailViewModel)
self.navigationController.pushViewController(vc, animated: true)
return .none
}
navigate에서 실행할 함수는 위와 같다. 지난 AppFlow와 다른 점은 이제서야 Scene을 띄워주기 위한 작업을 시작했다는 것. 나머지는 전과 동일하게 구현하면 된다.
마지막은 FlowContributors를 리턴하는 것인데, 예제에서는 none으로 아무런 트리거도 일어나지 않게 했다. 그렇다면 위의 navigate 메소드에서는 어떤 case가 작동할까? 이건 우리가 전 FlowContributor에서 파라미터로 넣은 Stepper에 의해서 작동합니다. 이걸 알아봅시다.
class ArticleViewModel: Stepper {
var steps: PublishRelay<Step> = .init()
var initialStep: Step {
return AppSteps.homeIsRequired
}
lazy var detailAction: Action<Article, Void> = {
return Action { [weak self] article in
self?.steps.accept(AppSteps.detailIsRequired(article))
return Observable.empty()
}
}()
//...
}
ViewModel의 다른 구현부는 제쳐두고 Stepper를 준수한 구현부만 살펴보자.
기존에 구현해두었던 ArticleViewModel에 Stepper를 채택했다.
AppStepper에서 했던 것처럼 steps를 구현하고, initialStep으로 homeIsRequired를 방출한다. 이 step이 우리가 위에서 만든 MainFlow의 navigate로 가고 이후에 발생하는 step은 steps에 바인딩된다.
steps는 Action 라이브러리로 ViewModel에서 cell이 select되는 로직을 미리 만들고 화면이 전환되도록 detailIsRequired step을 방출하는 코드를 구현했다.
이렇게 구현을 해주시고 실행시켜주시면 첫 뷰인 ArticleViewController가 탁하고 뜬다.
FlowCoordinator 내부에서
AppStepper -> initialStep: homeIsRequired 방출 -> AppFlow root(window)를 구성하고, 이 step을 받아 navigate 실행 -> navigate의 navigateHomeFlow 메소드 실행 및 FlowContributors 반환
반환된 FlowContributor 내부에서
ArticleViewModel(Stepper) -> initialStep: homeIsRequired 방출 -> MainFlow root(UINavigationController)를 구성하고, 이 step을 받아 navigate 실행 -> navigate의 homeVCPresent 메소드 실행 및 none 반환 -> ViewController 인스턴스 생성되고 화면에 뜬다!
사실 쓰면 좋다고 했지만 익숙해지고 이 흐름을 자연스럽게 사용하기까지는 시간이 좀 걸리는 듯하다. 설명이 미숙한 점이 많다. 오버엔지니어링의 위험이 있으니 작은 프로젝트보다 화면 전환이 많은 큰 프로젝트에서 고려해보자