代码下载
搭建UI
新建一个控制器,搭建如下UI:
模型层处理
定义如下数据模型用于操作github搜索的数据:
enum GitHubServiceError: Error {
case offline
case githubLimitReached
case networkError
}
struct Repository: CustomDebugStringConvertible {
var name: String
var url: URL
init(name: String, url: URL) {
self.name = name
self.url = url
}
var debugDescription: String {
return "\(name) | \(url)"
}
}
class Unique: NSObject {
}
enum GitHubCommand {
case changeSearch(text: String)
case loadMoreItems
case gitHubResponseReceived(SearchRepositoriesResponse)
}
struct Version: Hashable {
private let _unique: Unique
let value: Value
init(_ value: Value) {
self._unique = Unique()
self.value = value
}
func hash(into hasher: inout Hasher) {
hasher.combine(self._unique)
}
static func == (lhs: Version, rhs: Version) -> Bool {
return lhs._unique === rhs._unique
}
}
struct GitHubSearchRepositoriesState {
static let initial = GitHubSearchRepositoriesState(searchText: "")
var searchText: String
var shouldLoadNextPage: Bool
var repositories: Version<[Repository]>
var nextURL: URL?
var failure: GitHubServiceError?
init(searchText: String) {
self.searchText = searchText
shouldLoadNextPage = true
repositories = Version([])
nextURL = URL(string: "https://api.github.com/search/repositories?q=\(searchText.URLEscaped)")
failure = nil
}
static func reduce(state: GitHubSearchRepositoriesState, command: GitHubCommand) -> Self {
var result = state
switch command {
case let .changeSearch(text):
result = GitHubSearchRepositoriesState(searchText: text)
result.failure = state.failure
case let .gitHubResponseReceived(response):
switch response {
case let .success((repositories, url)):
result.repositories = Version(state.repositories.value + repositories)
result.shouldLoadNextPage = false
result.nextURL = url
result.failure = nil
case let .failure(error):
result.failure = error
}
case .loadMoreItems:
if state.failure == nil {
result.shouldLoadNextPage = true
}
}
return result
}
}
GitHubServiceError枚举表示GitHub搜索的网络错误。
Repository结构用于表示GitHub搜索的仓库信息,name和url属性存储仓库名称和地址。
GitHubCommand结构用于表示用户操作事件。
Version结构用于包装其他数据模型,使其遵守Hashable协议并实现==
比较函数。
GitHubSearchRepositoriesState结构表示程序当前状态,属性searchText(搜索的文本)、shouldLoadNextPage(是否加载下一页)、repositories(搜索到的结果仓库)、nextURL(下次获取数据的url地址)、failure(网络请求参数)。其reduce函数是根据GitHubSearchRepositoriesState、GitHubCommand参数返回GitHubSearchRepositoriesState,说白了就是根据操作处理状态。
定义一个数据类型SearchRepositoriesResponse
用于表示网络请求结果:
typealias SearchRepositoriesResponse = Result<(repositories: [Repository], nextURL: URL?), GitHubServiceError>
定义GitHubSearchRepositoriesAPI
类获取、解析、包装网络数据,具体实现可以查看源代码,使用该类的如下函数来搜索github仓库数据:
func loadSearchURL(searchURL: URL) -> Observable
ViewModel
定义GithubSearchViewModel
结构,属性loading表示网络加载的序列,属性sections表示列表数据的序列:
struct GithubSearchViewModel {
let loading: Driver
let sections: Driver<[SectionModel]>
init(search: RxCocoa.ControlProperty, loadMore: Observable<(Bool)>) {
let activity = ActivityIndicator()
loading = activity.loading
let searchText = search.orEmpty.changed
.asDriver()
.throttle(.milliseconds(300))
.distinctUntilChanged()
.map(GitHubCommand.changeSearch)
let loadNextPage = loadMore
.withLatestFrom(loading, resultSelector: { $0 && (!$1) })
.filter({ $0 })
.map({ _ in GitHubCommand.loadMoreItems })
.asDriver(onErrorDriveWith: Driver.empty())
let inputFeedback: (Driver) -> Driver = { state in
let performSearch = state.flatMapLatest { (state) -> Driver in
if (!state.shouldLoadNextPage) || state.searchText.isEmpty || state.nextURL == nil {
return Driver.empty()
}
return GitHubSearchRepositoriesAPI.sharedAPI.loadSearchURL(searchURL: state.nextURL!)
.trackActivity(activity)
.asDriver(onErrorJustReturn: Result.failure(GitHubServiceError.networkError))
.map(GitHubCommand.gitHubResponseReceived)
}
return Driver.merge(searchText, loadNextPage, performSearch)
}
sections = Driver.deferred {
let subject = ReplaySubject.create(bufferSize: 1)
let commands = inputFeedback(subject.asDriver(onErrorDriveWith: Driver.empty()))
return commands.scan(GitHubSearchRepositoriesState.initial, accumulator: GitHubSearchRepositoriesState.reduce(state:command:))
.do { (s) in
subject.onNext(s)
} onSubscribed: {
subject.onNext(GitHubSearchRepositoriesState.initial)
}.startWith(GitHubSearchRepositoriesState.initial)
}.map { [SectionModel(model: "Repositories", items: $0.repositories.value)] }
}
}
初始化方法分析:
- 初始化函数接收两个事件参数分别是search(表示搜索事件)、loadMore(表示加载更多的事件)
- 创建ActivityIndicator对象记录网络状态
- 使用一些操作使搜索事件的序列去空、防抖等并转化成元素为GitHubCommand的序列
- 加载更多的事件序列使用withLatestFrom操作符来根据当前网络加载状态来判断是否真正需要加载下一页,使用一些操作符去除不需要的元素、转化成元素为GitHubCommand的序列、转化为Driver类型的序列
- 定义一个inputFeedback闭包将元素为GitHubSearchRepositoriesState的Driver序列转化为元素为GitHubCommand的Driver序列,在闭包内部根据GitHubSearchRepositoriesState的属性创建一个执行网络的请求的performSearch序列,然后合并搜索、加载下一页、执行网络请求操作序列返回。
- 使用deferred操作符构建表示列表数据的Driver序列,构建一个ReplaySubject并将其转化为Driver作为参数执行inputFeedback闭包得到一个GitHubCommand序列,然后使用scan操作符扫描GitHubCommand序列用一个初始GitHubSearchRepositoriesState累计结果、使用GitHubSearchRepositoriesState的reduce函数处理每个元素,然后使用do操作符在序列被订阅和发出元素时让之前的ReplaySubject发出元素,这样做就能让每个GitHubCommand操作先执行GitHubSearchRepositoriesState的reduce函数然后根据结果判断是否执行网络请求(这个步骤需要注意),最后使用map操作符转化为目标类型序列。
数据绑定
先定义如下扩展,方便获取UIScrollView滚动到底部的序列:
extension UIScrollView {
func isNearBottomEdge(edgeOffset: CGFloat = 20.0) -> Bool {
self.contentOffset.y + self.bounds.size.height + edgeOffset > self.contentSize.height
}
}
extension Reactive where Base: UIScrollView {
func nearBottom(edgeOffset: CGFloat = 20.0) -> Observable {
contentOffset.map { _ in base.isNearBottomEdge(edgeOffset: edgeOffset) }
}
}
回到控制器中,构建如下TableViewSectionedDataSource辅助绑定UITableView:
let dataSource = TableViewSectionedDataSource>(cellForRow: { (ds, tv, ip) -> UITableViewCell in
let cell = CommonCell.cellFor(tableView: tv)
let repository = ds[ip]
cell.textLabel?.text = repository.name
cell.detailTextLabel?.text = repository.url.absoluteString
return cell
}, titleForHeader: { (ds, tv, i) -> String? in
let section = ds[i]
return section.items.count > 0 ? "\(section.items.count)个仓库" : "没有发现\(section.model)仓库"
})
在viewDidLoad函数中构建viewModel,绑定数据:
let viewModel = GithubSearchViewModel(search: searchBar.rx.text, loadMore: tableView.rx.nearBottom())
// 数据绑定
viewModel.sections
.drive(tableView.rx.items(dataSource: dataSource))
.disposed(by: bag)
// 选中数据
tableView.rx.modelSelected(Repository.self)
.subscribe(onNext: { (repository) in
if UIApplication.shared.canOpenURL(repository.url) {
if #available(iOS 10.0, *) {
UIApplication.shared.open(repository.url, completionHandler: nil)
} else {
UIApplication.shared.openURL(repository.url)
}
}
}).disposed(by: bag)
// 选中行
tableView.rx.itemSelected
.subscribe(onNext: { [weak self] in self!.tableView.deselectRow(at: $0, animated: true) })
.disposed(by: bag)
// 网络请求中
viewModel.loading.drive(onNext: { [weak self] in
UIApplication.shared.isNetworkActivityIndicatorVisible = $0
$0 && self!.tableView.isNearBottomEdge(edgeOffset: 20.0) ? self!.startAnimating() : self!.stopAnimating()
}).disposed(by: bag)
// 滑动tableView
self.tableView.rx.contentOffset.distinctUntilChanged()
.subscribe({ [weak self] _ in
if self!.searchBar.isFirstResponder {
self!.searchBar.resignFirstResponder()
}
}).disposed(by: bag)