到年底了,为什么不在 iOS 上尝试 ReX 呢?

相关代码链接:
项目链接:ReX
结合与服务器交互的 Demo :ReX-Todo-Demo
Rex 是一个极其轻量的框架,甚至是不依赖 Foundation 框架。你可以将它搭配任何响应式框架、任何绑定库使用,甚至你可以不搭配任何框架去使用。

从年初的 Swift 大会到年底,断断续续地用了 RxSwift 有小一年了,在学习了基本的语法之后不断地去到底我们能把这个响应式的框架用到什么程度。

直到十月份我学习了 Vue 和 React Native 的 Hello World ,以及看了 Vuex 对于 Flux 思想的实现,总算是找到了一套比较完整的解决方案。

知识预热

什么是 Flux

如果你对 Flux 有一定了解,你可以跳过这部分。

了解相关知识你可以参考下面几篇文章,也可以继续阅读,先了解的大概(文章是我随便选的,毕竟这一概念理解起来并不复杂)。

Flux 是 Facebook 提出的一种模式,但并未给出具体的实现(装完逼就跑的感觉真爽。。。)。

如果我的理解有不合理地方欢迎直接指出来。

在一个大型项目中,我们可能以 MVC 或者 MVVM 的形式去书写我们的代码,但单从这两个框架角度去看,它们没有给出解决状态改变状态共享等方案。

Flux 以单向数据流的形式保证了代码的可维护和易读性,并给出了合理的状态管理方案

在 Flux 中主要有四个关键词:

  • View:视图层,展示数据和接受用户输入行为。
  • Store:数据层,保存当前数据的状态。
  • State: 状态,依赖 Store ,保存在 Store 中,通常我们通过 State 获取状态,一般这些都是响应式的数据,可以用来绑定到 View 上。
  • Action:各种会改变状态行为,比如添加一个值、删除一个项目等。
  • Dispatch:分发 Action 到 Store 中。

下图是一个超精简的描述。

建立一个 State 到 View 的关系描述,即将 State 绑定到 View 上。

View 的变化(比如用户点击按钮)会生成一个 Action ,然后将 Action 分发(Dispatch)到对应的 State 中。

在 Redux 中会在对应的地方接收这个 Action ,并根据当前的状态,返回一个更新后的状态。

而这一状态变化会影响到之前建立好的 State 与 View 的描述的关系,即更新视图状态。

三者影响的方式大致如下图:

优势

在 Flux 中,有几项基本规定:

  • 不可以直接修改 State
  • 修改 State 只能通过 Dispatch 一个 Action

在基于上述规定书写我们的代码就会尽可能的让代码更好维护,我们来看 MVC 和 Flux 数据流向的区别:

夸张的 MVC
夸张的 MVC
可能我们写着写着不小心就写成这样了。
Flux
Flux

Flux 相比这种场景就好了很多,当然我们没有一定要求只有一个 State 。

在多个 ViewController 交互的场景下,共享一个 State 可以很好的解决状态共享的问题。

举个例子,两个 ViewController 一个是项目列表、一个是项目详情。

在我们更新了某个项目的名称时,自然我们也要去更新项目列表中对应的项目的名称。

每次都在 viewWillAppear 中更新一遍数据是比较尴尬的,因为查看一下详情,每个项目都没有什么变化,再去更新数据是浪费。而如果在项目详情更改后,我们 dispatch 一个 action ,这个 action 会更改项目信息 state ,这一 state 变化则会更新到项目列表页。这就解决了两个 ViewController 交互麻烦的问题。

那么接下来就是如何在 iOS 中应用这套方案的问题了。

相比 Redux 的实现套路,Vuex 显得更简单严格些,更容易明白找到对应的 Action 修改了什么,不像 Redux 那样,调用一个全局的 dispatch 那样懵逼。

参考 Vuex 的形式在 iOS 上实践

Vuex 的使用方式和 Redux 不太一样,我将在这一部分结合一个 Todo(带网络请求) 的 Demo 讲解如何合理使用 ReX 。

先来了解一下这个 Demo

Todo
Todo

首先,为了更契合我们实际开发的场景,所有的数据都是保存在服务器上的,并提供了下拉刷新、网络请求状态、请求结果的展示。

对于这个项目,我们提供了以下几个基本功能:

  • 添加一个待办事项(名称、备注)
  • 修改一个代办事项(名称、备注)
  • 标记完成该待办事项(列表页滑动后选择完成、详情页点击切换控件选择完成)
  • 删除该待办事项(列表页滑动删除、详情页点击按钮删除,删除前均有弹窗)

这个项目以来以下几个优秀的框架,建议你对此有一些了解:

github "DianQK/ReX" ~> 0.9
github "ReactiveX/RxSwift" ~> 3.0
github "RxSwiftCommunity/RxDataSources" ~> 1.0
github "ishkawa/APIKit" ~> 3.0
github "jdg/MBProgressHUD" ~> 1.0

相信这个 Demo 足以让你了解如何使用 ReX 进行 iOS 项目开发。

下面的内容、概念会大量参考 Vuex ,你可以从该文档中了解更多内容。

ReX 是什么

ReX 是一个参考 Vuex ,专为 iOS 应用程序开发的状态管理模式。你可以选择用它来集中式储存管理应用的所有组件状态。你也可以创建多个储存管理(Store),只要这些 Store 之间没有什么状态关联性。并提供了一个基本的插件工具,你可以用它来进行一些基本的调试。

什么是“状态管理模式”?

来看一个简单的计数管理应用。

这个例子依赖了 RxSwift 响应式框架,建议先对该框架有个基本的了解。或者你只需要了解绑定指的是什么。
class CountStore: Store {
fileprivate let count = Variable<Int>(0)
}
extension State where Base: CountStore {
var count: GetVariable<Int> {
return base.count.asGetVariable()
}
}
extension Mutation where Base: CountStore {
func increment() {
base.count.value += 1
}
}
你可以把 GetVariable 当作 Variable 看待。它和 Variable 唯一不同就是 value 对外是只读的。

这个状态自管理应用包含以下几个部分:

  • State,驱动应用的数据源;
  • Mutation,响应在 view 上的用户输入导致的状态变化。

以下是一个表示“单向数据流”理念的极简示意:

但是,当我们的应用遇到多个组件(ViewController)共享状态时,单向数据流的简洁性很容易被破坏,因为:

  1. 多个视图依赖于同一状态。
  2. 来自不同视图的行为需要变更同一状态。

对于问题一,传参的方法对于多层嵌套的组件(ViewController)将会非常繁琐,并且对于兄弟组件间的状态传递无能为力。

对于问题二,我们经常会采用父子组件直接引用或者通过事件来变更和同步状态的多份拷贝。以上的这些模式非常脆弱,通常会导致无法维护的代码。

因此,我们为什么不把组件的共享状态抽取出来,以一个全局单例模式管理呢?在这种模式下,我们的组件树构成了一个巨大的“视图”,不管在树的哪个位置,任何组件都能获取状态或者触发行为!

另外,通过定义和隔离状态管理中的各种概念并强制遵守一定的规则,我们的代码将会变得更结构化且易维护。

这就是 ReX 背后的基本思想,基本上前边几句话是拷贝 Vuex 的。

开始

每一个 ReX 应用的核心就是 store(仓库)。”store” 基本上就是一个容器,它包含着你的应用中大部分的状态(即 state)。Rex 和单纯的全局对象有以下两点不同:

  1. ReX 的状态存储是响应式的。当组件(ViewController/View)从 store 中读取状态的时候,若 store 中的状态发生变化,那么相应的组件(View)也会相应地得到高效更新。
  2. 不能直接改变 store 中的状态。改变 store 中的状态的唯一途径就是显式地提交 mutation。这样使得我们可以方便地跟踪每一个状态的变化。

CountStore

在解释状态管理模式中,我贴出了一个 CountStore 的代码。

import ReX
import RxSwift
class CountStore: Store {
fileprivate let count = Variable<Int>(0)
}
extension State where Base: CountStore {
var count: GetVariable<Int> {
return base.count.asGetVariable()
}
}
extension Mutation where Base: CountStore {
func increment() {
base.count.value += 1
}
}

CountStore 需要服从协议 Store 。

在 CountStore 中持有一个 count 的私有属性。

并在 State 中对外暴露一个 count 属性。注意,该属性是响应式的,我们可以订阅 count 的变化。

在 Mutation 中,我提供了一个 increment 方法。

你可以通过 store.state 来获取状态对象,以及通过 store.commit 方法触发状态变更:

let store = CountStore()
print(store.state.count.value) // -> 0
store.commit.increment()
print(store.state.count.value) // -> 1

我们通过提交 mutation 的方式,而非直接改变 store.state.count,是因为我们想要更明确地追踪到状态的变化。这个简单的约定能够让你的意图更加明显,这样你在阅读代码的时候能更容易地解读应用内部的状态改变

由于 store 中的状态是响应式的,我们可以这样写代码:

let store = CountStore()
_ = store.state.count.asObservable()
.subscribe(onNext: { count in
print(count)
}
store.commit.increment()

输出结果为:

0
1

现在我们回到之前我们提到的那个简单的 Todo 应用,我将结合这个应用讲解接下来的几个概念。

核心概念

在前面我们已经了解了 Todo 应用的基本功能,这次我们来看一下代码的基本结构:

  • TodoItemModel,定义了 Todo 的 Model 。
  • TodoRequest,定义了全部的网络请求。
  • TodoStore,一个 Todo 项目的 Store 。
  • TodoViewController,Todo 列表页。
  • TodoItemViewController,Todo 详情页。
class TodoStore: ReX.Store {
fileprivate let list = Variable<[TodoItemModel]>([])
}

TodoStore 持有了一个完整的 list ,这里保存着全部的数据。

State

在 ReX 中获取状态(展示状态)最简单的方法就是调用 store.state 。

extension ReX.State where Base: TodoStore {
var list: GetVariable<[TodoItemModel]> {
return base.list.asGetVariable()
}
}

我在 State 中扩展了一个对外暴露的 list 属性,我们可以直接绑定到 UITableView 上:

store.list.asObservable()
.observeOn(MainScheduler.asyncInstance)
.bindTo(tableView.rx.items(dataSource: dataSource))
.addDisposableTo(disposeBag)

Getter

在state中获取的list不能满足这个 Todo 应用的需求,创建两个 Section 数据,分别是未完成和已完成

store.list.asObservable()
.map { list in
return [TodoSectionModel(model: "未完成", items: list.filter { !$0.isCompleted }),TodoSectionModel(model: "已完成", items: list.filter { $0.isCompleted })] }
.observeOn(MainScheduler.asyncInstance)
.bindTo(tableView.rx.items(dataSource: dataSource))
.addDisposableTo(disposeBag)

我们可以在绑定之前做一些处理。

这个处理成两个 Section 的过程,我们可能还会在其他 View 上用到。

ReX 提供了 Getter 的扩展方法(你可以把它当作是响应式的 Get 方法):

extension ReX.Getter where Base: TodoStore {
var completedList: Observable<[TodoItemModel]> {
return base.list.asObservable().skip(1)
.map { $0.filter { $0.isCompleted } }
}
var uncompletedList: Observable<[TodoItemModel]> {
return base.list.asObservable().skip(1)
.map { $0.filter { !$0.isCompleted } }
}
var sectionList: Observable<[TodoSectionModel]> {
return base.list.asObservable()
.map { list in
return [TodoSectionModel(model: "未完成", items: list.filter { !$0.isCompleted }),TodoSectionModel(model: "已完成", items: list.filter { $0.isCompleted })] }
}
}

Getters 会暴露为 store.getter 对象:

store.getter.completedList
store.getter.uncompletedList
store.getter.sectionList

使用起来仍然是很方便:

store.getter.sectionList
.observeOn(MainScheduler.asyncInstance)
.bindTo(tableView.rx.items(dataSource: dataSource))
.addDisposableTo(disposeBag)

Mutation

更改 ReX 的 store 中的状态的唯一方法是提交 mutation 。ReX 中的 mutations 非常类似于事件:每个 mutation 的方法名/属性名表示事件类型,你可以在方法中传递各种你需要的参数。

extension ReX.Mutation where Base: TodoStore {
func deleteItem(_ item: TodoItemModel) {
if let index = base.list.value.index(where: { $0.id == item.id }) {
base.list.value.remove(at: index)
}
}

你也可以把它写成下面的样子:

可能是为了调用时更优雅些,我选择了下面这这种形式。
extension ReX.Mutation where Base: TodoStore {
func deleteItem() -> ((TodoItemModel) -> Void) {
return { [unowned store = self.base as TodoStore] item in
if let index = store.list.value.index(where: { $0.id == item.id }) {
store.list.value.remove(at: index)
}
}
}
}

mutation 必须是同步函数

一条重要的原则就是要记住 mutation 必须是同步函数。在 mutation 中不执行任何异步代码,可以保证每次 commit 一个 mutation 时,数据都会实时的改变。在调试时,这会变得非常友好。

为了处理异步操作,我们需要使用 Action 。

注意:你可以在 mutation 提交其他 mutation 。

Action

Action 类似于 mutation,不同在于:

  • Action 提交的是 mutation,而不是直接变更状态。
  • Action 可以包含任意异步操作。
这里我们先以 CountStore 为例。
import ReX
import RxSwift
class CountStore: Store {
fileprivate let count = Variable<Int>(0)
}
extension State where Base: CountStore {
var count: GetVariable<Int> {
return base.count.asGetVariable()
}
}
extension Mutation where Base: CountStore {
func increment() {
base.count.value += 1
}
}
extension Action where Base: CountStore { // 这里
func increment() {
(base as CountStore).commit.increment()
}
}

我为 CountStore 添加了一个 Action 。

分发 Action

Action 通过 store.dispatch 方法触发:

store.dispatch.increment()

由于 mutation 必须同步执行代码,Action 就不受此约束,我们可以在 action 内部执行异步操作:

extension Action where Base: CountStore {
func increment() {
DispatchQueue.main.async {
(base as CountStore).commit.increment()
}
}
}

获取 Action 异步执行结果

Action 通常是异步的,那么如何知道 action 什么时候结束呢?更重要的是,我们如何才能组合多个 action,以处理更加复杂的异步流程?

第一件事你需要清楚的是 store.dispatch 的返回的是被触发的 action 函数的返回值,因此你可以在 action 中返回 Promise:

store.dispatch 返回的正是我们添加的 action 方法。我们可以返回一个 Observable/Promise以获取执行的结果。

enum RequestState {
case isLoading
case success(String?)
case failure(String?)
}
extension ReX.Action where Base: TodoStore {
func updateList() -> (() -> Observable<RequestState>) {
return { [unowned store = self.base as TodoStore] in
Session.rx.send(TodoRequest.List())
.do(onNext: store.commit.updateList())
.map { _ in RequestState.success("更新成功") }
.startWith(RequestState.isLoading)
.catchErrorJustReturn(RequestState.failure("更新失败"))
}
}
}

比如刷新列表这一 Action ,我们需要是否在刷新刷新是否成功的结果。

我在 do 中提交了 mutation updateList 。

这两个状态我们没有必要放到 State 中管理,因为我们可能有很多这样的状态需要处理,比如是否在添加是否添加成功等等。

这些状态只需要在调用 Action 时获取对应的结果。

refresh.rx.controlEvent(.valueChanged)
.flatMap(store.dispatch.updateList()) // dispatch updateList
.bindTo(view.rx.requestState)
.addDisposableTo(disposeBag)

我在触发刷新事件处 dispatch 了 updateList() 。

在完整项目中的 Store

在完成项目中只维护一个 Store 在 iOS 项目中并非那么轻松。我更推荐根据具体场景创建相应的 Store 。

比如 Workflow: Powerful Automation Made Simple这款应用。

My Workflows
My Workflows

My Workflows 可以共享同一个 Store ,完成添加、编辑等操作。

Gallery
Gallery

Gallery 可以共享同一个 Store ,完成浏览商店、搜索商店等操作。

需要注意的是,Gallery 中有一个 Get Workflow ,这里应该 dispatch 上面 My Workflows Store 的 Action 。

总结

到这里最一个简单的使用总结:

State

  • State 中的属性应当是响应式的。

Getter

  • Getter 中的属性应当是响应式的。
  • Getter 类似于 State 的计算属性。

Mutation

  • Mutation 中所有代码应当是同步的。
  • Mutation 中可以提交其他 Mutation 。

Action

  • Action 中可以执行异步代码。
  • Action 只能通过提交 Mutation 方式修改 State 。
  • Action 可以返回 Observable 、 Promise 等获取执行结果或状态。

插件

有总是好的。

ReX 提供了插件机制。我们可以在这里做订阅 State 、提交 Mutation 、分发 Action 等事情。

let store = CountStore()

store.plugin
.use(state: { state in
_ = state.count.asObservable()
.subscribe(onNext: { count in
print("Plugin: \(count)")
})
}, getter: { getter in

}, mutation: { mutation in

}, action: { action in

})

_ = store.state.count.asObservable()
.subscribe(onNext: { count in
print(count)
}

print(store.state.count.value)
store.commit.increment()
print(store.state.count.value)

调用 store.plugin 后,有两种 use 方法添加插件。

extension PluginProxy where Base: Store {

public typealias S = Base

public func use<P: PluginProtocol>(_ plugin: P) where P.S == Base

@discardableResult
public func use(
state: StateHandler<S>? = nil,
getter: GetterHandler<S>? = nil,
mutation: MutationHandler<S>? = nil,
action: ActionHandler<S>? = nil
) -> BasePlugin<S>
}

你可以直接使用第二种方法在参数中传递 state 等处理方法。

为了更高级的定制化,你也可以传入一个实现 PluginProtocol 的实体。

public protocol PluginProtocol {
associatedtype S: Store
var state: StateHandler<S>? { get }
var getter: GetterHandler<S>? { get }
var mutation: MutationHandler<S>? { get }
var action: ActionHandler<S>? { get }
}

补充

复习一下项目链接和 Demo 链接。

项目链接:ReX
结合与服务器交互的 Demo :ReX-Todo-Demo

在项目中我还提供了一个不和服务器交互的 Demo ,相信这两个 Demo 足以给出开发时常见的解决方案。

一定记得,ReX 本身不依赖任何框架,你可以套用这套思想,依赖 ReactiveCocoa 、Bond 、ReactiveKit 、PromiseKit 等框架。你也可以使用其他的响应式框架。(甚至是 Callback

去看一下 ReX 源码,可能会让你感觉非常有趣。

在已有项目中应用 ReX 并不复杂,你可以直接在某个小的模块中使用看看效果,创建一个小的 Store 即可。

项目中对于 RxSwift 的使用还有一些地方需要改进,比如移除可以移除的重复代码、比如如何移除 RxDataSources 刷新时带有更新动画问题。当然这不是本文的重点,留给有兴趣的读者来思考吧~

最后的最后,我还为此添加了 Template 文件到项目中。

你可以直接在 0.9.2 中下载到。相关介绍可以阅读#89: Custom Xcode File Templates 🔨

希望这一整套的解决方案能为你开发时带来一些便利和愉快。

ReX

Q&A

Q 项目名称谁起的?

A 陈晓亮

Q Demo API 谁出的?

A 这不重要。

Q 有没有 ReX with Callback 的例子?

A ReXWithCallback.zip

Q 什么时候发 1.0 版本?

A 看源码你就能明白 1.0 可能不是很重要。(但 API 可能会略有变动

Q Template 为什么没有 Icon ?

A 你可以帮我提交一个,感恩。

Q ReX 文档什么时候更新?

A 月底前。(可能是吧。


没有银弹。

本文使用 Ulysses 编写,OmniGraffle 作图。