深入 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 全表扫描1800ms1800ms
FTS5 全文索引15ms3-8ms离线索引
加 Compose 防抖315ms*3-8ms同上

* 315ms 包含了 300ms 的防抖延迟,实际查询仍然是 15ms 以内。这个延迟对用户来说刚好是”输入后自然停顿”的感觉,完全无感。

最后说一个架构选型的取舍:如果数据量在 500 条以下,LIKE 查询加简单的 B-Tree 索引完全够用,引入 FTS 反而增加工程复杂度。500-5000 条的规模是 FTS 的甜点区——投入产出比最高。超过 5 万条,建议考虑 FTS5 的分区索引或外部搜索引擎(如 Lucene),单纯一把梭 FTS 不够用了。