Swift - RxSwift的使用详解71(RxFeedback架构3:GitHub资源搜索样例)
GitHub 资源搜索与展示这个功能我之前也做过,当时用的是 MVVM 架构(点击查看)。下面我使用 RxFeedback 来对其进行重构。
(3)使用 RxFeedback 架构自然还要引入 RxFeedback 库,具体步骤可以参考我之前写的这篇文章:
(2)接着定义好相关模型:GitHubModel.swift(需要实现 ObjectMapper 的 Mappable 协议,并设置好成员对象与 JSON 属性的相互映射关系。这里代码与之前那篇文章一样,没有变化。)
(3)为了前台页面能根据不同的响应情况(成功或失败)进行不同的处理。我们在 Result.swift 中定义了结果类型枚举,以及失败类型枚举。
(4)同时我们把网络请求和数据转换相关代码提取出来,作为一个专门的 Service:GitHubNetworkService.swift,方便使用。
(5)主视图控制器(ViewController.swift)里的代码就是本文的重点了。与 MVVM 架构不同的是,我们不再需要定义 ViewModel,而是通过状态、事件、反馈循环来实现整个页面的业务逻辑分离。
1,效果图
(1)当我们在表格上方的搜索框中输入文字时,会实时地去请求 GitHub 接口查询相匹配的资源库。
(2)数据返回后,会将结果数量显示在导航栏标题上,同时把匹配度最高的资源条目显示在表格中(这个是 GitHub 接口限制,由于数据太多,可能不会一次性全部返回)。
(3)在搜索过程中,页面中央会显示一个旋转的活动指示器,数据返回后指示器自动消失。
(4)如果搜索请求失败,会弹出相关的错误信息。
(5)点击某个单元格,会弹出显示该资源的详细信息(全名和描述)
(6)删除搜索框的文字后,表格内容同步清空,导航栏标题变成显示“hangge.com”
2,准备工作
(1)首先我们在项目中配置好 RxSwift、Alamofire、Moya、Result 这几个库,具体步骤可以参考我之前写的这篇文章:
(2)为了方便地将结果映射成自定义对象,我们还需要引入 ObjectMapper、Moya-ObjectMapper 这两个第三方库。具体步骤可以参考我之前写的这篇文章:
(3)使用 RxFeedback 架构自然还要引入 RxFeedback 库,具体步骤可以参考我之前写的这篇文章:
(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)) }) } }
(5)主视图控制器(ViewController.swift)里的代码就是本文的重点了。与 MVVM 架构不同的是,我们不再需要定义 ViewModel,而是通过状态、事件、反馈循环来实现整个页面的业务逻辑分离。
import UIKit import RxSwift import RxCocoa import RxFeedback class ViewController: UIViewController { //显示资源列表的tableView var tableView:UITableView! //搜索栏 var searchBar:UISearchBar! let disposeBag = DisposeBag() //状态 struct State { var search: String? { //搜索的文字 didSet { if search == nil || search!.isEmpty { self.title = "hangge.com" self.loading = false self.results = [] return } self.loading = true } } var title: String? //导航栏标题 var loading: Bool //当前正在加载 var results: [GitHubRepository] //搜索结果 var lastError: GitHubServiceError? //错误信息 //返回初始化状态 static var empty: State { return State(search: nil, title: "hangge.com", loading: false, results: [], lastError: nil) } } //事件 enum Event { case searchChanged(String) case response(SearchRepositoriesResponse) } override func viewDidLoad() { super.viewDidLoad() /**** 数据请求服务 ***/ let networkService = GitHubNetworkService() //创建表视图 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 //创建一个加载指示器 let loadingHUD = MBProgressHUD.showAdded(to: self.view, animated: false) Driver.system( initialState: State.empty, reduce: { (state: State, event: Event) -> State in switch event { case .searchChanged(let search): var result = state result.search = search result.lastError = nil return result case .response(.success(let repositories)): var result = state result.results = repositories.items result.title = "共有 \(repositories.totalCount!) 个结果" result.loading = false result.lastError = nil return result case .response(.failure(let error)): var result = state result.loading = false result.lastError = error return result } }, feedback: bind(self) { me, state in let subscriptions = [ //搜索结果绑定 state.map { $0.results } .drive(me.tableView.rx.items)(me.configureCell), //导航栏标题绑定 state.map { $0.title } .drive(me.navigationItem.rx.title), //加载指示器状态绑定 state.map { !$0.loading } .drive(loadingHUD.rx.isHidden), //消息指示器状态绑定 state.map { $0.lastError }.drive(onNext: { error in if let error = error{ print(error.displayMessage) let hud = MBProgressHUD.showAdded(to: self.view, animated: true) hud.mode = .text ////纯文本模式 hud.label.text = error.displayMessage hud.removeFromSuperViewOnHide = true //隐藏时从父视图中移除 hud.hide(animated: true, afterDelay: 2) //2秒钟后自动隐藏 } }) ] let events: [Signal<Event>] = [ me.searchBar.rx.text.orEmpty .changed .throttle(1, scheduler: MainScheduler.instance)//间隔超1秒才发 .asSignal(onErrorRecover: { _ in .empty() }) .map(Event.searchChanged) ] return Bindings(subscriptions: subscriptions, events: events) }, react(query: { $0.search }, effects: { search in if search.isEmpty { return Signal.empty() } return networkService.searchRepositories(query: search) .asSignal(onErrorJustReturn: .failure(.offline)) .map(Event.response) }) ) .drive() .disposed(by: disposeBag) //单元格点击 tableView.rx.modelSelected(GitHubRepository.self) .subscribe(onNext: {[weak self] item in //显示资源信息(完整名称和描述信息) self?.showAlert(title: item.fullName, message: item.description) }).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() } }
附:代码的拆分优化
上面样例中我们把 RxFeedBack 架构用到的 state、event、feedback 等等都定义在 ViewController 中,这样功能一旦多些,ViewController 里的代码就会变得十分冗长,难以阅读。下面我对代码进行改成,拆分成各个独立的文件。
1,GitHubRepositories+State.swift
将 State、Event 以及相关的 reduce 都放在这里面。
import Foundation //状态 struct GitHubRepositoriesState { var search: String? { //搜索的文字 didSet { if search == nil || search!.isEmpty { self.title = "hangge.com" self.loading = false self.results = [] return } self.loading = true } } var title: String? //导航栏标题 var loading: Bool //当前正在加载 var results: [GitHubRepository] //搜索结果 var lastError: GitHubServiceError? //错误信息 } //事件 enum GitHubRepositoriesEvent { case searchChanged(String) //搜索文字改变 case response(SearchRepositoriesResponse) //结果响应 } extension GitHubRepositoriesState { //返回初始化状态 static var empty: GitHubRepositoriesState { return GitHubRepositoriesState(search: nil, title: "hangge.com", loading: false, results: [], lastError: nil) } static func reduce(state: GitHubRepositoriesState, event: GitHubRepositoriesEvent) -> GitHubRepositoriesState { switch event { case .searchChanged(let search): var result = state result.search = search result.lastError = nil return result case .response(.success(let repositories)): var result = state result.results = repositories.items result.title = "共有 \(repositories.totalCount!) 个结果" result.loading = false result.lastError = nil return result case .response(.failure(let error)): var result = state result.loading = false result.lastError = error return result } } }
2,GitHubRepositories+Feedback.swift
这里面放置与 UI 无关的反馈。
import Foundation import RxSwift import RxCocoa import RxFeedback struct GitHubRepositoriesFeedback { //验证用户名 static func searchRepositories(networkService: GitHubNetworkService) -> (Driver<GitHubRepositoriesState>) -> Signal<GitHubRepositoriesEvent> { let query:(GitHubRepositoriesState) -> String? = { $0.search } let effects:(String) -> Signal<GitHubRepositoriesEvent> = { return networkService.searchRepositories(query: $0) .asSignal(onErrorJustReturn: .failure(.offline)) .map(GitHubRepositoriesEvent.response) } return react(query: query, effects: effects) } }
3,ViewController.swift
可以发现代码经过前面的提取之后,主视图控制器的代码变得简洁许多。
import UIKit import RxSwift import RxCocoa import RxFeedback class ViewController: UIViewController { //显示资源列表的tableView var tableView:UITableView! //搜索栏 var searchBar:UISearchBar! let disposeBag = DisposeBag() override func viewDidLoad() { super.viewDidLoad() /**** 数据请求服务 ***/ let networkService = GitHubNetworkService() //创建表视图 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 //创建一个加载指示器 let loadingHUD = MBProgressHUD.showAdded(to: self.view, animated: false) //UI绑定 let bindUI: (Driver<GitHubRepositoriesState>) -> Signal<GitHubRepositoriesEvent> = bind(self) { me, state in let subscriptions = [ //搜索结果绑定 state.map { $0.results } .drive(me.tableView.rx.items)(me.configureCell), //导航栏标题绑定 state.map { $0.title } .drive(me.navigationItem.rx.title), //加载指示器状态绑定 state.map { !$0.loading } .drive(loadingHUD.rx.isHidden), //消息指示器状态绑定 state.map { $0.lastError }.drive(onNext: { error in if let error = error{ print(error.displayMessage) let hud = MBProgressHUD.showAdded(to: self.view, animated: true) hud.mode = .text ////纯文本模式 hud.label.text = error.displayMessage hud.removeFromSuperViewOnHide = true //隐藏时从父视图中移除 hud.hide(animated: true, afterDelay: 2) //2秒钟后自动隐藏 } }) ] let events: [Signal<GitHubRepositoriesEvent>] = [ me.searchBar.rx.text.orEmpty .changed .throttle(1, scheduler: MainScheduler.instance)//间隔超1秒才发 .asSignal(onErrorRecover: { _ in .empty() }) .map(GitHubRepositoriesEvent.searchChanged) ] return Bindings(subscriptions: subscriptions, events: events) } Driver.system( initialState: GitHubRepositoriesState.empty, reduce: GitHubRepositoriesState.reduce, feedback: //UI反馈 bindUI, //非UI的自动反馈(资源搜索) GitHubRepositoriesFeedback.searchRepositories(networkService: networkService) ) .drive() .disposed(by: disposeBag) //单元格点击 tableView.rx.modelSelected(GitHubRepository.self) .subscribe(onNext: {[weak self] item in //显示资源信息(完整名称和描述信息) self?.showAlert(title: item.fullName, message: item.description) }).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() } }