使用Rx实现MVVM架构
简介
最近接手了一个N年前的老项目,由于实在是过于陈旧,于是提出想要重构项目该项目的想法,没想到和项目经理竟然达成了共识。在重构初始阶段项目框架搭建时,考虑到将来所承载的业务的可变性比较大,如果单纯的从翻新项目的角度出发,可能会在日后的迭代过程中埋下隐藏的炸弹,所以决定使用MVVM
架构进行新项目的研发。项目基础组件已经完成开发,目前,正在进行登录业务模块的功能开发,通过使用Rx
让MVVM
的实现不管是从可测试性还是可扩展性而言,都变得十分的优雅了。甚至对于业务的剥离的效果十分明显。
接下来,我把到目前为止的所有实现分为以下3个核心部分:
(1). MVVM
架构思想;
(2). 函数响应式编程;
(3). 登录的实践;
MVVM架构思想
分析下现在热门的几款架构模式中:MVP
、MVCS
、VIPER
以及MVVM
究其根本,其实都是冲MVC
衍变优化而来。当然,对于经典的MVC
我们用一句话概括:尽管这种设计足够优秀,但是在使用过程中很难确定究竟应该在哪个模块做什么事情,以至于出现了Controller
变臃肿和胖Model
的窘境。
回过头来看MVVM
,现在可以说但凡是个程序猿,没有不知道它的。不管你在项目中如何使用它,都逃离不掉3大模块:
(1). 数据层,也就是Model
;
(2). 视图模型层,也就是ViewModel
;
(3). 视图层,也就是View
或者ViewController
;
PS:有些会把它分为4块,我更倾向于ViewController
包含于视图层的观点。
正是由于在View
和Model
之间多加了一层ViewModel
,让原来无处安放的网络请求和大量的复杂业务有了明确的去处,解决了原来MVC
测试性差和日益臃肿的Controller
的问题。同样将业务抽离到ViewModel
中后,也降低了代码的耦合度,提高了复用性。因为对于同样的业务,如果采用在Controller
中实现的话,当不同界面的Controller
要使用到相同的业务时,就没办法服用之前的代码,而需要复制粘贴了。但是有了ViewModel
后,同样的业务,只需在对应的Controller
中执行同样的ViewModel
就行了。
MVVM
的精髓我认为是在于如何处理View
和Model
绑定的问题。按照以前的惯例,我们或许会在某一个cell
中声明一个model
属性,然后在实现文件中自定义它的setter
方法,最后拿到数据进行展示。除非你的model
没有做过翻译直接是一个dictionary
,或者通过id
类型使用kvc
取值,否则这种方法会导致view
和model
形成跨层的访问。从大大降低了代码的可阅读性,对于后期维护十分不便。
为了解决这样的跨层访问,通常ViewModel
中可以通过kvo
来实现model
到view
的映射。但是当view
接收到用户操作后需要进行model
修改时,常用的就是Target-Action
或delegate
,甚至于Notification
、block
也可以,这些方法并非不可取,也足够优秀,但是却比较繁杂。而双向绑定的的出现就完美且优雅的改变了这种方式,这也是为什么一提到MVVM
我们就能连想到Rx
或RAC
的原因了。
同样,正是由于双向绑定的出现,Controller
完成的职责就很明确了,即将ViewModel
和View
进行绑定。所以业内流传的这张图片就很好的体现了这一变化:
经过这样的衍变之后,不难看出MVVM
的关系基本就是:View
<-> C
<-> ViewModel
<-> Model
。最终,促成了业务和UI
的分离,规范了View
和Controller
强耦合的性质。逻辑上而言,只有Controller
知道需要呈现的View
,也只有它知道需要使用的业务ViewModel
,两个层级之间通过Controller
形成桥梁进行通信。当然这里有个问题是,当用户操作通过View
想要改变Model
的属性时,业务线会变得比较长,而这块将会在后面列表展示时体现出来,本次实践中暂不做讨论。
尽管MVVM
如此优秀,但是任然需要注意很多细节,否则就会重蹈MVC
胖Controller
的覆辙,我总结了下,大概有以下几点需要注意的:
(1). Controller
中尽量不要做业务相关的事情,它的主要职责是管理subview
和进行View
和ViewModel
的绑定;
(2). 业务逻辑尽量让ViewModel
来处理。同时应该避免ViewModel
过于庞大而导致和MVC
相同的问题,所以ViewModel
之间是可以存在依赖的;
(3). subview
是否能够直接引用ViewModel
?既然UIViewController
和UIView
都是View
层的组件,所以是可以引用的。但是反过来却不行,因为一旦ViewModel
包含了View
,就会导致View
产生耦合,代码复用性和可测试性都会降低;
(4). ViewModel
可以引用Model
。但是反过来却不行,原因同上;
函数响应式编程
通常我们在开发中会建立很多网状关系,代码如下:
int a = 1;
int b = a + 1;
print(b); // b = 2
a = 5;
print(b); // b = 2
这样的代码没有任何问题,但是我们想要的却是变量a
和变量b
形成对应关系,当a
改变时应该触发b
的更新操作。在没有响应式的前提下,需要花精力去维护一套这样的“关系”,无疑会增加开发成本。而响应式编程就是想通过某些操作来帮我们构建这种关系:
int a = 1;
int b <- a + 1; // <- 是响应式中定义的一种操作
print(b); // b = 2
a = 5;
print(b); // b = a + 1 = 6
所以响应式编程的核心就是通过定义好的操作来建立这种关系,而不是通过赋值等。响应式编程就是一种通过异步和数据流来建立事务关系的编程模型。它是一种基于事件的,专注于数据流和变化传递的编程范式。
而函数响应式编程就是通过函数来创建这种“关系”,并在适当的地方响应这种“关系”,从而得到正确的结果。
在众多函数响应式的框架中ReactiveX
应该算是代表了。它是一套完整的跨平台的解决方案,而RxSwift
则是它对iOS/macOS
平台支持的三方库。在RxSwift
中最核心的包括:
(1). Observable - 产生事件;
(2). Observer - 响应事件;
(3). Operator - 创建变化组合事件;
(4). Disposable - 管理绑定(订阅)的生命周期;
(5). Schedulers - 线程队列调配;
这里假设你对RxSwift
有一定的了解,理应知道这5大核心的左右和如何使用,如若不清楚的,可以通过连接自行查看中文教程。
我们有说到Rx
改变了常见的响应方式,包括Target-Action
、Delegate
、Notification
、Block
等等。从代码层面是如何体现的呢:
// MARK: Target-Action 使用 RxSwift 实现的对比
button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
func buttonTapped() {
print("button Tapped")
}
// Rx
button.rx.tap
.subscribe(onNext: {
print("button Tapped")
})
.disposed(by: disposeBag)
// MARK: Delegate 使用 RxSwift 实现的对比
class ViewController: UIViewController {
...
override func viewDidLoad() {
super.viewDidLoad()
scrollView.delegate = self
}
}
extension ViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
print("contentOffset: \(scrollView.contentOffset)")
}
}
// Rx
class ViewController: UIViewController {
...
override func viewDidLoad() {
super.viewDidLoad()
scrollView.rx.contentOffset
.subscribe(onNext: { contentOffset in
print("contentOffset: \(contentOffset)")
})
.disposed(by: disposeBag)
}
}
// MARK: 闭包或 Block 使用 RxSwift 实现的对比
URLSession.shared.dataTask(with: URLRequest(url: url)) {
(data, response, error) in
guard error == nil else {
print("Data Task Error: \(error!)")
return
}
guard let data = data else {
print("Data Task Error: unknown")
return
}
print("Data Task Success with count: \(data.count)")
}.resume()
// Rx
URLSession.shared.rx.data(request: URLRequest(url: url))
.subscribe(onNext: { data in
print("Data Task Success with count: \(data.count)")
}, onError: { error in
print("Data Task Error: \(error)")
})
.disposed(by: disposeBag)
// MARK: Notification 使用 RxSwift 实现的对比
var ntfObserver: NSObjectProtocol!
override func viewDidLoad() {
super.viewDidLoad()
ntfObserver = NotificationCenter.default.addObserver(
forName: .UIApplicationWillEnterForeground,
object: nil, queue: nil) { (notification) in
print("Application Will Enter Foreground")
}
}
deinit {
NotificationCenter.default.removeObserver(ntfObserver)
}
// Rx
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.rx
.notification(.UIApplicationWillEnterForeground)
.subscribe(onNext: { (notification) in
print("Application Will Enter Foreground")
})
.disposed(by: disposeBag)
}
// MARK: 多任务依赖使用 RxSwift 实现的对比
API.token(username: "beeth0ven", password: "987654321",
success: { token in
API.userInfo(token: token,
success: { userInfo in
print("获取用户信息成功: \(userInfo)")
},
failure: { error in
print("获取用户信息失败: \(error)")
})
},
failure: { error in
print("获取用户信息失败: \(error)")
})
// Rx
API.token(username: "beeth0ven", password: "987654321")
.flatMapLatest(API.userInfo)
.subscribe(onNext: { userInfo in
print("获取用户信息成功: \(userInfo)")
}, onError: { error in
print("获取用户信息失败: \(error)")
})
.disposed(by: disposeBag)
// MARK: 等待多个并发任务完成后处理结果使用 RxSwift 实现
Observable.zip(
API.teacher(teacherId: teacherId),
API.teacherComments(teacherId: teacherId)
).subscribe(onNext: { (teacher, comments) in
print("获取老师信息成功: \(teacher)")
print("获取老师评论成功: \(comments.count) 条")
}, onError: { error in
print("获取老师信息或评论失败: \(error)")
})
.disposed(by: disposeBag)
代码从来都不会说谎。RxSwift
从维护性、易读性甚至代码量上都远优于未使用时,对比十分明显。特别是对异步操作时简化了代码逻辑,统一了代码风格,而我们只需要专注的做业务相关的研发就可以了。同时,这也是为什么我们要使用它的原因。
那么Rx
是不是就是完美的呢?我认为至少存在以下问题:
(1). 学习Rx
的难度相对于老方法要难得多,学习成本大;
(2). 不便于调试。原来出现问题会在固定的地方发现,而现在经过 Rx
传递后可能被转移;
想要了解并学习更多关于RxSwift
的知识可以在这里获得 - RxSwift中文教程。
登录的实践
和普通登录页面一样,账号和密码通过对应的正则判断后控制了登录按钮的交互状态。点击登录后登录按钮关闭交互状态并禁止页面上的其他控件产生交互,如下图所示:
Rx实现MVVM的登录页面.png
在MVVM
设计模式中,ViewModel
的实现至关重要。良好的ViewModel
的设计将直接影响程序的维护性和可测试性等。在这篇文章中对ViewModel
的实现有十分重要的指导作用,我也是根据它的原理来设计和实现的 - 怎么搞定ViewModel。
不难看出,在本例中Inputs
是:
(1). 手机号输入事件;
(2). 密码输入事件;
(3). 登录按钮点击事件;
Outputs
是:
(1). 登录状态,控制登录按钮的UI
呈现。在正常状态下颜色亮一些,不可点击时颜色浅,登录执行中存在菊花;
(2). 账号和密码检测,项目中没有用到,将来可能会在UI
上体现出来;
(3). 登录中,用户不能和登录页面进行交互;
(4). 登录结果;
从而,得出Inputs
和Outputs
应该长这样:
inputs: (
account: Driver<String>,
password: Driver<String>,
loginTaps: Signal<()>
)
struct Outputs {
// 账号和密码的验证
let validatedAccount: Driver<SignValidationResult>
let validatedPassword: Driver<SignValidationResult>
let signupState: Driver<ActivityButton.State> // 登录状态
let signedIn: Driver<Bool> // 登录结果
let signingIn: Driver<Bool> // 登录中
}
这时候,我们可以通过服务的方式提供具体的验证实现和网络接口:
enum SignValidationResult {
case ok(message: String)
case empty
case failed(message: String)
}
extension SignValidationResult {
var isValid: Bool {
switch self {
case .ok:
return true
default:
return false
}
}
}
// 提供登录账号和登录密码正则判断的验证服务
protocol SignValidationService {
func validateAccount(_ account: String) -> SignValidationResult
func validatePassword(_ password: String) -> SignValidationResult
}
// 提供登录接口的服务
protocol MxsSignAPI {
func signup(_ account: String, password: String) -> Observable<Bool>
}
在验证服务中,由于项目本身使用的是本地正则来判断是否合法,是能够立即得到结果而并非异步等待,所以直接返回的验证结果的枚举。当这里的验证是由服务端执行的,那么在和服务端交互时,验证返回的应该是一个事件Observable <SignValidationResult>
,这样才能统一异步序列,否则就会出现因为异步开发导致的一些不太好处理的问题。当然,之所以使用协议来定义这些服务是想要增加扩展性和稳定性,当默认实现不满足需求时,可以通过新的实现来扩展功能,而无需对已存在的接口进行修改,只要满足条件,那么修改的就只有协议的实现部分,其他任然保持原样(PS:协议的默认实现我就不给出来了,留给你们想象的空间>_<) 。这样我们可以得出一个小技巧:异步操作时使用Observable
,否则服务方直接返回结果。
登录是一个异步的网络过程,当登录请求正在触发工程中时,输入框和按钮都是无法交互的。所以需要有一个东西能够监听到请求服务的状态变化,ActivityIndicator
正好能够做到这样的事情。ActivityIndicator
服从协议SharedSequenceConvertibleType
,直接调用asObservable()
就可以得到_loading
的状态。
_loading
可以看成一个发射器,当_relay
行为的值为0是发送一个false
的值,当_relay
行为值大于0时发送一个true
的值,表明当前还有Observable
正在执行。再通过increment
和decrement
来处理Observable
的数量:
private let _relay = BehaviorRelay(value: 0)
private let _loading: SharedSequence<SharingStrategy, Bool>
_loading = _relay.asDriver()
.map { $0 > 0 }
.distinctUntilChanged()
private func increment() {
_lock.lock()
_relay.accept(_relay.value + 1)
_lock.unlock()
}
private func decrement() {
_lock.lock()
_relay.accept(_relay.value - 1)
_lock.unlock()
}
为了能够将ActivityIndicator
和需要监听的Observable
绑定,专门为Observable
扩展了一个trackActivity()
的方法,传入一个ActivityIndicator
来跟踪Observable
的状态:
public func trackActivity(_ activityIndicator: ActivityIndicator) -> Observable<Element> {
return activityIndicator.trackActivityOfObservable(self)
}
class ActivityIndicator {
fileprivate func trackActivityOfObservable<Source: ObservableConvertibleType>(_ source: Source) -> Observable<Source.Element> {
return Observable.using({ () -> ActivityToken<Source.Element> in
self.increment()
return ActivityToken(source: source.asObservable(), disposeAction: self.decrement)
}) { t in
return t.asObservable()
}
}
}
通过调用Observable
的using
方法将increment
和decrement
与Observable
的生命周期绑定。当using
调用resourceFactory
时执行increment
,当dispose
时执行decrement
,这样就可以检测有多少个Observable
处于正在处理的状态了。
至此,ViewModel
实现代码如下:
class SignupViewModel {
struct Outputs {
let validatedAccount: Driver<SignValidationResult>
let validatedPassword: Driver<SignValidationResult>
let signupState: Driver<ActivityButton.State>
let signedIn: Driver<Bool>
let signingIn: Driver<Bool>
}
let outputs: Outputs
init(
inputs: (
account: Driver<String>,
password: Driver<String>,
loginTaps: Signal<()>
),
dependency: (
API: MxsSignAPI,
validationService: SignValidationService
) = (MxsSignDefaultAPI(), SignValidationDefaultService())
) {
let API = dependency.API
let validationService = dependency.validationService
let accountAndPassword = Driver.combineLatest(inputs.account, inputs.password) { ($0, $1) }
let activity = ActivityIndicator()
let signingIn = activity.asDriver()
let validatedAccount = inputs.account
.map { account in
return validationService.validateAccount(account)
}
let validatedPassword = inputs.password
.map { password in
return validationService.validatePassword(password)
}
let signedIn = inputs.loginTaps.withLatestFrom(accountAndPassword)
.flatMapLatest {
return API.signup($0.0, password: $0.1)
.trackActivity(activity)
.asDriver(onErrorJustReturn: false)
}
let signupState = Driver.combineLatest(
validatedAccount, validatedPassword, signingIn
) { username, password, signingIn -> ActivityButton.State in
if username.isValid && password.isValid {
if signingIn {
return ActivityButton.State.activiting
} else {
return ActivityButton.State.default
}
} else {
return ActivityButton.State.disable
}
}
.distinctUntilChanged()
outputs = Outputs(validatedAccount: validatedAccount,
validatedPassword: validatedPassword,
signupState: signupState,
signedIn: signedIn,
signingIn: signingIn)
}
}
将账号(account
)和密码(password
)的输入利用combineLatest
组合起来得到账号密码的组合序列accountAndPassword
,只要账号和密码中的任意一个改变都会触发一次事件。将账号和密码的输入,利用map
和提供的服务,转化为校验结果的序列validatedAccount
和validatedPassword
。当发送网络请求时,利用ActivityIndicator
监听请求状态,再和校验结果的信号组合,从而得到登录按钮当前所属状态。最后将得到的信号包装为Outputs
提供给Controller
用于绑定或事件,完成了ViewModel
的功能。
番外
在这次重构中,一边学习RxSwift
一边整理,真正体会到了MVVM
的精髓。由于登录业务还是比较简单的,还不能以偏概全的认为“就是这样”,在重构过程中,后面会遇到真正的难点,对于复杂页面和复杂业务的实现。到时候再以续集的博客形式记录。
最后附上本次RxSwift
官方的例子作为参考:GitHubSignup - 简易仔细阅读