Github Search(示例)

这个例子是我们经常会遇见的Github 搜索。它是使用 RxFeedback 重构以后的版本,你可以在这里下载这个例子

简介

这个 App 主要有这样几个交互:

  • 输入搜索关键字,显示搜索结果
  • 当请求时产生错误,就给出错误提示
  • 当用户滑动列表到底部时,加载下一页

State

这个是用于描述当前状态:

fileprivate struct State {
    var search: String {
        didSet { ... }
    }
    var nextPageURL: URL?
    var shouldLoadNextPage: Bool
    var results: [Repository]
    var lastError: GitHubServiceError?
}

...

extension State {
    var loadNextPage: URL? { return ... }
}

我们这个例子(Github 搜索) 就有这样几个状态:

  • search 搜索关键字
  • nextPageURL 下一页的 URL
  • shouldLoadNextPage 是否可以加载下一页
  • results 搜索结果
  • lastError 搜索时产生的错误
  • loadNextPage 加载下一页的触发

我们通常会使用这些状态来控制页面布局。

或者,用被请求的状态,触发另外一个事件。


Event

这个是用于描述所产生的事件:

fileprivate enum Event {
    case searchChanged(String)
    case response(SearchRepositoriesResponse)
    case scrollingNearBottom
}

事件通常会使状态发生变化,然后产生一个新的状态

extension State {
    ...
    static func reduce(state: State, event: Event) -> State {
        switch event {
        case .searchChanged(let search):
            var result = state
            result.search = search
            result.results = []
            return result
        case .scrollingNearBottom:
            var result = state
            result.shouldLoadNextPage = true
            return result
        case .response(.success(let response)):
            var result = state
            result.results += response.repositories
            result.shouldLoadNextPage = false
            result.nextPageURL = response.nextURL
            result.lastError = nil
            return result
        case .response(.failure(let error)):
            var result = state
            result.shouldLoadNextPage = false
            result.lastError = error
            return result
        }
    }
}

当发生某个事件时,更新当前状态

  • searchChanged 搜索关键字变更

    将搜索关键字更新成当前值,并且清空搜索结果。

  • startLoadingNextPage 触发加载下页

    允许加载下一页,如果下一页的 URL 存在,就加载下一页。

  • response(.success(...)) 搜索结果返回成功

    将搜索结果加入到对应的数组里面去,然后将相关状态更新。

  • response(.failure(...)) 搜索结果返回失败

    保存错误状态。


Feedback Loop

Feedback Loop 是用来引入附加作用的。

例如,你可以将状态输出到 UI 页面上,或者将 UI 事件输入到反馈循环里面去:

override func viewDidLoad() {
    super.viewDidLoad()

    ...


    let bindUI: (Driver<State>) -> Signal<Event> = bind(self) { me, state in
        let subscriptions = [
            state.map { $0.search }.drive(me.searchText!.rx.text),
            state.map { $0.lastError?.displayMessage }.drive(me.status!.rx.textOrHide),
            state.map { $0.results }.drive(searchResults.rx.items)(configureCell),
            state.map { $0.loadNextPage?.description }.drive(me.loadNextPage!.rx.textOrHide),
        ]

        let events: [Signal<Event>] = [
            me.searchText!.rx.text.orEmpty.changed.asSignal().map(Event.searchChanged),
            triggerLoadNextPage(state)
        ]

        return Bindings(subscriptions: subscriptions, events: events)
    }

    Driver.system(
            initialState: State.empty,
            reduce: State.reduce,
            feedback:
              // UI, user feedback
              bindUI,
              // NoUI, automatic feedback
              ...
        )
        .drive()
        .disposed(by: disposeBag)
}

这里定义的 subscriptions 就是如何将状态输出到 UI 页面上,而 events 则是如何将 UI 事件输入到反馈循环里面去。


被请求的状态

被请求的状态是,用于发出异步请求,以事件的形式返回结果。

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

    Driver.system(
          initialState: State.empty,
          reduce: State.reduce,
          feedback:
            // UI, user feedback
            ... ,
            // NoUI, automatic feedback
            react(query: { $0.loadNextPage }, effects: { resource in
                return URLSession.shared.loadRepositories(resource: resource)
                    .asDriver(onErrorJustReturn: .failure(.offline))
                    .map(Event.response)
            })
        )
        .drive()
        .disposed(by: disposeBag)
}

这里 loadNextPage 就是被请求的状态,当状态 loadNextPage 不为 nil 时,就请求加载下一页。


整体结构

现在我们看一下这个例子整体结构,这样可以帮助你理解这种架构。然后,以下是核心代码:

...
fileprivate struct State {
    var search: String {
        didSet {
            if search.isEmpty {
                self.nextPageURL = nil
                self.shouldLoadNextPage = false
                self.results = []
                self.lastError = nil
                return
            }
            self.nextPageURL = URL(string: "https://api.github.com/search/repositories?q=\(search.URLEscaped)")
            self.shouldLoadNextPage = true
            self.lastError = nil
        }
    }

    var nextPageURL: URL?
    var shouldLoadNextPage: Bool
    var results: [Repository]
    var lastError: GitHubServiceError?
}

fileprivate enum Event {
    case searchChanged(String)
    case response(SearchRepositoriesResponse)
    case scrollingNearBottom
}

// transitions
extension State {
    static var empty: State {
        return State(search: "", nextPageURL: nil, shouldLoadNextPage: true, results: [], lastError: nil)
    }

    static func reduce(state: State, event: Event) -> State {
        switch event {
        case .searchChanged(let search):
            var result = state
            result.search = search
            result.results = []
            return result
        case .scrollingNearBottom:
            var result = state
            result.shouldLoadNextPage = true
            return result
        case .response(.success(let response)):
            var result = state
            result.results += response.repositories
            result.shouldLoadNextPage = false
            result.nextPageURL = response.nextURL
            result.lastError = nil
            return result
        case .response(.failure(let error)):
            var result = state
            result.shouldLoadNextPage = false
            result.lastError = error
            return result
        }
    }
}

// queries
extension State {
    var loadNextPage: URL? {
        return self.shouldLoadNextPage ? self.nextPageURL : nil
    }
}

class GithubPaginatedSearchViewController: UIViewController {
    @IBOutlet weak var searchText: UISearchBar?
    @IBOutlet weak var searchResults: UITableView?
    @IBOutlet weak var status: UILabel?
    @IBOutlet weak var loadNextPage: UILabel?

    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()

        let searchResults = self.searchResults!

        let configureCell = { (tableView: UITableView, row: Int, repository: Repository) -> UITableViewCell in
            let cell = tableView.dequeueReusableCell(withIdentifier: "RepositoryCell") ?? UITableViewCell(style: .subtitle, reuseIdentifier: "RepositoryCell")

            cell.textLabel?.text = repository.name
            cell.detailTextLabel?.text = repository.url.description
            return cell
        }

        let triggerLoadNextPage: (Driver<State>) -> Signal<Event> = { state in
            return state.flatMapLatest { state -> Signal<Event> in
                if state.shouldLoadNextPage {
                    return Signal.empty()
                }

                return searchResults.rx.nearBottom
                    .skip(1)
                    .map { _ in Event.scrollingNearBottom }
            }
        }

        let bindUI: (Driver<State>) -> Signal<Event> = bind(self) { me, state in
            let subscriptions = [
                state.map { $0.search }.drive(me.searchText!.rx.text),
                state.map { $0.lastError?.displayMessage }.drive(me.status!.rx.textOrHide),
                state.map { $0.results }.drive(searchResults.rx.items)(configureCell),

                state.map { $0.loadNextPage?.description }.drive(me.loadNextPage!.rx.textOrHide),
                ]

            let events: [Signal<Event>] = [
                me.searchText!.rx.text.orEmpty.changed.asSignal().map(Event.searchChanged),
                triggerLoadNextPage(state)
            ]

            return Bindings(subscriptions: subscriptions, events: events)
        }

        Driver.system(
                initialState: State.empty,
                reduce: State.reduce,
                feedback:
                    // UI, user feedback
                    bindUI,
                    // NoUI, automatic feedback
                    react(request: { $0.loadNextPage }, effects: { resource in
                        return URLSession.shared.loadRepositories(resource: resource)
                            .asSignal(onErrorJustReturn: .failure(.offline))
                            .map(Event.response)
                    })
            )
            .drive()
            .disposed(by: disposeBag)
    }
}
...

这是使用 RxFeedback 重构以后的 Github Search。你可以对比一下使用 ReactorKit 重构以后的 Github Search 两者有许多相似之处。

results matching ""

    No results matching ""