通过MVVM使您的视图控制器节食

在本系列的上一篇文章中,我写了关于Model-View-Controller模式及其一些缺陷的文章。 尽管MVC给软件开发带来了明显的好处,但在大型或复杂的Cocoa应用程序中,它往往不尽人意。

不过,这不是新闻。 多年来,出现了几种架构模式,旨在解决“模型-视图-控制器”模式的缺点。 例如,您可能听说过MVP ,Model-View-Presenter和MVVM ,Model-View-ViewModel。 这些模式看起来和感觉都类似于Model-View-Controller模式,但是它们也解决了Model-View-Controller模式所遇到的一些问题。

1.为什么选择Model-View-ViewModel

在我偶然发现Model-View-ViewModel模式之前,我已经使用Model-View-Controller模式多年了。 MVVM成为可可社区的后来者也就不足为奇了,因为它的起源可以追溯到微软。 但是,MVVM模式已移植到Cocoa并适应Cocoa框架的要求和需求,并且最近在Cocoa社区中越来越受到关注。

最具吸引力的是MVVM感觉像是Model-View-Controller模式的改进版本。 这意味着它不需要剧烈改变思维定势。 实际上,一旦您了解了该模式的基础知识,就可以轻松实现它,而不比实现Model-View-Controller模式更困难。

2.节食View Controller

一篇文章中 ,我写道典型的Cocoa应用程序中的控制器与原始MVC模式中定义的Reenskaug控制器有些不同。 例如,在iOS上,视图控制器控制视图。 它的唯一职责是填充其管理的视图并响应用户交互。 但这不是大多数iOS应用程序中视图控制器的唯一责任,不是吗?

MVVM模式向组合中引入了第四个组件,即视图模型 ,它有助于重新定位视图控制器。 它通过接管视图控制器的某些职责来做到这一点。 请看下面的图,以更好地了解视图模型如何适合Model-View-ViewModel模式。

MVVM模式

如图所示,视图控制器不再拥有模型。 拥有模型的是视图模型,并且视图控制器向视图模型询问其需要显示的数据。

这是与“模型-视图-控制器”模式的重要区别。 视图控制器无法直接访问模型。 视图模型将其需要显示在视图中的数据交给视图控制器。

视图控制器及其视图之间的关系保持不变。 这很重要,因为这意味着视图控制器可以专注于填充其视图和处理用户交互。 这就是视图控制器的设计目的。

结果非常引人注目。 视图控制器节食,许多责任转移到视图模型。 您不再需要一个跨越数百甚至数千行代码的视图控制器。

3.视图模型的责任

您可能想知道视图模型如何适应更大的画面。 视图模型的任务是什么? 它与视图控制器有何关系? 那模型呢?

我之前显示给您的图表为我们提供了一些提示。 让我们从模型开始。 该模型不再由视图控制器拥有。 视图模型拥有该模型,并且充当视图控制器的代理。 每当视图控制器需要其视图模型中的一条数据时,后者就会向其模型询问原始数据,并以使其可以立即在其视图中使用的方式对其进行格式化。 视图控制器不负责数据操作和格式化。

该图还显示该模型归视图模型所有,而不是视图控制器。 还值得指出的是,Model-View-ViewModel模式尊重视图控制器及其视图的紧密关系,这是Cocoa应用程序的特征。 这就是为什么MVVM感觉很自然地适合可可应用的原因。

4.一个例子

由于Model-View-ViewModel模式不是Cocoa固有的,因此没有严格的规则来实现该模式。 不幸的是,许多开发人员对此感到困惑。 为了澄清一些事情,我想向您展示一个使用MVVM模式的应用程序的基本示例。 我们创建了一个非常简单的应用程序,该应用程序从Dark Sky API获取预定义位置的天气数据,并将当前温度显示给用户。

步骤1:建立专案

启动Xcode并基于Single View Application模板创建一个新项目。 我在本教程中使用Xcode 8和Swift 3。

设置项目

将项目命名为MVVM ,并将Language设置为Swift并将Devices设置iPhone

配置项目

步骤2:建立检视模型

在由模型-视图-控制器模式提供支持的典型Cocoa应用程序中,视图控制器将负责执行网络请求。 您可以使用管理器来执行网络请求,但是视图控制器仍会知道天气数据的来源。 更重要的是,它将接收原始数据,并且需要在将其显示给用户之前对其进行格式化。 这不是我们采用Model-View-ViewModel模式时采用的方法。

让我们创建一个视图模型。 创建一个新雨燕的文件,将其命名WeatherViewViewModel.swift,并定义了一个名为类WeatherViewViewModel

创建视图模型
import Foundation

class WeatherViewViewModel {

}

这个想法很简单。 视图控制器向视图模型询问预定义位置的当前温度。 由于视图模型将网络请求发送到Dark Sky API,因此该方法接受闭包,当视图模型具有用于视图控制器的数据时,将调用该闭包。 该数据可能是当前温度,但也可能是错误消息。 这就是视图模型的currentTemperature(completion:)方法的外观。 稍后我们将详细填写。

import Foundation

class WeatherViewViewModel {

    // MARK: - Type Alias

    typealias CurrentTemperatureCompletion = (String) -> Void

    // MARK: - Public API

    func currentTemperature(completion: @escaping CurrentTemperatureCompletion) {
    
    }

}

为了方便起见,我们声明了类型别名,并定义了一个方法currentTemperature(completion:) ,该方法接受类型为CurrentTemperatureCompletion的闭包。

如果您熟悉网络和URLSession API,则实现起来并不难。 看一下下面的代码,请注意我使用了枚举API来使所有内容保持整洁。

import Foundation

class WeatherViewViewModel {

    // MARK: - Type Alias

    typealias CurrentTemperatureCompletion = (String) -> Void

    // MARK: - API

    enum API {

        static let lat = 37.8267
        static let long = -122.4233
        static let APIKey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
        static let baseURL = URL(string: "https://api.darksky.net/forecast")!

        static var requestURL: URL {
            return API.baseURL
                .appendingPathComponent(API.APIKey)
                .appendingPathComponent("\(lat),\(long)")
        }
    }

    // MARK: - Public API

    func currentTemperature(completion: @escaping CurrentTemperatureCompletion) {
        let dataTask = URLSession.shared.dataTask(with: API.requestURL) { [weak self] (data, response, error) in
            // Helpers
            var formattedTemperature: String?

            if let data = data {
                formattedTemperature = self?.temperature(from: data)
            }

            DispatchQueue.main.async {
                completion(formattedTemperature ?? "Unable to Fetch Weather Data")
            }
        }
    	
		// Resume Data Task
        dataTask.resume()
    }

}

我尚未向您展示的唯一代码是temperature(from:)方法的实现。 在这种方法中,我们从Dark Sky响应中提取当前温度。

// MARK: - Helper Methods

func temperature(from data: Data) -> String? {
    guard let JSON = try? JSONSerialization.jsonObject(with: data, options: []) as? [String : Any] else {
        return nil
    }

    guard let currently = JSON?["currently"] as? [String : Any] else {
        return nil
    }

    guard let temperature = currently["temperature"] as? Double else {
        return nil
    }

    return String(format: "%.0f °F", temperature)
}

在生产应用程序中,我将选择一个更强大的解决方案来解析响应,例如ObjectMapperUnbox

步骤3:整合检视模型

现在,我们可以在视图控制器中使用视图模型。 我们为视图模型创建一个属性,并且还为用户界面定义了三个出口。

import UIKit

class ViewController: UIViewController {

    // MARK: - Properties

    @IBOutlet var temperatureLabel: UILabel!

    // MARK: -
    
    @IBOutlet var fetchWeatherDataButton: UIButton!

    // MARK: -

    @IBOutlet var activityIndicatorView: UIActivityIndicatorView!

    // MARK: -

    private let viewModel = WeatherViewViewModel()

}

注意,视图控制器拥有视图模型。 在此示例中,视图控制器还负责实例化其视图模型。 通常,我更喜欢将视图模型注入到视图控制器中,但是现在让我们保持简单。

在视图控制器的viewDidLoad()方法中,我们调用一个辅助方法fetchWeatherData()

// MARK: - View Life Cycle

override func viewDidLoad() {
    super.viewDidLoad()

    // Fetch Weather Data
    fetchWeatherData()
}

fetchWeatherData() ,我们向视图模型询问当前温度。 在请求温度之前,我们将隐藏标签和按钮并显示活动指示器视图。 在闭包中,我们传递给fetchWeatherData(completion:) ,我们通过填充温度标签并隐藏活动指示器视图来更新用户界面。

// MARK: - Helper Methods

private func fetchWeatherData() {
    // Hide User Interface
    temperatureLabel.isHidden = true
    fetchWeatherDataButton.isHidden = true

    // Show Activity Indicator View
    activityIndicatorView.startAnimating()

    // Fetch Weather Data
    viewModel.currentTemperature { [unowned self] (temperature) in
        // Update Temperature Label
        self.temperatureLabel.text = temperature
        self.temperatureLabel.isHidden = false

        // Show Fetch Weather Data Button
        self.fetchWeatherDataButton.isHidden = false

        // Hide Activity Indicator View
        self.activityIndicatorView.stopAnimating()
    }
}

该按钮连接到动作fetchWeatherData(_:) ,其中我们还调用了fetchWeatherData()帮助器方法。 如您所见,helper方法可帮助我们避免代码重复。

// MARK: - Actions

@IBAction func fetchWeatherData(_ sender: Any) {
    // Fetch Weather Data
    fetchWeatherData()
}

步骤4:创建用户界面

难题的最后一步是创建示例应用程序的用户界面。 打开Main.storyboard ,然后在垂直堆栈视图中添加标签和按钮。 我们还将在堆栈视图的顶部(垂直和水平居中)添加活动指示器视图。

创建用户界面

不要忘记连接电源和我们在ViewController类中定义的动作!

现在构建并运行该应用程序以进行尝试。 请记住,您需要Dark Sky API密钥才能使应用程序正常工作。 您可以在Dark Sky网站上注册免费帐户。

5.有什么好处?

即使我们仅将一点点的片段移至视图模型,您可能仍想知道为什么这样做是必要的。 我们获得了什么? 为什么要增加这一额外的复杂性层?

最明显的收获是视图控制器更精简,更专注于管理其视图。 这是视图控制器的核心任务:管理其视图。

但是有一个更微妙的好处。 由于视图控制器不负责从Dark Sky API获取天气数据,因此它不知道与该任务有关的详细信息。 天气数据可能来自其他天气服务或缓存的响应。 视图控制器不会知道,也不需要知道。

测试也大大改善。 众所周知,由于视图控制器与视图层的紧密关系,因此很难对其进行测试。 通过将一些业务逻辑移至视图模型,我们可以立即提高项目的可测试性。 测试视图模型非常容易,因为它们没有到应用程序视图层的链接。

结论

Model-View-ViewModel模式是设计Cocoa应用程序的重要一步。 视图控制器不是那么庞大,视图模型更易于编写和测试,因此您的项目变得更易于管理。

在这个简短的系列文章中,我们只是从头开始。 关于Model-View-ViewModel模式,还有很多要写的东西。 多年来,它已成为我最喜欢的模式之一,这就是为什么我不断对此进行讨论和写作的原因。 试试看,让我知道您的想法!

翻译自: https://code.tutsplus.com/tutorials/put-your-view-controllers-on-a-diet-with-mvvm--cms-29473