Swift ReactorKit 框架

ReactorKit.png

ReactorKit 是一个响应式、单向 Swift 应用框架。下面来介绍一下 ReactorKit 当中的基本概念和使用方法。

目录

基本概念

ReactorKit 是 FluxReactive Programming 的混合体。用户的操作和视图 view 的状态通过可被观察的流传递到各层。这些流是单向的:视图 view 仅能发出操作(action)流 ,反应堆仅能发出状态(states)流。

image

设计目标

  • 可测性:ReactorKit 的首要目标是将业务逻辑从视图 view 上分离。这可以让代码方便测试。一个反应堆不依赖于任何 view。这样就只需要测试反应堆和 view 数据的绑定。测试方法可点击查看
  • 侵入小:ReactorKit 不要求整个应用采用这一种框架。对于一些特殊的 view,可以部分的采用 ReactorKit。对于现存的项目,不需要重写任何东西,就可以直接使用 ReactorKit。
  • 更少的键入:对于一些简单的功能,ReactorKit 可以减少代码的复杂度。和其他的框架相比,ReactorKit 需要的代码更少。可以从一个简单的功能开始,逐渐扩大使用的范围。

View

View 用来展示数据。 view controller 和 cell 都可以看做一个 view。�view 需要做两件事:(1)绑定用户输入的操作流,(2)将状态流绑定到 view 对应的 UI 元素。view 层没有业务逻辑,只负责绑定操作流和状态流。

定义一个 view,只需要将一个现存的类符合协议 View。然后这个类就自动有了一个 reactor 的属性。view 的这个属性通常由外界设置。

class ProfileViewController: UIViewController, View {
  var disposeBag = DisposeBag()
}

profileViewController.reactor = UserViewReactor() // inject reactor

当这个 reactor 属性被设置(或修改)的时候,将自动调用 bind(reactor:) 方法。view 通过实现 bind(reactor:) 来绑定操作流和状态流。

func bind(reactor: ProfileViewReactor) {
  // action (View -> Reactor)
  refreshButton.rx.tap.map { Reactor.Action.refresh }
    .bind(to: reactor.action)
    .disposed(by: self.disposeBag)

  // state (Reactor -> View)
  reactor.state.map { $0.isFollowing }
    .bind(to: followButton.rx.isSelected)
    .disposed(by: self.disposeBag)
}

Storyboard 的支持

如果使用 storyboard 来初始一个 view controller,则需要使用 StoryboardView 协议。StoryboardView 协议和 View 协议相比,唯一不同的是 StoryboardView 协议是在 view 加载结束之后进行绑定的。

let viewController = MyViewController()
viewController.reactor = MyViewReactor() // will not executes `bind(reactor:)` immediately

class MyViewController: UIViewController, StoryboardView {
  func bind(reactor: MyViewReactor) {
    // this is called after the view is loaded (viewDidLoad)
  }
}

Reactor 反应堆

反应堆 Reactor 层,和 UI 无关,它控制着一个 view 的状态。reactor 最主要的作用就是将操作流从 view 中分离。每个 view 都有它对应的反应堆 reactor,并且将它所有的逻辑委托给它的反应堆 reactor。

定义一个 reactor 时需要符合 Reactor 协议。这个协议要求定义三个类型: Action, MutationState,另外它需要定义一个名为 initialState 的属性。

class ProfileViewReactor: Reactor {
  // represent user actions
  enum Action {
    case refreshFollowingStatus(Int)
    case follow(Int)
  }

  // represent state changes
  enum Mutation {
    case setFollowing(Bool)
  }

  // represents the current view state
  struct State {
    var isFollowing: Bool = false
  }

  let initialState: State = State()
}

Action 表示用户操作,State 表示 view 的状态,MutationActionState 之间的转化桥梁。reactor 将一个 action 流转化到 state 流,需要两步:mutate()reduce()

image

mutate()

mutate() 接受一个 Action,然后产生一个 Observable<Mutation>

func mutate(action: Action) -> Observable<Mutation>

所有的副作用应该在这个方法内执行,比如异步操作,或者 API 的调用。

func mutate(action: Action) -> Observable<Mutation> {
  switch action {
  case let .refreshFollowingStatus(userID): // receive an action
    return UserAPI.isFollowing(userID) // create an API stream
      .map { (isFollowing: Bool) -> Mutation in
        return Mutation.setFollowing(isFollowing) // convert to Mutation stream
      }

  case let .follow(userID):
    return UserAPI.follow()
      .map { _ -> Mutation in
        return Mutation.setFollowing(true)
      }
  }
}

reduce()

reduce() 由当前的 State 和一个 Mutation 生成一个新的 State

func reduce(state: State, mutation: Mutation) -> State

这个应该是一个简单的方法。它应该仅仅同步的返回一个新的 State。不要在这个方法内执行任何有副作用的操作。

func reduce(state: State, mutation: Mutation) -> State {
  var state = state // create a copy of the old state
  switch mutation {
  case let .setFollowing(isFollowing):
    state.isFollowing = isFollowing // manipulate the state, creating a new state
    return state // return the new state
  }
}

transform()

transform() 用来转化每一种流。这里包含三种 transforms() 的方法。

func transform(action: Observable<Action>) -> Observable<Action>
func transform(mutation: Observable<Mutation>) -> Observable<Mutation>
func transform(state: Observable<State>) -> Observable<State>

通过这些方法可以将流进行转化,或者将流和其他流进行合并。例如:在合并全局事件流时,最好使用 transform(mutation:) 方法。点击查看全局状态的更多信息。

另外,也可以通过这些方法进行测试。

func transform(action: Observable<Action>) -> Observable<Action> {
  return action.debug("action") // Use RxSwift's debug() operator
}

高级用法

Global States (全局状态)

和 Redux 不同, ReactorKit 不需要一个全局的 app state,这意味着你可以使用任何类型来管理全局 state,例如用 BehaviorSubject,或者 PublishSubject,甚至一个 reactor。ReactorKit 不需要一个全局状态,所以不管应用程序有多特殊,都可以使用 ReactorKit。

Action → Mutation → State 流中,没有使用任何全局的状态。你可以使用 transform(mutation:) 将一个全局的 state 转化为 mutation。例如:我们使用一个全局的 BehaviorSubject 来存储当前授权的用户,当 currentUser 变化时,需要发出 Mutation.setUser(User?),则可以采用下面的方案:

var currentUser: BehaviorSubject<User> // global state

func transform(mutation: Observable<Mutation>) -> Observable<Mutation> {
    return Observable.merge(mutation, currentUser.map(Mutation.setUser))
}

这样,当 view 每次向 reactor 产生一个 action 或者 currentUser 改变的时候,都会发送一个 mutation。

View Communication (View 通信)

多个 view 之间通信时,通常会采用回调闭包或者代理模式。ReactorKit 建议采用 reactive extensions 来解决。最常见的 ControlEvent 示例是 UIButton.rx.tap。关键思路就是将自定义的视图转化为像 UIButton 或者 UILabel 一样。

image

假设我们有一个 ChatViewController 来展示消息。 ChatViewController 有一个 MessageInputView,当用户点击 MessageInputView 上的发送按钮时,文字将会发送到 ChatViewController,然后 ChatViewController 绑定到对应的 reactor 的 action。下面是 MessageInputView 的 reactive extensions 的一个示例:

extension Reactive where Base: MessageInputView {
    var sendButtonTap: ControlEvent<String> {
        let source = base.sendButton.rx.tap.withLatestFrom(...)
        return ControlEvent(events: source)
    }
}

这样就是可以在 ChatViewController 中使用这个扩展。例如:

messageInputView.rx.sendButtonTap
  .map(Reactor.Action.send)
  .bind(to: reactor.action)

Testing 测试

ReactorKit 有一个用于测试的 built-in 功能。通过下面的指导,你可以很容易测试 view 和 reactor。

测试内容

首先,你要确定测试内容。有两个方面需要测试,一个是 view 或者一个是 reactor。

  • View
    • Action: 能否通过给定的用户交互发送给 reactor 对应的 action?
    • State: view 能否根据给定的 state 对属性进行正确的设置?
  • Reactor
    • State: state 能否根据 action 进行相应的修改?

View 测试

view 可以根据 stub reactor 进行测试。reactor 有一个 stub 的属性,它可以打印 actions,并且强制修改 states。如果启用了 reactor 的 stub,mutate()reduce() 将不会被执行。stub 有下面几个属性:

var isEnabled: Bool { get set }
var state: StateRelay<Reactor.State> { get }
var action: ActionSubject<Reactor.Action> { get }
var actions: [Reactor.Action] { get } // recorded actions

下面是一些测试示例:

func testAction_refresh() {
  // 1. prepare a stub reactor
  let reactor = MyReactor()
  reactor.stub.isEnabled = true

  // 2. prepare a view with a stub reactor
  let view = MyView()
  view.reactor = reactor

  // 3. send an user interaction programatically
  view.refreshControl.sendActions(for: .valueChanged)

  // 4. assert actions
  XCTAssertEqual(reactor.stub.actions.last, .refresh)
}

func testState_isLoading() {
  // 1. prepare a stub reactor
  let reactor = MyReactor()
  reactor.stub.isEnabled = true

  // 2. prepare a view with a stub reactor
  let view = MyView()
  view.reactor = reactor

  // 3. set a stub state
  reactor.stub.state.value = MyReactor.State(isLoading: true)

  // 4. assert view properties
  XCTAssertEqual(view.activityIndicator.isAnimating, true)
}

测试 Reactor

reactor 可以被单独测试。

func testIsBookmarked() {
    let reactor = MyReactor()
    reactor.action.onNext(.toggleBookmarked)
    XCTAssertEqual(reactor.currentState.isBookmarked, true)
    reactor.action.onNext(.toggleBookmarked)
    XCTAssertEqual(reactor.currentState.isBookmarked, false)
}

一个 action 有时会导致 state 多次改变。比如,一个 .refresh action 首先将 state.isLoading 设置为 true,并在刷新结束后设置为 false。在这种情况下,很难用 currentState 测试 stateisLoading 的状态更改过程。这时,你可以使用 RxTestRxExpect。下面是使用 RxExpect 的测试案例:

func testIsLoading() {
  RxExpect("it should change isLoading") { test in
    let reactor = test.retain(MyReactor())
    test.input(reactor.action, [
      next(100, .refresh) // send .refresh at 100 scheduler time
    ])
    test.assert(reactor.state.map { $0.isLoading })
      .since(100) // values since 100 scheduler time
      .assert([
        true,  // just after .refresh
        false, // after refreshing
      ])
  }
}

Scheduling 调度

定义 scheduler 属性来指定发出和观察的状态流的 scheduler。注意:这个队列 必须 是一个串行队列。scheduler 的默认值是 CurrentThreadScheduler

final class MyReactor: Reactor {
  let scheduler: Scheduler = SerialDispatchQueueScheduler(qos: .default)

  func reduce(state: State, mutation: Mutation) -> State {
    // executed in a background thread
    heavyAndImportantCalculation()
    return state
  }
}

示例

  • Counter: The most simple and basic example of ReactorKit
  • GitHub Search: A simple application which provides a GitHub repository search
  • RxTodo: iOS Todo Application using ReactorKit
  • Cleverbot: iOS Messaging Application using Cleverbot and ReactorKit
  • Drrrible: Dribbble for iOS using ReactorKit (App Store)
  • Passcode: Passcode for iOS RxSwift, ReactorKit and IGListKit example
  • Flickr Search: A simple application which provides a Flickr Photo search with RxSwift and ReactorKit
  • ReactorKitExample

依赖

其他

其他信息可以查看 github

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

推荐阅读更多精彩内容

  • 前段时间在RxSwift上做了一些实践,Rx确实是一个强大的工具,但同时也是一把双刃剑,如果滥用的话反而会带来副作...
    L_Zephyr阅读 4,195评论 0 15
  • Vuex是什么? Vuex 是一个专为 Vue.js应用程序开发的状态管理模式。它采用集中式存储管理应用的所有组件...
    萧玄辞阅读 3,113评论 0 6
  • 在还没遇到 ReactorKit 这个框架之前,我使用 RxSwift + MVVM 去构建如图的信息流时,确实为...
    灵度Ling阅读 7,678评论 0 41
  • 安装 npm npm install vuex --save 在一个模块化的打包系统中,您必须显式地通过Vue.u...
    萧玄辞阅读 2,932评论 0 7
  • 这段时间一直在忙文字工作,因为在学习部,因为有数不清的论文,也因为自己想要记录的生活点滴。有时候看看别人的生...
    琥珀姑娘阅读 457评论 6 12