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
三个理由:
- 与 Core Spotlight API 功能对等
- 大幅减少实现代码(从几十行减到两行)
- 提供额外功能:索引管理、更新通知、删除控制
(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 或磁盘操作时暂停indexDidUpdateNotification在save:或批量操作完成后异步发布- 通知的 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被调用零次或多次,返回匹配的CSSearchableItemcompletionHandler只调用一次,在所有结果返回后执行- 搜索前必须对用户输入做转义,防止查询语法错误
(13:13)
核心启发
1. 为笔记类 App 添加 Spotlight 索引
- 做什么:让用户在系统搜索中直接找到笔记内容
- 为什么值得做:笔记 App 的核心价值是快速检索,Spotlight 索引把检索范围扩展到系统级别
- 怎么开始:在 Core Data 模型中为笔记标题和内容属性勾选 “Index in Spotlight”,创建 delegate 并启动索引
2. 用 Spotlight 索引替代自建搜索
- 做什么:不再维护单独的搜索数据库,直接用
CSSearchQuery查询 Spotlight 索引 - 为什么值得做:减少代码量,搜索性能由系统优化,自动支持拼音、模糊匹配等
- 怎么开始:把现有的搜索逻辑替换为
CSSearchQuery,用foundItemsHandler和completionHandler处理结果
3. 为照片管理 App 添加标签搜索
- 做什么:让用户通过标签在 Spotlight 中找到照片
- 为什么值得做:照片数量庞大时,标签是最有效的检索方式;Spotlight 索引让标签搜索跨 App 可用
- 怎么开始:在
attributeSet(for:)中把标签名加入keywords数组,用户搜索任意标签名即可找到对应照片
4. 实现索引的按需启停
- 做什么:在 App 执行大量数据导入或同步时暂停 Spotlight 索引
- 为什么值得做:索引操作会消耗 CPU 和磁盘 I/O,在性能敏感时段暂停可避免卡顿
- 怎么开始:在数据导入开始前调用
stopSpotlightIndexing(),导入完成后调用startSpotlightIndexing()
5. 为用户提供隐私控制的索引删除
- 做什么:在设置页面增加”清除搜索索引”选项
- 为什么值得做:iOS 15 新增
deleteSpotlightIndex让用户无需删除数据即可清除搜索索引,满足隐私需求 - 怎么开始:在设置中添加开关,调用
deleteSpotlightIndex(completionHandler:)并在完成后给用户确认提示
关联 Session
- Build apps that share data through CloudKit and Core Data — 学习 Core Data 与 CloudKit 的数据共享,结合 Spotlight 索引实现跨设备搜索
- Craft search experiences in SwiftUI — 在 SwiftUI 中构建搜索界面,与 Spotlight 索引数据配合
- Add intelligence to your widgets — 为小组件添加智能推荐,与 Spotlight 索引数据联动
- Meet the Screen Time API — Screen Time API 的内容过滤与隐私控制
评论
GitHub Issues · utterances