MVVM设计模式

作者: pro648 | 来源:发表于2019-07-27 17:19 被阅读38次

    Model-View-ViewModel(简称MVVM)是一种结构设计模式(structural design pattern),将对象分成三个不同的组:

    MVVMUML.png
    1. Models:持有用户数据。通常为 struct 或 class。
    2. Views:在屏幕上显示视觉元素和控件。通常为UIView的子类。
    3. View models:将模型转换为可在视图上直接显示的值。为了方便传递时进行引用,通常为 class。

    MVVM 和 Model-View-Controller(简称MVC)很像。上面 MVVM UML 图中包含视图控制器。也就是,MVVM 模式包含 view controller,只是其作用被弱化了。

    在这篇文章中,将介绍如何实现 view model,并重构项目以使用 MVVM 模式。开始部分是一个关于视图模型的简单示例。最后,将获取一个 MVC 项目并重构为 MVVM。

    1. 何时使用 MVVM 模式

    当模型需要转换后才可以在视图显示时,使用 MVVM。例如,使用视图模型(view model)将Date转换为日期格式的String,将十进制转换为货币格式的String等。

    MVVM 模式与 MVC 模式并无冲突。如果没有 view model 部分,则将 model-to-view 转换代码放到控制器。但视图控制器已经做了像视图生命周期、IBAction 处理视图回调等各种任务,低耦合变得难以实现。MVC 也就成为了 Massive View Controller。

    如何避免过度使用视图控制器?可以在使用 MVC 模式之外,组合使用其他设计模式。Model-View-ViewModel就是其中之一。

    2. Playground example

    在 Xcode 中创建 playground。这部分示例将会创建一个宠物收养视图。

    2.1 Model

    Model 代码如下:

    import PlaygroundSupport
    import UIKit
    
    // MARK: - Model
    public class Pet {
        public enum Rarity {
            case common
            case uncommon
            case rare
            case veryRare
        }
        
        public let name: String
        public let birthday: Date
        public let rarity: Rarity
        public let image: UIImage
        
        public init(name: String,
                    birthday: Date,
                    rarity: Rarity,
                    image: UIImage) {
            self.name = name
            self.birthday = birthday
            self.rarity = rarity
            self.image = image
        }
    }
    

    这里声明了一个 Pet model,每个 pet 都有namebirthdayrarityimage四种属性。需要把这些属性显示到视图中,但birthdayrarity不能直接显示,需要使用 view model 进行转换。

    2.2 ViewModel

    ViewModel 代码如下:

    // MARK: - ViewModel
    public class PetViewModel {
        
        // 创建两个属性,并在初始化方法中设值。
        private let pet: Pet
        private let calendar: Calendar
        
        public init(pet: Pet) {
            self.pet = pet
            self.calendar = Calendar(identifier: .gregorian)
        }
        
        // 声明 name 和 image 为计算属性。
        public var name: String {
            return pet.name
        }
        
        public var image: UIImage {
            return pet.image
        }
        
        // 计算属性转换后,将可以使用显示。
        public var ageText: String {
            let today = calendar.startOfDay(for: Date())
            let birthday = calendar.startOfDay(for: pet.birthday)
            let components = calendar.dateComponents([.year],
                                                     from: birthday,
                                                     to: today)
            let age = components.year!
            return "\(age) years old"
        }
        
        // 根据 rarity 决定价格。
        public var adoptionFeeText: String {
            switch pet.rarity {
            case .common:
                return "$50.00"
            case .uncommon:
                return "75.00"
            case .rare:
                return "150.00"
            case .veryRare:
                return "$500.00"
            }
        }
    }
    

    nameimage直接返回,没有进行任何转换。若后期需要修改name(如添加前缀),可以直接在此修改。ageTextadoptionFeeText转换后直接返回需要显示的字符串。

    2.3 View

    View 代码如下:

    // MARK: - View
    public class PetView: UIView {
        public let imageView: UIImageView
        public let nameLabel: UILabel
        public let ageLabel: UILabel
        public let adoptionFeeLabel: UILabel
        
        public override init(frame: CGRect) {
            var childFrame = CGRect(x: 0,
                                    y: 16,
                                    width: frame.width,
                                    height: frame.height / 2)
            imageView = UIImageView(frame: childFrame)
            imageView.contentMode = .scaleAspectFit
            
            childFrame.origin.y += childFrame.height + 16
            childFrame.size.height = 30
            nameLabel = UILabel(frame: childFrame)
            nameLabel.textAlignment = .center
            
            childFrame.origin.y += childFrame.height
            ageLabel = UILabel(frame: childFrame)
            ageLabel.textAlignment = .center
            
            childFrame.origin.y += childFrame.height
            adoptionFeeLabel = UILabel(frame: childFrame)
            adoptionFeeLabel.textAlignment = .center
            
            super.init(frame: frame)
            
            backgroundColor = .white
            addSubview(imageView)
            addSubview(nameLabel)
            addSubview(ageLabel)
            addSubview(adoptionFeeLabel)
        }
        
        @available(*, unavailable)
        public required init?(coder aDecoder: NSCoder) {
            fatalError("init?(coder:) is not supported")
        }
    }
    

    这里创建了一个PetView,其有四个子视图。imageView显示宠物图片,另外三个 label 分别显示宠物nameage、adoption fee。最后,在调用init?(coder:)时抛出fatalError异常来表明不能使用该方法。

    2.4 具体应用

    现在,可以将其付诸实践。具体应用如下:

    // MARK: - Example
    let birthday = Date(timeIntervalSinceNow: (-3 * 86400 * 366))
    let image = UIImage(named: "direwolf")!
    let direwolf = Pet(name: "Direwolf",
                     birthday: birthday,
                     rarity: .veryRare,
                     image: image)
    
    // 使用 direwolf 创建 viewModel
    let viewModel = PetViewModel(pet: direwolf)
    
    let frame = CGRect(x: 0,
                       y: 0,
                       width: 300,
                       height: 420)
    let view = PetView(frame: frame)
    
    // 使用 viewModel 直接显示
    view.nameLabel.text = viewModel.name
    view.imageView.image = viewModel.image
    view.ageLabel.text = viewModel.ageText
    view.adoptionFeeLabel.text = viewModel.adoptionFeeText
    
    PlaygroundPage.current.liveView = view
    

    要看具体效果,选择 View > Assistant Editor > Show Assistant Editor,运行后如下:

    MVVMDirewolf.png

    最后,还有一点可以改进。在PetViewModel类关闭花括号后添加以下扩展:

    extension PetViewModel {
        public func configure(_ view: PetView) {
            view.nameLabel.text = name
            view.imageView.image = image
            view.ageLabel.text = ageText
            view.adoptionFeeLabel.text = adoptionFeeText
        }
    }
    

    现在,可以使用configure(_ view:)方法设置 view。

    找到以下代码:

    view.nameLabel.text = viewModel.name
    view.imageView.image = viewModel.image
    view.ageLabel.text = viewModel.ageText
    view.adoptionFeeLabel.text = viewModel.adoptionFeeText
    

    并用以下代码替换:

    viewModel.configure(view)
    

    这样可以把所有视图显示逻辑放到 view model 中。在实际应用中,是否这样操作需根据实际情况而定。如果只有一个视图使用此 view model,把configure(_ view:)方法放入视图模型中会很有用;如果有多个视图在使用此 ViewModel,把所有显示逻辑放到 view model 会让 view model 混乱。在这种情况下,为每个视图单独配置显示代码可能更为简洁。

    点击https://github.com/pro648/BasicDemos-iOS/blob/master/Model-View-ViewModel获取这一部分的源码。

    3. 使用 MVVM 重构已有项目

    在这一部分,将为 MVVMPattern app 添加功能。

    首先,在 github.com/pro648/BasicDemos-iOS/tree/master/MVVMPattern模版 下载这篇文章所需要的demo。MVVMPattern app 显示附近的咖啡店,数据由 Yelp 的 YelpAPI 提供,使用 CocoaPods 安装 YelpAPI。

    如果你对 CocoaPods 不熟悉,可以查看CocoaPods的安装与使用使用CocoaPods创建公开、私有pod这两篇文章。

    在运行 app 前,需要先注册 Yelp API key。在浏览器打开 https://www.yelp.com/developers/v3/manage_app 网页,根据提示填写注册信息。将获取到的 key 粘贴到 Resources/APIKeys.swift 文件提示的位置。

    运行后如下:

    MVVMLocation.png

    模拟器默认位置是 San Francisco,可以在模拟器菜单栏 Debug > Location 选择其他位置,也可以在 Xcode 调试区域直接选择其他城市。

    地图上只显示图钉体验不好,直接显示咖啡店评分信息会更好。

    打开MapPin.swift文件,MapPin类包含coordinatetitlerating三个属性,并对其进行转换以便 map view 可以直接显示。这里就是 view model 的功能。

    首先,更改类名称。在 MapPin 上右键,选择 Refactor > Rename。新的名称为 BusinessMapViewModel,这样会同时修改文件名称和类名称,更改 Models 组名称为 ViewModels。更改名称后使用 Sort by name 对文件系统重新排序。如下所示:

    MVVMFileHierarchy.png

    这样能清晰表明你在使用 MVVM 模式。

    BusinessMapViewModel需要更多属性才能显示更为有效的地图注释(map annotation),而非使用 MapKit 提供的普通图钉(pin)。

    BusinessMapViewModel.swift文件中的 import Foundation 替换为:

    import UIKit
    

    继续添加以下属性:

        public let image: UIImage
        public let ratingDescription: String
    

    将使用image替换 MapKit 默认的图钉图片,并在用户点击 annotation 时以副标题的形式显示ratingDescription

    使用以下代码替换init(coordinate:name:rating:)方法:

        public init(coordinate: CLLocationCoordinate2D,
                             name: String,
                             rating: Double,
                             image: UIImage) {
            self.coordinate = coordinate
            self.name = name
            self.rating = rating
            self.image = image
            self.ratingDescription = "\(rating) stars"
        }
    

    通过初始化程序接受image,使用rating设置ratingDescription

    MKAnnotation extension 添加以下计算属性(computed property):

        public var subtitle: String? {
            return ratingDescription
        }
    

    当点击 annotation 时,使用ratingDescription作为副标题。

    进入ViewController.swift文件,使用以下代码替换addAnnotations()方法:

        private func addAnnotations() {
            for business in businesses {
                guard let yelpCoordinate = business.location.coordinate else {
                    continue
                }
                
                let coordinate = CLLocationCoordinate2D(latitude: yelpCoordinate.latitude,
                                                        longitude: yelpCoordinate.longitude)
                let name = business.name
                let rating = business.rating
                let image: UIImage
                
                switch rating {
                case 0.0..<3.5:
                    image = UIImage(named: "bad")!
                case 3.5..<4.0:
                    image = UIImage(named: "meh")!
                case 4.0..<4.75:
                    image = UIImage(named: "good")!
                case 4.75..<5.0:
                    image = UIImage(named: "great")!
                default:
                    image = UIImage(named: "bad")!
                }
                
                let annotation = BusinessMapViewModel(coordinate: coordinate,
                                        name: name,
                                        rating: rating,
                                        image: image)
                mapView.addAnnotation(annotation)
            }
        }
    

    addAnnotations()方法与之前没有太大区别,只是添加了 switch 评分,以决定使用那张图片。

    如果此时运行 app,你会发现 map view 没有任何变化。这是因为需要在代理方法中提供自定义的 pin,annotation image 才可以显示。

    addAnnotations()方法下面添加以下方法:

        public func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
            guard let viewModel = annotation as? BusinessMapViewModel else {
                return nil
            }
            
            let identifier = "business"
            let annotationView: MKAnnotationView
            if let existingView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) {
                annotationView = existingView
            } else {
                annotationView = MKAnnotationView(annotation: viewModel, reuseIdentifier: identifier)
            }
            
            annotationView.image = viewModel.image
            annotationView.canShowCallout = true
            return annotationView
        }
    

    上述代码创建了MKAnnotationView,用以显示 annotation 图片。

    运行 app,可以看到自定义 annotation,点击 annotation 可以看到咖啡店名称和评分。

    MVVMAnnotation.png

    点击 https://github.com/pro648/BasicDemos-iOS/tree/master/MVVMPattern 获取重构后源码。

    总结

    以下是 Model-View-ViewModel 模式的关键点:

    • MVVM 有助于减少视图控制器功能,使其易于使用、维护。避免 Massive View Controller 的出现。
    • View models 类能够将对象转换为其他类型对象,将转换后的对象传递到视图控制器并显示在视图上。这对于将像DateDecimal类型 computed property 转换为类似于String类型,并直接显示到UILabelUIView中特别有效。
    • 如果只有一个视图使用该 view model,可以将所有配置放入视图模型;但是,如果多个视图使用该 view model,将所有显示逻辑放到 view model 可能使其混乱不堪。此时,将显示逻辑放到视图中更为简洁。
    • 如果 app 刚开始开发,MVC 可能是一个更好的起点,后续可以根据 app 需求的变化选择不同的设计模式。

    Demo名称:MVVMPattern
    源码地址:https://github.com/pro648/BasicDemos-iOS

    参考资料:

    1. Design Patterns by Tutorials: MVVM
    2. Model–view–viewmodel
    3. Introduction to MVVM

    欢迎更多指正:https://github.com/pro648/tips/wiki

    相关文章

      网友评论

        本文标题:MVVM设计模式

        本文链接:https://www.haomeiwen.com/subject/uwhelctx.html