深入 Android 应用内搜索全链路:从 FTS 全文索引到 Compose SearchView 的搜索体验架构
去年接手一个本地笔记应用的优化需求,用户反馈搜索太慢——2000 条笔记,输入关键词后等 2-3 秒才有结果。数据库里就是简单的 LIKE '%keyword%' 查询,数据量上去后直接不可用。
改完之后搜索响应降到 50ms 以内,思路不复杂,但涉及多个环节的配合。这篇文章把完整链路梳理出来。
为什么 LIKE 查询在大数据量下会崩
先看一个典型的搜索实现:
SELECT * FROM notes WHERE content LIKE '%keyword%' OR title LIKE '%keyword%';
这个查询有两个问题。第一,LIKE '%keyword%' 的前导通配符让 B-Tree 索引完全失效,SQLite 只能全表扫描。第二,中文分词天然不适合这种模式匹配——搜”性能优化”匹配不到”Android 性能优化实践”,而用户期望的正是这种语义关联。
全文搜索引擎(Full-Text Search, FTS) 解决的就是这类问题。它的思路不是逐行比对字符串,而是预先构建倒排索引(Inverted Index):把每条文档拆成词条(Term),记录每个词条出现在哪些文档中。
SQLite FTS5 的核心机制
FTS5 是 SQLite 内置的全文搜索引擎,2015 年随 SQLite 3.9.0 发布,目前已经是 Android 默认 SQLite 版本标配的特性。
建表与索引
CREATE VIRTUAL TABLE notes_fts USING fts5(
title,
content,
tokenize='unicode61'
);
VIRTUAL TABLE 意味着 FTS 表不直接存储原始数据,而是存储倒排索引。你 insert 一条记录,FTS 引擎自动完成分词、去停用词、建立索引:
INSERT INTO notes_fts(title, content) VALUES (
'Android Bitmap 内存优化',
'Bitmap 在 Java 堆中分配内存,容易导致 OOM...'
);
tokenize='unicode61' 指定了分词器。unicode61 是 FTS5 默认的分词算法,按 Unicode 6.1 标准对文本进行 Token 切分。它对英文天然友好(按空格和标点分词),但对中文的支持就比较粗糙了——它没有内置的中文分词词典,会把中文字符按单个字或标点边界切分。对于中文搜索场景,更推荐 icu 分词器或引入 jieba 等第三方分词库通过自定义 Tokenizer 接入。
三种匹配模式
-- 1. MATCH:核心查询,利用倒排索引
SELECT * FROM notes_fts WHERE notes_fts MATCH 'bitmap 内存';
-- 2. BM25 排序:按相关性排序
SELECT *, bm25(notes_fts) AS score
FROM notes_fts
WHERE notes_fts MATCH '内存优化'
ORDER BY score;
-- 3. 前缀查询:输入过程中实时补全
SELECT * FROM notes_fts WHERE notes_fts MATCH 'bit*';
MATCH 内部走的是倒排索引,时间复杂度与关键词命中数相关,而非总数据量。我对 5000 条笔记测试,LIKE 平均 1.8 秒,MATCH 稳定在 2-5ms。
BM25 是 FTS5 的默认排序算法,综合考虑了词频、逆文档频率和文档长度归一化。搜”内存泄漏”,反复提到这个术语的文档排在前面,只提了一次的排后面——这比 LIKE 的布尔匹配智能得多。
Room 中的 FTS5 集成实战
直接用 SQLite API 操作 FTS 表比较繁琐——建表语句特殊,查询需要 JOIN 回原表获取完整数据。Room 2.2+ 原生支持 FTS,只需要几个注解搞定。
数据表与 FTS 表双表设计
@Entity(tableName = "notes")
data class Note(
@PrimaryKey val id: Long,
val title: String,
val content: String,
val updatedAt: Long
)
@Fts4(contentEntity = Note::class, tokenizer = "unicode61")
@Entity(tableName = "notes_fts")
data class NoteFts(
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "content") val content: String
)
这里有个选择:@Fts4 还是手动建 FTS5 表?Room 目前只提供了 @Fts4 注解,因为 FTS4 和 FTS5 在 Room 层面的操作接口是一致的。如果你需要 FTS5 的特性(如 BM25 自定义参数、列过滤、更快的 rebuild),可以用 @RawQuery 手动执行 DDL 建 FTS5 虚拟表,Room 的普通查询可以正常 join FTS5 表。
contentEntity 表示 FTS 表是 Note 的索引表,Room 会自动同步写入。
查询实现
@Dao
interface NoteDao {
@Query("""
SELECT notes.* FROM notes
JOIN notes_fts ON notes.id = notes_fts.rowid
WHERE notes_fts MATCH :query
ORDER BY bm25(notes_fts)
""")
suspend fun search(query: String): List<Note>
@Insert
suspend fun insert(note: Note)
}
Room 自动管理事务、异步查询、协程支持,不用手动处理 beginTransaction,这点在索引同步场景下省了不少事。
增量同步策略
FTS 索引不需要每次全量重建。在 onCreate 中初始化索引,增量写入通过 @Transaction 包裹同步操作:
@Transaction
suspend fun insertWithFts(note: Note, fts: NoteFts) {
val id = insert(note)
// rowid 自动对齐,无需手动设置
}
rowid 是 FTS 表的隐式主键,自动与关联实体表的主键对齐——插入顺序一致就行,不需要显式设置。
Compose 端搜索体验的三层架构
后端索引准备好之后,前端搜索体验决定了用户感知到的”快不快”。我用 Compose 实现了三层搜索架构:
第一层:输入防抖(Debounce)
用户连续输入时不触发查询,停顿 300ms 后才发起:
@Composable
fun SearchScreen(viewModel: SearchViewModel) {
var query by remember { mutableStateOf("") }
LaunchedEffect(query) {
delay(300L) // debounce
viewModel.search(query)
}
}
这 300ms 同时解决了两个问题:减少无效查询次数,也避免输入过程中 UI 因结果变化而闪烁。
第二层:异步搜索与状态管理
class SearchViewModel(
private val noteDao: NoteDao
) : ViewModel() {
private val _searchResult = MutableStateFlow<SearchUiState>(SearchUiState.Idle)
val searchResult = _searchResult.asStateFlow()
fun search(query: String) {
if (query.length < 2) {
_searchResult.value = SearchUiState.Idle
return
}
viewModelScope.launch(Dispatchers.IO) {
val results = noteDao.search(query)
_searchResult.value = SearchUiState.Result(results)
}
}
}
关键词少于 2 个字符不触发搜索——这个阈值根据业务调整,笔记应用中 1 个字基本搜不出有效结果。
第三层:高亮与交互
用户看到搜索结果后需要快速定位关键词位置:
@Composable
fun SearchResultItem(note: Note, query: String) {
val annotatedTitle = buildAnnotatedString {
val lowerTitle = note.title.lowercase()
val lowerQuery = query.lowercase()
var start = 0
while (true) {
val index = lowerTitle.indexOf(lowerQuery, start)
if (index == -1) {
append(note.title.substring(start))
break
}
append(note.title.substring(start, index))
withStyle(SpanStyle(
background = Color(0xFFFFEB3B), // 黄色高亮
fontWeight = FontWeight.Bold
)) {
append(note.title.substring(index, index + query.length))
}
start = index + query.length
}
}
Text(text = annotatedTitle, maxLines = 1)
}
实际项目中我倾向于把高亮逻辑抽到 ViewModel 的 State 里,Composable 只负责渲染已标记好的 AnnotatedString,保持 UI 层的纯净。
我遇到的两个坑
第一个坑:FTS 表与实体表的主键不同步。 FTS 表用 rowid 作为隐式主键,而实体表可能用自增 ID。如果实体表发生删除或主键跳号,FTS 表的 rowid 和实体表 ID 就对不上了。解决方案是 FTS 建表时用 content= 指定外部内容表,让 FTS 自己不存数据,只存索引——但 Room 的 @Fts4 注解默认就是 content= 模式,内部已经处理了同步,不用额外操心。
第二个坑:中日韩文本的分词效果。 默认的 unicode61 分词器对 CJK 文本按 bigram(二元组)切分,比如”内存优化”会被切成”内存""存优""优化”。这样搜”内存”能命中,搜”优化”也能命中——看起来没问题,问题是索引膨胀严重,每个 CJK 文档的索引大小是原文的 2-3 倍。如果对资源敏感,考虑用 tokenize='porter unicode61' 减少冗余,或者外部接入 ICU 分词器。
全链路性能数据
我在 5000 条中文笔记(平均 800 字/条)的测试集上做了对比:
| 方案 | 首次搜索 | 连续搜索 | 索引进度 |
|---|---|---|---|
| LIKE 全表扫描 | 1800ms | 1800ms | 无 |
| FTS5 全文索引 | 15ms | 3-8ms | 离线索引 |
| 加 Compose 防抖 | 315ms* | 3-8ms | 同上 |
* 315ms 包含了 300ms 的防抖延迟,实际查询仍然是 15ms 以内。这个延迟对用户来说刚好是”输入后自然停顿”的感觉,完全无感。
最后说一个架构选型的取舍:如果数据量在 500 条以下,LIKE 查询加简单的 B-Tree 索引完全够用,引入 FTS 反而增加工程复杂度。500-5000 条的规模是 FTS 的甜点区——投入产出比最高。超过 5 万条,建议考虑 FTS5 的分区索引或外部搜索引擎(如 Lucene),单纯一把梭 FTS 不够用了。