搜索|ViewModel 层搜索逻辑的设计

了解如何使用 Swift 的 Observation 框架和 didSet 属性观察器,在 SwiftUI 应用中构建高效响应式的搜索功能架构。

搜索|ViewModel 层搜索逻辑的设计

使用 Swift 提供的 Observation + didSet 来实现 ViewModel 层的搜索功能。

这篇文章专注于搜索逻辑的设计与实现,对于搜索框 UI 界面的实现,可参考下面这篇文章:

搜索|使用 .searchable 添加搜索框
了解如何使用 SwiftUI 提供的 searchable 修饰器,为你的 App 添加方便好用的搜索功能。

搜索逻辑数据流

在 ViewModel 中存储搜索字符串

首先,在 ViewModel 中定义搜索文本属性:

@Observable
@MainActor
final class BookmarksViewModel: BaseViewModel {
    /// 电影搜索文本
    var movieSearchText: String = ""

    /// 书籍搜索文本  
    var bookSearchText: String = ""
}

设计要点:

  • 使用 @Observable 宏让属性变化可被 SwiftUI 观察
  • 分离不同类型的搜索文本,保持独立状态
  • 使用 @MainActor 确保 UI 更新在主线程

使用 didSet 触发数据检索

为搜索属性添加 didSet 观察器:

@Observable
@MainActor
final class BookmarksViewModel: BaseViewModel {
    /// 电影搜索文本
    var movieSearchText: String = "" {
        didSet {
            Task { await fetch() }
        }
    }

    /// 书籍搜索文本  
    var bookSearchText: String = "" {
        didSet {
            Task { await fetch() }
        }
    }
}

设计要点:

  • 自动响应文本变化,无需手动监听
  • 创建异步任务,不阻塞 UI 线程

实现智能防抖机制

实现防抖功能,避免频繁查询:

// BaseViewModel 协议中的 fetch 方法
func fetch() async {
    // 取消之前的防抖任务
    debounceTask?.cancel()

    // 创建新的防抖任务
    debounceTask = Task { @MainActor [weak self] in
        guard let self = self else { return }

        do {
            // 等待防抖延迟
            try await Task.sleep(for: .milliseconds(100))

            // 检查任务是否被取消
            guard !Task.isCancelled else { return }

            // 执行实际的数据获取
            await self.performFetch()
        } catch {
            // 处理错误
        }
    }
}

防抖效果:

  • 用户快速输入 "abc" 时,只会在停止输入 100ms 后执行一次查询
  • 有效减少数据库压力和网络请求

使用 Task.detached 在后台执行查询

在 performFetch 中执行实际的数据查询。

如果使用 SwiftData,可使用 DataActor 将任务安全的分派到后台线程

UI 层的双向数据绑定

在 SearchBarView 中实现与 ViewModel 的绑定:

struct SearchBarView: View {
    var bookmarkType: BookmarkType

    var body: some View {
        @Bindable var viewModel = BookmarksViewModel.shared

        TextField(
            "搜索\(bookmarkType == .movie ? "电影" : "书籍")...",
            text: bookmarkType == .movie
                ? $viewModel.movieSearchText
                : $viewModel.bookSearchText
        )
        .submitLabel(.search)
    }
}

绑定特点:

  • 使用 @Bindable 创建双向绑定
  • 根据内容类型动态选择绑定的属性
  • TextField 的每次输入都会更新 ViewModel