はじめに
つい最近iOSアプリを開発していた際に、RxFlowなるものを知りました。しかし、RxFlowは英語ですら文献があまりなく、日本語に至ってはほぼ皆無に等しいぐらいなので、せっかくだから今回勉強した内容を書き記しておこうと思います。
(ただ英語の場合は公式レポジトリに記載されてるリンクを読めば十分だからかも?しれません)
RxFlow is 何?
RxFlowはRxSwiftCommunityによって運用されてるプロジェクトです。一言で言うと、ビューコントローラーからナビゲーションの機能を切り離すコーディネーターパターンを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
ではそのフローにおいて親となるビューコントローラーやウィンドウ(UIViewController
やUIWindow
はRxFlowにて予めextension
でPresentable
を実装している)を指定します。ここで指定したビューコントローラーやウィンドウが次のフローに遷移する際に使われます。そして、その次のフローへの遷移(およびそのフロー内でのステップに対する処理)を定義するのが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
では複数のステッパーのsteps
をObservable.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
に対して、次のフローのコントリビューター(新しいステップを発行するもの)を伝えるためのデータ構造体です。Presentable
とStepper
を組み合わせたenum
ぐらいの認識で良さそうです。一方、FlowContributors
は次のFlowContributor
が複数あるのか、一つなのか、ない(何もしない)のか、などをFlowCoordinator
に対して伝えるためのenum
、FlowContributor
のコンテナといったところではないでしょうか。このFlowContributors
がFlow
のnavigate
関数で出てきたのにしれっと無視してた返り値の型になります。
以下は次のビューコントローラーを表示する際の一例です。
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
}
}
方針
ナビゲーションのパターンの前に、前提としてStep
やFlow
をどのように設計・構成したかという話を簡単にしたいと思います。RxFlow導入当初は「ステップがフロー毎に存在しても良いのではないか?」と思っていたのですが、実際に実装していくにつれRxFlowDemoのようにステップは1つのみにするのが最も良いように思えました。最初は特に問題なさそうだったのですが、2つの異なる画面からある同じ画面に遷移する場合に困りました。仮にFlowA
とStepA
のペアとFlowB
とStepB
のペアがあり、どちらも同じ画面に遷移したい場合、その画面のStepper
がStepA
かStepB
のどちらのステップをaccept
したら良いか分からないからです。もちろん、プロトコルなどで制約を設けることで共通化は可能かもしれませんが、どうしても無理矢理感が拭えず、ステップは1つにすべきという結論に至りました。
ボツになったパターン
採用したパターン
こういった理由でRxFlowDemoにおいてもステップはDemoStep
のみ定義されていたのかもしれませんね。
次のフローを指定して遷移
AppDelegate
から初期フローを構築していく場合、ログイン画面を表示するか、オンボード画面を表示するかなどの判定を行い、ダッシュボード画面のフローへ遷移するといったナビゲーションを行うと思います。そのような場合は、次のFlow
とStepper
をフローコントリビューターとして.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
は次のフローへ遷移する準備ができた際にコールされるコールバックです。ここにpresent
やpushViewController
といったこれまでの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
ではなくビューコントローラーを渡しています。
何も変更せず遷移
先ほどの例と同様に、UINavigationController
でpushViewController
したいが、特に画面を遷移せずクローズするしかない場合などは.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のソースコードを読めば大抵なんとかなりそうな気がします。