主诉

有用户留言:“搜索像憋着气,一下快一下慢。”我盯着输入法候选框发了会儿呆,决定把这口气顺过来。

既往史

这不是云检索。我在首启时通过 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/次

几千条数据不该这样抖。问题不是量级,是查询形态。


诊断:几处“暗伤”

  1. 谓词用的是三连 CONTAINS(汉字/拼音/首字母),在 SQLite 上基本等价全表扫;
  2. 列表首屏只显示“成语+释义前缀”,却把整条对象都解包;
  3. 排序按 hanzi 的默认规则,既不友好也难走索引;
  4. 每次键入都在主队列 context 上同步 fetch,UI 与 IO 抢同一口气;
  5. 增量更新时只改了业务字段,忘了重算搜索相关的“衍生字段”,让老数据“口音”没改掉。

处置:五步手术,步步能回退

① 重铸检索字段:把“包含”变成“可索引的前缀”

落地时为每条记录生成一个归一化 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%)
  • P95312 ms → 96 ms(-69.2%)
  • 输入期间掉帧告警:由偶发 →
  • 用户可感:连敲三字时,列表不再“憋气”,滚动无顿点

出院医嘱:三件一直有效的小事

  • 键入流别急:去抖 120 ms + 可取消的后台查询,是移动端的体感基线;
  • 任何更新都要顺带重算衍生字段(searchKey/pinyinSortKey),并写对账日志;
  • 指标看 P95,不只看均值;在 Instruments 里把 “Fetch Rows / Fetched Faults” 养成随手一眼。

随访与预案

  • 目前的量级(几千条)不需要引入 FTS;如果未来上到十万级,再评估 FTS5 虚表 或“本地索引文件 + 结果映射回 Core Data”;
  • 复杂筛选(多字段交叉)优先用可组合的前缀键,尽量避免落回 %like%;
  • 版本升级时跑一次 轻量验收脚本:随机 100 个 token,对比结果集和耗时,防回退。

尾声

那条“像在憋气”的评论现在翻不到前排了。不是因为我写了什么“炫技”的算法,而是把数据库听得懂的话说清楚,让它少一点猜。

我喜欢这个节奏:一天只做几刀,但每一刀都能回退,都能自证。产品该快的地方快,剩下的留给内容和口碑。用户敲下“风”的时候,列表秒回一句“风马牛不相及”,这口气,就顺了。