RxSwift官方实例十一(github搜索)

代码下载

搭建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)

你可能感兴趣的:(RxSwift官方实例十一(github搜索))