Model-View-ViewModel(簡稱MVVM)是一種結(jié)構(gòu)設(shè)計模式(structural design pattern),將對象分成三個不同的組:
- Models:持有用戶數(shù)據(jù)。通常為 struct 或 class疏哗。
- Views:在屏幕上顯示視覺元素和控件谒麦。通常為
UIView
的子類。 - 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
兵睛、birthday
、rarity
、image
四種屬性祖很。需要把這些屬性顯示到視圖中笛丙,但birthday
和rarity
不能直接顯示,需要使用 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"
}
}
}
name
和image
直接返回哥桥,沒有進行任何轉(zhuǎn)換。若后期需要修改name
(如添加前綴)趟脂,可以直接在此修改泰讽。ageText
和adoptionFeeText
轉(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,運行后如下:
最后腮介,還有一點可以改進肥矢。在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 文件提示的位置。
運行后如下:
模擬器默認位置是 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)重新排序晚岭。如下所示:
這樣能清晰表明你在使用 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 可以看到咖啡店名稱和評分沦泌。
點擊 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)換后的對象傳遞到視圖控制器并顯示在視圖上没咙。這對于將像
Date
、Decimal
類型 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
參考資料: