본문 링크 (Original Link)

RxFlow 파트 2 : 실전

2018.11.14

# • # • #

by Thibault Wittemberg, translated by pilgwon

image1

지난 글에선 RxFlow라는 iOS 프레임워크에 대해서 알아보았습니다. 저는 이 프레임워크를 몇 달 동안 작업중이었고 이젠 세상에 내놓을 준비가 됐다고 생각합니다. 아직 안 읽어보셨다면 꼭 읽어보는 것을 제안드립니다.

RxFlow는 다음과 같은 목표를 가지고 있습니다.

용어 정리를 빠르게 다시 한 번 하겠습니다.

그리고 또한 RxFlow 는 프로토콜 기반 프로그래밍을 사용해서 상속된 계층으로 인해 고통받지 않아도 됩니다.

RxFlow 저장소안에는 데모 어플리케이션이 있습니다. 이 예제에선 꽤 많은 종류의 네비게이션을 보여줍니다.

image2

모든 것은 상태(State)로 통합니다

RxFlow 는 네비게이션 상태 변경을 반응형스러운 방법으로 조절하는 일을 주로 합니다. 여러 컨텍스트에서 재사용되기 위해선 이러한 상태들은 사용자가 보고 있는 현재 네비게이션 Flow를 잊어야 합니다. “나는 이 화면으로 갈거야” 대신에, 상태는 “누군가 또는 무언가가 이 행동을 했어”와 같이 표시해줘야 합니다. 그리고 RxFlow 는 현재 네비게이션 Flow의 값을 보고 올바른 화면을 고를 것입니다. RxFlow 에선 이러한 네비게이션 상태를 Step 이라고 합니다.

Step 을 정의할 때 Enums만한 것이 없습니다.

예를 들어, 데모 어플리케이션에선 아래처럼 가능한 모든 네비게이션들의 목록을 보여줍니다.

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 가 구현돼야 합니다.

다음은 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 는 반응형 프로그래밍에 의존하고 있기 때문에 내재돼있는 개념을 쉽게 이해할 수 있습니다.

네비게이팅은 NextFlowItem을 만드는 것으로 구성됩니다

기본적으로 NextFlowItemPresentableStepper 를 가지고 있는 간단한 데이터 구조입니다.

PresentableCoordinator 에게 다음에 보여줄 것이 무엇인지 알려주고 StepperCoordinator 에게 Step 을 발생하기 위해 해야 할 다음 일을 알려줍니다.

모든 종류의 UIViewController는 Presentable 입니다. 또한 언젠가는 Flow 에 설명된 대로 완전히 새로운 네비게이션 공간을 만들고 싶을 수도 있으니 FlowPresentable 이라고 할 수 있습니다.

그러면 왜 CoordinatorPresentable 을 알아야 할까요?

Presentable 은 표현(present)될 수 있는 무언가를 추상화한 것입니다. Step 은 연결된 Presentable 이 보여지기 전까지는 발생될 수 없으며 PresentableCoordinator 가 구독할 반응형 옵저버블(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 스트림에 새로운 값을 발생시킵니다.

네비게이션 과정을 정리하자면,

하나의 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 가 아직 보여지지 않았을 때(위 예제의 경우엔 팝업 뒤에 첫번째 네비게이션 스택이 숨어있을 때), 발생되는 StepCoordinator 에 의해서 무시될 수도 있습니다.

더 일반적인 관점에서 본다면 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)]
    }
    ...
}

네비게이션 프로세스 미리 불러오기

이제 FlowStep 을 섞고, 네비게이션 액션을 트리거하고 NextFlowItem 을 생성하는 법까지 익혔으니 남은 것은 딱 하나입니다. 어플리케이션이 시작할 때 네비게이션 프로세스를 미리 불러오는 것이죠.

모든 것은 AppDelegate 에서 일어나고 복잡하지도 않습니다.

다음은 데모 앱 예제입니다.

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: willNavigatedidNavigate 에겐 리액티브 익스텐션이 존재합니다. 예를 들면 이 둘을 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 에 관한 다음 글은 리액티브 매커니즘을 구현하는 데에 사용하는 팁과 요령에 대해서 얘기해보겠습니다.

채널 고정!