MVVM設(shè)計模式

Model-View-ViewModel(簡稱MVVM)是一種結(jié)構(gòu)設(shè)計模式(structural design pattern),將對象分成三個不同的組:

MVVMUML.png
  1. Models:持有用戶數(shù)據(jù)。通常為 struct 或 class疏哗。
  2. Views:在屏幕上顯示視覺元素和控件谒麦。通常為UIView的子類。
  3. View models:將模型轉(zhuǎn)換為可在視圖上直接顯示的值。為了方便傳遞時進行引用援岩,通常為 class稿茉。

MVVM 和 Model-View-Controller(簡稱MVC)很像锹锰。上面 MVVM UML 圖中包含視圖控制器。也就是漓库,MVVM 模式包含 view controller恃慧,只是其作用被弱化了。

在這篇文章中渺蒿,將介紹如何實現(xiàn) view model痢士,并重構(gòu)項目以使用 MVVM 模式。開始部分是一個關(guān)于視圖模型的簡單示例茂装。最后怠蹂,將獲取一個 MVC 項目并重構(gòu)為 MVVM。

1. 何時使用 MVVM 模式

當(dāng)模型需要轉(zhuǎn)換后才可以在視圖顯示時少态,使用 MVVM城侧。例如,使用視圖模型(view model)將Date轉(zhuǎn)換為日期格式的String彼妻,將十進制轉(zhuǎn)換為貨幣格式的String等嫌佑。

MVVM 模式與 MVC 模式并無沖突。如果沒有 view model 部分澳骤,則將 model-to-view 轉(zhuǎn)換代碼放到控制器歧强。但視圖控制器已經(jīng)做了像視圖生命周期、IBAction 處理視圖回調(diào)等各種任務(wù)为肮,低耦合變得難以實現(xiàn)摊册。MVC 也就成為了 Massive View Controller。

如何避免過度使用視圖控制器颊艳?可以在使用 MVC 模式之外茅特,組合使用其他設(shè)計模式。Model-View-ViewModel就是其中之一棋枕。

2. Playground example

在 Xcode 中創(chuàng)建 playground白修。這部分示例將會創(chuàng)建一個寵物收養(yǎng)視圖。

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 都有name兵睛、birthdayrarityimage四種屬性祖很。需要把這些屬性顯示到視圖中笛丙,但birthdayrarity不能直接顯示,需要使用 view model 進行轉(zhuǎn)換假颇。

2.2 ViewModel

ViewModel 代碼如下:

// MARK: - ViewModel
public class PetViewModel {
    
    // 創(chuàng)建兩個屬性胚鸯,并在初始化方法中設(shè)值。
    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
    }
    
    // 計算屬性轉(zhuǎn)換后姜钳,將可以使用顯示。
    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"
    }
    
    // 根據(jù) 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直接返回哥桥,沒有進行任何轉(zhuǎn)換。若后期需要修改name(如添加前綴)趟脂,可以直接在此修改泰讽。ageTextadoptionFeeText轉(zhuǎn)換后直接返回需要顯示的字符串。

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")
    }
}

這里創(chuàng)建了一個PetView昔期,其有四個子視圖已卸。imageView顯示寵物圖片,另外三個 label 分別顯示寵物name硼一、age累澡、adoption fee。最后般贼,在調(diào)用init?(coder:)時拋出fatalError異常來表明不能使用該方法愧哟。

2.4 具體應(yīng)用

現(xiàn)在,可以將其付諸實踐哼蛆。具體應(yīng)用如下:

// 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 創(chuàng)建 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類關(guān)閉花括號后添加以下擴展:

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

現(xiàn)在,可以使用configure(_ view:)方法設(shè)置 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 中甘改。在實際應(yīng)用中,是否這樣操作需根據(jù)實際情況而定灭抑。如果只有一個視圖使用此 view model十艾,把configure(_ view:)方法放入視圖模型中會很有用;如果有多個視圖在使用此 ViewModel腾节,把所有顯示邏輯放到 view model 會讓 view model 混亂忘嫉。在這種情況下荤牍,為每個視圖單獨配置顯示代碼可能更為簡潔。

點擊https://github.com/pro648/BasicDemos-iOS/blob/master/Model-View-ViewModel獲取這一部分的源碼榄融。

3. 使用 MVVM 重構(gòu)已有項目

在這一部分参淫,將為 MVVMPattern app 添加功能救湖。

首先愧杯,在 github.com/pro648/BasicDemos-iOS/tree/master/MVVMPattern模版 下載這篇文章所需要的demo。MVVMPattern app 顯示附近的咖啡店鞋既,數(shù)據(jù)由 Yelp 的 YelpAPI 提供力九,使用 CocoaPods 安裝 YelpAPI。

如果你對 CocoaPods 不熟悉邑闺,可以查看CocoaPods的安裝與使用跌前、使用CocoaPods創(chuàng)建公開、私有pod這兩篇文章陡舅。

在運行 app 前抵乓,需要先注冊 Yelp API key。在瀏覽器打開 https://www.yelp.com/developers/v3/manage_app 網(wǎng)頁靶衍,根據(jù)提示填寫注冊信息灾炭。將獲取到的 key 粘貼到 Resources/APIKeys.swift 文件提示的位置。

運行后如下:

MVVMLocation.png

模擬器默認位置是 San Francisco颅眶,可以在模擬器菜單欄 Debug > Location 選擇其他位置蜈出,也可以在 Xcode 調(diào)試區(qū)域直接選擇其他城市。

地圖上只顯示圖釘體驗不好涛酗,直接顯示咖啡店評分信息會更好铡原。

打開MapPin.swift文件,MapPin類包含coordinate商叹、title燕刻、rating三個屬性,并對其進行轉(zhuǎn)換以便 map view 可以直接顯示剖笙。這里就是 view model 的功能卵洗。

首先,更改類名稱枯途。在 MapPin 上右鍵忌怎,選擇 Refactor > Rename。新的名稱為 BusinessMapViewModel酪夷,這樣會同時修改文件名稱和類名稱榴啸,更改 Models 組名稱為 ViewModels。更改名稱后使用 Sort by name 對文件系統(tǒng)重新排序晚岭。如下所示:

MVVMFileHierarchy.png

這樣能清晰表明你在使用 MVVM 模式鸥印。

BusinessMapViewModel需要更多屬性才能顯示更為有效的地圖注釋(map annotation),而非使用 MapKit 提供的普通圖釘(pin)。

BusinessMapViewModel.swift文件中的 import Foundation 替換為:

import UIKit

繼續(xù)添加以下屬性:

    public let image: UIImage
    public let ratingDescription: String

將使用image替換 MapKit 默認的圖釘圖片库说,并在用戶點擊 annotation 時以副標(biāo)題的形式顯示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設(shè)置ratingDescription潜的。

MKAnnotation extension 添加以下計算屬性(computed property):

    public var subtitle: String? {
        return ratingDescription
    }

當(dāng)點擊 annotation 時骚揍,使用ratingDescription作為副標(biāo)題。

進入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()方法與之前沒有太大區(qū)別信不,只是添加了 switch 評分,以決定使用那張圖片亡呵。

如果此時運行 app抽活,你會發(fā)現(xiàn) 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
    }

上述代碼創(chuàng)建了MKAnnotationView,用以顯示 annotation 圖片汁胆。

運行 app梭姓,可以看到自定義 annotation,點擊 annotation 可以看到咖啡店名稱和評分沦泌。

MVVMAnnotation.png

點擊 https://github.com/pro648/BasicDemos-iOS/tree/master/MVVMPattern 獲取重構(gòu)后源碼糊昙。

總結(jié)

以下是 Model-View-ViewModel 模式的關(guān)鍵點:

  • MVVM 有助于減少視圖控制器功能,使其易于使用谢谦、維護释牺。避免 Massive View Controller 的出現(xiàn)。
  • View models 類能夠?qū)ο筠D(zhuǎn)換為其他類型對象回挽,將轉(zhuǎn)換后的對象傳遞到視圖控制器并顯示在視圖上没咙。這對于將像DateDecimal類型 computed property 轉(zhuǎn)換為類似于String類型千劈,并直接顯示到UILabel祭刚、UIView中特別有效。
  • 如果只有一個視圖使用該 view model墙牌,可以將所有配置放入視圖模型涡驮;但是,如果多個視圖使用該 view model喜滨,將所有顯示邏輯放到 view model 可能使其混亂不堪捉捅。此時,將顯示邏輯放到視圖中更為簡潔虽风。
  • 如果 app 剛開始開發(fā)棒口,MVC 可能是一個更好的起點寄月,后續(xù)可以根據(jù) app 需求的變化選擇不同的設(shè)計模式。

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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末无牵,一起剝皮案震驚了整個濱河市漾肮,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌茎毁,老刑警劉巖克懊,帶你破解...
    沈念sama閱讀 211,265評論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異充岛,居然都是意外死亡保檐,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,078評論 2 385
  • 文/潘曉璐 我一進店門崔梗,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人垒在,你說我怎么就攤上這事蒜魄。” “怎么了场躯?”我有些...
    開封第一講書人閱讀 156,852評論 0 347
  • 文/不壞的土叔 我叫張陵谈为,是天一觀的道長。 經(jīng)常有香客問我踢关,道長伞鲫,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 56,408評論 1 283
  • 正文 為了忘掉前任签舞,我火速辦了婚禮秕脓,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘儒搭。我一直安慰自己吠架,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 65,445評論 5 384
  • 文/花漫 我一把揭開白布搂鲫。 她就那樣靜靜地躺著傍药,像睡著了一般。 火紅的嫁衣襯著肌膚如雪魂仍。 梳的紋絲不亂的頭發(fā)上拐辽,一...
    開封第一講書人閱讀 49,772評論 1 290
  • 那天,我揣著相機與錄音擦酌,去河邊找鬼俱诸。 笑死,一個胖子當(dāng)著我的面吹牛仑氛,可吹牛的內(nèi)容都是我干的乙埃。 我是一名探鬼主播闸英,決...
    沈念sama閱讀 38,921評論 3 406
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼介袜!你這毒婦竟也來了甫何?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,688評論 0 266
  • 序言:老撾萬榮一對情侶失蹤遇伞,失蹤者是張志新(化名)和其女友劉穎辙喂,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體鸠珠,經(jīng)...
    沈念sama閱讀 44,130評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡巍耗,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,467評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了渐排。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片炬太。...
    茶點故事閱讀 38,617評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖驯耻,靈堂內(nèi)的尸體忽然破棺而出亲族,到底是詐尸還是另有隱情,我是刑警寧澤可缚,帶...
    沈念sama閱讀 34,276評論 4 329
  • 正文 年R本政府宣布霎迫,位于F島的核電站,受9級特大地震影響帘靡,放射性物質(zhì)發(fā)生泄漏知给。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,882評論 3 312
  • 文/蒙蒙 一描姚、第九天 我趴在偏房一處隱蔽的房頂上張望涩赢。 院中可真熱鬧,春花似錦轰胁、人聲如沸谒主。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,740評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽霎肯。三九已至,卻和暖如春榛斯,著一層夾襖步出監(jiān)牢的瞬間观游,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,967評論 1 265
  • 我被黑心中介騙來泰國打工驮俗, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留懂缕,地道東北人。 一個月前我還...
    沈念sama閱讀 46,315評論 2 360
  • 正文 我出身青樓王凑,卻偏偏與公主長得像搪柑,于是被迫代替她去往敵國和親聋丝。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 43,486評論 2 348

推薦閱讀更多精彩內(nèi)容