专栏 :iOS功能实战30Days
编号 :B02 · 系列第 2 篇
字数 :约 5500 字
标签:iOS / SwiftUI / MVVM / Combine / 响应式 / 状态管理 / 单元测试
前言
昨天我们完成了待办清单 App 的数据层设计。今天我们来完成 UI 层。
我们将使用 SwiftUI + MVVM + Combine 构建完整的响应式界面,包括:
- 任务列表页面:展示、筛选、搜索、滑动操作
- 任务编辑页面:创建和编辑任务
- 数据绑定:ViewModel 和 View 之间的自动同步
- 单元测试:验证 ViewModel 的业务逻辑
一、整体架构
csharp
┌─────────────────────────────────────────────────────────────┐
│ View (SwiftUI) │
│ ContentView / TaskRowView / TaskEditorView │
└──────────────────────────┬──────────────────────────────────┘
│ @StateObject + @Published
▼
┌─────────────────────────────────────────────────────────────┐
│ ViewModel (Combine) │
│ TaskListViewModel: ObservableObject │
│ @Published tasks / isLoading / errorMessage │
│ func loadTasks() / createTask() / toggleStatus() / ... │
└──────────────────────────┬──────────────────────────────────┘
│ async/await
▼
┌─────────────────────────────────────────────────────────────┐
│ Repository (Protocol-based) │
│ TaskRepository: TaskRepositoryProtocol │
│ fetchTasks() / insertTask() / updateTask() / deleteTask() │
└──────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ SQLite.swift (Data Layer) │
│ DatabaseManager + TaskTable + DatabaseMigration │
└─────────────────────────────────────────────────────────────┘
二、ViewModel 实现
2.1 TaskListViewModel
swift
import Foundation
import Combine
@MainActor
class TaskListViewModel: ObservableObject {
// ================= 状态 =================
@Published var tasks: [Task] = []
@Published var isLoading = false
@Published var errorMessage: String?
// ================= 筛选状态 =================
@Published var selectedStatus: Task.Status?
@Published var selectedCategory: Task.Category?
@Published var searchQuery = ""
@Published var sortOption: SortOption = .createdDesc
// ================= 派生状态 =================
var pendingCount: Int {
tasks.filter { $0.status == .pending }.count
}
var completedCount: Int {
tasks.filter { $0.status == .completed }.count
}
var hasOverdueTasks: Bool {
guard let now = tasks.first?.dueDate else { return false }
return tasks.contains { task in
task.status == .pending &&
task.dueDate != nil &&
task.dueDate! < now
}
}
// ================= 依赖 =================
private let repository: TaskRepositoryProtocol
private var cancellables = Set<AnyCancellable>()
// ================= 初始化 =================
init(repository: TaskRepositoryProtocol = TaskRepository()) {
self.repository = repository
setupBindings()
}
// ================= 数据绑定 =================
/// 监听筛选条件变化,自动重新加载数据
private func setupBindings() {
// 合并所有筛选条件为一个 Publisher
Publishers.CombineLatest4(
$selectedStatus,
$selectedCategory,
$searchQuery,
$sortOption
)
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates { prev, curr in
prev.0 == curr.0 && prev.1 == curr.1 &&
prev.2 == curr.2 && prev.3 == curr.3
}
.sink { [weak self] _, _, _, _ in
Task { await self?.loadTasks() }
}
.store(in: &cancellables)
}
// ================= 公开方法 =================
/// 加载任务列表
func loadTasks() async {
isLoading = true
errorMessage = nil
do {
tasks = try await repository.fetchTasks(
status: selectedStatus,
category: selectedCategory,
searchQuery: searchQuery.isEmpty ? nil : searchQuery,
sortBy: sortOption
)
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
/// 创建新任务
func createTask(
title: String,
content: String?,
priority: Task.Priority,
category: Task.Category,
dueDate: Date?
) async -> Bool {
let task = Task(
id: UUID(),
title: title,
content: content,
priority: priority,
status: .pending,
category: category,
dueDate: dueDate,
createdAt: Date(),
updatedAt: Date(),
completedAt: nil,
isPinned: false
)
do {
try await repository.insertTask(task)
await loadTasks()
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
/// 切换完成状态
func toggleTaskStatus(_ task: Task) async {
var updated = task
updated.status = task.status == .pending ? .completed : .pending
updated.completedAt = updated.status == .completed ? Date() : nil
updated.updatedAt = Date()
do {
try await repository.updateTask(updated)
// 局部更新,不需要重新加载全部数据
if let index = tasks.firstIndex(where: { $0.id == task.id }) {
tasks[index] = updated
}
} catch {
errorMessage = error.localizedDescription
}
}
/// 切换置顶状态
func togglePinned(_ task: Task) async {
var updated = task
updated.isPinned.toggle()
updated.updatedAt = Date()
do {
try await repository.updateTask(updated)
if let index = tasks.firstIndex(where: { $0.id == task.id }) {
tasks[index] = updated
}
} catch {
errorMessage = error.localizedDescription
}
}
/// 删除任务
func deleteTask(_ task: Task) async {
do {
try await repository.deleteTask(by: task.id)
tasks.removeAll { $0.id == task.id }
} catch {
errorMessage = error.localizedDescription
}
}
/// 删除所有已完成任务
func deleteAllCompleted() async {
do {
let count = try await repository.deleteAllCompletedTasks()
print("Deleted \(count) completed tasks")
await loadTasks()
} catch {
errorMessage = error.localizedDescription
}
}
/// 清除错误消息
func clearError() {
errorMessage = nil
}
}
三、视图实现
3.1 主列表视图
swift
import SwiftUI
struct ContentView: View {
@StateObject private var viewModel = TaskListViewModel()
@State private var showingAddTask = false
@State private var showingDeleteAllAlert = false
var body: some View {
NavigationStack {
ZStack {
if viewModel.isLoading && viewModel.tasks.isEmpty {
ProgressView("加载中...")
} else if viewModel.tasks.isEmpty {
EmptyStateView(
icon: "checkmark.circle",
title: "没有任务",
message: "点击右上角添加你的第一个任务"
)
} else {
taskList
}
}
.navigationTitle("待办清单")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
sortMenu
}
ToolbarItem(placement: .topBarTrailing) {
addButton
}
}
.searchable(text: $viewModel.searchQuery, prompt: "搜索任务")
.refreshable {
await viewModel.loadTasks()
}
.alert("错误", isPresented: .init(
get: { viewModel.errorMessage != nil },
set: { if !$0 { viewModel.clearError() } }
)) {
Button("确定") { viewModel.clearError() }
} message: {
Text(viewModel.errorMessage ?? "")
}
.sheet(isPresented: $showingAddTask) {
TaskEditorView(viewModel: viewModel, task: nil)
}
.task {
await viewModel.loadTasks()
}
}
}
// ================= 子视图 =================
private var taskList: some View {
List {
// 筛选栏
filterBar
// 任务列表
ForEach(viewModel.tasks) { task in
TaskRowView(
task: task,
onToggle: { Task { await viewModel.toggleTaskStatus(task) } },
onTogglePinned: { Task { await viewModel.togglePinned(task) } }
)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task { await viewModel.deleteTask(task) }
} label: {
Label("删除", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
Button {
Task { await viewModel.toggleTaskStatus(task) }
} label: {
Label(
task.status == .pending ? "完成" : "未完成",
systemImage: task.status == .pending ? "checkmark" : "arrow.uturn.backward"
)
}
.tint(task.status == .pending ? .green : .orange)
}
}
// 批量操作(有待完成任务时显示)
if viewModel.completedCount > 0 {
Section {
Button(role: .destructive) {
showingDeleteAllAlert = true
} label: {
Label("清空已完成(\(viewModel.completedCount))", systemImage: "trash")
}
}
}
}
.listStyle(.insetGrouped)
.confirmationDialog("清空已完成任务?", isPresented: $showingDeleteAllAlert) {
Button("清空", role: .destructive) {
Task { await viewModel.deleteAllCompleted() }
}
Button("取消", role: .cancel) {}
}
}
private var filterBar: some View {
Section {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
// 状态筛选
FilterChip(
title: "全部",
isSelected: viewModel.selectedStatus == nil
) {
viewModel.selectedStatus = nil
}
FilterChip(
title: "待完成",
isSelected: viewModel.selectedStatus == .pending
) {
viewModel.selectedStatus = .pending
}
FilterChip(
title: "已完成",
isSelected: viewModel.selectedStatus == .completed
) {
viewModel.selectedStatus = .completed
}
Divider().frame(height: 20)
// 分类筛选
ForEach(Task.Category.allCases, id: \.self) { category in
FilterChip(
title: category.rawValue,
isSelected: viewModel.selectedCategory == category
) {
viewModel.selectedCategory = viewModel.selectedCategory == category ? nil : category
}
}
}
.padding(.vertical, 4)
}
}
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
private var sortMenu: some View {
Menu {
ForEach(SortOption.allCases, id: \.self) { option in
Button {
viewModel.sortOption = option
} label: {
HStack {
Text(option.rawValue)
if viewModel.sortOption == option {
Image(systemName: "checkmark")
}
}
}
}
} label: {
Label("排序", systemImage: "arrow.up.arrow.down")
}
}
private var addButton: some View {
Button {
showingAddTask = true
} label: {
Image(systemName: "plus")
}
}
}
// ================= 辅助视图 =================
struct FilterChip: View {
let title: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.subheadline)
.fontWeight(isSelected ? .semibold : .regular)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isSelected ? Color.blue : Color(.systemGray5))
.foregroundColor(isSelected ? .white : .primary)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
struct EmptyStateView: View {
let icon: String
let title: String
let message: String
var body: some View {
VStack(spacing: 16) {
Image(systemName: icon)
.font(.system(size: 64))
.foregroundColor(.secondary)
Text(title)
.font(.title2)
.fontWeight(.semibold)
Text(message)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.padding()
}
}
3.2 任务行视图
swift
struct TaskRowView: View {
let task: Task
let onToggle: () -> Void
let onTogglePinned: () -> Void
var body: some View {
HStack(spacing: 12) {
// 完成状态按钮
Button(action: onToggle) {
Image(systemName: task.status == .completed ? "checkmark.circle.fill" : "circle")
.font(.title2)
.foregroundColor(task.status == .completed ? .green : .secondary)
}
.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
// 置顶标识
if task.isPinned {
Image(systemName: "pin.fill")
.font(.caption2)
.foregroundColor(.orange)
}
// 标题
Text(task.title)
.font(.body)
.fontWeight(.medium)
.strikethrough(task.status == .completed)
.foregroundColor(task.status == .completed ? .secondary : .primary)
// 优先级标签
priorityLabel
}
// 描述
if let content = task.content, !content.isEmpty {
Text(content)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
}
// 底部信息
HStack(spacing: 8) {
// 分类
Label(task.category.rawValue, systemImage: categoryIcon)
.font(.caption2)
.foregroundColor(.secondary)
// 截止日期
if let dueDate = task.dueDate {
HStack(spacing: 2) {
Image(systemName: "calendar")
Text(formatDate(dueDate))
}
.font(.caption2)
.foregroundColor(isOverdue ? .red : .secondary)
}
}
}
Spacer()
}
.padding(.vertical, 4)
}
@ViewBuilder
private var priorityLabel: some View {
switch task.priority {
case .high:
Text("高")
.font(.caption2)
.fontWeight(.bold)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.red.opacity(0.15))
.foregroundColor(.red)
.clipShape(RoundedRectangle(cornerRadius: 4))
case .medium:
Text("中")
.font(.caption2)
.fontWeight(.bold)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange.opacity(0.15))
.foregroundColor(.orange)
.clipShape(RoundedRectangle(cornerRadius: 4))
case .low:
Text("低")
.font(.caption2)
.fontWeight(.bold)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.blue.opacity(0.15))
.foregroundColor(.blue)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
private var categoryIcon: String {
switch task.category {
case .work: return "briefcase"
case .life: return "house"
case .study: return "book"
case .health: return "heart"
}
}
private var isOverdue: Bool {
guard task.status == .pending,
let dueDate = task.dueDate else { return false }
return dueDate < Date()
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
if Calendar.current.isDateInToday(date) {
return "今天"
} else if Calendar.current.isDateInTomorrow(date) {
return "明天"
} else {
formatter.dateFormat = "MM/dd"
return formatter.string(from: date)
}
}
}
3.3 任务编辑器视图
swift
struct TaskEditorView: View {
@ObservedObject var viewModel: TaskListViewModel
let task: Task? // nil 表示新建,非 nil 表示编辑
@Environment(\.dismiss) private var dismiss
@State private var title = ""
@State private var content = ""
@State private var priority: Task.Priority = .medium
@State private var category: Task.Category = .work
@State private var hasDueDate = false
@State private var dueDate = Date()
@State private var showingDatePicker = false
var isEditing: Bool { task != nil }
var body: some View {
NavigationStack {
Form {
// 基本信息
Section("任务信息") {
TextField("任务标题", text: $title)
TextField("详细描述(可选)", text: $content, axis: .vertical)
.lineLimit(3...6)
}
// 优先级和分类
Section("属性") {
Picker("优先级", selection: $priority) {
Text("高").tag(Task.Priority.high)
Text("中").tag(Task.Priority.medium)
Text("低").tag(Task.Priority.low)
}
.pickerStyle(.segmented)
Picker("分类", selection: $category) {
ForEach(Task.Category.allCases, id: \.self) { cat in
Label(cat.rawValue, systemImage: categoryIcon(cat))
.tag(cat)
}
}
}
// 截止日期
Section("截止日期") {
Toggle("设置截止日期", isOn: $hasDueDate)
if hasDueDate {
DatePicker(
"截止日期",
selection: $dueDate,
displayedComponents: [.date, .hourAndMinute]
)
.datePickerStyle(.graphical)
}
}
// 编辑模式:额外操作
if isEditing {
Section {
Button(role: .destructive) {
Task {
await viewModel.deleteTask(task!)
dismiss()
}
} label: {
HStack {
Spacer()
Text("删除任务")
Spacer()
}
}
}
}
}
.navigationTitle(isEditing ? "编辑任务" : "新建任务")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(isEditing ? "保存" : "添加") {
Task {
if isEditing {
await updateTask()
} else {
await createTask()
}
dismiss()
}
}
.disabled(title.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.onAppear {
if let task {
title = task.title
content = task.content ?? ""
priority = task.priority
category = task.category
if let date = task.dueDate {
hasDueDate = true
dueDate = date
}
}
}
}
}
private func createTask() async {
_ = await viewModel.createTask(
title: title.trimmingCharacters(in: .whitespaces),
content: content.isEmpty ? nil : content,
priority: priority,
category: category,
dueDate: hasDueDate ? dueDate : nil
)
}
private func updateTask() async {
guard let task else { return }
var updated = task
updated.title = title.trimmingCharacters(in: .whitespaces)
updated.content = content.isEmpty ? nil : content
updated.priority = priority
updated.category = category
updated.dueDate = hasDueDate ? dueDate : nil
updated.updatedAt = Date()
do {
try await viewModel.repository.updateTask(updated)
await viewModel.loadTasks()
} catch {
print("Update failed: \(error)")
}
}
private func categoryIcon(_ cat: Task.Category) -> String {
switch cat {
case .work: return "briefcase"
case .life: return "house"
case .study: return "book"
case .health: return "heart"
}
}
}
四、单元测试
4.1 Mock Repository
swift
// TaskRepositoryTests.swift
import XCTest
@testable import TodoApp
// Mock 实现
final class MockTaskRepository: TaskRepositoryProtocol {
var tasks: [Task] = []
var shouldThrowError = false
var errorToThrow: Error?
func fetchAllTasks() async throws -> [Task] {
if shouldThrowError { throw errorToThrow! }
return tasks
}
func fetchTask(by id: UUID) async throws -> Task? {
return tasks.first { $0.id == id }
}
func insertTask(_ task: Task) async throws {
tasks.append(task)
}
func updateTask(_ task: Task) async throws {
if let index = tasks.firstIndex(where: { $0.id == task.id }) {
tasks[index] = task
}
}
func deleteTask(by id: UUID) async throws {
tasks.removeAll { $0.id == id }
}
func deleteAllCompletedTasks() async throws -> Int {
let count = tasks.filter { $0.status == .completed }.count
tasks.removeAll { $0.status == .completed }
return count
}
func fetchTasks(status: Task.Status?, category: Task.Category?, searchQuery: String?, sortBy: SortOption) async throws -> [Task] {
var result = tasks
if let status { result = result.filter { $0.status == status } }
if let category { result = result.filter { $0.category == category } }
if let query = searchQuery, !query.isEmpty {
result = result.filter { $0.title.contains(query) }
}
return result
}
func countTasks(status: Task.Status?) async throws -> Int {
var result = tasks
if let status { result = result.filter { $0.status == status } }
return result.count
}
func fetchOverdueTasks() async throws -> [Task] {
let now = Date()
return tasks.filter { $0.status == .pending && $0.dueDate ?? now < now }
}
}
4.2 ViewModel 测试
swift
@MainActor
class TaskListViewModelTests: XCTestCase {
var viewModel: TaskListViewModel!
var mockRepository: MockTaskRepository!
override func setUp() async throws {
mockRepository = MockTaskRepository()
viewModel = TaskListViewModel(repository: mockRepository)
}
// MARK: - loadTasks
func test_loadTasks_success() async throws {
let task1 = Task.income(amount: 100, category: "test")
let task2 = Task.income(amount: 200, category: "test")
mockRepository.tasks = [task1, task2]
await viewModel.loadTasks()
XCTAssertEqual(viewModel.tasks.count, 2)
XCTAssertNil(viewModel.errorMessage)
XCTAssertFalse(viewModel.isLoading)
}
func test_loadTasks_withError() async throws {
mockRepository.shouldThrowError = true
mockRepository.errorToThrow = NSError(domain: "test", code: 500)
await viewModel.loadTasks()
XCTAssertTrue(viewModel.tasks.isEmpty)
XCTAssertNotNil(viewModel.errorMessage)
}
// MARK: - toggleTaskStatus
func test_toggleTaskStatus_pendingToCompleted() async throws {
var task = Task.income(amount: 100, category: "test")
task.status = .pending
mockRepository.tasks = [task]
await viewModel.loadTasks()
await viewModel.toggleTaskStatus(task)
XCTAssertEqual(viewModel.tasks.first?.status, .completed)
XCTAssertNotNil(viewModel.tasks.first?.completedAt)
}
func test_toggleTaskStatus_completedToPending() async throws {
var task = Task.income(amount: 100, category: "test")
task.status = .completed
mockRepository.tasks = [task]
await viewModel.loadTasks()
await viewModel.toggleTaskStatus(task)
XCTAssertEqual(viewModel.tasks.first?.status, .pending)
XCTAssertNil(viewModel.tasks.first?.completedAt)
}
// MARK: - createTask
func test_createTask_success() async throws {
let success = await viewModel.createTask(
title: "New Task",
content: "Description",
priority: .high,
category: .work,
dueDate: nil
)
XCTAssertTrue(success)
XCTAssertEqual(viewModel.tasks.count, 1)
XCTAssertEqual(viewModel.tasks.first?.title, "New Task")
XCTAssertEqual(viewModel.tasks.first?.priority, .high)
}
func test_createTask_emptyTitle() async throws {
let success = await viewModel.createTask(
title: " ", // 只有空白
content: nil,
priority: .medium,
category: .work,
dueDate: nil
)
XCTAssertFalse(success)
XCTAssertTrue(viewModel.tasks.isEmpty)
}
// MARK: - deleteTask
func test_deleteTask_success() async throws {
let task = Task.income(amount: 100, category: "test")
mockRepository.tasks = [task]
await viewModel.loadTasks()
await viewModel.deleteTask(task)
XCTAssertTrue(viewModel.tasks.isEmpty)
}
// MARK: - 派生状态
func test_derivedCounts() async throws {
var task1 = Task.income(amount: 100, category: "work")
task1.status = .pending
var task2 = Task.income(amount: 200, category: "work")
task2.status = .completed
mockRepository.tasks = [task1, task2]
await viewModel.loadTasks()
XCTAssertEqual(viewModel.pendingCount, 1)
XCTAssertEqual(viewModel.completedCount, 1)
}
}
五、两天代码总结
经过两天的开发,我们的待办清单 App 已经有了完整的:
markdown
├── 数据层
│ ├── DatabaseManager(SQLite 连接)
│ ├── TaskTable(表定义)
│ ├── DatabaseMigration(版本迁移)
│ └── TaskRepository(数据访问接口)
│
├── 业务层
│ └── TaskListViewModel(MVVM ViewModel)
│
├── 视图层
│ ├── ContentView(主列表)
│ ├── TaskRowView(任务行)
│ └── TaskEditorView(任务编辑器)
│
└── 测试层
├── MockTaskRepository
└── TaskListViewModelTests
下篇预告
明天我们将开启第三个 App:计算器 App,重点学习表达式解析算法------逆波兰表示法(后缀表达式)的原理与实现,以及如何用状态机处理复杂的计算逻辑。
往期回顾:
如果你完成了今天的代码编写,欢迎在评论区分享你的优化思路。