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<Value>: 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<Value>, rhs: Version<Value>) -> 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<SearchRepositoriesResponse>

ViewModel

定义GithubSearchViewModel结构,属性loading表示网络加载的序列,属性sections表示列表数据的序列:

struct GithubSearchViewModel {
    let loading: Driver<Bool>
    let sections: Driver<[SectionModel<String, Repository>]>

    init(search: RxCocoa.ControlProperty<String?>, 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<GitHubSearchRepositoriesState>) -> Driver<GitHubCommand> = { state in
            let performSearch = state.flatMapLatest { (state) -> Driver<GitHubCommand> 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<GitHubSearchRepositoriesState>.deferred {
            let subject = ReplaySubject<GitHubSearchRepositoriesState>.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<Bool> {
        contentOffset.map { _ in base.isNearBottomEdge(edgeOffset: edgeOffset) }
    }
}

回到控制器中,构建如下TableViewSectionedDataSource辅助绑定UITableView:

let dataSource = TableViewSectionedDataSource<SectionModel<String, Repository>>(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)
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 225,337评论 6 524
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 96,560评论 3 406
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 172,632评论 0 370
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 61,219评论 1 303
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 70,219评论 6 401
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 53,670评论 1 316
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 42,018评论 3 431
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 41,000评论 0 280
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 47,552评论 1 326
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 39,565评论 3 347
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 41,692评论 1 355
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 37,280评论 5 351
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 43,009评论 3 341
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 33,435评论 0 25
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 34,587评论 1 277
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 50,276评论 3 383
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 46,752评论 2 367

推荐阅读更多精彩内容