Swift - RxSwift的使用详解74(ReactorKit架构3:GitHub资源搜索样例)
GitHub 资源搜索与展示这个功能我之前也做过,当时分别用的是 MVVM 架构(点击查看)、以及 RxFeedback 架构(点击查看)。下面我使用 ReactorKit 来对其进行重构。
(3)使用 ReactorKit 架构自然还要引入 ReactorKit 库,具体步骤可以参考我之前写的这篇文章:
(2)接着定义好相关模型:GitHubModel.swift(需要实现 ObjectMapper 的 Mappable 协议,并设置好成员对象与 JSON 属性的相互映射关系。这里代码与之前那篇文章一样,没有变化。)
(3)为了前台页面能根据不同的响应情况(成功或失败)进行不同的处理。我们在 Result.swift 中定义了结果类型枚举,以及失败类型枚举。
(4)同时我们把网络请求和数据转换相关代码提取出来,作为一个专门的 Service(GitHubNetworkService.swift),方便使用。
(6)下面就是本文的重点了,在这里我们定义一个反应器(ViewReactor.swift),作用是将 Action 转换成 State。
(7)主视图控制器(ViewController.swift)里的代码就很简单了,只需要将页面上的用户行为绑定到 reactor 里的 action 上,同时将 reactor 里的 state 绑定到页面 UI 控件上即可。
1,效果图
(1)当我们在表格上方的搜索框中输入文字时,会实时地去请求 GitHub 接口查询相匹配的资源库。
(2)数据返回后,将查询结果数量显示在导航栏标题上,同时把匹配度最高的资源条目显示显示在表格中(这个是 GitHub 接口限制,由于数据太多,可能不会一次全部都返回)。
(3)在搜索过程中在页面中央会显示一个旋转的活动指示器,数据返回后指示器自动消失。
(4)如果搜索请求失败,会弹出相关的错误信息。
(5)点击某个单元格,会弹出显示该资源的详细信息(全名和描述)
(6)删除搜索框的文字后,表格内容同步清空,导航栏标题变成显示“hangge.com”
2,准备工作
(1)首先我们在项目中配置好 RxSwift、Alamofire、Moya、Result 这几个库,具体步骤可以参考我之前写的这篇文章:
(2)为了方便将结果映射成自定义对象,我们还需要引入 ObjectMapper、Moya-ObjectMapper 这两个第三方库。具体步骤可以参考我之前写的这篇文章:
(3)使用 ReactorKit 架构自然还要引入 ReactorKit 库,具体步骤可以参考我之前写的这篇文章:
(4)网络活动指示器以及错误提示框我使用的是 MBProgressHUD,具体步骤可以参考我之前写的这篇文章:
3,样例代码
(1)我们先创建一个 GitHubAPI.swift 文件作为网络请求层,里面的内容如下(这里代码与之前那篇文章一样,没有变化):
- 首先定义一个 provider,即请求发起对象。往后我们如果要发起网络请求就使用这个 provider。
- 接着声明一个 enum 来对请求进行明确分类,这里我们只有一个枚举值表示查询资源。
- 最后让这个 enum 实现 TargetType 协议,在这里面定义我们各个请求的 url、参数、header 等信息。
import Foundation import Moya import RxMoya //初始化GitHub请求的provider let GitHubProvider = MoyaProvider<GitHubAPI>() /** 下面定义GitHub请求的endpoints(供provider使用)**/ //请求分类 public enum GitHubAPI { case repositories(String) //查询资源库 } //请求配置 extension GitHubAPI: TargetType { //服务器地址 public var baseURL: URL { return URL(string: "https://api.github.com")! } //各个请求的具体路径 public var path: String { switch self { case .repositories: return "/search/repositories" } } //请求类型 public var method: Moya.Method { return .get } //请求任务事件(这里附带上参数) public var task: Task { print("发起请求。") switch self { case .repositories(let query): var params: [String: Any] = [:] params["q"] = query params["sort"] = "stars" params["order"] = "desc" return .requestParameters(parameters: params, encoding: URLEncoding.default) default: return .requestPlain } } //是否执行Alamofire验证 public var validate: Bool { return false } //这个就是做单元测试模拟的数据,只会在单元测试文件中有作用 public var sampleData: Data { return "{}".data(using: String.Encoding.utf8)! } //请求头 public var headers: [String: String]? { return nil } }
(2)接着定义好相关模型:GitHubModel.swift(需要实现 ObjectMapper 的 Mappable 协议,并设置好成员对象与 JSON 属性的相互映射关系。这里代码与之前那篇文章一样,没有变化。)
import Foundation import ObjectMapper //包含查询返回的所有库模型 struct GitHubRepositories: Mappable { var totalCount: Int! var incompleteResults: Bool! var items: [GitHubRepository]! //本次查询返回的所有仓库集合 init() { print("init()") totalCount = 0 incompleteResults = false items = [] } init?(map: Map) { } // Mappable mutating func mapping(map: Map) { totalCount <- map["total_count"] incompleteResults <- map["incomplete_results"] items <- map["items"] } } //单个仓库模型 struct GitHubRepository: Mappable { var id: Int! var name: String! var fullName:String! var htmlUrl:String! var description:String! init?(map: Map) { } // Mappable mutating func mapping(map: Map) { id <- map["id"] name <- map["name"] fullName <- map["full_name"] htmlUrl <- map["html_url"] description <- map["description"] } }
(3)为了前台页面能根据不同的响应情况(成功或失败)进行不同的处理。我们在 Result.swift 中定义了结果类型枚举,以及失败类型枚举。
import Foundation //响应结果枚举 enum Result<T, E: Error> { case success(T) //成功(里面是返回的数据) case failure(E) //失败(里面是错误原因) } //失败情况枚举 enum GitHubServiceError: Error { case offline case githubLimitReached } //失败枚举对应的错误信息 extension GitHubServiceError { var displayMessage: String { switch self { case .offline: return "网络链接失败!" case .githubLimitReached: return "请求太频繁,请稍后再试!" } } }
(4)同时我们把网络请求和数据转换相关代码提取出来,作为一个专门的 Service(GitHubNetworkService.swift),方便使用。
import RxSwift import RxCocoa import ObjectMapper typealias SearchRepositoriesResponse = Result<(GitHubRepositories), GitHubServiceError> class GitHubNetworkService { //搜索资源数据 func searchRepositories(query:String) -> Observable<SearchRepositoriesResponse> { return GitHubProvider.rx.request(.repositories(query)) .filterSuccessfulStatusCodes() .mapObject(GitHubRepositories.self) .map{ .success($0) } //成功返回 .asObservable() .catchError({ error in print("发生错误:",error.localizedDescription) //失败返回(GitHub接口对请求频率有限制,太频繁会被拒绝:403) return Observable.of(.failure(.githubLimitReached)) }) } }
(6)下面就是本文的重点了,在这里我们定义一个反应器(ViewReactor.swift),作用是将 Action 转换成 State。
import RxSwift final class ViewReactor: Reactor { //数据请求服务 let networkService = GitHubNetworkService() //代表用户行为 enum Action { case updateQuery(String?) //查询条件内容改变 } //代表状态变化 enum Mutation { case setQuery(String?) //设置查询条件文字 case setLoading(Bool) //设置搜索状态 case setRepos(SearchRepositoriesResponse) //设置搜索结果 } //代表页面状态 struct State { var query: String? //搜索文字 var loading: Bool = false //当前正在加载 var results: [GitHubRepository] = [] //搜索结果集 var lastError: GitHubServiceError? //错误信息 var totalCount: Int = 0 //搜索结果数 var title: String { get { if query == nil || query!.isEmpty { return "hangge.com" } else { return "共有 \(totalCount) 个结果" } } } } //初始页面状态 let initialState: State init() { self.initialState = State() } //实现 Action -> Mutation 的转换 func mutate(action: Action) -> Observable<Mutation> { switch action { //搜索文字内容变化 case let .updateQuery(query): //如果查询条件为空则直接清空结果(不需要查询) guard !query!.isEmpty else { return Observable.just(Mutation.setQuery(query)) } //依次执行下面2个状态变化动作 return Observable.concat([ //设置搜索文字 Observable.just(Mutation.setQuery(query)), //设置搜索状态 Observable.just(Mutation.setLoading(true)), //设置搜索结果 networkService.searchRepositories(query: query!) //如果查询条件再次变化,但上一次搜索还未完成,上一次搜索会自动取消 .takeUntil(self.action.filter(isUpdateQueryAction)) .map(Mutation.setRepos), //设置搜索状态 Observable.just(Mutation.setLoading(false)) ]) } } //实现 Mutation -> State 的转换 func reduce(state: State, mutation: Mutation) -> State { //从旧状态那里复制一个新状态 var state = state //根据状态变化设置响应的状态值 switch mutation { //设置查询条件 case let .setQuery(query): state.query = query //查询条件为空则直接清空结果 if state.query!.isEmpty { state.results = [] state.totalCount = 0 } //设置搜索状态 case let .setLoading(value): state.loading = value //设置响应结果(成功) case .setRepos(.success(let repositories)): state.results = repositories.items state.totalCount = repositories.totalCount state.lastError = nil //设置响应结果(失败) case .setRepos(.failure(let error)): state.results = [] state.totalCount = 0 state.lastError = error } //返回新状态 return state } //判断当前的 action 是否是查询条件改变 private func isUpdateQueryAction(_ action: Action) -> Bool { if case .updateQuery = action { return true } else { return false } } }
(7)主视图控制器(ViewController.swift)里的代码就很简单了,只需要将页面上的用户行为绑定到 reactor 里的 action 上,同时将 reactor 里的 state 绑定到页面 UI 控件上即可。
import UIKit import RxSwift import RxCocoa class ViewController: UIViewController, StoryboardView { //显示资源列表的tableView var tableView:UITableView! //搜索栏 var searchBar:UISearchBar! //活动指示器 var loadingHUD:MBProgressHUD! var disposeBag = DisposeBag() override func viewDidLoad() { super.viewDidLoad() //创建表视图 self.tableView = UITableView(frame:self.view.frame, style:.plain) //创建一个重用的单元格 self.tableView!.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") self.view.addSubview(self.tableView!) //创建表头的搜索栏 self.searchBar = UISearchBar(frame: CGRect(x: 0, y: 0, width: self.view.bounds.size.width, height: 56)) self.tableView.tableHeaderView = self.searchBar //创建一个加载指示器 self.loadingHUD = MBProgressHUD.showAdded(to: self.view, animated: false) //设置reactor,会自动触发bind()方法 self.reactor = ViewReactor() //单元格点击 self.tableView.rx.modelSelected(GitHubRepository.self) .subscribe(onNext: {[weak self] item in //显示资源信息(完整名称和描述信息) self?.showAlert(title: item.fullName, message: item.description) }).disposed(by: disposeBag) } //处理绑定事件(该方法会在 self.reactor 变化时自动触发) func bind(reactor: ViewReactor) { //Action(实现 View -> Reactor 的绑定) searchBar.rx.text.orEmpty.changed //搜索框文字改变事件 .throttle(0.3, scheduler: MainScheduler.instance) .distinctUntilChanged() .map(Reactor.Action.updateQuery) //转换为 Action.updateQuery .bind(to: reactor.action) //绑定到 reactor.action .disposed(by: disposeBag) // State(实现 Reactor -> View 的绑定) reactor.state.map { $0.results } //得到最新搜索结果 .bind(to: tableView.rx.items)(self.configureCell) //绑定到表格上 .disposed(by: disposeBag) reactor.state.map { $0.title } //得到最新标题文字 .bind(to: navigationItem.rx.title) //绑定到导航栏上 .disposed(by: disposeBag) reactor.state.map { !$0.loading } //得到请求状态 .bind(to: loadingHUD.rx.isHidden) //绑定到加载指示器上 .disposed(by: disposeBag) //错误信息显示 reactor.state.map{ $0.lastError } .subscribe(onNext: { [weak self] error in if let error = error, let view = self?.view { print(error.displayMessage) let hud = MBProgressHUD.showAdded(to: view, animated: true) hud.mode = .text ////纯文本模式 hud.label.text = error.displayMessage hud.removeFromSuperViewOnHide = true //隐藏时从父视图中移除 hud.hide(animated: true, afterDelay: 2) //2秒钟后自动隐藏 } }) .disposed(by: disposeBag) } //单元格配置 func configureCell(tableView: UITableView, row: Int, item: GitHubRepository) -> UITableViewCell { let cell = UITableViewCell(style: .subtitle, reuseIdentifier: "Cell") cell.textLabel?.text = item.name cell.detailTextLabel?.text = item.htmlUrl return cell } //显示消息 func showAlert(title:String, message:String){ let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) let cancelAction = UIAlertAction(title: "确定", style: .cancel, handler: nil) alertController.addAction(cancelAction) self.present(alertController, animated: true, completion: nil) } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() } }