使用Rx实现MVVM架构

2019-11-18  本文已影响0人  Yeeshe

简介

最近接手了一个N年前的老项目,由于实在是过于陈旧,于是提出想要重构项目该项目的想法,没想到和项目经理竟然达成了共识。在重构初始阶段项目框架搭建时,考虑到将来所承载的业务的可变性比较大,如果单纯的从翻新项目的角度出发,可能会在日后的迭代过程中埋下隐藏的炸弹,所以决定使用MVVM架构进行新项目的研发。项目基础组件已经完成开发,目前,正在进行登录业务模块的功能开发,通过使用RxMVVM的实现不管是从可测试性还是可扩展性而言,都变得十分的优雅了。甚至对于业务的剥离的效果十分明显。

接下来,我把到目前为止的所有实现分为以下3个核心部分:
   (1). MVVM架构思想;
   (2). 函数响应式编程;
   (3). 登录的实践;

MVVM架构思想

分析下现在热门的几款架构模式中:MVPMVCSVIPER以及MVVM究其根本,其实都是冲MVC衍变优化而来。当然,对于经典的MVC我们用一句话概括:尽管这种设计足够优秀,但是在使用过程中很难确定究竟应该在哪个模块做什么事情,以至于出现了Controller变臃肿和胖Model的窘境。

回过头来看MVVM,现在可以说但凡是个程序猿,没有不知道它的。不管你在项目中如何使用它,都逃离不掉3大模块:
   (1). 数据层,也就是Model
   (2). 视图模型层,也就是ViewModel
   (3). 视图层,也就是View或者ViewController
   PS:有些会把它分为4块,我更倾向于ViewController包含于视图层的观点。

正是由于在ViewModel之间多加了一层ViewModel,让原来无处安放的网络请求和大量的复杂业务有了明确的去处,解决了原来MVC测试性差和日益臃肿的Controller的问题。同样将业务抽离到ViewModel中后,也降低了代码的耦合度,提高了复用性。因为对于同样的业务,如果采用在Controller中实现的话,当不同界面的Controller要使用到相同的业务时,就没办法服用之前的代码,而需要复制粘贴了。但是有了ViewModel后,同样的业务,只需在对应的Controller中执行同样的ViewModel就行了。

MVVM的精髓我认为是在于如何处理ViewModel绑定的问题。按照以前的惯例,我们或许会在某一个cell中声明一个model属性,然后在实现文件中自定义它的setter方法,最后拿到数据进行展示。除非你的model没有做过翻译直接是一个dictionary,或者通过id类型使用kvc取值,否则这种方法会导致viewmodel形成跨层的访问。从大大降低了代码的可阅读性,对于后期维护十分不便。

为了解决这样的跨层访问,通常ViewModel中可以通过kvo来实现modelview的映射。但是当view接收到用户操作后需要进行model修改时,常用的就是Target-Actiondelegate,甚至于Notificationblock也可以,这些方法并非不可取,也足够优秀,但是却比较繁杂。而双向绑定的的出现就完美且优雅的改变了这种方式,这也是为什么一提到MVVM我们就能连想到RxRAC的原因了。

同样,正是由于双向绑定的出现,Controller完成的职责就很明确了,即将ViewModelView进行绑定。所以业内流传的这张图片就很好的体现了这一变化:

MVVM.gif

经过这样的衍变之后,不难看出MVVM的关系基本就是:View <-> C <-> ViewModel <-> Model。最终,促成了业务和UI的分离,规范了ViewController强耦合的性质。逻辑上而言,只有Controller知道需要呈现的View,也只有它知道需要使用的业务ViewModel,两个层级之间通过Controller形成桥梁进行通信。当然这里有个问题是,当用户操作通过View想要改变Model的属性时,业务线会变得比较长,而这块将会在后面列表展示时体现出来,本次实践中暂不做讨论。

尽管MVVM如此优秀,但是任然需要注意很多细节,否则就会重蹈MVCController的覆辙,我总结了下,大概有以下几点需要注意的:
   (1). Controller中尽量不要做业务相关的事情,它的主要职责是管理subview和进行ViewViewModel的绑定;
   (2). 业务逻辑尽量让ViewModel来处理。同时应该避免ViewModel过于庞大而导致和MVC相同的问题,所以ViewModel之间是可以存在依赖的;
   (3). subview是否能够直接引用ViewModel?既然UIViewControllerUIView都是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-ActionDelegateNotificationBlock等等。从代码层面是如何体现的呢:

// 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). 登录结果;
从而,得出InputsOutputs应该长这样:

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正在执行。再通过incrementdecrement来处理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()
        }
    }
}

通过调用Observableusing方法将incrementdecrementObservable的生命周期绑定。当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和提供的服务,转化为校验结果的序列validatedAccountvalidatedPassword。当发送网络请求时,利用ActivityIndicator监听请求状态,再和校验结果的信号组合,从而得到登录按钮当前所属状态。最后将得到的信号包装为Outputs提供给Controller用于绑定或事件,完成了ViewModel的功能。

番外

在这次重构中,一边学习RxSwift一边整理,真正体会到了MVVM的精髓。由于登录业务还是比较简单的,还不能以偏概全的认为“就是这样”,在重构过程中,后面会遇到真正的难点,对于复杂页面和复杂业务的实现。到时候再以续集的博客形式记录。
最后附上本次RxSwift官方的例子作为参考:GitHubSignup - 简易仔细阅读

上一篇下一篇

猜你喜欢

热点阅读