注册
iOS

iOS RXSwift 7.3

RxFeedback

Travis CI platforms pod Carthage compatible Swift Package Manager compatible

作者

Krunoslav Zaher 是 RxFeedback 的作者。他也是 RxSwift 的创始人以及 ReactiveX 组织 的核心成员。他有 16 年以上的编程经验( VR 引擎,BPM 系统,移动端应用程序,机器人等),最近在研究响应式编程。

介绍

RxSwift 最简单的架构

typealias Feedback<State, Event> = (Observable<State>) -> Observable<Event>

public static func system<State, Event>(
initialState: State,
reduce: @escaping (State, Event)
-> State,
feedback: Feedback<State, Event>...
) -> Observable<State>

为什么?

  • 直接
    • 已经发生 -> Event
    • 即将发生 -> Request
    • 执行 Request -> Feedback loop
  • 声明式
    • 首先系统行为被明确声明出来,然后在调用 subscribe 后开始运作 => 编译时就保证了不会有“未处理状态”
  • 容易调试
    • 大多数逻辑是 纯函数,可以通过 xCode 调试器调试,或者将命令打印出来
  • 适用于任何级别
    • 整个系统
    • 应用程序(state 被储存在数据库中,CoreData, Firebase, Realm)
    • view controller (state 被储存在 system 操作符)
    • 在 feedback loop 中(feedback loop 中 调用另一个 system 操作符)
  • 容易做依赖注入
  • 易测试
    • Reducer 是 纯函数,只需调用他并断言结果即可
    • 伴随 附加作用 的测试 -> TestScheduler
  • 可以处理循环依赖
  • 完全从附加作用中分离业务逻辑
    • 业务逻辑可以在不同平台之间转换

示例

Counter.gif

Observable.system(
initialState: 0,
reduce: { (state, event) -> State in
switch event {
case .increment:
return state + 1
case .decrement:
return state - 1
}
},
scheduler: MainScheduler.instance,
feedback:
// UI is user feedback
bind(self) { me, state -> Bindings<Event> in
let subscriptions = [
state.map(String.init).bind(to: me.label.rx.text)
]

let events = [
me.plus.rx.tap.map { Event.increment },
me.minus.rx.tap.map { Event.decrement }
]

return Bindings(
subscriptions: subscriptions,
events: events
)
}
)

这是一个简单计数的例子,只是用于演示 RxFeedback 架构。

State

系统状态用 State 表示:

typealias State = Int
  • 这里的状态就是计数的数值

Event

事件用 Event 表示:

enum Event {
case increment
case decrement
}
  • increment 增加数值事件
  • decrement 减少数值事件

当产生 Event 时更新状态:

Observable.system(
initialState: 0,
reduce: { (state, event) -> State in
switch event {
case .increment:
return state + 1
case .decrement:
return state - 1
}
},
scheduler: MainScheduler.instance,
feedback: ...
)
  • increment 状态数值加一
  • decrement 状态数值减一

Feedback Loop

状态输出到 UI 页面上,或者将 UI 事件输入到反馈循环里面去:

Observable.system(
initialState: 0,
reduce: { ... },
scheduler: MainScheduler.instance,
feedback:
// UI is user feedback
bind(self) { me, state -> Bindings<Event> in
let subscriptions = [
state.map(String.init).bind(to: me.label.rx.text)
]

let events = [
me.plus.rx.tap.map { Event.increment },
me.minus.rx.tap.map { Event.decrement }
]

return Bindings(
subscriptions: subscriptions,
events: events
)
}
)
  • 将状态数值用 label 显示出来
  • 将增加按钮的点击,作为增加数值事件传入
  • 将减少按钮的点击,作为减少数值事件传入

安装

CocoaPods

CocoaPods 是一个 Cocoa 项目的依赖管理工具。你可以通过以下命令安装他:

$ gem install cocoapods

将 RxFeedback 整合到项目中来,你需要在 Podfile 中指定他:

pod 'RxFeedback', '~> 3.0'

然后运行以下命令:

$ pod install

Carthage

Carthage 是一个分散式依赖管理工具,他将构建你的依赖并提供二进制框架。

你可以通过以下 Homebrew 命令安装 Carthage:

$ brew update
$ brew install carthage

将 RxFeedback 整合到项目中来,你需要在 Cartfile 中指定他:

github "NoTests/RxFeedback" ~> 3.0

运行 carthage update 去构建框架,然后将 RxFeedback.framework 拖入到 Xcode 项目中来。由于 RxFeedback 对 RxSwift 和 RxCocoa 有依赖,所以你也需要将 RxSwift.framework 和 RxCocoa.framework 拖入到 Xcode 项目中来。

Swift Package Manager

Swift Package Manager 是一个自动分发 Swift 代码的工具,他已经被集成到 Swift 编译器中。

一旦你配置好了 Swift 包,添加 RxFeedback 就非常简单了,你只需要将他添加到文件 Package.swift 的 dependencies 的值中。

dependencies: [
.package(url: "https://github.com/NoTests/RxFeedback.swift.git", majorVersion: 1)
]

与其他架构的区别

  • Elm - 非常相似,feedback loop 用作 附加作用, 而不是 Cmd, 要执行的 附加作用 被编码到 state 中,并且通过 feedback loop 完成请求
  • Redux - 也很像,不过采用 feedback loops 而不是 middleware
  • Redux-Observable - observables 观察状态,与视图和状态之间的 middleware
  • Cycle.js - 一言难尽 :),请咨询 @andrestaltz
  • MVVM - 将状态和 附加作用 分离,而且不需要 View

示例

下一节将用 Github Search 来演示如何使用 RxFeedback

Github Search(示例)

GithubPaginatedSearchFull.gif

这个例子是我们经常会遇见的Github 搜索。它是使用 RxFeedback 重构以后的版本,你可以在这里下载这个例子

简介

这个 App 主要有这样几个交互:

  • 输入搜索关键字,显示搜索结果
  • 当请求时产生错误,就给出错误提示
  • 当用户滑动列表到底部时,加载下一页

State

这个是用于描述当前状态:

fileprivate struct State {
var search: String {
didSet { ... }
}
var nextPageURL: URL?
var shouldLoadNextPage: Bool
var results: [Repository]
var lastError: GitHubServiceError?
}

...

extension State {
var loadNextPage: URL? { return ... }
}

我们这个例子(Github 搜索) 就有这样几个状态:

  • search 搜索关键字
  • nextPageURL 下一页的 URL
  • shouldLoadNextPage 是否可以加载下一页
  • results 搜索结果
  • lastError 搜索时产生的错误
  • loadNextPage 加载下一页的触发

我们通常会使用这些状态来控制页面布局。

或者,用被请求的状态,触发另外一个事件。


Event

这个是用于描述所产生的事件:

fileprivate enum Event {
case searchChanged(String)
case response(SearchRepositoriesResponse)
case startLoadingNextPage
}

事件通常会使状态发生变化,然后产生一个新的状态

extension State {
...
static func reduce(state: State, event: Event) -> State {
switch event {
case .searchChanged(let search):
var result = state
result.search = search
result.results = []
return result
case .startLoadingNextPage:
var result = state
result.shouldLoadNextPage = true
return result
case .response(.success(let response)):
var result = state
result.results += response.repositories
result.shouldLoadNextPage = false
result.nextPageURL = response.nextURL
result.lastError = nil
return result
case .response(.failure(let error)):
var result = state
result.shouldLoadNextPage = false
result.lastError = error
return result
}
}
}

当发生某个事件时,更新当前状态

  • searchChanged 搜索关键字变更

    将搜索关键字更新成当前值,并且清空搜索结果。

  • startLoadingNextPage 触发加载下页

    允许加载下一页,如果下一页的 URL 存在,就加载下一页。

  • response(.success(...)) 搜索结果返回成功

    将搜索结果加入到对应的数组里面去,然后将相关状态更新。

  • response(.failure(...)) 搜索结果返回失败

    保存错误状态。


Feedback Loop

Feedback Loop 是用来引入附加作用的。

例如,你可以将状态输出到 UI 页面上,或者将 UI 事件输入到反馈循环里面去:

override func viewDidLoad() {
super.viewDidLoad()

...

Driver.system(
initialState: State.empty,
reduce: State.reduce,
feedback:
// UI, user feedback
UI.bind(self) { me, state in
let subscriptions = [
state.map { $0.search }.drive(me.searchText!.rx.text),
state.map { $0.lastError?.displayMessage }.drive(me.status!.rx.textOrHide),
state.map { $0.results }.drive(searchResults.rx.items(cellIdentifier: "repo"))(configureRepository),
state.map { $0.loadNextPage?.description }.drive(me.loadNextPage!.rx.textOrHide),
]
let events = [
me.searchText!.rx.text.orEmpty.changed.asDriver().map(Event.searchChanged),
triggerLoadNextPage(state)
]
return UI.Bindings(subscriptions: subscriptions, events: events)
},
// NoUI, automatic feedback
...
)
.drive()
.disposed(by: disposeBag)
}

这里定义的 subscriptions 就是如何将状态输出到 UI 页面上,而 events 则是如何将 UI 事件输入到反馈循环里面去。


被请求的状态

被请求的状态是,用于发出异步请求,以事件的形式返回结果。

override func viewDidLoad() {
super.viewDidLoad()
...

Driver.system(
initialState: State.empty,
reduce: State.reduce,
feedback:
// UI, user feedback
... ,
// NoUI, automatic feedback
react(query: { $0.loadNextPage }, effects: { resource in
return URLSession.shared.loadRepositories(resource: resource)
.asDriver(onErrorJustReturn: .failure(.offline))
.map(Event.response)
})
)
.drive()
.disposed(by: disposeBag)
}

这里 loadNextPage 就是被请求的状态,当状态 loadNextPage 不为 nil 时,就请求加载下一页。


整体结构

现在我们看一下这个例子整体结构,这样可以帮助你理解这种架构。然后,以下是核心代码:

...
fileprivate struct State {
var search: String {
didSet {
if search.isEmpty {
self.nextPageURL = nil
self.shouldLoadNextPage = false
self.results = []
self.lastError = nil
return
}
self.nextPageURL = URL(string: "https://api.github.com/search/repositories?q=\(search.URLEscaped)")
self.shouldLoadNextPage = true
self.lastError = nil
}
}

var nextPageURL: URL?
var shouldLoadNextPage: Bool
var results: [Repository]
var lastError: GitHubServiceError?
}

fileprivate enum Event {
case searchChanged(String)
case response(SearchRepositoriesResponse)
case startLoadingNextPage
}

// transitions
extension State {
static var empty: State {
return State(search: "", nextPageURL: nil, shouldLoadNextPage: true, results: [], lastError: nil)
}
static func reduce(state: State, event: Event) -> State {
switch event {
case .searchChanged(let search):
var result = state
result.search = search
result.results = []
return result
case .startLoadingNextPage:
var result = state
result.shouldLoadNextPage = true
return result
case .response(.success(let response)):
var result = state
result.results += response.repositories
result.shouldLoadNextPage = false
result.nextPageURL = response.nextURL
result.lastError = nil
return result
case .response(.failure(let error)):
var result = state
result.shouldLoadNextPage = false
result.lastError = error
return result
}
}
}

// queries
extension State {
var loadNextPage: URL? {
return self.shouldLoadNextPage ? self.nextPageURL : nil
}
}

class GithubPaginatedSearchViewController: UIViewController {
@IBOutlet weak var searchText: UISearchBar?
@IBOutlet weak var searchResults: UITableView?
@IBOutlet weak var status: UILabel?
@IBOutlet weak var loadNextPage: UILabel?

private let disposeBag = DisposeBag()

override func viewDidLoad() {
super.viewDidLoad()

let searchResults = self.searchResults!

searchResults.register(UITableViewCell.self, forCellReuseIdentifier: "repo")

let triggerLoadNextPage: (Driver<State>) -> Driver<Event> = { state in
return state.flatMapLatest { state -> Driver<Event> in
if state.shouldLoadNextPage {
return Driver.empty()
}

return searchResults.rx.nearBottom.map { _ in Event.startLoadingNextPage }
}
}

func configureRepository(_: Int, repo: Repository, cell: UITableViewCell) {
cell.textLabel?.text = repo.name
cell.detailTextLabel?.text = repo.url.description
}

let bindUI: (Driver<State>) -> Driver<Event> = UI.bind(self) { me, state in
let subscriptions = [
state.map { $0.search }.drive(me.searchText!.rx.text),
state.map { $0.lastError?.displayMessage }.drive(me.status!.rx.textOrHide),
state.map { $0.results }.drive(searchResults.rx.items(cellIdentifier: "repo"))(configureRepository),
state.map { $0.loadNextPage?.description }.drive(me.loadNextPage!.rx.textOrHide),
]
let events = [
me.searchText!.rx.text.orEmpty.changed.asDriver().map(Event.searchChanged),
triggerLoadNextPage(state)
]
return UI.Bindings(subscriptions: subscriptions, events: events)
}

Driver.system(
initialState: State.empty,
reduce: State.reduce,
feedback:
// UI, user feedback
bindUI,
// NoUI, automatic feedback
react(query: { $0.loadNextPage }, effects: { resource in
return URLSession.shared.loadRepositories(resource: resource)
.asDriver(onErrorJustReturn: .failure(.offline))
.map(Event.response)
})
)
.drive()
.disposed(by: disposeBag)
}
}
...

这是使用 RxFeedback 重构以后的 Github Search。你可以对比一下使用 ReactorKit 重构以后的 Github Search 两者有许多相似之处。

0 个评论

要回复文章请先登录注册