WWDC Quick Look 💓 By SwiftGGTeam
Showcase app data in Spotlight

Showcase app data in Spotlight

观看原视频

Highlight

Core Data 在 iOS 15 和 macOS Monterey 中引入 NSCoreDataCoreSpotlightDelegate,只需两行代码即可将 Core Data 数据自动索引到 Spotlight,支持自定义属性集、启停控制和全文搜索。

核心内容

用户的数据被困在 App 里

你做了一个照片标签 App,用户给几百张照片打上了标签。某天用户想找到一张”Natural Bridges State Park”的照片,但他忘了在哪个 App 里。他在系统 Spotlight 搜索,结果一无所获——因为数据完全困在你的 App 里。

这就是 NSCoreDataCoreSpotlightDelegate 要解决的问题。它自动监听 Core Data 的数据变化,把内容索引到 Spotlight。用户无需打开你的 App,就能在系统搜索中找到里面的内容。

00:48

为什么用这个 delegate

三个理由:

  1. 与 Core Spotlight API 功能对等
  2. 大幅减少实现代码(从几十行减到两行)
  3. 提供额外功能:索引管理、更新通知、删除控制

02:05

两步开启索引

第一步:在 Core Data 模型编辑器中,为要索引的属性勾选 “Index in Spotlight”,并设置 Spotlight Display Name(用 NSExpression 定义)。

第二步:创建 delegate 并启动索引。

let spotlightDelegate = NSCoreDataCoreSpotlightDelegate(
    forStoreWith: description,
    coordinator: coordinator
)
spotlightDelegate.startSpotlightIndexing()

关键点:

  • 存储类型必须是 SQLite
  • 必须启用 persistent history tracking
  • iOS 15 起使用 forStoreWith:coordinator: 初始化器,旧的 forStoreWith:model: 已废弃

02:40

详细内容

自定义 Spotlight 索引

基础实现够用,但自定义后更灵活。创建一个子类来定义 domain、index name 和属性集。

class TagsSpotlightDelegate: NSCoreDataCoreSpotlightDelegate {
    override func domainIdentifier() -> String {
        return "com.example.apple-samplecode.tags"
    }

    override func indexName() -> String? {
        return "tags-index"
    }

    override func attributeSet(for object: NSManagedObject) -> CSSearchableItemAttributeSet? {
        if let photo = object as? Photo {
            let attributeSet = CSSearchableItemAttributeSet(contentType: .image)
            attributeSet.identifier = photo.uniqueName
            attributeSet.displayName = photo.userSpecifiedName
            attributeSet.thumbnailData = photo.thumbnail?.data

            for case let tag as Tag in photo.tags ?? [] {
                if let name = tag.name {
                    if attributeSet.keywords != nil {
                        attributeSet.keywords?.append(name)
                    } else {
                        attributeSet.keywords = [name]
                    }
                }
            }
            return attributeSet
        } else if let tag = object as? Tag {
            let attributeSet = CSSearchableItemAttributeSet(contentType: .text)
            attributeSet.displayName = tag.name
            return attributeSet
        }
        return nil
    }
}

关键点:

  • domainIdentifier 区分不同索引,多个索引时尤其重要
  • indexName 给索引命名,便于后续管理
  • attributeSet(for:) 为每个托管对象定义 Spotlight 中显示的元数据
  • 如果模型索引了关系属性,必须重写此方法定义关系的哪些字段参与索引
  • CSSearchableItemAttributeSet 的并发访问有未定义行为,单线程修改

06:24

在 Core Data Stack 中集成

import Foundation
import CoreData

class CoreDataStack {
    private(set) var spotlightIndexer: TagsSpotlightDelegate?

    lazy var persistentContainer: NSPersistentContainer = {
        let container = NSPersistentContainer(name: "Tags")

        guard let description = container.persistentStoreDescriptions.first else {
            fatalError("Failed to retrieve a persistent store description.")
        }

        description.type = NSSQLiteStoreType
        description.setOption(true as NSNumber,
            forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
        description.setOption(true as NSNumber,
            forKey: NSPersistentHistoryTrackingKey)

        container.loadPersistentStores(completionHandler: { (_, error) in
            guard let error = error as NSError? else { return }
            fatalError("Failed to load persistent stores: \(error)")
        })

        spotlightIndexer = TagsSpotlightDelegate(
            forStoreWith: description,
            coordinator: container.persistentStoreCoordinator
        )

        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
        container.viewContext.automaticallyMergesChangesFromParent = true

        do {
            try container.viewContext.setQueryGenerationFrom(.current)
        } catch {
            fatalError("Failed to pin viewContext: \(error)")
        }

        return container
    }()
}

关键点:

  • NSPersistentHistoryTrackingKey 必须开启,delegate 依赖历史记录追踪变化
  • NSPersistentStoreRemoteChangeNotificationPostOptionKey 启用远程变更通知
  • delegate 在 loadPersistentStores 之后创建,但必须在任何数据变更前启动索引

05:24

启停控制和更新通知

class PhotosViewController: UICollectionViewController {
    private var spotlightUpdateObserver: NSObjectProtocol?
    var spotlightIndexer: TagsSpotlightDelegate!

    override func viewDidLoad() {
        super.viewDidLoad()
        toggleSpotlightIndexing(enabled: true)
    }

    private func toggleSpotlightIndexing(enabled: Bool) {
        if enabled {
            spotlightIndexer.startSpotlightIndexing()
        } else {
            spotlightIndexer.stopSpotlightIndexing()
        }

        let center = NotificationCenter.default
        if spotlightIndexer.isIndexingEnabled && spotlightUpdateObserver == nil {
            spotlightUpdateObserver = center.addObserver(
                forName: NSCoreDataCoreSpotlightDelegate.indexDidUpdateNotification,
                object: nil,
                queue: .main
            ) { notification in
                let userInfo = notification.userInfo
                let storeID = userInfo?[NSStoreUUIDKey] as? String
                let token = userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken
                print("Store \(storeID ?? "") indexed up to token \(String(describing: token))")
            }
        } else if !enabled {
            if let observer = spotlightUpdateObserver {
                center.removeObserver(observer)
                spotlightUpdateObserver = nil
            }
        }
    }

    @IBAction func deleteSpotlightIndex(_ sender: Any) {
        toggleSpotlightIndexing(enabled: false)
        spotlightIndexer.deleteSpotlightIndex { error in
            if let err = error {
                print("Error deleting index: \(err.localizedDescription)")
            } else {
                print("Spotlight index deleted.")
            }
        }
    }
}

关键点:

  • startSpotlightIndexing()stopSpotlightIndexing() 控制索引时机,适合在 App 执行大量 CPU 或磁盘操作时暂停
  • indexDidUpdateNotificationsave: 或批量操作完成后异步发布
  • 通知的 userInfo 包含 NSStoreUUIDKey(存储 UUID)和 NSPersistentHistoryTokenKey(历史 token)
  • deleteSpotlightIndex 是 iOS 15 新增,无需删除 Core Data 数据即可清除索引

09:51

用 Spotlight 索引实现 App 内全文搜索

索引完成后,可以用同样的数据在 App 内做全文搜索,无需额外存储。

extension PhotosViewController: UISearchResultsUpdating {
    func updateSearchResults(for searchController: UISearchController) {
        guard let userInput = searchController.searchBar.text, !userInput.isEmpty else {
            dataProvider.performFetch(predicate: nil)
            reloadCollectionView()
            return
        }

        let escapedString = userInput
            .replacingOccurrences(of: "\\", with: "\\\\")
            .replacingOccurrences(of: "\"", with: "\\\"")
        let queryString = "(keywords == \"" + escapedString + "*\"cwdt)"

        searchQuery = CSSearchQuery(
            queryString: queryString,
            attributes: ["displayName", "keywords"]
        )

        searchQuery?.foundItemsHandler = { items in
            DispatchQueue.main.async {
                self.spotlightFoundItems += items
            }
        }

        searchQuery?.completionHandler = { error in
            guard error == nil else {
                print("CSSearchQuery error: \(error!)")
                return
            }
            DispatchQueue.main.async {
                self.dataProvider.performFetch(searchableItems: self.spotlightFoundItems)
                self.reloadCollectionView()
                self.spotlightFoundItems.removeAll()
            }
        }

        searchQuery?.start()
    }
}

关键点:

  • CSSearchQuery 直接查询 Spotlight 索引,无需自建搜索逻辑
  • 查询字符串语法:keywords == "搜索词*"cwdt
  • 修饰符:c 不区分大小写,d 不区分重音,w 基于单词匹配
  • foundItemsHandler 被调用零次或多次,返回匹配的 CSSearchableItem
  • completionHandler 只调用一次,在所有结果返回后执行
  • 搜索前必须对用户输入做转义,防止查询语法错误

13:13

核心启发

1. 为笔记类 App 添加 Spotlight 索引

  • 做什么:让用户在系统搜索中直接找到笔记内容
  • 为什么值得做:笔记 App 的核心价值是快速检索,Spotlight 索引把检索范围扩展到系统级别
  • 怎么开始:在 Core Data 模型中为笔记标题和内容属性勾选 “Index in Spotlight”,创建 delegate 并启动索引

2. 用 Spotlight 索引替代自建搜索

  • 做什么:不再维护单独的搜索数据库,直接用 CSSearchQuery 查询 Spotlight 索引
  • 为什么值得做:减少代码量,搜索性能由系统优化,自动支持拼音、模糊匹配等
  • 怎么开始:把现有的搜索逻辑替换为 CSSearchQuery,用 foundItemsHandlercompletionHandler 处理结果

3. 为照片管理 App 添加标签搜索

  • 做什么:让用户通过标签在 Spotlight 中找到照片
  • 为什么值得做:照片数量庞大时,标签是最有效的检索方式;Spotlight 索引让标签搜索跨 App 可用
  • 怎么开始:在 attributeSet(for:) 中把标签名加入 keywords 数组,用户搜索任意标签名即可找到对应照片

4. 实现索引的按需启停

  • 做什么:在 App 执行大量数据导入或同步时暂停 Spotlight 索引
  • 为什么值得做:索引操作会消耗 CPU 和磁盘 I/O,在性能敏感时段暂停可避免卡顿
  • 怎么开始:在数据导入开始前调用 stopSpotlightIndexing(),导入完成后调用 startSpotlightIndexing()

5. 为用户提供隐私控制的索引删除

  • 做什么:在设置页面增加”清除搜索索引”选项
  • 为什么值得做:iOS 15 新增 deleteSpotlightIndex 让用户无需删除数据即可清除搜索索引,满足隐私需求
  • 怎么开始:在设置中添加开关,调用 deleteSpotlightIndex(completionHandler:) 并在完成后给用户确认提示

关联 Session

评论

GitHub Issues · utterances