LoginSignup
19
11

More than 5 years have passed since last update.

【Swift】VIPERとRxSwiftを使ってサンプルを作成する

Last updated at Posted at 2018-01-13

前回の続きです。

経緯

普段はDelegateパターンをよく使っているのですが、

・メソッドの宣言と呼び出す箇所が分かれているため探すのに苦労する(Cmd+クリックを多用する)
・処理が離れているため一連の処理の流れが把握するのに時間がかかる。
・クラス間のやりとりが雑多になる(書き方の問題なのでしょうが、双方を行ったり来たりする処理が増えます。)

このようなことからReactiveプログラミングを学ぼうと思い、
VIPERを使用して作成したサンプルに今回はRxSwiftを加えるとどうなるのかを試してみました。

RxSwiftのバージョンは4.1.0です。

実装

作成物はGithubに上げています。

以下にQiitaから取得したデータをリスト表示する処理の一部を記載します。

基本的な流れとしては、
・Viewでユーザーの入力を受け取り、Signal, DriverとしてPresenterに渡す
・PresenterからInteractor呼び出す
・InteractorからAPI経由でデータを取得し、PresenterへデータをSingleとして渡す
・Presenterでデータを加工/計算などを行い、Signal, DriverとしてViewに渡す

QiitaItemDTO.swift
struct QiitaItem: Decodable, Equatable {
    let id: String
    let likes_count: Int
    let reactions_count: Int
    let comments_count: Int
    let title: String
    let created_at: String
    let updated_at: String
    let url: URL
    let tags: [Tag]
    let user: User?

    static func ==(lhs: QiitaItem, rhs: QiitaItem) -> Bool {
        return lhs.id == rhs.id
        && lhs.likes_count == rhs.likes_count
        && lhs.reactions_count == rhs.reactions_count
        && lhs.comments_count == rhs.comments_count
        && lhs.title == rhs.title
        && lhs.created_at == rhs.created_at
        && lhs.updated_at == rhs.updated_at
        && lhs.tags == rhs.tags
        && lhs.url == rhs.url
        && lhs.user == rhs.user
    }


    struct Tag: Decodable, Equatable {
        let name: String

        static func ==(lhs: QiitaItem.Tag, rhs: QiitaItem.Tag) -> Bool {
            return lhs.name == rhs.name
        }
    }

    struct User: Decodable, Equatable {
        let github_login_name: String?
        let profile_image_url: String?

        static func ==(lhs: QiitaItem.User, rhs: QiitaItem.User) -> Bool {
            return lhs.github_login_name == rhs.github_login_name
            && lhs.profile_image_url == rhs.profile_image_url
        }
    }
}
QiitaItemsListInteractor.swift
final class QiitaItemsListInteractor {

    weak var output: QiitaItemsListInteractorOutputInterface?

    // 参照を保持しておく必要がある
    private var api: API.QiitaItemsApi!
}

// MARK: - Extensions -

extension QiitaItemsListInteractor: QiitaItemsListInteractorInterface {
    func fetchList(query: String, page: Int) -> Single<Result<[QiitaItem]>> {

        api = API.QiitaItemsApi.init(query: query, page: page)

        return Single.create(subscribe: { [weak self] observer in

            guard let `self` = self else {
                observer(.error(OtherError.unknownError))
                return Disposables.create()
            }

            self.api.fetchQiitaItemsData().response { result in

                DispatchQueue.main.async {
                    switch result {
                    case .response(let data):
                        observer(.success(Result.success(data)))
                    case .error(let error):
                        observer(.error(error))
                    }
                }
            }
            return Disposables.create()
        })
    }   
}
QiitaItemsListPresenter.swift
final class QiitaItemsListPresenter {

    struct State: Equatable {
        let trigger: TriggerType
        let state: NetworkState
        let nextPage: Int
        fileprivate(set) var shouldLoadNext: Bool
        let contents: [QiitaItem]
        let error: Error?

        var canLoad: Bool {
            let loadingState: [NetworkState] = [.requesting, .reachedBottom]
            return !loadingState.contains(state)
        }

        static func ==(lhs: QiitaItemsListPresenter.State, rhs: QiitaItemsListPresenter.State) -> Bool {
            return lhs.trigger == rhs.trigger
                && lhs.state == rhs.state
                && lhs.nextPage == rhs.nextPage
                && lhs.shouldLoadNext == rhs.shouldLoadNext
                && lhs.contents == rhs.contents
        }

        static var initial = State(
            trigger: .refresh,
            state: .nothing,
            nextPage: 1,
            shouldLoadNext: true,
            contents: [],
            error: nil
        )
    }

    // MARK: - Public properties -

    let didItemsChange: Driver<[QiitaItem]>
    let didStateChange: Signal<State>
    let didErrorChange: Signal<Error>

    // MARK: - Private properties -

    private let didItemsChangeRelay: BehaviorRelay<[QiitaItem]>
    private let didStateChangeRelay: PublishRelay<State>
    private let didErrorChangeRelay: PublishRelay<Error>

    private weak var _view: QiitaItemsListViewInterface?
    private var _interactor: QiitaItemsListInteractorInterface
    private var _wireframe: QiitaItemsListWireframeInterface
    private let disposeBag = DisposeBag()

    private var state: State = State.initial {
        didSet {
            if state == oldValue {
                return
            }
            state.shouldLoadNext = (state.nextPage != oldValue.nextPage)

            if state.contents != oldValue.contents {
                didItemsChangeRelay.accept(state.contents)
            }

            if state.error != nil {
                didErrorChangeRelay.accept(state.error!)
            }
            didStateChangeRelay.accept(state)
        }
    }

    // MARK: - Lifecycle -

    init(wireframe: QiitaItemsListWireframeInterface, view: QiitaItemsListViewInterface, interactor: QiitaItemsListInteractorInterface) {
        _wireframe = wireframe
        _view = view
        _interactor = interactor

        let didStateChangeRelay = PublishRelay<State>()
        self.didStateChangeRelay = didStateChangeRelay
        self.didStateChange = didStateChangeRelay.asSignal()

        let didItemsChangeRelay = BehaviorRelay<[QiitaItem]>(value: [])
        self.didItemsChangeRelay = didItemsChangeRelay
        self.didItemsChange = didItemsChangeRelay.asDriver()

        let didErrorChangeRelay = PublishRelay<Error>()
        self.didErrorChangeRelay = didErrorChangeRelay
        self.didErrorChange = didErrorChangeRelay.asSignal()
    }
}

// MARK: - Extensions -

extension QiitaItemsListPresenter: QiitaItemsListPresenterInterface {

    func bind(input: Input) {

        input.searchBarText.drive(onNext: { [weak self] text in

            guard let `self` = self else { return }

            if !self.state.canLoad { return }

            self._view?.showLoading()

            self.fetchFirstPage(trigger: .searchTextChange, text: text)
        })
        .disposed(by: disposeBag)

        input.didReachBottom
            .withLatestFrom(input.searchBarText)
            .emit(onNext: { [weak self] text in

                guard let `self` = self else { return }

                if !self.state.canLoad || !self.state.shouldLoadNext { return }

                self.state = State(trigger: .loadMore,
                              state: .requesting,
                              nextPage: self.state.nextPage,
                              shouldLoadNext: false,
                              contents: self.state.contents,
                              error: nil
                )

                self._view?.showLoading()

                self.fetchList(text: text)
            })
            .disposed(by: disposeBag)

        input.didItemSelect.drive(onNext: { [weak self] item in

            guard let `self` = self else { return }

            self._wireframe.showDetail(item: item)
        })
        .disposed(by: disposeBag)

        input.searchBarCancelTap.emit(onNext: { [weak self] _ in
            guard let `self` = self else { return }

            self._view?.showLoading()

            self.fetchFirstPage(trigger: .searchTextChange)
        })
        .disposed(by: disposeBag)

        input.refreshDidChange
            .withLatestFrom(input.searchBarText)
            .emit(onNext: { [weak self] text in

            guard let `self` = self else { return }

            self.fetchFirstPage(trigger: .refresh, text: text)
        })
        .disposed(by: disposeBag)     
    }

    private func fetchFirstPage(trigger: TriggerType, text: String? = nil) {

        state = State(trigger: trigger,
                           state: .requesting,
                           nextPage: 1,
                           shouldLoadNext: true,
                           contents: [],
                           error: nil
        )

        fetchList(text: text ?? "")
    }

    private func fetchList(text: String) {

        _view?.showLoading()

        _interactor
            .fetchList(query: text, page: state.nextPage)
            .subscribeOn(MainScheduler.instance)
            .subscribe(
                onSuccess: { [weak self] result in

                    guard let `self` = self else { return }

                    switch result {
                    case let .success(items):
                        self.fetchedList(items: items)
                    case let .error(error):
                        self.fetchedListError(error: error)
                    }
                },
                onError: { error in
                    self.fetchedListError(error: error)
            })
            .disposed(by: disposeBag)
    }

    private func fetchedList(items: [QiitaItem]) {

        var newtWorkState: NetworkState
        if state.contents.count != 0 && items.count == 0 {
            newtWorkState = .reachedBottom
        } else {
            newtWorkState = .requestFinished
        }

        state = State(trigger: state.trigger,
                      state: newtWorkState,
                      nextPage: state.nextPage + 1,
                      shouldLoadNext: state.shouldLoadNext,
                      contents: state.contents + items,
                      error: nil
        )
    }

    private func fetchedListError(error: Error) {

        state = State(trigger: state.trigger,
                      state: .nothing,
                      nextPage: 1,
                      shouldLoadNext: true,
                      contents: [],
                      error: error
        )
    }
}
QiitaItemsListViewController.swift
final class QiitaItemsListViewController: UIViewController {

    @IBOutlet weak var noResultLabel: UILabel!
    @IBOutlet weak var tableView: UITableView!

    // MARK: - Public properties -

    var presenter: QiitaItemsListPresenterInterface!
    var didReachedBottom: Signal<Void>

    // MARK: - Private properties -

    private var didReachedBottomRelay: PublishRelay<Void>


    private var searchController = UISearchController(searchResultsController: nil)

    private let refreshControl = UIRefreshControl()
    private let disposeBag = DisposeBag()


    // MARK: - Lifecycle -

    required init?(coder aDecoder: NSCoder) {

        let didReachedBottomRelay = PublishRelay<Void>()
        self.didReachedBottomRelay = didReachedBottomRelay
        self.didReachedBottom = didReachedBottomRelay.asSignal()

        super.init(coder: aDecoder)
    }

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

    private func _setupUI() {
        navigationItem.title = "List"

        definesPresentationContext = true
        searchController.obscuresBackgroundDuringPresentation = false

        presenter.didStateChange.emit(onNext: { [weak self] state in

            guard let `self` = self else { return }

            if state.state == .requestFinished {
                self.hideLoading()
                self.refreshControl.endRefreshing()
            }

            if state.state == .requestFinished
                && state.trigger == .searchTextChange {

                DispatchQueue.main.async {
                    self.scrolltoTop()
                }
            }
        }).disposed(by: disposeBag)

        presenter.bind(input: (

            searchBarText: searchController.searchBar.rx.text
                .orEmpty
                .debounce(0.2, scheduler: MainScheduler.instance)
                .distinctUntilChanged()
                .asDriver(onErrorJustReturn: ""),

            didItemSelect: tableView.rx
                .modelSelected(QiitaItem.self)
                .asDriver(),

            didReachBottom: didReachedBottom,

            searchBarCancelTap: searchController.searchBar.rx
                .cancelButtonClicked.asSignal(),

            refreshDidChange: refreshControl.rx
                .controlEvent(.valueChanged)
                .asSignal()
        ))
    }

    private func _setTableView() {

        tableView.register(QiitaItemTableViewCell.self)
        tableView.refreshControl = self.refreshControl

        tableView.tableFooterView = UIView()

        tableView.rx.setDelegate(self)
            .disposed(by: disposeBag)

        if #available(iOS 11.0, *) {
            self.navigationItem.searchController = self.searchController
            self.navigationItem.hidesSearchBarWhenScrolling = true
        } else {
            tableView.tableHeaderView = self.searchController.searchBar
        }

        if #available(iOS 10.0, *) {
            tableView.prefetchDataSource = self
        }

        tableView.rx.reachBottom.asSignal(onErrorSignalWith: .empty())
            .emit(onNext: { [weak self] _ in

                guard let `self` = self else { return }

                self.didReachedBottomRelay.accept(())
            })
            .disposed(by: disposeBag)

        tableView.rx.willDisplayCell.asDriver()
            .drive(onNext: { [weak self] (cell, indexPath) in

                guard let `self` = self else { return }

                if !self.cellHeightList.keys.contains(indexPath) {
                    self.cellHeightList[indexPath] = cell.frame.size.height
                }
            }).disposed(by: disposeBag)

        tableView.rx.didEndDisplayingCell.asDriver()
            .drive(onNext: { [weak self] cell, indexPath in

                guard let `self` = self else { return }

                guard let imageLoadOperation = self.imageLoadOperations[indexPath] else { return }

                imageLoadOperation.cancel()

                self.imageLoadOperations.removeValue(forKey: indexPath)
            })
            .disposed(by: disposeBag)

        presenter.didItemsChange.drive(tableView.rx.items) { [weak self] (tableView, row, element) in

            guard let `self` = self else { return UITableViewCell() }

            let indexPath = IndexPath(row: row, section: 0)

            return self.configureCell(indexPath: indexPath, item: element)
            }
            .disposed(by: disposeBag)
    }    
}

良かった点

・「これが変わると何かが起き、その結果が返ってきたらこれをする」という一連の流れを一箇所で見ることができる。

・同じ処理に対して複数の処理が必要なとき(処理結果をログに書き込むなど)に同じような処理を書かなくてよくなる。

課題

・RxSwift(Reactiveプログラミング)の書き方に慣れていないためもっとサンプルを作成して書き方を身につける。

・Operatorに対する理解が乏しいためどういう時に何を使うのかをもっと学ぶ。



Try! Swift2017 NYCで紹介されていたRxfeedbackも試してみたので、またの機会に投稿してみたいと思います。

またReactiveSwiftでも試してみたいと思います。

長文失礼致しました。

19
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
11