30 Apps 第 2 天:待办清单 App —— MVVM + Combine 响应式 UI

专栏 :iOS功能实战30Days
编号 :B02 · 系列第 2 篇
字数 :约 5500 字
标签:iOS / SwiftUI / MVVM / Combine / 响应式 / 状态管理 / 单元测试


前言

昨天我们完成了待办清单 App 的数据层设计。今天我们来完成 UI 层。

我们将使用 SwiftUI + MVVM + Combine 构建完整的响应式界面,包括:

  1. 任务列表页面:展示、筛选、搜索、滑动操作
  2. 任务编辑页面:创建和编辑任务
  3. 数据绑定:ViewModel 和 View 之间的自动同步
  4. 单元测试:验证 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,重点学习表达式解析算法------逆波兰表示法(后缀表达式)的原理与实现,以及如何用状态机处理复杂的计算逻辑。


往期回顾


如果你完成了今天的代码编写,欢迎在评论区分享你的优化思路。

相关推荐
冰凌时空2 小时前
手写 Swift 运行时:objc_msgSend 的汇编级解析
ios·openai·ai编程
2601_956002812 小时前
AdGuardPro_TS.ipa2026最新版ipa 下载后浏览器无广告 官方正版2026最新版pc免费下载(看到请立即转存 资源随时失效)ios必下
macos·ios·cocoa·ipa
子兮曰2 小时前
AI Coding 为什么全选了 TUI?从 Claude Code 到 Codex CLI,终端架构的底层逻辑
前端·后端·ai编程
JiaHao汤2 小时前
深入理解 Claude Code 规则体系.md
ai编程
AI原来如此2 小时前
[特殊字符]2026AI Agent入门学习路径
学习·ai·大模型·ai编程
Daniel_Coder2 小时前
iOS Widget 开发-12:Widget 深度链接与导航
ios·swiftui·swift·widget·intents
小坏讲微服务2 小时前
SpringBoot整合SpringAI配置多平台API密钥
java·人工智能·spring boot·后端·flask·ai编程·claude code
暗不需求2 小时前
深入浅出 LangChain Memory:从无状态到有记忆的智能对话
面试·langchain·ai编程
han_3 小时前
如何寻找、安装和管理 AI Skill?
人工智能·ai编程·claude