Github Search(示例)

Github Search(示例) - 图1

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

简介

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

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

State

Github Search(示例) - 图2

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

  1. fileprivate struct State {
  2. var search: String {
  3. didSet { ... }
  4. }
  5. var nextPageURL: URL?
  6. var shouldLoadNextPage: Bool
  7. var results: [Repository]
  8. var lastError: GitHubServiceError?
  9. }
  10. ...
  11. extension State {
  12. var loadNextPage: URL? { return ... }
  13. }

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

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

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

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


Event

Github Search(示例) - 图3

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

  1. fileprivate enum Event {
  2. case searchChanged(String)
  3. case response(SearchRepositoriesResponse)
  4. case startLoadingNextPage
  5. }

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

  1. extension State {
  2. ...
  3. static func reduce(state: State, event: Event) -> State {
  4. switch event {
  5. case .searchChanged(let search):
  6. var result = state
  7. result.search = search
  8. result.results = []
  9. return result
  10. case .startLoadingNextPage:
  11. var result = state
  12. result.shouldLoadNextPage = true
  13. return result
  14. case .response(.success(let response)):
  15. var result = state
  16. result.results += response.repositories
  17. result.shouldLoadNextPage = false
  18. result.nextPageURL = response.nextURL
  19. result.lastError = nil
  20. return result
  21. case .response(.failure(let error)):
  22. var result = state
  23. result.shouldLoadNextPage = false
  24. result.lastError = error
  25. return result
  26. }
  27. }
  28. }

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

  • searchChanged 搜索关键字变更

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

  • startLoadingNextPage 触发加载下页

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

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

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

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

    保存错误状态。


Feedback Loop

Github Search(示例) - 图4

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

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

  1. override func viewDidLoad() {
  2. super.viewDidLoad()
  3. ...
  4. Driver.system(
  5. initialState: State.empty,
  6. reduce: State.reduce,
  7. feedback:
  8. // UI, user feedback
  9. UI.bind(self) { me, state in
  10. let subscriptions = [
  11. state.map { $0.search }.drive(me.searchText!.rx.text),
  12. state.map { $0.lastError?.displayMessage }.drive(me.status!.rx.textOrHide),
  13. state.map { $0.results }.drive(searchResults.rx.items(cellIdentifier: "repo"))(configureRepository),
  14. state.map { $0.loadNextPage?.description }.drive(me.loadNextPage!.rx.textOrHide),
  15. ]
  16. let events = [
  17. me.searchText!.rx.text.orEmpty.changed.asDriver().map(Event.searchChanged),
  18. triggerLoadNextPage(state)
  19. ]
  20. return UI.Bindings(subscriptions: subscriptions, events: events)
  21. },
  22. // NoUI, automatic feedback
  23. ...
  24. )
  25. .drive()
  26. .disposed(by: disposeBag)
  27. }

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


被请求的状态

Github Search(示例) - 图5

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

  1. override func viewDidLoad() {
  2. super.viewDidLoad()
  3. ...
  4. Driver.system(
  5. initialState: State.empty,
  6. reduce: State.reduce,
  7. feedback:
  8. // UI, user feedback
  9. ... ,
  10. // NoUI, automatic feedback
  11. react(query: { $0.loadNextPage }, effects: { resource in
  12. return URLSession.shared.loadRepositories(resource: resource)
  13. .asDriver(onErrorJustReturn: .failure(.offline))
  14. .map(Event.response)
  15. })
  16. )
  17. .drive()
  18. .disposed(by: disposeBag)
  19. }

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


整体结构

Github Search(示例) - 图6

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

  1. ...
  2. fileprivate struct State {
  3. var search: String {
  4. didSet {
  5. if search.isEmpty {
  6. self.nextPageURL = nil
  7. self.shouldLoadNextPage = false
  8. self.results = []
  9. self.lastError = nil
  10. return
  11. }
  12. self.nextPageURL = URL(string: "https://api.github.com/search/repositories?q=\(search.URLEscaped)")
  13. self.shouldLoadNextPage = true
  14. self.lastError = nil
  15. }
  16. }
  17. var nextPageURL: URL?
  18. var shouldLoadNextPage: Bool
  19. var results: [Repository]
  20. var lastError: GitHubServiceError?
  21. }
  22. fileprivate enum Event {
  23. case searchChanged(String)
  24. case response(SearchRepositoriesResponse)
  25. case startLoadingNextPage
  26. }
  27. // transitions
  28. extension State {
  29. static var empty: State {
  30. return State(search: "", nextPageURL: nil, shouldLoadNextPage: true, results: [], lastError: nil)
  31. }
  32. static func reduce(state: State, event: Event) -> State {
  33. switch event {
  34. case .searchChanged(let search):
  35. var result = state
  36. result.search = search
  37. result.results = []
  38. return result
  39. case .startLoadingNextPage:
  40. var result = state
  41. result.shouldLoadNextPage = true
  42. return result
  43. case .response(.success(let response)):
  44. var result = state
  45. result.results += response.repositories
  46. result.shouldLoadNextPage = false
  47. result.nextPageURL = response.nextURL
  48. result.lastError = nil
  49. return result
  50. case .response(.failure(let error)):
  51. var result = state
  52. result.shouldLoadNextPage = false
  53. result.lastError = error
  54. return result
  55. }
  56. }
  57. }
  58. // queries
  59. extension State {
  60. var loadNextPage: URL? {
  61. return self.shouldLoadNextPage ? self.nextPageURL : nil
  62. }
  63. }
  64. class GithubPaginatedSearchViewController: UIViewController {
  65. @IBOutlet weak var searchText: UISearchBar?
  66. @IBOutlet weak var searchResults: UITableView?
  67. @IBOutlet weak var status: UILabel?
  68. @IBOutlet weak var loadNextPage: UILabel?
  69. private let disposeBag = DisposeBag()
  70. override func viewDidLoad() {
  71. super.viewDidLoad()
  72. let searchResults = self.searchResults!
  73. searchResults.register(UITableViewCell.self, forCellReuseIdentifier: "repo")
  74. let triggerLoadNextPage: (Driver<State>) -> Driver<Event> = { state in
  75. return state.flatMapLatest { state -> Driver<Event> in
  76. if state.shouldLoadNextPage {
  77. return Driver.empty()
  78. }
  79. return searchResults.rx.nearBottom.map { _ in Event.startLoadingNextPage }
  80. }
  81. }
  82. func configureRepository(_: Int, repo: Repository, cell: UITableViewCell) {
  83. cell.textLabel?.text = repo.name
  84. cell.detailTextLabel?.text = repo.url.description
  85. }
  86. let bindUI: (Driver<State>) -> Driver<Event> = UI.bind(self) { me, state in
  87. let subscriptions = [
  88. state.map { $0.search }.drive(me.searchText!.rx.text),
  89. state.map { $0.lastError?.displayMessage }.drive(me.status!.rx.textOrHide),
  90. state.map { $0.results }.drive(searchResults.rx.items(cellIdentifier: "repo"))(configureRepository),
  91. state.map { $0.loadNextPage?.description }.drive(me.loadNextPage!.rx.textOrHide),
  92. ]
  93. let events = [
  94. me.searchText!.rx.text.orEmpty.changed.asDriver().map(Event.searchChanged),
  95. triggerLoadNextPage(state)
  96. ]
  97. return UI.Bindings(subscriptions: subscriptions, events: events)
  98. }
  99. Driver.system(
  100. initialState: State.empty,
  101. reduce: State.reduce,
  102. feedback:
  103. // UI, user feedback
  104. bindUI,
  105. // NoUI, automatic feedback
  106. react(query: { $0.loadNextPage }, effects: { resource in
  107. return URLSession.shared.loadRepositories(resource: resource)
  108. .asDriver(onErrorJustReturn: .failure(.offline))
  109. .map(Event.response)
  110. })
  111. )
  112. .drive()
  113. .disposed(by: disposeBag)
  114. }
  115. }
  116. ...

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