首先我们来明确一下,测试中最核心的东西是什么。当然是数据,我们永远是围绕着数据来的,那么之前一些架构的问题是什么。无论哪个框架,数据的流通都是双向的,当数据流通成为单向了会怎么样呢?
in data ==> Module ==> out data
这样我们伪造数据进行测试就会非常方便了。按照这个思想就有了数据单向流通的架构。
这个概念最早是在web中提出的,应用在React里,官方的方案是Redux
。现在swift也提出了一种实现ReSwift
。
我在之前写React的时候使用过这种方案,从开发角度来说,这种方案会大大增加开发难度,代码量也会大量增加,而且开发思路也需要从以前的思考方式转换过来。但是如果我们把这个思路转换过来,其实对整个流程是更加简化和分离的。
从测试角度看,我觉得无疑是我知道的最可测的一种框架,甚至可以测试部分视图的逻辑。
那么总的来说,很难说这种结构的好坏,就算不考虑增加的开发时间,也是一种难以给以一种评价的方案。
方案的几个核心是:
关于pure function,我就不做太多介绍了,简单的说,就是同一输入必定会有相同的输出,是非常容易测试的一种函数。
首先,我们来看一下官方的架构图。
可以看到,数据流动方向都是朝一个方向进行的。那么下面从每个模块来介绍下,还是以star button为例子。
视图状态机,也是所有会更新界面数据保存的地方,可以认为相当于ViewModel。
首先我们star会有以下几种视觉样式
enum StarButtonState {
case star
case staring
case unstar
case unstaring
}
所以State可以定义为
struct StarState: StateType {
var state: StarButtonState
var starCount
}
首先我们定义几种状态机转换的Action类型
struct StarAction: Action { }
struct StaringAction: Action { }
struct UnstarAction: Action { }
struct UnstaringAction: Action { }
以及相应的功能以及状态变更,这里异步请求采用延迟来代表。
func star(id: String) -> Store<StarState>.ActionCreator {
return { state, store in
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
store.dispatch(StarAction())
}
return StaringAction()
}
}
func unstar(id: String) -> Store<StarState>.ActionCreator {
return { state, store in
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
store.dispatch(UnstarAction())
}
return UnstaringAction()
}
}
视图层其实很简单,只需要根据State的不同来更新就可以了。注意的是,更新都是无状态的,和上一个状态无关,所以view层是个无状态层。
class StarButton: UIButton, StoreSubscriber {
let store = Store<StarState>(reducer: starReducer, state: nil)
override init(frame: CGRect) {
super.init(frame: frame)
self.store.subscribe(self)
}
func newState(state: StarState) {
// update UI
}
}
状态转换器,唯一可以更新State的地方。
func starReducer(action: Action, state: StarState?) -> StarState {
var state = state ?? StarState(state: .star, starCount: 0)
switch action {
case _ as StarAction:
state.state = .star
state.starCount += 1
case _ as StaringAction:
state.state = .staring
case _ as UnstarAction:
state.state = .unstar
state.starCount -= 1
case _ as UnstaringAction:
state.state = .unstaring
default:
break
}
return state
}
那么最重要的就是数据如何传递的了。首先要明确的是每个模块能够修改的,或者说是传递的,只能是下个模块。
比如,用户star button触发了一个事件:
func onButton(sender: StarButton) {
if (store.state.state == .unstar) {
store.dispatch(star(id: id))
}
else if (store.state.state == .star) {
store.dispatch(unstar(id: id))
}
}
此时会创建Action,也就是将view事件转换为Action。然后会传递到store中,store会调用Reducer进行处理。Reducer更新state之后又会触发store的subscribe事件,回到view的func newState(state: StarState)
。
View (User Event)
==(create)==> ActionCreator/Action
==(dispatch)==> Store <--(Update State)--> Reducer
\==(subscribe)==> View (newState)
大概的一个流程就是这样了。
接下来说说这样做的模块化的优势。
首先,我们需要有函数式编程的概念,函数也是一等公民,所以ActionCreator
和Reducer
都是独立的模块。
作为使用者,我们在不需要像MVC一样知道这些api所代表的操作功能,相对应的,我们需要去了解一个模块的动作(Action),比如以上例子就是
func star(id: String)
func unstar(id: String)
这样的划分比MVC要友好的多,真正的把逻辑功能从原本的C中分离开。需要触发这个行为也非常简单store.dispatch(star(id: id))
。相比MVP,行为更加的独立,每个行为之间完全没有联系,也不会产生干扰影响。同时因为每个行为的独立性,可复用程度也就越高。
Reducer则代表了view层的更新,也可以非常明确的知道每个状态的变更发生了什么。相比其他模式,将界面更新完全交给view或者Controller,Reducer是最明确也是最清晰的。同时Reducer也是独立的,可以替换的。
对于UIKit层面我们无法单元测试,所以测试的主要部分是Action
和Reducer
。这两个模块可以说都是pure function
或者在某些条件下是pure function
的,所以测试也非常的简单。
和这个模式比较像的有状态机模式和Reactive。
状态机模式也是实现对应功能,以及对应状态,然后通过子类化的方式去实现Reducer的功能。
Reactive则比较像ActionCreator,只是Reactive返回的是信号量。
从上面可以看出这是一套非常优秀的模块划分方案,但同时也会大大增加代码量,而且需要改变以前的思维模式。而对于目前国内的现状来看,很难有这么多时间和精力让整个项目都使用这种模式。
但是这种模式的特点也非常的明显,在处理比较复杂的交互行为,并且存在较多的视图状态的时候,会是一种比较好的方案。比如视频播放界面。
所以个人认为,在一些简单的场景下并不需要使用该方案,但是在一些复杂的交互页面,而且又非常想要引入单元测试的场景,可以酌情考虑下这种方案。这种方案要求人们的思维方式的改变,需要有一定的函数式编程的概念。
虽然不一定会直接使用ReSwift,但是这种思想有很多值得借鉴的地方,利用这种思想做出类似的效果,以便达到可以容易进行白盒测试的目的。
以上虽然说不会全部使用该方案,但也可以部分使用。比如独立的小模块,亦或是app层面的一些东西。下次可以讨论下app层面如何来利用单向数据流来简化流程。
本文来自网易实践者社区,经作者段家顺授权发布。