简化View Controllers:MVVM 还是 Presentation Controls? [翻译]

[翻译]原文连接:Simplification of iOS View Controllers: MVVM or Presentation Controls?

后续文章翻译地址:在Presentation Controls中更新Model(2 in 2)

我们都曾经见过几百行甚至上千行代码的View Controllers。一种简化view Controller复杂度的策略是使用Model-View-ViewModel(MVVM)设计模式。但是它并把view Controller拆分成更小、更简单、更容易理解的组建的唯一办法,这篇文章将阐述使用Presentation Control来大小同样效果的办法,它甚至可以和MVVM一起使用。

面临的问题:复杂的view Controller

view Controllers经常包含太多的功能,这是我们需要解决的问题,除了更新view,处理用户交互,view Controllers经常被(界面UI的)状态、网络请求、错误处理、验证等功能塞满。让我们看一个传统的view Controller的例子。假如我们有一个旅行计划app,它显示了我们旅程的出发、到达时间,如下图所示:

blog1

所有的信息可以用一个非常简单的model表示:

class Trip {

    let departure: NSDate
    let arrival: NSDate
    let actualDeparture: NSDate

    init(departure: NSDate, arrival: NSDate, actualDeparture: NSDate? = nil) {
        self.departure = departure
        self.arrival = arrival
        self.actualDeparture = actualDeparture ?? departure
    }
}

这个model有出发时间、到达时间和实际出发时间。这个view在右上方展示了日期(为了简化问题,我们假设出发时间和到达时间总是同一天),出发时间在左,到达时间在右,在出发时间和到时时间之间是行程所需时间。如果实际出发时间晚点了,出发时间显示红色,并在页面下方现实晚点时间。

如果不使用MVVM或者Presentation Controls,我们view Controller的代码会是这样的:

class ViewController: UIViewController {

    @IBOutlet weak var dateLabel: UILabel!
    @IBOutlet weak var departureTimeLabel: UILabel!
    @IBOutlet weak var arrivalTimeLabel: UILabel!
    @IBOutlet weak var durationLabel: UILabel!
    @IBOutlet weak var delayLabel: UILabel!

    // in a real app the trip would be set from another view controller or loaded from a server or something...
    let trip = Trip(departure: NSDate(timeIntervalSince1970: 1444396193), arrival: NSDate(timeIntervalSince1970: 1444397193), actualDeparture: NSDate(timeIntervalSince1970: 1444396493))

    override func viewDidLoad() {
        super.viewDidLoad()

        dateLabel.text = NSDateFormatter.localizedStringFromDate(trip.departure, dateStyle: .ShortStyle, timeStyle: .NoStyle)
        departureTimeLabel.text = NSDateFormatter.localizedStringFromDate(trip.departure, dateStyle: .NoStyle, timeStyle: .ShortStyle)
        arrivalTimeLabel.text = NSDateFormatter.localizedStringFromDate(trip.arrival, dateStyle: .NoStyle, timeStyle: .ShortStyle)

        let durationFormatter = NSDateComponentsFormatter()
        durationFormatter.allowedUnits = [.Hour, .Minute]
        durationFormatter.unitsStyle = .Short
        durationLabel.text = durationFormatter.stringFromDate(trip.departure, toDate: trip.arrival)

        let delay = trip.actualDeparture.timeIntervalSinceDate(trip.departure)
        if delay > 0 {
            durationFormatter.unitsStyle = .Full
            delayLabel.text = String.localizedStringWithFormat(NSLocalizedString("%@ delay", comment: "Show the delay"), durationFormatter.stringFromTimeInterval(delay)!)
            departureTimeLabel.textColor = .redColor()
        } else {
            delayLabel.hidden = true
        }
    }

}

幸亏我们使用了StoryBoard,我们不需要用任何代码去处理所有view的位置,否则代码会更多更复杂。如果这是所有view Controller的所有内容的话,我们可能不需要做任何改变,因为它足够小很方便维护。但是,在实际app开发过程中,view Controllers需要做比这多得多的事情,在这种情况下,我们最好把事情简化,拆分成不同的概念,这样做也可以大大改进可测试性。

方法1:使用MVVM

我们可以改进我们的代码通过使用MVVM模式,这样做代码会是这样:

class TripViewViewModel {

    let date: String
    let departure: String
    let arrival: String
    let duration: String
    let delay: String?
    let delayHidden: Bool
    let departureTimeColor: UIColor

    init(_ trip: Trip) {
        date = NSDateFormatter.localizedStringFromDate(trip.departure, dateStyle: .ShortStyle, timeStyle: .NoStyle)
        departure = NSDateFormatter.localizedStringFromDate(trip.departure, dateStyle: .NoStyle, timeStyle: .ShortStyle)
        arrival = NSDateFormatter.localizedStringFromDate(trip.arrival, dateStyle: .NoStyle, timeStyle: .ShortStyle)

        let durationFormatter = NSDateComponentsFormatter()
        durationFormatter.allowedUnits = [.Hour, .Minute]
        durationFormatter.unitsStyle = .Short
        duration = durationFormatter.stringFromDate(trip.departure, toDate: trip.arrival)!

        let delay = trip.actualDeparture.timeIntervalSinceDate(trip.departure)
        if delay > 0 {
            durationFormatter.unitsStyle = .Full
            self.delay = String.localizedStringWithFormat(NSLocalizedString("%@ delay", comment: "Show the delay"), durationFormatter.stringFromTimeInterval(delay)!)
            departureTimeColor = .redColor()
            delayHidden = false
        } else {
            self.delay = nil
            departureTimeColor = UIColor(red: 0, green: 0, blue: 0.4, alpha: 1)
            delayHidden = true
        }
    }
}

我们拆分出来一个类:TripViewViewModel,把所有内容Trip model中的内容转换成view所需的东西,需要注意的是,这个新的类并不知道UIView类的任何内容(比如UIlabel)。绑定操作仍然在View Controller中进行,它的代码会是这个样子:

class ViewController: UIViewController {

    @IBOutlet weak var dateLabel: UILabel!
    @IBOutlet weak var departureTimeLabel: UILabel!
    @IBOutlet weak var arrivalTimeLabel: UILabel!
    @IBOutlet weak var durationLabel: UILabel!
    @IBOutlet weak var delayLabel: UILabel!

    let tripModel = TripViewViewModel(Trip(departure: NSDate(timeIntervalSince1970: 1444396193), arrival: NSDate(timeIntervalSince1970: 1444397193), actualDeparture: NSDate(timeIntervalSince1970: 1444396493)))

    override func viewDidLoad() {
        super.viewDidLoad()

        dateLabel.text = tripModel.date
        departureTimeLabel.text = tripModel.departure
        arrivalTimeLabel.text = tripModel.arrival
        durationLabel.text = tripModel.duration
        delayLabel.text = tripModel.delay
        delayLabel.hidden = tripModel.delayHidden
        departureTimeLabel.textColor = tripModel.departureTimeColor
    }

}

现在,这是一个从MVVM的属性到UIView的属性的一对一绑定,我们的view Controller的复杂度大大的减少了。并且我们的TripViewViewModel非常容易做单元测试。

把逻辑挪到Model中

我们现在的TripViewViewModel类中的一些逻辑放到Trip model中更合适一些,特别是那些与presentation无关的计算.

class Trip {

    let departure: NSDate
    let arrival: NSDate
    let actualDeparture: NSDate
    let delay: NSTimeInterval
    let delayed: Bool
    let duration: NSTimeInterval

    init(departure: NSDate, arrival: NSDate, actualDeparture: NSDate? = nil) {
        self.departure = departure
        self.arrival = arrival
        self.actualDeparture = actualDeparture ?? departure

        // calculations
        duration = self.arrival.timeIntervalSinceDate(self.departure)
        delay = self.actualDeparture.timeIntervalSinceDate(self.departure)
        delayed = delay > 0
    }
}

现在我们把旅途时间和晚点时间的计算放到Trip model中了.
presentation的逻辑仍然在TripViewViewModel中,它现在直接使用mode属性的计算结果.

class TripViewViewModel {

    let date: String
    let departure: String
    let arrival: String
    let duration: String
    let delay: String?
    let delayHidden: Bool
    let departureTimeColor: UIColor

    init(_ trip: Trip) {
        date = NSDateFormatter.localizedStringFromDate(trip.departure, dateStyle: .ShortStyle, timeStyle: .NoStyle)
        departure = NSDateFormatter.localizedStringFromDate(trip.departure, dateStyle: .NoStyle, timeStyle: .ShortStyle)
        arrival = NSDateFormatter.localizedStringFromDate(trip.arrival, dateStyle: .NoStyle, timeStyle: .ShortStyle)

        let durationFormatter = NSDateComponentsFormatter()
        durationFormatter.allowedUnits = [.Hour, .Minute]
        durationFormatter.unitsStyle = .Short
        duration = durationFormatter.stringFromTimeInterval(trip.duration)!

        delayHidden = !trip.delayed
        if trip.delayed {
            durationFormatter.unitsStyle = .Full
            delay = String.localizedStringWithFormat(NSLocalizedString("%@ delay", comment: "Show the delay"), durationFormatter.stringFromTimeInterval(trip.delay)!)
            departureTimeColor = .redColor()
        } else {
            self.delay = nil
            departureTimeColor = UIColor(red: 0, green: 0, blue: 0.4, alpha: 1)
        }
    }
}

因为TripViewViewModel的属性没有变过(增加,减少,改名,并非属性的值没有变过),我们的View Controller保持不便.

方法2:Presentation Controls

如果你像我一样,你可能感觉在TripViewViewModel类中设置类似delayHidden和departureTimeColor这样的属性有点奇怪.他们很明显与UILabels的hidden和textColor有关.但我们需要拷贝很多额外的属性值.
那么,我们可以把这些presentation的逻辑放到UIView中?
这些体积小的,控制model和views之间的绑定关系的类这就是答案:Presentation Controls.MVVM和Presentation Controls的主要不同就是后者不知道UIView的任何内容(不包含UIview的实例).Presentation Controls就像只负责presentation任务的微型View Controller.

这个presentation任务,你也可以通过纯写代码或者nib文件来实现, 但是在Storyboards中它无法很好的实现.当我们想减少view controller中的复杂度的时候,我们不想把storyboard scene中的独立views拆分到一个个单独的nib中.

你可能不知道我们在NSObject中创建storyboard scene中views的outlets, 而不是在View Controller(类)中创建.我们可以把NSObject中创建的outlest和views关联.首先,创建 presentation control,这是一个新的类,我们命名为TripPresentationControl:

class TripPresentationControl: NSObject {

}

确保它是NSObject的子类,否则从 Interface Builder中创建它的时候会报错.
现在到Interface Builder的Object Library中去,拖一个 new Object 到 scene中.

1

在Identity Inspector中改变它的 presentation control class为:TripPresentationControl

2
你现在可以像使用view controller一样使用它了,连线scene中的view到这个类的outlets.通过这个方式,我们把所有的labels移动到了presentation control中,并把他们与scene关联.

class TripPresentationControl: NSObject {

    @IBOutlet weak var dateLabel: UILabel!
    @IBOutlet weak var departureTimeLabel: UILabel!
    @IBOutlet weak var arrivalTimeLabel: UILabel!
    @IBOutlet weak var durationLabel: UILabel!
    @IBOutlet weak var delayLabel: UILabel!

    var trip: Trip! {
        didSet {
            dateLabel.text = NSDateFormatter.localizedStringFromDate(trip.departure, dateStyle: .ShortStyle, timeStyle: .NoStyle)
            departureTimeLabel.text = NSDateFormatter.localizedStringFromDate(trip.departure, dateStyle: .NoStyle, timeStyle: .ShortStyle)
            arrivalTimeLabel.text  = NSDateFormatter.localizedStringFromDate(trip.arrival, dateStyle: .NoStyle, timeStyle: .ShortStyle)

            let durationFormatter = NSDateComponentsFormatter()
            durationFormatter.allowedUnits = [.Hour, .Minute]
            durationFormatter.unitsStyle = .Short
            durationLabel.text = durationFormatter.stringFromTimeInterval(trip.duration)!

            delayLabel.hidden = !trip.delayed
            if trip.delayed {
                durationFormatter.unitsStyle = .Full
                delayLabel.text = String.localizedStringWithFormat(NSLocalizedString("%@ delay", comment: "Show the delay"), durationFormatter.stringFromTimeInterval(trip.delay)!)
                departureTimeLabel.textColor = .redColor()
            }
        }
    }
}

当然了,你可以创建多个presentation controls用来拆分复杂的view controllers.

如你所见,我们也创建了一个trip属性,它包含了我们所有presentation的逻辑, 很像之前的TripViewViewModel, 但是现在我们直接在presentation给labels赋值.(很像之前的TripViewViewModel并不知道labels的任何信息)

下图是在StoryBoard中 创建scene中的view controller 与 Presentation Control中的outlet的联系.

2

紧接着,加载我们的view的时候把trip(model)传给 presentation control,现在的view controller看起来是这个样子:

 

class ViewController: UIViewController {

    @IBOutlet var tripPresentationControl: TripPresentationControl!

    let trip = Trip(departure: NSDate(timeIntervalSince1970: 1444396193), arrival: NSDate(timeIntervalSince1970: 1444397193), actualDeparture: NSDate(timeIntervalSince1970: 1444396493))

    override func viewDidLoad() {
        super.viewDidLoad()

        tripPresentationControl.trip = trip
    }
}

非常的简洁,对不对?

那么,问题来了,还需要MVVM么?

那么,应该放弃使用MVVM么?完全没必要. MVVM和Presentation control有他们各自的优点,你甚至可以同时使用他们.只使用MVVM,你可以会比只使用Presentation controls多写一些代码.但这些额外的代码会使MVVM测试起来更方便,因为你不需要关心UIViews,如果你想测试Presentation Controls,你必须创建这些views,幸好这些view是非常非常简单的views.你不需要复制这个view的结构,也不需要实例化view controllers.并且需要设置的UIViews的大部分属性,测试起来非常的简单,比如UILabel的text属性.

在一些大型的项目中MVVM和Presentation Controls可以很好的结合使用.在这种情况下你应该把MVVM对象传递给Presentation Control,Presentation Control现在是views,MVVM,Model之间的一层.像网络请求这样的逻辑仍然放在MVVM中比较好,因为你肯定不希望把复杂的网络请求和views(绑定)放在一个组件中.

总结:

好了,现在是时候由你决定在你的项目中,你是使用MVVM,Presentation Controls或者两者结合了.如果你的view controllers有超过1000行的代码,使用两者结合应该是一个好的选择.如果你的view controllers非常非常的简单,你应该使用传统的View Controller模式.

在不久的将来,我会写一个相关的文章,纤细阐述在Presentation Controls中如何处理model的改变和用户交互.

如果你想阅读更多iOS中MVVM的文章,我建议你读Ash Furrow的 Introduction to MVVM , 和 Srdan Rasic 的From MVC to MVVM in Swift

最好加入 11月9日 在 Amsterdam 的 do {iOS} 研讨会 ,在这里Natasha “the Robot” Murashev(代码女神)讲做一个关于 Protocol-oriented MVVM 的讲座.如果你喜欢本文,你肯定想知道她关于MVVM的讲解.

 

系列文章:

Simplification of iOS View Controllers: MVVM or Presentation Controls? (1 in 2)

在Presentation Controls中更新Model(2 in 2)