L is B BLOG

株式会社L is Bの社員ブログです。会社の取り組みや技術ブログを発信しています。

RxSwiftを部分的に導入してみてわかった3つの効果と4つのハマりどころ

directのiOSアプリを開発している吉岡です。最近は try! Swiftというカンファレンスの開催が間近になってきたのでそわそわしています。

directのiOSアプリにRxSwiftを部分的に導入しましたが、どのような効果があってどのようなはまりどころがありどのように解決したのかを紹介します。

始めに

RxSwiftはiOSアプリ開発で使われることが多いライブラリではないか思います。iOS界隈の勉強会、他社のブログでも事例を見かけることが多いと感じます。

github.com

僕は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なしで記述すると複雑性のある機能です。

f:id:rikusouda:20180221182020p:plain:h500 f:id:rikusouda:20180221182041p:plain:h500

複数の非同期処理を処理の流れ通りに記述できる

下記のような一連の処理をすることを考えます。

  1. UIButtonをタップする
  2. 現在の所在地を取得する(非同期)
  3. UIAlertViewで現在地を表示して、投稿してもよいか確認する
  4. 投稿する

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.promptForRxSwiftのサンプルにある実装を使っています。
途中で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に変更がない場合は前回使ったモジュールを使い回すことができ、ビルド時間の増加を防ぐことができました。

f:id:rikusouda:20180222100416p:plain:w700

公式のガイドでは./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というライブラリをすることで、その少し面倒な実装を代わりにやってもらうことができます。

github.com

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は利用者が多いライブラリなので多くの人がその解決策を見つけており、コミュニティから得られる情報を使うことで多くの課題は解決できる場面もあるのではないでしょうか。今回ははまりどころの解決に下記の参考情報が大いに役立ちました。

参考情報

Hiring

弊社では様々な技術を取り入れて開発効率を上げていく取り組みを続けています。改善の余地がある状態なのでまだまだ新しい技術に挑戦するチャンスがあります。ベストプラクティスを追いかけ続けられるエンジニアを弊社では募集しています。求人要項もありますが、まずはお話を聞きに来ていただくだけでもOKですのでお気軽にご連絡ください。