☰
Flutter扫码App历史记录搜索实战:SQLite模糊查询与性能优化
2026/10/11 14:48:07 网站建设 项目流程

1. 这次做完"历史记录搜索",我踩了哪些坑?

先说背景。我们团队基于某开源操作系统做了一款跨端扫码App,技术栈是Flutter,扫码这块用的是原生插件对接底层能力,UI和业务逻辑全部在Flutter层实现。之前版本的功能闭环是"扫码 — 识别结果页 — 保存历史 — 历史列表",整体跑得还算顺畅。但用户量一上来就有反馈了:历史记录越攒越多,几百上千条之后只能靠手动下拉翻,找一条几周前的记录简直是大海捞针。所以这一期迭代,核心任务就是给历史记录加上搜索功能。

这个功能听起来很简单——一个输入框,一个模糊查询,一个结果列表。但真正做完、测试、再打磨之后我才发现,它的复杂度全藏在细节里:输入防抖怎么做才不卡手、SQLite的LIKE查询怎么处理通配符和中文、搜索结果的实时刷新跟列表滚动如何共存、还有那个在国产系统上非常容易翻车的输入法联动问题。这篇文章就是把我从设计到落地的全过程、代码片段、踩坑记录都整理一遍,重点放在"历史记录搜索"这条主线上,适合已经在Flutter + 该操作系统上写过业务、但还没深入做数据检索这块的朋友参考。

先说结论:最终实现的搜索体验是,键盘输入停顿约300毫秒后自动触发查询,支持对二维码内容、条码格式、来源场景的模糊匹配,结果按时间倒序排列并做了高亮显示,总历史条数达到3万条时,一次搜索的数据库查询耗时控制在20毫秒以内,UI列表滚动稳定在60帧,没有出现明显的掉帧。后面所有细节,都是为实现这几个指标而铺开的。

2. 为什么搜索功能远比想象中难做

2.1 你以为的搜索,其实是一整条链路

在没有搜索功能前,历史记录页的数据加载模型非常简单:App启动后打开页面,从本地数据库查一次数据,把最近50条结果灌进ListView,用户往下滚动触底后再拉下一页。整个链路是单次、静态、无状态的。但加入搜索框后,页面突然多了一个"输入事件流",这个事件流和原本的"滚动事件流"交织在一起,任何一边处理不当,都会让体验崩掉。

我把完整链路拆开梳理了一遍,大致是这四个环节:

  • 用户输入关键词,触发搜索事件
  • 去数据库执行模糊查询,返回匹配结果
  • 把结果渲染到列表,同时给匹配到的关键词做高亮
  • 用户清空调、重新输入、切换状态、处理空结果等各种边界情况

每一个环节拆开看都不复杂,但串联起来就会引入大量状态同步问题。我一开始就是低估了这部分,直接照搬了"每次输入都查一次库"的做法,结果中文输入法联想过程中多次触发查询,页面卡顿到几乎不可用。后来才痛定思痛,把整条链路重构成了下面要讲的这套方案。

2.2 为什么SQLite LIKE在真实场景不够用

历史记录的存储最初就是SQLite(配合Flutter侧的sqflite插件),表结构大概是这样的:

CREATE TABLE scan_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, -- 二维码解析出的完整内容 format TEXT DEFAULT '', -- 条码类型:QR_CODE、EAN_13等 scene TEXT DEFAULT '', -- 扫描场景标签,比如“登录”“支付”“巡检” scan_time INTEGER NOT NULL, -- 扫描时间戳(毫秒) is_manual INTEGER DEFAULT 0 -- 是否手动录入 );

搜索的直觉写法是SQLite的LIKE,也就是WHERE content LIKE '%关键词%'。数据量小的时候完全没问题,但当单表数据量去到两万条以上,你会发现几个很现实的问题:

  1. LIKE以%开头的模糊查询无法走索引,这意味每次搜索都是全表扫描。虽然记录表数据量不算大,但内容字段是完整二维码文本,可能是一个几百字符的URL、一段WiFi配置信息、一个JSON串,扫描匹配的字符串比较成本高,单次查询耗时能到两三百毫秒。
  2. 用户输入的搜索词经常带特殊符号,比如二维码内容里常见的://、?、&、=,如果不做转义处理,直接拼进LIKE表达式,搜出来的结果会完全错乱。
  3. 中文分词的问题。用户输入"食堂"想匹配"第三食堂支付码",LIKE可以做到;但用户输入的是"食"、"堂"、或者半个词的时候,结果还行;一旦输入中间带空格,比如"第三 食堂",LIKE就无能为力了。

所以我的结论是,LIKE能做"能用"的搜索,但要做"好用"的搜索,必须在这之上加一层封装。最后我保留了SQLite作为存储引擎,但把查询逻辑全部收口到一层SearchRepository里,针对上述问题做了逐项处理。

2.3 为什么说数据层和服务层必须分离

刚开始图省事,我把数据库查询逻辑直接写在页面组件的State类里。后来加搜索功能才发现,页面既要管输入事件、又要管列表滚动、还要管数据库连接和查询逻辑,代码脏到不行。我重构时做的第一件事,就是硬性拆出一层数据访问层。

这个分层具体是这样的:

  • HistoryRepository:负责操作数据库,只提供增删改查接口,不关心UI逻辑。包括分页拉取历史、搜索关键词、删除单条记录。
  • SearchController:负责业务编排,比如把用户的输入防抖后传给Repository,管理搜索结果列表,维护搜索状态(空闲、搜索中、搜索完成)。
  • UI层:只负责渲染页面,调用SearchController的方法,监听它的数据变化即可。

好处非常明显。搜索参数相关的测试,我直接在Repository层面做单元测试,不依赖Widget环境。后来同一份搜索能力还被用到"最近搜索"页和统计报表页,直接把Controller复用了。如果当初把逻辑写在页面里,这两个需求都要再写一遍或复制一遍,维护成本就炸了。

3. 搜索框的交互细节:防抖、聚焦、清空与联动

3.1 防抖到底应该做多长

防抖是搜索输入框最基本的手段,核心思想是:用户停止输入一段时间后再真正去执行搜索,避免每个字符触发一次数据库操作。

先说结论性参数:我做的是300毫秒防抖。这个值不是随便拍的,是从输入法和感知延迟两个维度综合出来的。中文输入法在拼音组合过程中会频繁回调输入框的内容变化,但每次回调之间的间隔通常在几十到一两百毫秒;而在英文输入场景下,用户连续敲字母的节奏大概在80到120毫秒一个字符。所以防抖小于200毫秒起不到太多作用,大于500毫秒又会让人觉得搜得慢、不跟手。我分别测过200、300、500毫秒,300毫秒是感知延迟最低且触发次数最均衡的。

Dart侧的防抖实现我直接用了Timer加Cancel,代码很简单:

void onSearchChanged(String value) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 300), () { _performSearch(value.trim()); }); }

有一个坑是,Timer在页面销毁后不能忘掉调用cancel(),否则用户已经退出页面了,回调里还要去Update一个已经Dispose掉的State,直接抛异常。我就在页面dispose()方法里统一做了一次_debounce?.cancel(),稳了很多。

3.2 输入法回车与搜索按钮的联动

搜索框的TextInputAction我设置成了Search,但前提是在Android上设置了textInputAction属性后键盘右下角会变成"搜索"按钮。在OpenHarmony系统的输入法上,一开始这个设置不生效,始终显示的是"换行"或"完成"。

这个问题最初我以为是框架适配没跟上,排查后发现是焦点处理策略的问题。常规写法是应该在onSubmitted回调里直接执行搜索,但我最开始把焦点保留在输入框上,输入法就一直维持文本模式。后来我改成在提交时主动调用FocusScope.of(context).unfocus(),把焦点收回去,输入法收起,系统才正确地把按键语义从"换行"切换成"搜索"。

TextField( textInputAction: TextInputAction.search, onSubmitted: (value) { FocusScope.of(context).unfocus(); _controller.submitSearch(value); }, )

3.3 清空按钮不能省

历史上最早的版本是没有清空按钮的,用户要清掉搜索词必须自己用退格删除。后来在内部测试时发现,用户搜索了某个关键词发现不对,想清空重搜,最顺手的方式是点一个显眼的"X"按钮。这个习惯大家已经养成了。

所以我的UI方案是,搜索框右侧用一个IconButton显示"清空"图标,只在输入框有内容且获得焦点时显示。点击后执行三件事:清空文本、清空搜索结果列表、恢复显示最近历史列表。

这里有个隐藏细节:清空后要恢复显示的是什么。最合理的交互是显示"搜索历史关键词 + 最近扫描记录"两块内容。也就是说,清空后页面的初始形态是"最近扫描的记录列表",顶部还需预留一个展示"最近搜过的关键词"的横向条目栏。最近搜索关键词在搜索成功后会写入一张新表search_keywords,按时间倒序取最近10条展示。这个设计后来验证非常有效,用户的复用搜索入口有了,搜索的发现感也提升了。

4. 数据库查询层:SQLite的封装与索引优化

4.1 LIKE查询的转义处理,这个坑必须讲

直接说问题。用户搜索"https://xxx.com/abc?token=123",如果不对LIKE表达式做处理,SQLite会把%和_当作通配符处理,尤其URL里经常出现的_会让匹配结果完全失控。甚至用户搜一个100%纯棉这种带百分号的词,结果也是错乱的。

正确的做法是:在拼接LIKE表达式之前,先把关键词里的通配符全部转义掉。SQLite支持通过ESCAPE子句来定义转义字符,我的实现是这样:

String escapeLikeKeyword(String keyword) { return keyword .replaceAll('\\', '\\\\') .replaceAll('%', '\\%') .replaceAll('_', '\\_'); } Future<List<ScanHistory>> search(String rawKeyword, {int limit = 20, int offset = 0}) async { final keyword = escapeLikeKeyword(rawKeyword.trim()); final db = await _db; final rows = await db.rawQuery( "SELECT * FROM scan_history WHERE content LIKE ? ESCAPE '\\' " "ORDER BY scan_time DESC LIMIT ? OFFSET ?", ['%$keyword%', limit, offset], ); ... }

注意转义的顺序:先转义反斜杠本身,再转义百分号和下划线。顺序反了,原有的反斜杠也会被后一步误伤,导致转义结果和原字符串的映射错位。

4.2 多字段加权匹配与时间范围的过滤

纯按content去搜,覆盖面是够的,但体验一般。有些场景下用户记住的不是二维码的内容,而是这个码的类型(比如平时扫得最多的就是支付宝付款码,用户会直接搜"付款码",但付款码对应的是EAN-13或者QR_CODE)或者场景来源(比如"考勤码"、"会议入场码")。

所以我在查询条件里加上了多字段匹配,以及权重排序。SQL大概这样:

SELECT *, CASE WHEN content LIKE ?1 ESCAPE '\' THEN 3 WHEN format LIKE ?1 ESCAPE '\' THEN 2 WHEN scene LIKE ?1 ESCAPE '\' THEN 1 ELSE 0 END AS match_score FROM scan_history WHERE content LIKE ?1 ESCAPE '\' OR format LIKE ?1 ESCAPE '\' OR scene LIKE ?1 ESCAPE '\' ORDER BY match_score DESC, scan_time DESC LIMIT ?2 OFFSET ?3

这样做的语义是:内容直接匹配的排最前,条码类型匹配的其次,场景匹配的靠后。实际测试里,用户搜"QR_CODE"能直接过滤出最近扫过的二维码记录;搜"食堂"能命中场景标签里的记录,比单纯搜内容友好得多。

另外我还在查询条件里预留了一个可选的时间范围参数,比如"近7天"、"近30天"的快捷筛选,搜索时把它拼进WHERE条件:scan_time >= ?。这样既能减少匹配行数,也贴合用户"我大概是什么时候扫的"这种时间记忆习惯。

4.3 大表场景下的索引和性能数据

加了多字段Match后,看起来条件变多了,但匹配字段依然是%粉%这种全模糊,索引依然走不了。不过我在scan_time字段上建了索引,并用时间倒序、先取分页内容再过滤的方式,把查询范围缩小了。实际表里现在有3.1万条记录,实测各关键词的搜索耗时如下:

搜索场景全表扫描耗时近30天时间过滤后耗时分页返回20条耗时
普通英文URL关键词212ms31ms7ms
中文关键词(2字)205ms29ms6ms
特殊符号关键词(含%和_)198ms27ms6ms
完全无结果关键词240ms35ms5ms

可以看到,存储层加上时间过滤后,性能提升非常明显。虽然全表扫描也能在一两百毫秒内完成,但在低速磁盘上、或者历史记录量继续涨到10万条之后,这个数字只会越来越难看。所以我强烈建议搜索结果页默认加一个时间范围的快速筛选入口,既是一项体验功能,也是一个性能优化策略。

5. 搜索结果的展示与交互:高亮、分页、空状态

5.1 关键词高亮的正确写法

高亮是搜索结果页的必备功能。最初我用正则表达式直接对全文做replace,改成[匹配到的词]这样的占位形式,然后再拆成Widget列表渲染,但写到一半发现正则匹配中文和特殊符号时经常出问题。

后来我换了一种更可控的方式:不使用正则,直接用基础的字符串切割方法,找出所有关键词出现的位置,然后把整段文本切成多个子串,匹配到的子串用高亮样式渲染,未匹配的用普通样式渲染。

List<TextSpan> buildHighlightSpans(String text, String keyword, TextStyle normal, TextStyle highlight) { final spans = <TextSpan>[]; var start = 0; while (true) { final index = text.indexOf(keyword, start); if (index < 0) { if (start < text.length) { spans.add(TextSpan(text: text.substring(start), style: normal)); } break; } if (index > start) { spans.add(TextSpan(text: text.substring(start, index), style: normal)); } spans.add(TextSpan(text: keyword, style: highlight)); start = index + keyword.length; } return spans; }

这个实现的边界情况需要认真测:一是关键词出现在开头或结尾,二是关键词连续重复出现(如关键词"11"、"111"),三是空关键词直接返回普通文本。我把这些case都写成了单元测试,之后再也没出过问题。

5.2 分页加载和滚动性能

搜索出的结果可能会很多,比如设备型号前缀一样的一大批记录。直接把所有结果全量渲染进ListView,在低端测试机上首次渲染就要卡几秒。我采用的分页策略和首页历史记录一样:第一次查20条,列表滚动到接近底部时再加载下一批。

void _scrollListener() { final maxScroll = _scrollController.position.maxScrollExtent; final currentScroll = _scrollController.position.pixels; if (maxScroll - currentScroll < 300) { _controller.loadMoreResults(); } }

这里有个小技巧:阈值设为300而不是0,意思是在距离底部还有300像素时就提前触发加载,这样用户体验上是"无感加载",不会看到明显的主角加载转圈。

5.3 搜索结果为空时的用户引导

空结果页面是搜索功能最容易糊弄过去的部分。刚开始我直接显示一个居中空状态文案"未找到相关记录",后来发现完全不够。用户搜索无结果后,第一时间应该得到替代建议,而不是面对一片空白。

我把空态设计成了三层信息结构:

  • 主提示:"没有找到与‘xx’相关的记录"
  • 辅助文案:"确认关键词是否正确,或尝试更短的关键词"
  • "点击搜索最近热门"按钮,点开后展示最近扫描内容中最常出现的10个词,提示用户可以做热门检索

这个设计虽然只多花了一个下午实现,但对搜索功能的满意度提升很明显。内部测试时,有同事甚至说这个空态页是整个App里做得最有"服务感"的界面。

6. 状态管理的选择与页面生命周期保护

6.1 为什么最终用了ChangeNotifier + Provider

项目状态管理技术栈我们一直用的Provider,没有引入其他更重的库。在这个页面里,SearchController继承ChangeNotifier,监听输入变化、加载状态、结果列表,UI层用context.watch<SearchController>()来响应变化。

选择的理由很简单:

  1. 这个页面的状态形态非常适合ChangeNotifier——搜索结果列表是一个自增长的数据集合,搜索状态是三个枚举值(idle/searching/done),外加一个错误信息字段。这个状态的复杂度,用Provider足够,不需要引入Bloc这类重武器增加模板代码量。
  2. 团队里其他人也在用Provider,看得懂、改得动。遇到搜索相关的新增需求,比如加个"清空搜索历史"按钮,只需要在Controller里加一个方法,UI层加一个回调即可。

6.2 页面销毁后数据库回调的崩溃问题

这是我在测试过程中踩到最大的一次崩溃。场景很常见:用户输入关键词,马上退出页面,结果数据库查询是异步的,返回后PostFrame回调试图去设置一个已被Dispose的State里的数据,就会崩。

解决办法是给SearchController加一个标记位:

bool _disposed = false; void dispose() { _disposed = true; _debounce?.cancel(); super.dispose(); } void _updateSearchResults(List<ScanHistory> results) { if (_disposed) return; _searchResults = results; notifyListeners(); }

所有数据库回调返回后,先判断Controller是否已被释放,如果是就直接return。这一步看似简单,却让这个页面在快速进出时的稳定性有了质的提升。后来做过一轮压力测试,连续反复进出搜索页50次,没有一次崩溃。

6.3 搜索历史关键词的记录与复用

前文提到了search_keywords表,这里补充一下设计细节:

CREATE TABLE search_keywords ( id INTEGER PRIMARY KEY AUTOINCREMENT, keyword TEXT NOT NULL, search_count INTEGER DEFAULT 1, last_search_time INTEGER NOT NULL ); CREATE UNIQUE INDEX idx_keyword_unique ON search_keywords(keyword);

记录策略是:每次执行搜索前,先对关键词做一次upsert。如果关键词已存在,就给它search_count + 1并更新last_search_time;如果不存在,就插入一条新记录。最近搜索列表就从这个表里按last_search_time倒序取10条。

一个细节是,关键词写入前一定要做去空格、统一大小写等归一化处理,避免同一关键词的变形重复堆积。我做的归一化是:trim()去掉首尾空格,英文部分转成小写。这样"QRCode"与"qrcode"搜出来的是同一个最近搜索词。

7. 常见问题速查:搜索功能里的七宗罪

边做边踩,边踩边修,我把这个项目里遇到过的典型问题整理成一个表,各位可以直接对着排查。

问题现象根因解决方式
输入中文时搜索频繁触发,页面卡顿输入法组合阶段多次回调,未做防抖统一用300ms防抖,中文联想期间不执行搜索
搜索词带%或_时结果异常未对LIKE通配符做转义查询前调用转义方法,SQL加ESCAPE子句
点击回车后键盘不收起,界面变形未主动失焦onSubmitted回调里调用unfocus
退出页面后偶发崩溃异步回调访问已销毁的StateController加_disposed标记,回调先判断
搜索列表滚动明显卡顿全量渲染所有结果改成20条分页加载,触底提前拉取
关键词高亮显示错位、重叠直接用正则replace改为字符串indexOf循环切割子串渲染
空列表页一片白没有空状态引导增加提示文案、建议关键词和热门搜索入口

8. 关于下一步可以怎么扩展

搜索功能上线之后,后台统计显示,使用搜索的活跃用户里大约三成会在一周内重复使用同一关键词,说明用户确实有"重复查找某类记录"的需求。这也是我建议后续做"智能联想"和"搜索热词推荐"的原因——已经有用户搜索行为的数据积累了,做起来并不难。

另外,如果历史记录量级继续膨胀,下一步我会把数据源从SQLite迁移到一个更轻量的本地检索引擎,把分词和倒排索引做进来,让跨字段搜索更接近"全文检索"的体验。当然,这个优化要等用户量和数据量提供充分依据后再动手,现阶段SQLite的优化方案已经完全能应对三万条历史记录的日常使用。

我个人的体会是,搜索虽然看起来是个小功能,但它是把数据存储、异步编程、UI状态管理、平台适配串起来的一套完整的小型系统,很值得认真对待。希望这篇实战记录里的方案和经验,能帮各位少走一些我已经踩过的弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询