案例:Todo 列表的 ViewModel 实现
1. ViewModel 的设计目标
在本案例中,我们将为 Todo 列表应用实现一个完整的 ViewModel 层,主要实现以下功能:
- 管理 Todo 项的数据集合
- 处理添加/删除/更新 Todo 项的逻辑
- 提供过滤和排序功能
- 作为 View 层和 Model 层之间的桥梁
2. 基础 ViewModel 结构
import Combine
import SwiftUI
class TodoListViewModel: ObservableObject {
// MARK: - 发布属性
@Published var todos: [TodoItem] = []
@Published var filterText: String = ""
@Published var showCompletedOnly = false
// MARK: - 私有属性
private var cancellables = Set<AnyCancellable>()
// MARK: - 初始化
init() {
loadInitialData()
setupBindings()
}
}
3. 核心业务逻辑实现
3.1 数据操作方法
extension TodoListViewModel {
func addTodo(title: String) {
let newItem = TodoItem(
id: UUID(),
title: title,
isCompleted: false,
createdAt: Date()
)
todos.append(newItem)
}
func toggleCompletion(for todo: TodoItem) {
if let index = todos.firstIndex(where: { $0.id == todo.id }) {
todos[index].isCompleted.toggle()
}
}
func deleteTodo(at indexSet: IndexSet) {
todos.remove(atOffsets: indexSet)
}
}
3.2 数据绑定与过滤
extension TodoListViewModel {
private func setupBindings() {
$filterText
.combineLatest($showCompletedOnly)
.map { [weak self] (filterText, showCompleted) in
self?.filterTodos(text: filterText, showCompleted: showCompleted) ?? []
}
.assign(to: &$todos)
}
private func filterTodos(text: String, showCompleted: Bool) -> [TodoItem] {
var filtered = todos
if !text.isEmpty {
filtered = filtered.filter { $0.title.localizedCaseInsensitiveContains(text) }
}
if showCompleted {
filtered = filtered.filter { $0.isCompleted }
}
return filtered
}
}
4. 与 View 层的集成
4.1 提供视图所需数据
extension TodoListViewModel {
var completedTodosCount: Int {
todos.filter { $0.isCompleted }.count
}
var totalTodosCount: Int {
todos.count
}
var progressPercentage: Double {
guard !todos.isEmpty else { return 0 }
return Double(completedTodosCount) / Double(totalTodosCount)
}
}
4.2 在 SwiftUI 中的使用示例
struct TodoListView: View {
@StateObject var viewModel = TodoListViewModel()
var body: some View {
VStack {
// 搜索和筛选控制
SearchAndFilterView(viewModel: viewModel)
// 进度显示
ProgressView(value: viewModel.progressPercentage)
.padding()
// 列表主体
List {
ForEach(viewModel.todos) { todo in
TodoRowView(todo: todo) {
viewModel.toggleCompletion(for: todo)
}
}
.onDelete(perform: viewModel.deleteTodo)
}
// 添加新项目
AddTodoView { newTitle in
viewModel.addTodo(title: newTitle)
}
}
}
}
5. 测试要点
5.1 单元测试示例
class TodoListViewModelTests: XCTestCase {
var viewModel: TodoListViewModel!
override func setUp() {
super.setUp()
viewModel = TodoListViewModel()
}
func testAddTodo() {
let initialCount = viewModel.todos.count
viewModel.addTodo(title: "Test Todo")
XCTAssertEqual(viewModel.todos.count, initialCount + 1)
XCTAssertEqual(viewModel.todos.last?.title, "Test Todo")
}
func testToggleCompletion() {
viewModel.addTodo(title: "Test")
let initialState = viewModel.todos[0].isCompleted
viewModel.toggleCompletion(for: viewModel.todos[0])
XCTAssertNotEqual(viewModel.todos[0].isCompleted, initialState)
}
}
6. 实现注意事项
- 单一职责原则:ViewModel 只负责业务逻辑,不包含视图布局信息
- 不可变数据:在传递数据给 View 时使用值类型(struct)
- 线程安全:确保数据修改都在主线程进行
- 内存管理:合理使用
[weak self]避免循环引用 - 可测试性:所有业务逻辑都应该可以被独立测试
7. 完整案例代码结构
TodoApp/
├── Models/
│ └── TodoItem.swift
├── ViewModels/
│ └── TodoListViewModel.swift
├── Views/
│ ├── TodoListView.swift
│ ├── TodoRowView.swift
│ └── AddTodoView.swift
└── Tests/
└── TodoListViewModelTests.swift
通过这个案例,我们展示了如何在 SwiftUI 中实现一个符合 MVVM 模式的 ViewModel,它清晰地分离了业务逻辑和视图表现,同时保持了良好的可测试性和可维护性。
