Swift 的 MVVM(Model-View-ViewModel)架构是一种设计模式,旨在通过将界面逻辑与业务逻辑分离,提高代码的可维护性和可测试性。在 Swift 和 SwiftUI 开发中,MVVM 是一种非常常见的架构。
MVVM 核心组成部分
1、Model(模型)
表示应用的核心数据结构和业务逻辑。
负责数据存储、数据操作以及与后台服务的交互。
应尽量保持独立,不直接依赖视图或视图模型。
struct Location: Identifiable, Equatable {
let id: UUID
var name: String
var description: String
var latitude: Double
var longitude: Double
}
2、View(视图)
展示用户界面的部分,通常由 SwiftUI 的视图结构实现。
响应用户交互事件并传递到 ViewModel 处理。
绑定 ViewModel 来显示数据,通过 @State 或 @Binding 来更新界面。
struct ContentView: View {
@StateObject private var viewModel = ViewModel()
var body: some View {
Map {
ForEach(viewModel.locations) { location in
// 显示数据
}
}
}
}
3、ViewModel(视图模型)
位于 View 和 Model 之间的中间层,充当桥梁。
负责将 Model 的数据转化为 View 所需的格式。
包含业务逻辑,但不直接与 View 耦合。
使用 SwiftUI 的 @Published 属性来通知 View 数据发生改变。
class ViewModel: ObservableObject {
@Published var locations: [Location] = []
@Published var selectedPlace: Location?
}
MVVM 的数据流动
1、ViewModel 通过 Model 获取数据。
2、Model 中的数据更新后,通过 ViewModel 的绑定通知 View。
3、用户与 View 交互后,ViewModel 处理交互逻辑,并将变更传递回 Model。
SwiftUI 中 MVVM 实现示例
示例:简单的任务管理应用
1、Model
struct Task: Identifiable {
let id = UUID()
var title: String
var isCompleted: Bool
}
2、ViewModel
import Combine
class TaskViewModel: ObservableObject {
// 使用 @Published 来自动通知视图更新
@Published var tasks: [Task] = [
Task(title: "Learn Swift", isCompleted: false),
Task(title: "Build MVVM App", isCompleted: false)
]
// 添加新任务
func addTask(title: String) {
let newTask = Task(title: title, isCompleted: false)
tasks.append(newTask)
}
// 切换任务完成状态
func toggleTaskCompletion(task: Task) {
if let index = tasks.firstIndex(where: { $0.id == task.id }) {
tasks[index].isCompleted.toggle()
}
}
}
3、View
import SwiftUI
struct TaskListView: View {
// 绑定到 ViewModel
@StateObject private var viewModel = TaskViewModel()
@State private var newTaskTitle = ""
var body: some View {
NavigationView {
VStack {
List {
ForEach(viewModel.tasks) { task in
HStack {
Text(task.title)
.strikethrough(task.isCompleted, color: .black)
Spacer()
Button(action: {
viewModel.toggleTaskCompletion(task: task)
}) {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
}
}
}
}
HStack {
TextField("New Task", text: $newTaskTitle)
.textFieldStyle(RoundedBorderTextFieldStyle())
Button("Add") {
guard !newTaskTitle.isEmpty else { return }
viewModel.addTask(title: newTaskTitle)
newTaskTitle = ""
}
.padding(.horizontal)
}
.padding()
}
.navigationTitle("Tasks")
}
}
}
MVVM 的核心思想
1、职责分离
Model 专注于数据和业务逻辑。
ViewModel 专注于将数据转化为 UI 可用的形式,并管理 UI 的状态。
View 专注于显示内容和用户交互。
2、双向绑定
View 和 ViewModel 之间通常通过绑定实现双向通信。
在 SwiftUI 中,通过 @StateObject、@Published、@Binding 等完成。
3、解耦
View 不直接依赖 Model,而是通过 ViewModel 间接获取数据。
这样可以独立开发和测试 Model 和 View。
MVVM 的优点
1、可测试性
ViewModel 独立于 View,可以单独测试逻辑和数据处理。
视图的代码只负责 UI 渲染,避免掺杂逻辑。
2、可维护性
逻辑清晰、模块化,易于理解和维护。
数据变动或界面调整的影响范围更小。
3、重用性
ViewModel 中的逻辑可以被多个视图重用。
View 和 Model 的分离使得更换 UI 框架时不需要大幅调整逻辑。
4、适合响应式编程
SwiftUI 的绑定机制(如 @Published 和 @StateObject)天然支持 MVVM 的数据流动模式。
最佳实践
1、保持单一职责:每个组件应仅负责自己的任务。
2、避免臃肿的 ViewModel:将复杂逻辑分解为多个小的辅助类或函数。
3、关注 SwiftUI 特性:结合 @StateObject、@Published 和 @Binding 实现数据驱动。
分层设计模式
struct ContentView: View {
@State private var locations = [Location]()
@State private var selectedPlace: Location?
...
.sheet(item: $selectedPlace) { place in
EditView(location: place) { newLocation in
print("Updating \(newLocation)")
if let index = locations.firstIndex(of: place) {
locations[index] = newLocation
}
}
}
...
}
通常ContentView视图代码,都会在视图内定义@State对象,但是ContentView是一个视图层,而@State保管的数据属于数据层,因此可以考虑创建一个单独类型来管理数据层的数据。
创建一个名为:ContentView-ViewModel的swift文件:
extension ContentView {
@Observable
class ViewModel {
var locations = [Location]()
var selectedPlace: Location?
}
}
使用 extension 的方式将 ViewModel 定义放在 ContentView 的命名空间下,使其逻辑更加清晰。
class ViewModel作为 ContentView 的视图模型(ViewModel)。
在 SwiftUI 架构中,视图模型(ViewModel)通常负责管理数据和业务逻辑,并将状态绑定到视图上。
在SwiftUI中,通过@State引用ViewModel,绑定它的状态到视图。
struct ContentView: View {
@State private var viewModel = ViewModel()
...
.sheet(item: $viewModel.selectedPlace) { place in
EditView(location: place) { newLocation in
print("Updating \(newLocation)")
if let index = viewModel.locations.firstIndex(of: place) {
viewModel.locations[index] = newLocation
}
}
}
...
}
同时,将原先的locations和selectedPlace属性添加上viewModel前缀:
// locations数组
locations[index] // 修改前
viewModel.locations[index] // 修改后
// selectedPlace属性
$selectedPlace // 修改前
$viewModel.selectedPlace // 修改后
通过分层设计,可以将视图中的数据封装到扩展中的ViewModel类中,在主视图(ContentView)中管理视图模型的生命周期和状态绑定。
需要注意的是,因为 SwiftUI 的状态管理机制需要一个显式的引用或绑定,所以需要在ContentView引用viewModel,以便在状态更新时触发视图重绘。
@State private var viewModel = ViewModel()
限制视图编辑模型数据
为了更好的实现逻辑与布局分开,应该限制编写视图模型数据,因此可以将视图模型中的属性修改为:
private(set) var locations = [Location]()
private(set)是一种访问控制的声明,表示可以被外部读取,但只能在其定义域内修改。
在扩展中,添加add或update等修改方法:
extension ContentView {
@Observable
class ViewModel {
private(set) var locations = [Location]()
var selectedPlace: Location?
func addLocation(at point: CLLocationCoordinate2D) {
let newLocation = Location(id: UUID(), name: "New location", description: "", latitude: point.latitude, longitude: point.longitude)
locations.append(newLocation)
}
func update(location: Location) {
guard let selectedPlace else { return }
if let index = locations.firstIndex(of: selectedPlace) {
locations[index] = location
}
}
}
}
配置private(set)后,视图中原本的修改代码无法继续工作:
Cannot assign through subscript: 'locations' setter is inaccessible
将修改locations赋值的操作改写到ViewModel中执行:
修改前的locations赋值:
.sheet(item: $viewModel.selectedPlace) { place in
EditView(location: place) { newLocation in
print("Updating \(newLocation)")
if let index = viewModel.locations.firstIndex(of: place) {
viewModel.locations[index] = newLocation
}
}
}
修改后的locations赋值:
.sheet(item: $viewModel.selectedPlace) { place in
EditView(location: place) {
viewModel.update(location: $0)
}
}
视图模型完成对ContentView的接管,实现的效果为:视图用于呈现数据,视图模型用于管理数据。
为什么需要引用ViewModel()
1、ViewModel 是引用类型 (class)
ViewModel 是一个类,而 @State 是用于管理 SwiftUI 中值类型的状态(如结构体)。因此,不能直接在视图中声明 @State var locations 或 @State var selectedPlace,因为它们现在属于引用类型的 ViewModel 中的属性。
解决这个问题的正确方式是将整个 ViewModel 作为一个状态对象,通过 @StateObject 或 @State 在视图中管理其生命周期。
2、状态绑定与 SwiftUI 响应机制
SwiftUI 需要通过状态绑定(@State, @StateObject, @Binding 等)来观察数据的变化并重新渲染视图。
即使将 locations 和 selectedPlace 放入扩展的 ViewModel 中,仍然需要在视图中有一个绑定来跟踪 ViewModel 的变化。
3、ViewModel 的独立管理
使用 @State private var viewModel = ViewModel() 的好处是将状态管理逻辑集中在 ViewModel 中,使视图更简洁。视图的职责是通过绑定与 ViewModel 交互,而不是直接管理多个独立的状态属性。
分层设计的优势
1、模块化和命名空间:
使用 extension 将 ViewModel 嵌套在 ContentView 中,表明它是专属于 ContentView 的逻辑组件,增强了代码的可读性和组织性。
2、响应式 UI 更新:
@Observable 简化了状态绑定和更新的代码,避免手动实现 @Published 和 ObservableObject 的模式。
3、SwiftUI 的最佳实践:
SwiftUI 的架构鼓励将视图逻辑和状态分离。视图模型用于处理数据,而视图专注于 UI。
总结
MVVM 在 SwiftUI 中非常适合:
Model 表示核心数据结构和业务逻辑。
ViewModel 提供状态和逻辑绑定,负责数据管理。
View 通过声明式语法展示 UI 并绑定 ViewModel 的数据。
相关文章
Introducing MVVM into your SwiftUI project:https://www.hackingwithswift.com/books/ios-swiftui/introducing-mvvm-into-your-swiftui-project
完整代码
ContentView文件
import SwiftUI
import MapKit
import CoreLocation
let startPosition = MapCameraPosition.region(
MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 35.561591, longitude: 119.619717),
span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01)
)
)
struct ContentView: View {
@State private var viewModel = ViewModel()
var body: some View {
MapReader { proxy in
Map(initialPosition: startPosition) {
ForEach(viewModel.locations) { location in
Annotation(location.name, coordinate: location.coordinate) {
Image(systemName: "star.circle")
.resizable()
.foregroundStyle(.red)
.frame(width: 44, height: 44)
.background(.white)
.clipShape(.circle)
.onLongPressGesture {
viewModel.selectedPlace = location
}
}
}
}
.sheet(item: $viewModel.selectedPlace) { place in
EditView(location: place) {
viewModel.update(location: $0)
}
}
.onTapGesture { position in
if let coordinate = proxy.convert(position, from: .local) {
viewModel.addLocation(at: coordinate)
}
}
}
}}
#Preview {
ContentView()
}
EditView文件
import Foundation
import SwiftUI
struct EditView: View {
@Environment(\.dismiss) var dismiss
var location: Location
var onSave: (Location) -> Void
@State private var name: String
@State private var description: String
@State private var loadingState = LoadingState.loading
@State private var pages = [Page]()
init(location: Location,onSave: @escaping (Location) -> Void) {
self.location = location
self.onSave = onSave
_name = State(initialValue: location.name)
_description = State(initialValue: location.description)
}
func fetchNearbyPlaces() async {
let urlString = "https://en.wikipedia.org/w/api.php?ggscoord=\(location.latitude)%7C\(location.longitude)&action=query&prop=coordinates%7Cpageimages%7Cpageterms&colimit=50&piprop=thumbnail&pithumbsize=500&pilimit=50&wbptterms=description&generator=geosearch&ggsradius=10000&ggslimit=50&format=json"
guard let url = URL(string: urlString) else {
print("Bad URL: \(urlString)")
return
}
do {
let (data, _) = try await URLSession.shared.data(from: url)
// we got some data back!
let items = try JSONDecoder().decode(Result.self, from: data)
// success – convert the array values to our pages array
pages = items.query.pages.values.sorted()
loadingState = .loaded
} catch {
// if we're still here it means the request failed somehow
loadingState = .failed
}
}
var body: some View {
NavigationStack {
Form {
Section {
TextField("Place name", text: $name)
TextField("Description", text: $description)
}
Section("Nearby…") {
switch loadingState {
case .loaded:
ForEach(pages, id: \.pageid) { page in
Text(page.title)
.font(.headline)
+ Text(": ") +
Text(page.description)
}
case .loading:
Text("Loading…")
case .failed:
Text("Please try again later.")
}
}
}
.navigationTitle("Place details")
.toolbar {
Button("Save") {
var newLocation = location
newLocation.id = UUID()
newLocation.name = name
newLocation.description = description
onSave(newLocation)
dismiss()
}
}
}
.task {
await fetchNearbyPlaces()
}
}
}
#Preview {
EditView(location: .example) { _ in }
}
enum LoadingState {
case loading, loaded, failed
}