iOS
Rx
Swift
RxSwift

RxSwiftを使わない場合と使った場合のコードの比較

iOS用のアプリ開発で、Rxを導入すべきかどうかの判断材料として、同じようなアプリをRxSwiftを使わずに書いた場合と、使って書いた場合を比較してみました。

アプリケーションの概略

簡易なRSSリーダーです。
画面上部のピッカーでフィードを選択し、その右の「Load」ボタンをタップすると、画面下部のテーブルビューに記事一覧が表示されます。
記事タイトルをタップすると、Safariが起動し、記事を閲覧することができます。



Simulator Screen Shot - iPhone SE - 2017-12-21 at 09.19.20.png

コード比較

コード全体はGitHubのリポジトリ にアップしてあります。
(RSSフィードの扱いを容易にするため、FeedKitというライブラリを使用しています。リポジトリにPodsは含まれていませんので、実際にアプリを動かすには、 $ git clone をした後、$ pod install してください)

以下、比較のために、メインになるViewController.swiftをそれぞれ掲載しました。

Rxを使わなかった場合のコード

ViewController.swift
//
//  ViewController.swift
//  RssReaderWithoutRx
//
//  Created by Kazuyoshi Kakihara on 2017/12/04.
//  Copyright © 2017年 Kazuyoshi Kakihara. All rights reserved.
//

import UIKit
import FeedKit

class ViewController: UIViewController, UIPickerViewDelegate, UIPickerViewDataSource, UITableViewDataSource, UITableViewDelegate {

    /// 読み込み対象のフィードの配列
    private let rssFeeds = [
        ("TechChrunch Japan", "http://jp.techcrunch.com/feed/"),
        ("Engadget Japaneese", "http://japanese.engadget.com/rss.xml"),
        ("Impress Watch", "http://www.watch.impress.co.jp/headline/rss/headline.rdf"),
        ("ASCII.jp", "http://ascii.jp/cate/1/rss.xml"),
        ("GIZMODO", "https://www.gizmodo.jp/index.xml"),
        ("GIGAZINE", "http://gigazine.net/news/rss_2.0/"),
        ("マイナビニュース", "http://feeds.news.mynavi.jp/rss/mynavi/index"),
        ("ITmedia", "http://rss.itmedia.co.jp/rss/2.0/itmedia_all.xml")
    ]

    /// フィードアイテムの内容を保持するstruct
    private struct RssFeedItem {
        let title: String?
        let date: Date?
        let link: String?
    }

    /// フィード読み込みの結果を保持する配列
    private var rssFeedItems: [RssFeedItem] = []

    /// storyboard上のpickerView
    @IBOutlet weak var rssPickerView: UIPickerView!

    /// storyboard上のtableView
    @IBOutlet weak var entriesTableView: UITableView!

    /// Loadボタンがタップされたときの動作
    ///
    /// - Parameter sender: Action発生元
    @IBAction func onLoadButtonTapped(_ sender: Any) {
        // feedを読み、テーブルビューに反映させる
        loadRssFeed()
    }

    /// バックグラウンドでFeedを読み、Parseし、テーブルビューに反映する
    func loadRssFeed() {
        let urlString = rssFeeds[rssPickerView.selectedRow(inComponent: 0)].1
        let feedURL = URL(string: urlString)!
        let parser = FeedParser(URL: feedURL)
        parser?.parseAsync(queue: DispatchQueue.global(qos: .userInitiated)) { (result) in
            switch result {
            case let .atom(feed):
                self.rssFeedItems = feed.entries?.map({
                    RssFeedItem(
                        title: $0.title,
                        date: $0.updated,
                        link: $0.links?.first?.attributes?.href)}) ?? []
            case let .rss(feed):
                self.rssFeedItems = feed.items?.map({
                    RssFeedItem(
                        title: $0.title,
                        date: $0.pubDate,
                        link: $0.link)}) ?? []
            case let .json(feed):
                self.rssFeedItems = feed.items?.map({
                    RssFeedItem(
                        title: $0.title,
                        date: $0.datePublished,
                        link: $0.url)}) ?? []
            case let .failure(error):
                print(error)
                return
            }

            // UI更新はメインキューで
            DispatchQueue.main.async {
                self.entriesTableView.reloadData()
            }
        }
    }

    /// 与えられたURL文字列に対応したアプリケーションを起動する
    ///
    /// - Parameter urlString: アプリケーションを起動させるURL文字列
    func openApplicationWithUrlString(urlString: String) {
        if let url = URL(string: urlString) {
            if UIApplication.shared.canOpenURL(url) {
                UIApplication.shared.open(url, options: [:], completionHandler: nil)
            }
        }
    }

    // MARK: UIViewController のライフサイクルイベント

    override func viewDidLoad() {
        super.viewDidLoad()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }

    // MARK: UIPickerViewDataSource

    /// UIPickerViewの列の数を返す
    ///
    /// - Parameter in: UIPickerView
    /// - Returns: 列の数
    func numberOfComponents(in: UIPickerView) -> Int {
        return 1
    }

    /// UIPickerViewの行の数を返す
    ///
    /// - Parameters:
    ///   - pickerView: UIPickerView
    ///   - component: 列番号
    /// - Returns: 行の数
    func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
        return rssFeeds.count
    }

    // MARK: UIPickerViewDelegate

    /// UIPickerViewの行ごとに表示する文字列
    ///
    /// - Parameters:
    ///   - pickerView: UIPickeerView
    ///   - row: 行の位置
    ///   - component: 列の位置
    /// - Returns: 表示する文字列
    func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
        return rssFeeds[row].0
    }

    // MARK: UITableViewDataSource

    /// UITableViewのセルの内容をセット
    ///
    /// - Parameters:
    ///   - tableView: UITableView
    ///   - indexPath: 対象のcellのindexPath
    /// - Returns: 生成されたcell
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell(style: .subtitle, reuseIdentifier: "RssTableViewCell")
        let rssFeedItem = rssFeedItems[indexPath.row]

        // エントリーのタイトルをセット
        cell.textLabel?.text = rssFeedItem.title ?? ""

        // エントリーの日付をja_JP localeでセット
        let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "ja_JP")
        dateFormatter.dateFormat = "yyyy/MM/dd HH:mm:ss"
        if let date = rssFeedItem.date {
            cell.detailTextLabel?.text = dateFormatter.string(from: date)
        } else {
            cell.detailTextLabel?.text = "-"
        }

        return cell
    }

    /// UITableViewの行数
    ///
    /// - Parameters:
    ///   - tableView: UITableView
    ///   - section: セクション番号
    /// - Returns: 行数
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return rssFeedItems.count
    }

    // MARK: UITableViewDelegate

    /// UITableViewのセルが選択されたときの動作。標準ブラウザを起動する
    ///
    /// - Parameters:
    ///   - tableView: 選択されたtableView
    ///   - indexPath: 選択されたindexPath
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        if let link = rssFeedItems[indexPath.row].link {
            openApplicationWithUrlString(urlString: link)
        }
    }
}

Rxを使った場合のコード

ViewController.swift
//
//  ViewController.swift
//  RssReaderWithRx
//
//  Created by Kazuyoshi Kakihara on 2017/12/04.
//  Copyright © 2017年 Kazuyoshi Kakihara. All rights reserved.
//

import UIKit
import FeedKit
import RxSwift
import RxCocoa

class ViewController: UIViewController {

    /// rx用のDisposeBag
    private let disposeBag = DisposeBag()

    /// 読み込み対象のフィードの配列
    private let rssFeeds = [
        ("TechChrunch Japan", "http://jp.techcrunch.com/feed/"),
        ("Engadget Japaneese", "http://japanese.engadget.com/rss.xml"),
        ("Impress Watch", "http://www.watch.impress.co.jp/headline/rss/headline.rdf"),
        ("ASCII.jp", "http://ascii.jp/cate/1/rss.xml"),
        ("GIZMODO", "https://www.gizmodo.jp/index.xml"),
        ("GIGAZINE", "http://gigazine.net/news/rss_2.0/"),
        ("マイナビニュース", "http://feeds.news.mynavi.jp/rss/mynavi/index"),
        ("ITmedia", "http://rss.itmedia.co.jp/rss/2.0/itmedia_all.xml")]

    /// フィードアイテムの内容を保持するstruct
    private struct RssFeedItem {
        let title: String?
        let date: Date?
        let link: String?
    }

    /// フィード読み込みの結果を保持する配列、Variableとして宣言
    private var rssFeedItems: Variable<[RssFeedItem]> = Variable([])

    /// storyboard上のpickerView
    @IBOutlet weak var rssPickerView: UIPickerView!

    /// storyboard上のtableView
    @IBOutlet weak var entriesTableView: UITableView!

    /// storyboard上のloadButton
    @IBOutlet weak var loadButton: UIButton!

    /// バックグラウンドでFeedを読み、Parseし、rssFeedItemsにセットする(テーブルには自動反映される)
    func loadRssFeed() {
        let urlString = rssFeeds[rssPickerView.selectedRow(inComponent: 0)].1
        let feedURL = URL(string: urlString)!
        let parser = FeedParser(URL: feedURL)
        parser?.parseAsync(queue: DispatchQueue.global(qos: .userInitiated)) { (result) in
            switch result {
            case let .atom(feed):
                self.rssFeedItems.value = feed.entries?.map({
                    RssFeedItem(
                        title: $0.title,
                        date: $0.updated,
                        link: $0.links?.first?.attributes?.href)}) ?? []
            case let .rss(feed):
                self.rssFeedItems.value = feed.items?.map({
                    RssFeedItem(
                        title: $0.title,
                        date: $0.pubDate,
                        link: $0.link)}) ?? []
            case let .json(feed):
                self.rssFeedItems.value = feed.items?.map({
                    RssFeedItem(
                        title: $0.title,
                        date: $0.datePublished,
                        link: $0.url)}) ?? []
            case let .failure(error):
                print(error)
                return
            }
        }
    }

    /// 与えられたURL文字列に対応したアプリケーションを起動する
    ///
    /// - Parameter urlString: アプリケーションを起動させるURL文字列
    func openApplicationWithUrlString(urlString: String) {
        if let url = URL(string: urlString) {
            if UIApplication.shared.canOpenURL(url) {
                UIApplication.shared.open(url, options: [:], completionHandler: nil)
            }
        }
    }

    // MARK: UIViewControllerのライフサイクルイベント

    override func viewDidLoad() {
        super.viewDidLoad()

        // pickerView初期化
        Observable.just(rssFeeds)
            .bind(to: rssPickerView.rx.itemTitles) { _, item in
                return item.0
            }
            .disposed(by: disposeBag)

        // loadButton タップ時の動作
        loadButton.rx.tap
            .subscribe({ [unowned self] _ in
                self.loadRssFeed()
            })
            .disposed(by: disposeBag)

        // tableView初期化(セルの表示設定)
        rssFeedItems.asObservable()
            .bind(to: entriesTableView.rx.items) { (tableview, row, rssFeedItem) in
                let cell = UITableViewCell(style: .subtitle, reuseIdentifier: "RssTableViewCell")

                // エントリーのタイトルをセット
                cell.textLabel?.text = rssFeedItem.title ?? ""

                // エントリーの日付をja_JP localeでセット
                if let date = rssFeedItem.date {
                    let dateFormatter = DateFormatter()
                    dateFormatter.locale = Locale(identifier: "ja_JP")
                    dateFormatter.dateFormat = "yyyy/MM/dd HH:mm:ss"
                    cell.detailTextLabel?.text = dateFormatter.string(from: date)
                } else {
                    cell.detailTextLabel?.text = "-"
                }

                return cell
            }
            .disposed(by: disposeBag)

        // tableViewのセルをタップしたときの動作
        entriesTableView.rx.itemSelected
            .subscribe(onNext: { [unowned self] indexPath in
                if let link = self.rssFeedItems.value[indexPath.row].link {
                    self.openApplicationWithUrlString(urlString: link)
                }
            })
            .disposed(by: disposeBag)
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }

}

比較のポイント

行数

関数ごとにコメントを入れるスタイルを採用していることもあり、Rxを使わなかったほうが約190行、使ったほうが150行となっています。コメントを削除すると顕著な差にはなりません。
画面上にコントロール3つ程度のアプリですと、Rxを使ったからといって、行数が極端に増えたり減ったりということはないようです。

構造の差異

Rxを使わなかった場合

ViewControllerのクラス宣言のところで、

  • UIPickerViewDelegate
  • UIPickerViewDataSource
  • UITableViewDataSource
  • UITableViewDelegate

の4つのデリゲートの使用が宣言されています。

そして、108行目の

// MARK: UIPickerViewDataSource

以降でこれらのデリゲートが実装されています。
お約束といえばお約束のコードなのですが、慣れていないとコードを書いたり読んだりするのが面倒かもしれません。

また、51行目から始まる func loadRssFeed() の最後の方で、メインキューでテーブル内容を更新する処理が呼び出されています。

// UI更新はメインキューで
DispatchQueue.main.async {
    self.entriesTableView.reloadData()
}

非同期処理に慣れていないと、この画面更新処理をメインキュー内で行うのを忘れて、コンパイル時には見つけられない難しいバグを埋め込んでしまうことがよくありますね。

Rxを使った場合

ViewControllerのクラス宣言のところにデリゲートの使用宣言がありません。

一方で、ViewControllerのライフサイクルイベントの

override func viewDidLoad()

のところに、RxSwiftのお約束の処理が並んでいます。

また、こちらの func loadRssFeed() ではあくまでフィード内容を保持する rssFeedItems という Variable が更新されるだけで、UIにはまったく触れていないのがポイントです。

それぞれの利点・欠点

サンプルの規模が小さすぎてそれぞれの差異がはっきりとしない部分もあるのですが、それは一方で、アプリの規模が小さいのであればRxを使おうが使うまいが大差はないともいえます。

これ以上開発規模が大きくなり、コントロールの数が増えたり、あるいはフィード受信などのイベントに応じて複数のビューが書き換わるような仕様が追加されたりしたら、Rxを使っておいたほうが対応しやすそうです。特にイベントに応答するビューの数が増えてきたらRxの効果がはっきりしてくるでしょう。

一方で、RxSwiftはネットで検索してもまだまだ事例が少ないので、「これってどうやって書けばいいの?」という場面になったとき、オーソドックスな書き方よりも苦戦が予想されます。