Swift设计模式MVVM架构
Swift设计模式MVVM架构

Swift设计模式MVVM架构

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
    }

    如果您认为这篇文章给您带来了帮助,您可以在此通过支付宝或者微信打赏网站开放者。

    发表回复

    您的电子邮箱地址不会被公开。 必填项已用 * 标注