主诉
有用户留言:“搜索像憋着气,一下快一下慢。”我盯着输入法候选框发了会儿呆,决定把这口气顺过来。
既往史
这不是云检索。我在首启时通过 HTTPS 拉全量(上千条高频成语),落到 Core Data(SQLite 后端);之后每天拉增量更新。搜索完全本地,离线也能用。
初诊:先摸脉,再下刀
那晚我没改一行代码,只开了 Instruments(Core Data Fetches + Time Profiler) 和 OSLog signpost。连续敲“f”“fe”“fen”… 记录了 100 次样本(iPhone 15):
- 构建谓词:约 8 ms
- fetch:120–180 ms(抖动明显)
- cell 绑定:约 35 ms
- 端到端:170–220 ms/次
几千条数据不该这样抖。问题不是量级,是查询形态。
诊断:几处“暗伤”
- 谓词用的是三连 CONTAINS(汉字/拼音/首字母),在 SQLite 上基本等价全表扫;
- 列表首屏只显示“成语+释义前缀”,却把整条对象都解包;
- 排序按 hanzi 的默认规则,既不友好也难走索引;
- 每次键入都在主队列 context 上同步 fetch,UI 与 IO 抢同一口气;
- 增量更新时只改了业务字段,忘了重算搜索相关的“衍生字段”,让老数据“口音”没改掉。
处置:五步手术,步步能回退
① 重铸检索字段:把“包含”变成“可索引的前缀”
落地时为每条记录生成一个归一化 searchKey:小写、去标点、空格分词,拼接“汉字 + 全拼 + 首字母”。
“风马牛不相及”
→ "风马牛不相及 fengmaniu buxiangji fmn bxj"
谓词从 CONTAINS 换成 LIKE 前缀(能吃索引):
let like = token.lowercased()
let pred = NSPredicate(format:
"searchKey LIKE[cd] %@ OR searchKey LIKE[cd] %@",
like + "*", " " + like + "*") // 词首与词中前缀
)
并在 Data Model 勾选 searchKey 的 Indexed。
直接收益:fetch 平均从 ~150 ms 降到 ~60–70 ms,抖动收敛。
—
② 少拿一点:只取首屏需要的属性
默认 includesPropertyValues = true 会把整条属性都带出来。我收紧为首屏所需:
let req = NSFetchRequest<NSManagedObject>(entityName: "Idiom")
req.predicate = pred
req.fetchLimit = 50
req.fetchBatchSize = 25
req.includesPropertyValues = true
req.propertiesToFetch = ["hanzi", "brief", "searchKey"]
req.returnsObjectsAsFaults = true
收益:对象解包次数减少,cell 绑定从 ~35 ms 降到 ~18 ms。
—
③ 把查询搬到后台,还要“能取消”
改为 后台 context 执行,并在连续键入时去抖 + 取消上一个查询,只渲染最后一次结果。
var pending: NSAsynchronousFetchResult<Idiom>?
func search(_ token: String) {
debounce.fire(after: .milliseconds(120)) { [weak self] in
guard let self = self else { return }
self.background.perform {
let req = makeRequest(token)
let async = NSAsynchronousFetchRequest<Idiom>(fetchRequest: req) { result in
let items = result.finalResult ?? []
DispatchQueue.main.async { self.adapter.apply(items) }
}
self.pending?.cancel()
self.pending = try? self.background.execute(async) as? NSAsynchronousFetchResult<Idiom>
}
}
}
收益:键入流畅度肉眼提升,UI 掉帧告警消失。
—
④ 排序别为难数据库:用可计算的键
中文直接按 hanzi 排效果一般。我在落地时额外存一个 pinyinSortKey(不带声调/空格):
req.sortDescriptors = [NSSortDescriptor(key: "pinyinSortKey", ascending: true)]
收益:人眼更顺,排序阶段 CPU 从 ~10 ms 降到 ~2–3 ms。
—
⑤ 更新既要“补课”,也要“对账”
增量更新合并后,统一重算 searchKey/pinyinSortKey,并写一次“更新数/重算数”到 OSLog,预防“语音没改掉”的旧数据。
一次性补旧账:用 Batch Update 很快扫完几千条:
let update = NSBatchUpdateRequest(entityName: "Idiom")
update.propertiesToUpdate = [
"searchKey": NSExpression(forFunction: "normalize:", arguments: [
NSExpression(forKeyPath: "hanzi"),
NSExpression(forKeyPath: "pinyin"),
NSExpression(forKeyPath: "initial")
]),
"pinyinSortKey": NSExpression(forFunction: "toPinyinSortKey:",
arguments: [NSExpression(forKeyPath: "pinyin")])
]
update.resultType = .updatedObjectsCountResultType
try container.viewContext.execute(update)
复查:数字要能自证
同机同库,前后各 100 次采样(排除首装预热):
- 平均耗时:188 ms → 56 ms(-70.2%)
- P95:312 ms → 96 ms(-69.2%)
- 输入期间掉帧告警:由偶发 → 无
- 用户可感:连敲三字时,列表不再“憋气”,滚动无顿点
出院医嘱:三件一直有效的小事
- 键入流别急:去抖 120 ms + 可取消的后台查询,是移动端的体感基线;
- 任何更新都要顺带重算衍生字段(searchKey/pinyinSortKey),并写对账日志;
- 指标看 P95,不只看均值;在 Instruments 里把 “Fetch Rows / Fetched Faults” 养成随手一眼。
随访与预案
- 目前的量级(几千条)不需要引入 FTS;如果未来上到十万级,再评估 FTS5 虚表 或“本地索引文件 + 结果映射回 Core Data”;
- 复杂筛选(多字段交叉)优先用可组合的前缀键,尽量避免落回 %like%;
- 版本升级时跑一次 轻量验收脚本:随机 100 个 token,对比结果集和耗时,防回退。
尾声
那条“像在憋气”的评论现在翻不到前排了。不是因为我写了什么“炫技”的算法,而是把数据库听得懂的话说清楚,让它少一点猜。
我喜欢这个节奏:一天只做几刀,但每一刀都能回退,都能自证。产品该快的地方快,剩下的留给内容和口碑。用户敲下“风”的时候,列表秒回一句“风马牛不相及”,这口气,就顺了。