Edited at

RxFlow使ってみた


はじめに

つい最近iOSアプリを開発していた際に、RxFlowなるものを知りました。しかし、RxFlowは英語ですら文献があまりなく、日本語に至ってはほぼ皆無に等しいぐらいなので、せっかくだから今回勉強した内容を書き記しておこうと思います。

(ただ英語の場合は公式レポジトリに記載されてるリンクを読めば十分だからかも?しれません)


RxFlow is 何?

RxFlowRxSwiftCommunityによって運用されてるプロジェクトです。一言で言うと、ビューコントローラーからナビゲーションの機能を切り離すコーディネーターパターンをRxで提供するライブラリです。アプリケーションの状態からリアクティブに画面の遷移を行うことができるようになります。


RxFlowの各要素

以降では、公式のサンプルであるRxFlowDemoを基にRxFlowで必要となる各概念を見ていきたいと思います。ただし、説明のため実際のサンプルのソースコードを改変(簡易化)しています。


Step

Stepはアプリケーションにおけるナビゲーションの状態です。例えば、ログインが必要な状態、ログイン処理が終了した状態などなど。これを基にナビゲーションを行います。特に制約などなかったと思いますが、状態を示すものなので素直に公式のデモと同じようにenumで定義するのが良いと思います。

enum DemoStep: Step {

// Login
case loginIsRequired
case userIsLoggedIn

// Movie
case moviesAreRequired
case movieIsPicked (withId: Int)
case castIsPicked (withId: Int)
}


Flow

FlowではStepに対してどのようなナビゲーションを行うかを定義します。またFlowはプロトコルとして以下の定義を強制します。

protocol Flow: ... {

var root: Presentable { get }
func navigate(to step: Step) -> FlowContributors
}

rootではそのフローにおいて親となるビューコントローラーやウィンドウ(UIViewControllerUIWindowはRxFlowにて予めextensionPresentableを実装している)を指定します。ここで指定したビューコントローラーやウィンドウが次のフローに遷移する際に使われます。そして、その次のフローへの遷移(およびそのフロー内でのステップに対する処理)を定義するのがnavigate関数です。navigate関数には次のステップが引数として渡されるので、これを基にどのような遷移をするかを定義します。以下は簡単なFlowの例です。

class TestFlow: Flow {

private let rootViewController = UINavigationController()
var root: Presentable {
return rootViewController
}

func navigate(to step: Step) -> FlowContributors {
guard let step = step as? DemoStep else { return .none }
switch step {
case .moviesAreRequired: return navigateToMovieListScreen()
case .movieIsPicked(let movieId): return navigateToMovieDetailScreen(with: movieId)
case .castIsPicked(let castId): return navigateToCastDetailScreen(with: castId)
default: return .none
}
}
// ...
}


Stepper

Stepperは次のステップを発行するトリガーです。画面遷移の多くは何かしらの操作を起点とするので、MVVMの場合はビューモデルをStepperとして定義することが多くなると思います。また、ステッパーでは基本的にvar steps: PublishRelay<Step> { get }に対してacceptすることで次のステップを発行しますが、初期値やコールバックなども用意されています。

protocol Stepper {

var steps: PublishRelay<Step> { get }
var initialStep: Step { get }

func readyToEmitSteps ()
}

extension Stepper {

var initialStep: Step {
return NoneStep()
}

func readyToEmitSteps () {}
}

以下は驚くほどシンプルなステッパーの例です。

class TestViewModel: Stepper {

let steps = PublishRelay<Step>()

func pick(movieId: Int) {
steps.accept(DemoStep.movieIsPicked(withId: movieId))
}
}


OneStepper

Stepperには初期値だけを持つOneStepperという派生クラスが用意されています。引数としてStepを与えると、それをinitialStepとして返すだけのクラスです。ビューモデルやユーザーの操作に依存しないFlowなどに使えます。

class OneStepper: Stepper {

let steps = PublishRelay<Step>()
private let singleStep: Step

init(withSingleStep singleStep: Step) {
self.singleStep = singleStep
}

public var initialStep: Step {
return self.singleStep
}
}


CompositeStepper

ユーザーの操作だけでなく何かイベントに応じてナビゲーションしたいなど、複数のステッパーからステップを変更したい場合はCompositeStepperを用いることで可能です。CompositeStepperでは複数のステッパーのstepsObservable.mergeで1つにまとめてself.stepsにバインドしています。

class CompositeStepper: Stepper {

private let disposeBag = DisposeBag()
private let innerSteppers: [Stepper]
let steps = PublishRelay<Step>()

init(steppers: [Stepper]) {
self.innerSteppers = steppers
}

func readyToEmitSteps() {
let initialSteps = Observable<Step>.from(self.innerSteppers.map { $0.initialStep })
let nextSteps = Observable<Step>
.merge(self.innerSteppers.map { $0.steps.asObservable() })

initialSteps
.concat(nextSteps)
.bind(to: self.steps)
.disposed(by: self.disposeBag)
}
}


FlowCoordinator

FlowCoordinatorは開発者が定義したフローやステップの組み合わせを適切にハンドリングしてくれる・・・らしいです。というのも、このコーディネーターに関してはRxFlowが提供してくれたものをAppDelegateで使うだけで、他では一切使いませんでした。wikipediaによればコーディネーターとは「ものごとを調整する役の人」らしいですね。

class AppDelegate: UIResponder, UIApplicationDelegate {

let disposeBag = DisposeBag()
var coordinator = FlowCoordinator()
// ...

func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

self.coordinator.rx.willNavigate.subscribe(onNext: { (flow, step) in
print("will navigate to flow=\(flow) and step=\(step)")
}).disposed(by: self.disposeBag)

self.coordinator.rx.didNavigate.subscribe(onNext: { (flow, step) in
print("did navigate to flow=\(flow) and step=\(step)")
}).disposed(by: self.disposeBag)

// ...

self.coordinator.coordinate(flow: appFlow, with: appStepper)

return true
}
}


FlowContributor / FlowContributors

FlowContributorは先程出てきたFlowCoordinatorに対して、次のフローのコントリビューター(新しいステップを発行するもの)を伝えるためのデータ構造体です。PresentableStepperを組み合わせたenumぐらいの認識で良さそうです。一方、FlowContributorsは次のFlowContributorが複数あるのか、一つなのか、ない(何もしない)のか、などをFlowCoordinatorに対して伝えるためのenumFlowContributorのコンテナといったところではないでしょうか。このFlowContributorsFlownavigate関数で出てきたのにしれっと無視してた返り値の型になります。

以下は次のビューコントローラーを表示する際の一例です。

private func navigateToMovieDetailScreen (with movieId: Int) -> FlowContributors {

let viewModel = MovieDetailViewModel(withMovieId: movieId)
let viewController = MovieDetailViewController.instantiate(withViewModel: viewModel)
self.rootViewController.pushViewController(viewController, animated: true)
return .one(flowContributor: .contribute(withNextPresentable: viewController, withNextStepper: viewModel))
}

このFlowContributorおよびFlowContributorsの値によってどのようにフローを制御していくかが決まってきます。


基本的なナビゲーションのパターン

実際に使ってみないとよく分からないことが多いと思うので、ここでは実例(もちろんRxFlowDemo)を用いて見ていきたいと思います。なお、ここではnavigate関数内のswitchでどのようなFlowContributorsを返すかにフォーカスしていきます。

func navigate(to step: Step) -> FlowContributors {

guard let step = step as? DemoStep else { return .none }
switch step {
case: .someStep: return navigateSomeScreen()
default: return .none
}
}


方針

ナビゲーションのパターンの前に、前提としてStepFlowをどのように設計・構成したかという話を簡単にしたいと思います。RxFlow導入当初は「ステップがフロー毎に存在しても良いのではないか?」と思っていたのですが、実際に実装していくにつれRxFlowDemoのようにステップは1つのみにするのが最も良いように思えました。最初は特に問題なさそうだったのですが、2つの異なる画面からある同じ画面に遷移する場合に困りました。仮にFlowAStepAのペアとFlowBStepBのペアがあり、どちらも同じ画面に遷移したい場合、その画面のStepperStepAStepBのどちらのステップをacceptしたら良いか分からないからです。もちろん、プロトコルなどで制約を設けることで共通化は可能かもしれませんが、どうしても無理矢理感が拭えず、ステップは1つにすべきという結論に至りました。


ボツになったパターン

multi_step.png


採用したパターン

one_step.png

こういった理由でRxFlowDemoにおいてもステップはDemoStepのみ定義されていたのかもしれませんね。


次のフローを指定して遷移

AppDelegateから初期フローを構築していく場合、ログイン画面を表示するか、オンボード画面を表示するかなどの判定を行い、ダッシュボード画面のフローへ遷移するといったナビゲーションを行うと思います。そのような場合は、次のFlowStepperをフローコントリビューターとして.oneを返します。

private func navigationToDashboardScreen() -> FlowContributors {

let dashboardFlow = DashboardFlow()
Flows.whenReady(flow1: dashboardFlow) { [unowned self] root in
self.rootViewController.pushViewController(root, animated: false)
}
let stepper = OneStepper(withSingleStep: DemoStep.dashboardIsRequired)
return .one(flowContributor: .contribute(withNextPresentable: dashboardFlow,
withNextStepper: stepper))
}

Flows.whenReadyは次のフローへ遷移する準備ができた際にコールされるコールバックです。ここにpresentpushViewControllerといったこれまでのUIKitを用いて行う遷移の実装を行います。


次のステッパーを指定して遷移

UINavigationControllerにてpushViewControllerする場合、次のコントローラーとそのビューモデルを用意すると思います。その際、ビューモデルで画面操作をハンドリングすることが多くなると思いますが、そのような場合はフローは変更せず次のステッパーを指定して遷移することになると思います。

private func navigateToMovieDetailScreen (with movieId: Int) -> FlowContributors {

let viewModel = MovieDetailViewModel(withMovieId: movieId)
let viewController = MovieDetailViewController.instantiate(withViewModel: viewModel)
self.rootViewController.pushViewController(viewController, animated: true)
return .one(flowContributor: .contribute(withNextPresentable: viewController,
withNextStepper: viewModel))
}

フローを変更した場合とは異なり、withNextPresentableにはFlowではなくビューコントローラーを渡しています。


何も変更せず遷移

先ほどの例と同様に、UINavigationControllerpushViewControllerしたいが、特に画面を遷移せずクローズするしかない場合などは.noneでフローに影響を与えず遷移することも可能です。また、popViewControllerなどで画面をクローズしたい場合にも使えます。

private func navigateToCastDetailScreen (with castId: Int) -> FlowContributors {

let viewModel = CastDetailViewModel(withCastId: castId)
let viewController = CastDetailViewController.instantiate(withViewModel: viewModel)
self.rootViewController.pushViewController(viewController, animated: true)
return .none
}


複数のフローを指定して遷移

UITabBarControllerなどはタブ毎に独立したナビゲーションを行うため、それぞれのタブ毎にフローを用意する必要があると思います。そのようなときは.multipleを用いて複数のフローをコントロールする必要があります。RxFlowではフローとステップの組み合わせで制御するため、一方のフローでステップを変更したとしても、もう一方のフローには影響がありません。それぞれ独立したフローとして定義することができます。

private func navigateToDashboard() -> FlowContributors {

let wishListFlow = WishlistFlow()
let watchedFlow = WatchedFlow()

Flows.whenReady(flow1: wishListFlow, flow2: watchedFlow) { [unowned self] (root1, root2) in
let tabBarItem1 = UITabBarItem(title: "Wishlist", image: nil, selectedImage: nil)
let tabBarItem2 = UITabBarItem(title: "Watched", image: nil, selectedImage: nil)
root1.tabBarItem = tabBarItem1
root2.tabBarItem = tabBarItem2
self.rootViewController.setViewControllers([root1, root2], animated: false)
}

return .multiple(flowContributors: [.contribute(withNextPresentable: wishListFlow,
withNextStepper: OneStepper(withSingleStep: DemoStep.moviesAreRequired)),
.contribute(withNextPresentable: watchedFlow,
withNextStepper: OneStepper(withSingleStep: DemoStep.moviesAreRequired))])
}


フローの終了

ビューコントローラーからpresentしたUINavigationをクローズする場合、現在のフローを終了することになると思います。そのようなときは.endを使用します。

class ParentFlow: Flow {

// ...

func navigate(to step: Step) -> FlowContributors {
guard let step = step as? DemoStep else { return .none }
switch step {
case: .settingsAreComplete:
self.rootViewController.presentedViewController?.dismiss(animated: true)
return .none
default: return .none
}
}
}

class ChildFlow: Flow {

// ...

func navigate(to step: Step) -> FlowContributors {
guard let step = step as? DemoStep else { return .none }
switch step {
case: .settingsAreComplete:
return .end(forwardToParentFlowWithStep: DemoStep.settingsAreComplete)
default: return .none
}
}
}


導入してみた感想


やはり最初は難しい

ナビゲーションの機能をビューコントローラーから分離するという点に関してはVIPERを知っていたのでなんとなく想像できたのですが、コーディネーターパターンは名前しか知りませんでした。また、各要素の概念は名前からなんとなく理解はできるものの、実際に使ってみるまでフロー間の関係などがイマイチ分かりませんでした。


慣れると便利

慣れてくるとフローを定義してステッパーでステップを変更するという基本の繰り返しであることが分かりました。ステップに対してフローでリアクティブに遷移をハンドリングするという感じですかね。また、フローの切り分け方も最初は悩みましたが、rootを基準に考えてフローを作っていくとそこまで難しい話ではないように思えてきました。


その他

サンプルを参考にしながら実装するだけでDIも簡単にできるため、ビューコントローラーの再利用性が向上しそうなのも良かったです。また、様々なパターンを想定して設計せずとも、少なくともRxFlowDemoで実装されているようなパターンに関しては、レポジトリを参考にするだけで簡単に実装できるのもありがたいです。まだまだ使い始めたばかりですが、分からないことがあった場合はRxFlowDemoのソースコードを読めば大抵なんとかなりそうな気がします。