从 Flutter 迁移到 OpenHarmony 平台之后,我遇到的第一个“看起来简单、做起来磨人”的需求就是端侧模糊搜索。用户在一个文件管理工具里输入 report 三个字母,期望能直接命中 2024_Annual_Report_Final.docx,用 String.contains 做不到这种漏字母的跨词匹配;改用正则后,几万条文件的列表一输入就卡顿。最后是 fuzzy 这个纯 Dart 的模糊搜索库解决了问题,再搭配 isolate 做异步计算,OpenHarmony 上的搜索响应稳定在了毫秒级。这篇就把从选型、原理到工程落地的完整过程拆开讲一遍,适合正在做 Flutter for OpenHarmony 应用、又被搜索或过滤场景折磨的开发者参考。
1. 项目背景与整体设计思路
1.1 为什么要把模糊搜索做在端侧
先聊一个再普通不过的场景:应用里有个文件列表,几千到几万条记录,用户需要在搜索框里输入关键词,实时筛出想要的结果。这在 PC 端或服务端都不算难,但在 OpenHarmony 这种端侧设备上,问题会变得很具体——网络不可靠、数据隐私敏感、交互延迟要求高,用户每敲一个字符都期望立即看到反馈。
把搜索放到端侧做,最直接的好处是离线可用。文件管理器、笔记应用、通讯录、系统设置这类工具型应用,很多使用场景是没有网络的,或者用户根本不希望自己的通讯录、文件列表被上传到某个服务器去做“智能搜索”。端侧搜索天然没有这个顾虑,数据不出设备,响应也不依赖网络带宽。
但端侧搜索有个绕不开的痛点:设备算力有限,内存也紧张。尤其是在 OpenHarmony 早期适配的电视、平板、工控设备上,CPU 主频和内存余量都不如主流手机,一遇到大列表、高频输入,普通字符串匹配就会把 UI 线程卡成 PPT。这也是我最初踩坑的地方——第一版功能上线后,用户反馈搜索框输入两个字都要转圈,问题就出在把所有数据都放在主 isolate 里硬扫。
所以,端侧模糊搜索并不是把“模糊匹配”做成一个函数那么简单,它需要同时解决三个问题:匹配逻辑够不够智能、计算效率能不能跟上输入节奏、以及 UI 线程能不能不被阻塞。fuzzy 库加上 isolate 异步方案,恰好把这三个问题都覆盖了。
1.2 方案选型:从 contains 到 fuzzy 的进化
先说说我为什么放弃传统方案。第一版我用的是 String.contains,代码简单到不行:
dart复制final matched = allFiles.where((f) => f.name.contains(keyword)).toList();
这个写法在小数据量、用户输入完整关键词的时候能跑,但一旦遇到“只记得文件名的前半段或中间几个字母”,结果就是空列表。用户输入 dep 想找 department_budget.xlsx,contains 只做子串匹配,dep 没有连续出现在文件名里,就搜不到。用户不会管你是子串匹配还是模糊匹配,他们只记得自己曾经见过这个文件。
第二版我换成正则,支持在查询词里加通配符。效率上反而更糟,正则引擎对每个字符串做状态机匹配,开销比 contains 大一个量级。在大列表上做实时筛选,输入一个字母就要跑一遍全量正则,明显不现实。
后来我调研了几个模糊搜索方案,最终把目标锁定在纯 Dart 的 fuzzy 包上。选它的理由很直接:
| 对比项 | 原生 contains/正则 | fuzzy 库 |
|---|---|---|
| 匹配方式 | 子串/正则完全匹配 | 子序列模糊匹配,允许跳过字符 |
| 评分能力 | 无,只能返回是否命中 | 返回 0-1 分数,支持按相关度排序 |
| 对 Flutter 跨端支持 | 写在哪端就在哪端 | 纯 Dart,Android/iOS/OpenHarmony 表现一致 |
| 大数据量性能 | 每次 O(n*m) 硬扫原始字符串 | 构造时预处理索引,搜索时显著提速 |
更关键的一点是,fuzzy 是纯 Dart 实现,不需要引入 C++、Swift 或 Kotlin 的原生代码。这意味着我在 OpenHarmony 上不需要写任何平台通道,同一套代码在 Windows、Android 和 OpenHarmony 上都能跑,对一个跨端项目来说省了太多维护成本。
1.3 整体架构设计
确定方案后,整个搜索模块的架构我分成三层来设计。
第一层是数据层,负责持有全量源数据,并且在应用启动时或数据刷新后构建 Fuzzy 索引。这里有个很关键的原则:索引只构建一次,绝不能每次搜索都 new 一个 Fuzzy 实例。第二层是逻辑层,负责接收搜索关键词、做防抖处理、调用搜索方法、接收结果,动作尽可能轻,只做调度不做重计算。第三层是 UI 层,负责展示搜索框和结果列表,监听输入变化,并且只在拿到最终结果时才刷新界面。
分层的好处是职责单一:算法要替换时只动数据层,输入频率控制要调参时只动逻辑层,UI 布局调整不碰搜索逻辑。我在实际开发中遇到过很多“搜索框卡一下”、“结果闪现后消失”的问题,十有八九是三层之间互相牵连导致的,比如在 UI 事件回调里直接做重计算、或者在搜索过程中又去重建索引。
数据流是这样走的:用户在输入框打字,触发输入事件,事件经过 200ms 防抖后把关键词交给搜索服务,搜索服务从已经构建好的 Fuzzy 索引中查询,返回带分数的结果列表,UI 层拿到结果后 setState 更新列表。整个过程里,UI 线程只需要处理一次结果刷新,而不是每敲一个字符就全量扫描一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. fuzzy 模糊搜索核心原理
2.1 模糊搜索到底在“模糊”什么
很多第一次接触模糊搜索的人会以为它是“更高级的正则”,这个理解不够准确。fuzzy 这类库的核心思想是子序列匹配,它不要求查询词在源字符串里连续出现,只要求查询词的每个字符按顺序出现在源字符串中即可。
举个例子:源字符串是 “flutter_app_guide”,查询词是 “ftr”。contains 会直接返回 false,因为 “ftr” 不是一个连续子串。但模糊匹配会认为:字符 f 在位置 0,字符 t 在位置 3,字符 r 在位置 4,三个字符按顺序都找到了,所以这条记录应该被匹配到。对于“只记得大概拼写”的场景,这种能力非常宝贵。
匹配到了之后,光有 yes/no 还不够。用户输入 foa 时,结果列表里可能有 “file_open_action” 和 “foo_bar_archive” 两条都能匹配,但它们到底谁更接近用户意图?“file_open_action” 的前三个字符刚好是 f、o、a 的顺序,且位置靠前;“foo_bar_archive” 里的 f 在位置 0,但 o 和 a 隔得很远。显然前者的相关度更高。所以模糊搜索还要解决“排序”的问题,这就是评分机制存在的意义。
fuzzy 库对每个匹配结果都会算出一个 score,score 越小代表相关度越高。它综合考虑三个因素:命中的字符在源字符串中的起始位置,靠前的位置分数更好;命中的字符是否连续,连续命中优于零散命中;命中字符之间的间隔距离,间隔越小分数越好。这样得到的结果列表,就是用户实际最想要的排序。
2.2 fuzzy 库的匹配与打分机制
从源码实现的角度看,fuzzy 库在构造实例时会对所有待匹配文本做预处理。它会把字符串统一转成方便后续匹配的结构,比如折叠大小写、记录字符序列的索引位置。搜索时,查询词会和这份预处理数据做比对,而不是每次都从原始字符串开始遍历。这也是它能在大数据量下保持性能的重要原因。
核心用法非常简单。假设我有一份文件对象列表,每个对象包含文件名和路径:
dart复制import 'package:fuzzy/fuzzy.dart';
final files = <FileItem>[
FileItem('annual_report_2024.docx', '/docs/'),
FileItem('meeting_notes_03.txt', '/notes/'),
// 几千条...
];
final fuzzy = Fuzzy<FileItem>(
files,
options: FuzzyOptions<FileItem>(
threshold: 0.4,
key: (item) => item.name,
),
);
final results = fuzzy.search('rpt2024');
for (final result in results) {
print('匹配到: ${result.item.name}, 分数: ${result.score}');
}
这里的 key 参数很关键,它告诉 fuzzy 从对象里取哪个字段去做匹配。如果你的搜索逻辑里有时要匹配文件名、有时要匹配路径,也可以把 key 换成返回组合字符串的函数,比如 (item) => '${item.name} ${item.path}',这样两边都能搜到。
threshold 是另一个值得花时间调的参数。它控制匹配的严格程度,0.0 表示非常严格,基本要求连续匹配;1.0 表示非常宽松,稍有沾边就会进结果。我实验过很多次,0.3 到 0.5 之间的体验最自然。如果阈值设得太低,用户输错一个字母就可能什么都搜不到;设得太高,结果列表会混入大量无关项,排序价值就被稀释了。
2.3 毫秒级背后的算法取舍
说完原理,再聊性能。fuzzy 库匹配单个字符串的算法复杂度,粗略理解是 O(n*m),n 是源字符串长度,m 是查询词长度。听起来很高,几万条记录乘起来貌似很吓人,但实际运行并不会慢到不可接受,这得益于几个重要的优化。
首先是预处理。刚才提到,源字符串在构造 Fuzzy 实例时就被处理过一遍,搜索时的每个字符比较都是基于这份预处理数据进行的,省掉了重复的大小写转换和字符遍历开销。其次是快速失败机制,代码在扫描时会尽早判断某个候选串是否可能达到阈值,达不到就直接跳过,不会傻傻地把整个字符串走完。最后是阈值裁剪,搜索结果里超过 threshold 的记录会在排序前被过滤掉,所以最后需要排序的列表往往远小于全量数据。
这里我补充一个数据规模的概念:在普通终端设备上,处理一万条左右的短字符串,一次搜索通常只需要几毫秒;五万条时,如果阈值和索引配置得当,也能控制在十几毫秒内。真正会出问题的是几十万条甚至上百万条的场景,到那个量级就需要引入更复杂的数据结构了,比如把候选文本拆成倒排索引,或者用 Trie 树按前缀分组。但绝大多数端侧应用的数据量到不了这一步,先用好 fuzzy 加 isolate 的组合,性价比最高。
3. 环境搭建与工程落地
3.1 OpenHarmony 上跑 Flutter 的环境准备
做 Flutter for OpenHarmony 开发,第一步不是写代码,而是把工具链整理干净。官方推荐使用 DevEco Studio 来构建和调试 OpenHarmony 应用,但 Flutter 侧还需要一个支持 ohos 平台的 Flutter SDK 分支,以及配套的 hdc 工具连接设备。
我这边实际用下来,用 fvm 管理多个 Flutter 版本是最省心的方式。因为 OpenHarmony 分支的 Flutter 版本和官方 stable 不一定同步,你可能同时需要维护一个普通 Android 项目和这个鸿蒙项目,两个项目的 Flutter 版本可能不一样。fvm 可以按项目目录锁定 SDK 版本,切换项目时自动切换,不用反复改 PATH。
bash复制# 安装指定版本的 flutter 并绑定到当前项目
fvm install 3.19.0
fvm use 3.19.0
# 创建支持 ohos 平台的项目
fvm flutter create --platforms ohos .
环境变量方面,确认 ANDROID_HOME 指向 SDK 目录,并确保 DevEco Studio 的 hdc 命令能在终端里直接调用。连接设备后,用 hdc list targets 检查设备是否被识别,能列出设备信息再跑 fvm flutter devices,这时候应该能看到 ohos 设备或者模拟器。
3.2 创建工程并接入 fuzzy
项目创建好之后,接入 fuzzy 只需要改一行 pubspec.yaml:
yaml复制dependencies:
flutter:
sdk: flutter
fuzzy: ^0.5.0
运行 fvm flutter pub get 完成依赖拉取。这里提醒一句:因为 OpenHarmony 分支的 Flutter SDK 可能和官方版本有细微差异,建议在依赖拉取后跑一次 fvm flutter analyze,确认没有 API 层面的不兼容。
如果不想用最新版本,也可以直接依赖 git 仓库里的某个 commit,锁定一个你验证过的版本。生产环境我倾向于锁版本,不轻易跟随 minor 更新,避免上游 API 变动影响稳定性。
3.3 完整搜索功能实现
接下来直接看核心代码。我先定义一个数据模型,再构造一个搜索服务类:
dart复制class FileItem {
final String name;
final String path;
final int size;
const FileItem({
required this.name,
required this.path,
required this.size,
});
}
搜索结果页的核心逻辑可以封装成一个 SearchController,它持有全量数据和 Fuzzy 索引:
dart复制class SearchController {
final List<FileItem> _source;
late final Fuzzy<FileItem> _fuzzy;
SearchController(this._source) {
_fuzzy = Fuzzy<FileItem>(
_source,
options: FuzzyOptions<FileItem>(
threshold: 0.4,
key: (item) => item.name,
ignoreLocation: true,
sortResults: true,
),
);
}
List<FileItem> search(String keyword) {
if (keyword.trim().isEmpty) {
return _source;
}
final results = _fuzzy.search(keyword);
return results.map((r) => r.item).toList();
}
}
ignoreLocation 这个参数的意思是,不要因为命中字符出现的位置靠后就过度惩罚分数。在文件搜索场景里,文件名中间出现关键词是很正常的,比如 “sales_report_2024”,用户搜 report,命中的字符在中间而不是开头,如果按位置严格加权,这个文件可能排得很靠后,体验反而不对。这个参数也是我调了几次之后才注意到的。
UI 部分,搜索框直接用 TextField 的 onChanged 回调,配合之前说的防抖:
dart复制class FileSearchPage extends StatefulWidget {
const FileSearchPage({super.key});
@override
State<FileSearchPage> createState() => _FileSearchPageState();
}
class _FileSearchPageState extends State<FileSearchPage> {
late final SearchController _controller;
List<FileItem> _results = [];
Timer? _debounce;
@override
void initState() {
super.initState();
_controller = SearchController(loadTestData());
_results = _controller.search('');
}
void _onKeywordChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 200), () {
setState(() {
_results = _controller.search(value);
});
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
onChanged: _onKeywordChanged,
decoration: const InputDecoration(
hintText: '输入关键词搜索',
prefixIcon: Icon(Icons.search),
),
),
Expanded(
child: ListView.builder(
itemCount: _results.length,
itemBuilder: (context, index) {
final item = _results[index];
return ListTile(
title: Text(item.name),
subtitle: Text(item.path),
);
},
),
),
],
);
}
}
这个版本已经能跑,满足小数据量场景。但要注意,我这里在 UI 线程里直接调用了 _controller.search(value),如果数据量到了几万条,这个调用会阻塞 UI 线程,键盘弹起和输入就会出现掉帧,也就是我开始说的卡顿问题。接下来进入性能优化部分。
4. 性能优化:从明显卡顿到毫秒级
4.1 先定位瓶颈在哪里
说优化之前,先聊一下怎么判断系统到底卡在哪。不要一上来就乱改代码,先用数据说话。
我在本地构造了不同规模的数据集做测试,用一个简单的 Stopwatch 量搜索耗时:
dart复制final sw = Stopwatch()..start();
final results = _controller.search('report');
sw.stop();
print('搜索 ${_source.length} 条数据: ${sw.elapsedMilliseconds}ms');
实测下来,纯 fuzzy 搜索本身在不同数据量下的耗时大致是这样(参考值,在不同设备上有浮动):
| 数据量 | 平均搜索耗时 | 主观体验 |
|---|---|---|
| 1000 条 | < 1ms | 毫无压力 |
| 10000 条 | 2 - 5ms | 基本无感 |
| 50000 条 | 10 - 20ms | 能接受但开始有感知 |
| 200000 条 | 60 - 120ms | 输入明显卡顿 |
这里有个很重要的结论:当数据量到五万条以上时,光是把 Fuzzy 对象放在 UI 线程里调用,就会在快速输入时产生掉帧。因为 Flutter 每一帧的预算只有 16ms 左右,搜索耗时一旦超过这个数,UI 线程就无法及时响应输入事件和渲染帧。
另外,除了搜索本身,还有一个容易忽略的瓶颈是结果列表的构建。search 返回的是 SearchResult 对象,里面带了 score 和 matches 信息,如果我把这些对象直接映射成新的列表,几万条数据一次映射也是不小的开销。在测试时我专门把 Mapping 这一步也计时了,确认它不是主要瓶颈后才集中精力处理搜索计算。
4.2 用 isolate 分担搜索计算
定位到瓶颈在搜索计算本身后,解决方案很明确:把搜索任务丢到后台 isolate 去执行,算完再把结果传回 UI 线程。Flutter 里最简单的方式是 compute,它是 Isolate.run 的便捷封装,适合一次性的耗时任务。
dart复制class SearchPayload {
final List<String> sourceNames;
final String query;
final double threshold;
const SearchPayload({
required this.sourceNames,
required this.query,
required this.threshold,
});
}
List<String> searchInIsolate(SearchPayload payload) {
final fuzzy = Fuzzy<String>(
payload.sourceNames,
options: FuzzyOptions<String>(
threshold: payload.threshold,
ignoreLocation: true,
),
);
final results = fuzzy.search(payload.query);
return results.map((r) => r.item).toList();
}
调用端改成:
dart复制Future<List<String>> _searchAsync(String keyword) async {
final payload = SearchPayload(
sourceNames: _allNames,
query: keyword,
threshold: 0.4,
);
return await compute(searchInIsolate, payload);
}
到这里我要说一个非常重要的坑:compute 每次调用都会在新的 isolate 里执行函数,如果每次都在 isolate 里重新构造 Fuzzy 对象,那预处理索引的时间也要算进去。数据量一上来,构造索引本身可能就需要几十毫秒,如果把这个时间算进每次搜索,性能反而不如直接在 UI 线程里复用已经构建好的 Fuzzy 实例。
所以 compute 这种方案更适合“数据量中等、不能阻塞 UI 线程、但能接受每次重新构建索引”的场景。如果数据量很大且输入频率高,更好的方案是启动一个常驻后台 isolate,在主 isolate 启动时把源数据发给它,让它在后台构建好 Fuzzy 索引,然后通过 SendPort 接收搜索请求、返回搜索结果。这样索引只构建一次,后续搜索请求只是轻量消息往返。
常驻 isolate 的代码会稍微复杂一些,需要自己管理 ReceivePort 生命周期,但对于生产环境的大数据量搜索,这个复杂度是值得的。我在项目里的做法是:如果数据量超过十万条,就走常驻 isolate;如果只有一两万条,直接用主 isolate 同步搜索就够了,中间的 compute 方案反而因为每次重建索引而显得尴尬。
4.3 防抖、节流与索引复用
除了 isolate,防抖是另一个必须做的优化。用户在搜索框里输入一个关键字,比如“report”,中间会依次触发 r、re、rep、repo、repor、report 六次输入事件。如果不做防抖,每一次输入都会触发一次搜索,哪怕每次搜索只要 5ms,累积起来也会让 UI 线程应接不暇。
防抖的思路很简单:用户停止输入一段时间后才真正执行搜索。我常用的时间是 200ms,既能保证响应速度,又能显著减少搜索次数。
dart复制Timer? _debounce;
void _onKeywordChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 200), () {
_performSearch(value);
});
}
防抖之后,用户输入 report 时,实际只会执行最后一次完整关键词的搜索,中间五次输入全部被取消掉了。配合每秒最多一次到两次的搜索频率,再结合 isolate 异步计算,UI 线程的压力就小了很多。
索引复用这一点我也要强调。Fuzzy 实例一旦构建,就不要在每次搜索时重新创建。它的内部结构是针对整个数据集的,重复构建不会带来任何收益,只会白白消耗 CPU 和内存。要把索引的生命周期和数据的生命周期绑定:数据刷新时才重建 Fuzzy,搜索时只读不写,充分复用。
4.4 实测数据与优化前后对比
经过这几个优化后,我重新跑了一遍测试,结果比较直观:
| 数据量 | 优化前(主线程+无防抖) | 优化后(isolate+防抖) |
|---|---|---|
| 10000 条 | 输入掉帧,偶发卡顿 | 稳定 < 5ms,流畅 |
| 50000 条 | 明显卡顿,键盘输入延迟 | 搜索 < 20ms,可接受 |
| 200000 条 | 接近不可用 | 异步搜索,输入流畅,结果稍后出现 |
优化后的体验,关键词是“流畅”而不是“无延迟”。搜索从同步变成了异步,结果不再是在输入瞬间立刻出现,而是会有几百毫秒的间隔,但这个间隔对用户来说是无感的。真正关键的是输入过程和列表刷新过程都不再阻塞 UI 线程,整个交互是流畅的。
这里还有一个细节:在异步搜索场景下,要注意处理“竞态条件”。用户先输入 “report”,还没等结果返回,又输入了 “finance”,这时候前一个搜索任务的结果如果后返回,就会覆盖新结果,造成列表显示异常。解决办法是在发起新搜索时记录一个自增的请求序号,只有最新请求的结果才被允许更新 UI。这个坑我在实际开发里踩过,一定要加。
5. 常见问题与排查技巧实录
5.1 环境构建类问题
这个章节写一下我在 Flutter for OpenHarmony 开发过程中真正遇到过的问题,希望能帮你少走弯路。
第一个是 VS Code 里跑 Flutter 工程时常见的报错:unable to find suitable visual studio toolc。这个提示看起来很莫名其妙,好像和 OpenHarmony 没关系,实际上它出现在两种场景:一种是你同时装了 Android 工具链但路径不对;另一种是某些原生插件编译时需要 C++ 工具链,而系统里没有可用的 Visual Studio Build Tools。解决思路是先用 flutter doctor -v 看 Android toolchain 是否全部绿色,再检查 NDK 路径有没有在 local.properties 里配好。在 Windows 上开发 Flutter 原生插件尤其容易出现这类问题,需要确保安装了 VS Build Tools 的“使用 C++ 的桌面开发”工作负载。
第二个是构建 OpenHarmony 应用时遇到的 Gradle 报错:you are applying flutter's main gradle plugin imperatively using the apply script。这个问题在新旧版本混合的环境里非常典型。Flutter 新版本已经放弃了在 build.gradle 里用 apply script 引入主 Gradle 插件的方式,改为在 settings.gradle 里通过 plugin management 声明。如果项目模板是从旧版本创建、又被新版 Flutter 工具打开,就会同时存在两套配置,冲突报错。解决办法是校准模板:打开 android/settings.gradle,确保主插件在 plugins block 里声明,然后删除 build.gradle 底部的 apply 相关代码行。如果你用 fvm 切换过 Flutter 版本,这类配置冲突更容易出现,切换后最好重新生成一次 android 目录或者手动对齐模板。
第三个经常被忽略的问题是 OpenHarmony 分支的版本匹配。直接用官方 Flutter stable 分支去构建 ohos 平台工程,大概率会失败,因为构建脚本、工具链配置都和官方分支不同。一定不要盲目追新,最好锁定在 OpenHarmony 社区推荐的 Flutter SDK 版本范围,并保持 fvm 锁定。项目里其他同事如果用了不同版本,很容易出现“我这边能跑你那边报错”的尴尬,团队协作时建议固定一个统一版本。
5.2 运行与渲染异常
OpenHarmony 跑 Flutter 应用时,最常见的异常是画面渲染异常,表现为页面白屏、画面花屏或者只有静态帧不刷新。遇到这类问题,优先级最高的排查手段是看设备日志。连接设备后执行 hdc hilog 搜索 flutter 或 render 相关字段,经常能看到 GPU 初始化失败、surface 创建异常这类关键信息。
如果是模拟器上出现的渲染问题,可以先降低预期——模拟器的 GPU 兼容性通常不如真机,优先用真机复现。真机上如果也出现,可以尝试在运行时关闭 GPU 加速,Flutter 支持通过 --enable-software-rendering 强制走软件渲染。这个方案虽然会牺牲一部分性能,但能快速判断问题是不是出在 GPU 驱动兼容层上。如果软件渲染流畅,那基本可以确定是设备 GPU 与 Flutter 引擎的适配问题,需要看 OpenHarmony 分支的版本更新,或者换个设备交叉验证。
另外还有一种情况是应用能启动但列表滚动时画面撕裂。这种问题多半和垂直同步设置、帧率模式有关,可以先检查 window 相关的视频模式配置,再逐步排查是 Flutter 引擎渲染线程还是 GPU 层面的问题。排查切不可上来就在业务代码里找原因,先确认在最小 Demo 里能不能复现,分母越小越容易定位。
5.3 搜索性能相关实践心得
最后分享几个搜索业务层面的实战细节。
第一,对源数据做规范化。文件搜索场景里,用户可能会输入大写也可能小写,甚至中英文混排,建议在传入 Fuzzy 之前把所有待匹配文本统一转成小写,并去掉首尾空格。如果数据中有特殊符号,例如下划线、连字符,也可以根据业务决定是否保留。对源数据预处理越干净,搜索的准确率和性能就越好。
第二,选择正确的 key 字段。如果你搜索的目标是一个对象,不要在 key 里只放一个字段。比如文件搜索,用户的意图可能是文件名、后缀名甚至所在目录,直接构造一个组合字符串作为 key,让 fuzzy 在这些字段的拼接结果上做子序列匹配,效果会好很多。但这个组合字符串不要每次都动态拼接,最好在数据加载时就生成并缓存,避免搜索时反复做字符串插值。
第三,认真调 threshold。这个参数直接影响搜索结果的质量。我在项目里做了一组小实验:threshold 在 0.2 左右时,用户输入漏一个字母就完全搜不到;0.7 以上时,结果里会出现大量只有一两个字母命中的无关项;0.4 左右时,匹配质量和召回率比较均衡。这个值没有绝对标准,一定要放在真实数据集上反复测,直到搜索结果符合直觉。
第四,也是最重要的心得:不要把所有搜索逻辑都塞进一个 Widget 里。把搜索服务封装成独立类,把数据加载、索引构建、查询、排序这些职责拆开,页面只负责展示和交互。这样不管以后是把 fuzzy 换成其他算法,还是把同步搜索改成异步搜索,都只是替换一个类的实现,不会牵一发而动全身。
在 OpenHarmony 这种多设备、多版本的碎片化生态里,可维护性有时候比性能更重要。fuzzy 加 isolate 这套方案虽然不能解决所有问题,但至少让搜索功能在数据量可控的端侧应用里,做到了又快又稳,也给后续扩展留下了余地。
