directのiOSアプリを開発している吉岡(@rikusouda)です。最近は try! Swiftというカンファレンスの開催が間近になってきたのでそわそわしています。
directのiOSアプリにRxSwiftを部分的に導入しましたが、どのような効果があってどのようなはまりどころがありどのように解決したのかを紹介します。
始めに
RxSwiftはiOSアプリ開発で使われることが多いライブラリではないか思います。iOS界隈の勉強会、他社のブログでも事例を見かけることが多いと感じます。
僕はRxSwiftについて下記のような誤解をしていました
- MVVMを実現するためのもの(MVVMを使わない場合は効果が薄い)
- 学習コストがかなり高い(「ストリーム」とかの概念の理解が必要)
directではObjective-Cのコードが70%くらい残っており、既存コードをRxSwiftできれいにMVVMにするのはある程度のハードルを感じています。
しかし、きっちりとMVVMを導入しなかったとしても部分的な導入で効果が感じられましたし、RxSwiftのすべてを知らなくても知っている範囲で有効活用ができることもわかりました。またいくつかハマりどころがありましたので、その解決方法も紹介します。
この記事は Swift 4.0 とRxSwift 4.1.1 を元に記述しています。
得られた効果
2018/2に提供したQRコードによるログイン機能の実装でRxSwiftを利用しました。そのままのコードは載せられないので少し別の問題に置き換えて便利だった場面を紹介します。
QRコードによるログインは下記のような画面で、QRコードを読み取るたびにエラーを表示したりログインを促したりします。非同期処理とUIAlertViewを組み合わせるなど、RxSwiftなしで記述すると複雑性のある機能です。
複数の非同期処理を処理の流れ通りに記述できる
下記のような一連の処理をすることを考えます。
- UIButtonをタップする
- 現在の所在地を取得する(非同期)
- UIAlertViewで現在地を表示して、投稿してもよいか確認する
- 投稿する
RxSwiftを使わない場合、実装があちこちに散らばってしまい、処理の全体像をつかむことが難しい場合があります。RxSwiftを使うことで下記のようにまとめられました。
import UIKit import RxSwift import RxCocoa class LocationManager { static func getLocation() -> Single<String> { return Single<String>.create { (observer) -> Disposable in // 受信した体で少し遅れてデータが取得される DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + 1.0) { observer(.success("東京")) } return Disposables.create {} } } } class ViewController: UIViewController { @IBOutlet weak var postButton: UIButton! @IBOutlet weak var messageLabel: UILabel! let disposeBag = DisposeBag() let message = BehaviorRelay<String>(value: "") override func viewDidLoad() { super.viewDidLoad() self.message .asDriver() .drive(messageLabel.rx.text) .disposed(by: self.disposeBag) self.postButton.rx.tap.asDriver() .flatMapLatest { _ in // ★非同期のAPI呼び出し LocationManager.getLocation().asDriver(onErrorDriveWith: Driver.empty()) } .flatMapLatest { (location) in // ★UIAlertControllerの表示 // https://github.com/ReactiveX/RxSwift/blob/master/RxExample/RxExample/Services/Wireframe.swift DefaultWireframe.shared.promptFor("現在地 \(location)を投稿しますか", cancelAction: "キャンセル", actions: ["投稿"]) .asDriver(onErrorDriveWith: Driver.empty()) .map { ($0, location) } } .flatMap { (actionAndLocation) -> Driver<String> in // ★UIAlertController操作後の処理 switch actionAndLocation.0 { case "投稿": return Driver.just(actionAndLocation.1) default: return Driver.empty() } } .map { "私は今 \($0) にいます" } .drive(onNext: { [unowned self] (message) in // ★投稿 self.postMessage(message) }, onCompleted: nil, onDisposed: nil) .disposed(by: self.disposeBag) } private func postMessage(_ message: String) { // 投稿した結果を受信した体で更新 self.message.accept(message) } }
上記のように、複数の非同期処理がある場合はflatMap
でつなぐことで実際の振る舞いの順番通り記述できました。処理の流れを把握するためにあちこちのコードを追いかけながら見る必要がないという点でシンプルになっています。従来のプログラミングスタイルになれていると非同期処理をネストさせてしまいたくなりますが、連続した非同期処理はflatMapでつないでいくという意識でいることが、処理全体をシンプルにするこつではないかと考えます。ちなみにflatMapLatest
を使っているのは、非同期処理が終わらないうちに次のイベントが来た場合に前のイベントの処理をキャンセルするようにするためです。
途中で使っているDefaultWireframe.shared.promptFor
はRxSwiftのサンプルにある実装を使っています。
途中でasDriver()
を使ってDriverに変換しているのは、Observableの場合に一度でもエラーが流れると購読が終了されてしまうためです。Driverはエラーを表現しないためUIで使用する場合には扱いやすいです。
RxSwiftにはRxCocoaというライブラリも付随していて、これを使うことでUIKitのクラス群との親和性が高く取っつきやすい印象です。
時間経過に関する処理をシンプルに書ける
先ほどの例で、投稿されたメッセージを一定時間後に画面から消したいとします。RxSwiftだと下記のようにシンプルに記述ができます
messageLabel.rx .observe(String.self, #keyPath(UILabel.text)) .asDriver(onErrorDriveWith: Driver.empty()) .debounce(3.0) .flatMap { (text) -> Driver<String> in if text == "" { // 無限に流れ続けないようにここでとめる return Driver.empty() } else { return Driver.just("") } } .drive(messageLabel.rx.text) .disposed(by: self.disposeBag)
debounce
で時間指定をすることで「最後にイベントが発生してから一定時間経過後」に一度だけ処理を行うことが簡単に行えます。ほかにも、便利な機能がたくさんありますが、習熟度に合わせて活用していくのがよいかもしれません。
UIViewControllerの呼び出し側で処理を差し込める
特定の場面からUIViewControllerを表示したときにだけ特別な処理をしたいことがある場合に、下記のように処理を差し込むことができます。
let viewController = HogeViewController() viewController.rx.sentMessage(#selector(UIViewController.viewDidAppear(_:))) .subscribe(onNext: { [unowned viewController] _ in // 差し込みたい処理 }, onError: nil, onCompleted: nil, onDisposed: nil) .disposed(by: viewController.rx.disposeBag) viewController.rx.sentMessage(#selector(UIViewController.viewWillDisappear(_:))) .subscribe(onNext: { _ in // 差し込みたい処理 }, onError: nil, onCompleted: nil, onDisposed: nil) .disposed(by: viewController.rx.disposeBag) self.present(viewController, animated: true)
ちなみに、viewController.rx.disposeBag
の部分はNSObject_Rxというライブラリを使っています。自分でプロパティを追加するのが難しい場面でもdisposeBagを容易しやすくなります。
ハマりどころ
CI環境でのビルド、テスト時間が10分くらい増加した
directではRxSwiftをCarthageで導入しました。そしてライブラリのバイナリはリポジトリにコミットしない運用を今はしています。これにより、今までBitriseでテストをしたときに10分程度で終わっていたのが20分くらいになってしまいました。さすがにこの増加は無視ができません。
directではBitriseのキャッシュ機能を使って問題を回避することにしました。Bitriseでは前回の生成したファイルの一部を一時的に保存しておき、次回実行時にそれを取得する操作ができます。これによりCartfile.resolved
に変更がない場合は前回使ったモジュールを使い回すことができ、ビルド時間の増加を防ぐことができました。
公式のガイドでは./Carthage -> ./Carthage/Cachefile
を使うように書かれていますが、BitriseのCarthageステップを使わない場合は./Carthage/Cachefile
が作られません。directではfastlaneでcarthage bootstrap
させているため./Carthage -> ./Cartfile.resolved
にしました。
fastlane利用時はFastfileでcarthageを使っているところでcache_builds
を有効にすることも忘れてはなりません。
carthage( platform: "iOS", cache_builds: true, )
Objective-Cで実装されたクラスをSwiftでextensionしたときにDisposeBagを作るのが大変
もともとObjective-Cで開発されていたプロジェクトにSwiftを部分導入して開発している場合には、既存のObjective-Cクラスの一部をSwiftのextensionで実装する場面があると思います。この場合、Swiftのextension内でプロパティを追加することができないため、少し面倒な実装をしなければdisposeBagを生やすことができません。NSObject-Rxというライブラリをすることで、その少し面倒な実装を代わりにやってもらうことができます。
extension HogeViewController { func configureBinding() { self.repository.getData() .asDriver(onErrorDriveWith: Driver.empty()) .drive(self.hogeLabel.rx.text) .disposed(by: self.rx.disposeBag) } }
self.rx.disposeBag
のようにdisposeBagを使うことができます。
一度errorが発生すると止まる
Observableなどはerrorやcompleteが発生するとそれ以上データが流れてこなくなります。継続的に発生するイベントをUIにバインドするような用途では都合が悪いです。モデル層のAPIの戻り値がSingleの場合、それをそのままflatMapで流し込むとエラーが混入してしまう可能性があります。
directではそれを防ぐために「UIにバインドするストリームはDriverで扱う」という方針にしました。flatMapで本流のストリームに流す前に必ずasDriver
することでエラーを除去または値に変換することができます。
デバッグしにくい
従来のステップ実行のようなデバッグがしにくいのでObservableやDiverを流れるデータを追いかけるのが難しいように感じられます。そのような場合は.debug()
というのを挟むことでストリームを流れてくるデータをデバッグ出力することができます。
messageLabel.rx .observe(String.self, #keyPath(UILabel.text)) .asDriver(onErrorDriveWith: Driver.empty()) .debounce(3.0) .debug("消す前", trimOutput: false) // 追加 .flatMap { (text) -> Driver<String> in if text == "" { // 無限に流れ続けないようにここでとめる return Driver.empty() } else { return Driver.just("") } } .debug("消した後", trimOutput: false) // 追加 .drive(messageLabel.rx.text) .disposed(by: self.disposeBag)
デバッグアウトの出力
2018-02-22 11:08:20.688: 消す前 -> Event next(Optional("私は今 東京 にいます")) 2018-02-22 11:08:20.688: 消した後 -> Event next() 2018-02-22 11:08:23.689: 消す前 -> Event next(Optional(""))
「私は今 東京 にいます」という文字列が空文字列に変更されている様子がわかります。
まとめ
僕自身、実プロダクトでRxSwiftを使ったのは初めてで、それまでは勉強会や各種記事で得た知識や、個人で簡単なお試しアプリを作ってRxSwiftのメリットを理解している程度でしたが、実際に取り入れてみると思っていた以上にコードをすっきりさせることができました。まだまだフル活用できているとは言いがたいのですが部分的な活用であってもそのメリットを体感しています。冒頭でMVVMにしていないような書き方をしていましたが、最初はViewControllerに実装していましたが最終的にはViewModelに分離しました。いきなりMVVMにすることは難しいかもしれませんが、部分的なバインドから始めて段階的にMVVMに近づけていくアプローチもとれると思います。
一方、いくつかのはまりどころで苦労しました。はまりどころはありますが、RxSwiftは利用者が多いライブラリなので多くの人がその解決策を見つけており、コミュニティから得られる情報を使うことで多くの課題は解決できる場面もあるのではないでしょうか。今回ははまりどころの解決に下記の参考情報が大いに役立ちました。
参考情報
- iOSDC Japan 2016 08/20 Track A / RxSwiftは開発をどう変えたか? / ishkawa - YouTube (動画)
- Rx エラーしうるオブザーバブルをflatMapする話 - Qiita
- メルカリアッテのRxSwift実装ガイド // Speaker Deck
- Everyday Reactive (動画あり: 英語)
Hiring
弊社では様々な技術を取り入れて開発効率を上げていく取り組みを続けています。改善の余地がある状態なのでまだまだ新しい技術に挑戦するチャンスがあります。ベストプラクティスを追いかけ続けられるエンジニアを弊社では募集しています。求人要項もありますが、まずはお話を聞きに来ていただくだけでもOKですのでTwitterなどで@rikusoudaに気軽にお声かけください。