问题
在 MVC 的结构中,UIViewController 比其他层更难以测试。因为UIViewcontroller中的有很高的耦合性,逻辑与视图的生命周期紧密的联系在一起,而且于对视图进行 mock/stub 也很困难,所以就 MVC 结构来说,UIViewcontroller 基本是无法进行有效的测试的。
MVVM
说MVVM是什么之前,先来说说不是什么。
MVVM 不是 ReactiveCocoa, RxSwift, KVO, 等等,也不是 “Model - View - ‘Magic’ -ViewModel”,但是并不是不可以使用这些,只是单纯的MVVM根本就不需要这些框架。
在团队开发中,其实我是不太推荐使用这些太重的框架的,具体有下列几个原因
- 学习曲线高,是否所有人都能接受。
- 调试相对困难。
- 这种改变开发模式的框架,都有一定的危险性,框架如果一旦不再维护或更新,将可能要导致应用重写。
什么是MVVM
Model
Model 是用来表示我们会对其进行处理的实际数据,而且其中不应该包含逻辑。
View
View 是直接与用户交互的,也不应该包含逻辑,只需要告诉它如何显示,并且在触发事件之后进行转发。
ViewModel
ViewModel 会跟踪 View 的事件,和Model 层传给它的数据。他会公开属性和方法,帮住View 保持最新的状态。
ViewController (iOS)
在 MVVM 中,并没有ViewController,但是在iOS 中你可以直接把ViewController理解成 View,当然你可以把它叫做 MVCVM ,不要在意这些细节。ViewController 监听事件的触发,然后通知ViewModel进行处理。
Example
下面我们用个登录的例子说明在iOS中使用MVVM,
当用户名和密码无效时,让文本框变成红色,用户名和密码有效时,激活提交按钮。
下面来看如何实现。
Model
在这个例子中没有真正的 Model, 如果存在Model,应该是容纳注册/登录信息。
View
忽略这个没任何设计的设计。
ViewModel
让我们先来简单的看一下ViewModel 的接口有哪些成员
import Foundation
import UIKit
@objc protocol ViewModelDelegate {
func reloadViews()
func alertInfo()
func moveToHomeScreen()
}
protocol ViewModelInterface {
weak var delegate: ViewModelDelegate? { get set }
var userNameBorderColor: UIColor? { get }
var passwordBorderColor: UIColor? { get }
var loginButtonEnable: Bool { get }
func userNameDidChange(text: String?)
func passwordDidChange(text: String?)
func login()
}
你可以看到有些的属性的是只读的,为了保持单向数据流,这是很有必要的。ViewController只能调用 ViewModel的方法,而ViewModel只能通知ViewController 更新当前的状态。
关于ViewModel 这里有一些规则:
- ViewModel 不能直接调用 ViewController 的方法,所以这里有一个委托来处理这些事情。
- 状态变更之后,ViewModel 必须通过委托通知 ViewController 重新加载视图。
- ViewModel 只能在主线程调用委托方法。
ViewModel 中暴露的属性必须是与ViewController 匹配的类型,这样可以避免属性类型的转换和解包操作。比如说,View的backgroundColor 为 UIColor 的可选类型,在ViewModel 中也必须定义相同类型** UIColor? **。
下面再来看看ViewModel 的实现
import UIKit
public class ViewModel: NSObject, ViewModelInterface {
weak var delegate: ViewModelDelegate?
var userNameBorderColor: UIColor? {
return usernameValid ? UIColor.blackColor() : UIColor.redColor()
}
var passwordBorderColor: UIColor? {
return passwordValid ? UIColor.blackColor() : UIColor.redColor()
}
var loginButtonEnable: Bool {
return usernameValid && passwordValid
}
private var usernameValid = false
private var passwordValid = false
private var userName: String?
private var password: String?
func userNameDidChange(text: String?) {
if text?.characters.count < 6 {
usernameValid = false
} else {
usernameValid = true
}
userName = text
delegate?.reloadViews()
}
func passwordDidChange(text: String?) {
if text?.characters.count < 6 {
passwordValid = false
} else {
passwordValid = true
}
password = text
delegate?.reloadViews()
}
func login() {
delegate?.moveToHomeScreen()
}
}
从这里你就可以看出事件的流动,调用ViewModel 的方法更新暴露的状态属性,然后通过委托告诉ViewController 可以更新状态了。
ViewController
在ViewController 也应该遵守几个规则
- ViewController 不能被ViewModel 直接调用,而是通过协议中隐式通知。
- reloadViews 更新视图的方法 可以在任何地方调用多次。
下面来看代码
class ViewController: UIViewController, ViewModelDelegate {
@IBOutlet weak var userNameField: UITextField!
@IBOutlet weak var passwordField: UITextField!
@IBOutlet weak var loginButton: UIButton!
var viewModel: ViewModel!
override func viewDidLoad() {
super.viewDidLoad()
viewModel = ViewModel()
viewModel.delegate = self
viewModel.layer.borderWidth = 2.0
viewModel.layer.borderWidth = 2.0
userNameField.addTarget(self, action: #selector(userNameChanged(_:)), forControlEvents: .EditingChanged)
passwordField.addTarget(self, action: #selector(passwordChanged(_:)), forControlEvents: .EditingChanged)
reloadViews()
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}
//MARK: Action
dynamic func userNameChanged(field: UITextField) {
viewModel.userNameDidChange(field.text)
}
dynamic func passwordChanged(field: UITextField) {
viewModel.passwordDidChange(field.text)
}
@IBAction func loginAction(sender: AnyObject) {
viewModel.login()
}
//MARK: ViewModelDelegate
dynamic func reloadViews() {
userNameField.layer.borderColor = viewModel.userNameBorderColor.CGColor
passwordField.layer.borderColor = viewModel.passwordBorderColor.CGColor
loginButton.enabled = viewModel.loginButtonEnable
}
dynamic func toHomeView() {
viewModel.moveToHomeScreen()
}
dynamic func alertInfo() {
}
}
你可以看到ViewController 中没有一句逻辑,当用户行为发生变化时,比如输入文本变化,或者按下按钮,之后告诉ViewModel。然后可能会被ViewModel通知需要重新加载视图,或者跳转页面。
这样我们可以很轻松的写出ViewModel 的单元测试,而不需要再去测试ViewController。
func testInvalidUsernameInvalidPassword() {
viewModel.userNameTextDidChangeToText(nil)
viewModel.passwordTextDidChangeToText(nil)
XCTAssertEqual(viewModel.userNameBorderColor, UIColor.redColor())
XCTAssertEqual(viewModel.passwordBorderColor, UIColor.redColor())
XCTAssertFalse(viewModel.loginButtonEnable)
}
func testValidUsernameInvalidPassword() {
viewModel.usernameTextDidChangeToText("dimsky")
viewModel.passwordTextDidChangeToText(nil)
XCTAssertEqual(viewModel.userNameBorderColor, UIColor.blackColor())
XCTAssertEqual(viewModel.passwordBorderColor, UIColor.redColor())
XCTAssertFalse(viewModel.loginButtonEnable)
}
func testInvalidUsernameValidPassword() {
viewModel.userNameTextDidChangeToText(nil)
viewModel.passwordTextDidChangeToText("balabala")
XCTAssertEqual(viewModel.useNnameBorderColor, UIColor.redColor())
XCTAssertEqual(viewModel.passwordBorderColor, UIColor.blackColor())
XCTAssertFalse(viewModel.loginButtonEnable)
}
总结
这样的结构让页面与逻辑和模型很清晰,并且很容易测试,而且不需要任何第三方库完成。
思考
从上面可以看出 在reloadViews 方法中做了很多事,如果需要刷新的视图过多,可能会导致性能为题。我们可能需要一个提供数据与视图绑定的框架,如 RXSwift。如果你的应用很庞大,或许可以考虑,但如果相对简单,还是不要入坑的好。