☰
漏字、换词序、空查询:用 FuzzyKot 做一次鸿蒙搜索交互排查
2026/10/14 1:39:15 网站建设 项目流程

1. 这个搜索框为什么没有结果

把 HarmonyOS 少打几个字母,输入 HrmnyOS,搜索页没有出现任何条目。一个写着“模糊匹配”的示例遇到这种情况,很容易让人先怀疑适配出了问题。可同样的输入切换评分方式后,HarmonyOS 相关标题又出现了。真正要查看的是当前策略怎样定义“匹配”,以及页面有没有忠实地呈现这个定义。

这次接入 FuzzyKot,把原 Kotlin 匹配实现编译到鸿蒙 Native,再做一个可以切换策略的搜索页。数据只有十二条本地标题,包括鸿蒙、Kotlin、React Native 等关键词。这样的规模方便逐项查看返回结果,也方便保留无结果时的真实状态。

上游 README 的示例图展示了这个库原本的使用场景,作者为 Konstantin Tskhovrebov,版权说明见原仓的 MIT 许可证。本文的鸿蒙操作演示则来自后面的设备录制。

默认入口调用 MicroFuzz.ratio,它使用 SMART 策略。查看原源码可以看到,SMART 关注词首以及具有一定长度的连续片段,因此不能预期任意删掉几个字母都能命中。当前标题集合里,HrmnyOS 得不到合适结果,是这组输入在当前策略下的实际行为。

先保留这个空状态,再切换到 Weighted Ratio。MatePad Edge 真机上的操作过程如下,同一个输入得到不同的返回列表。

2. 沿着输入继续试:漏字、中文和换词序

页面提供 MicroFuzz、Weighted Ratio、Token Sort 三个选项,切换时保留关键词。这一点很方便:比较的是同一组候选标题和同一段输入,界面条件不会跟着改变。Weighted Ratio 在这个漏字样例里能返回 HarmonyOS 相关标题,Token Sort 则适合继续观察单词顺序变化。

另一组预设是 Native React。切到 Token Sort 后,React Native 性能优化排在前面。这个策略先处理词序再评分,让词的排列变化对结果产生不同影响。它与前缀、连续片段匹配各有用途,具体选择要结合常见查询的拼写和词序。

试验输入

观察重点

本次示例的行为

HrmnyOS

漏字后切换策略

SMART 无合适结果;Weighted Ratio 返回 HarmonyOS 相关标题

鸿蒙

中文标题匹配

返回当前本地集合中的相关项

Native React

单词顺序变化

Token Sort 返回 React Native 性能优化等标题

实际入口就在 harmony/src/Demo.kt。查询截取到一百二十八个字符并去掉首尾空白,再交给所选评分函数;extractTop 最多返回六条,cutoff 为一。这个较低的阈值用于观察差别,实际业务应该拿自己的标题和查询样本来选择阈值。

package harmony import com.github.terrakok.fuzzykot.* private val choices=listOf( "HarmonyOS 应用开发", "HarmonyOS 桌面快捷键", "鸿蒙平板使用指南", "Kotlin Multiplatform 入门", "Kotlin 协程与重试", "Compose Multiplatform 界面", "Flutter 音频波形", "React Native 性能优化", "DevEco Studio 调试笔记", "原生 C++ 与 ArkTS 桥接", "你好,鸿蒙开发者", "OpenHarmony Native 调用" ) fun search(query:String,mode:Int):String { val safe=query.take(128).trim() if(safe.isEmpty()) return "{\"results\":[],\"note\":\"输入关键词,再看真实匹配结果\"}" val scorer: (String,String)->Int = when(mode) { 1 -> { a,b -> Levenshtein.weightedRatio(a.lowercase(),b.lowercase()) } 2 -> { a,b -> Levenshtein.tokenSortRatio(a.lowercase(),b.lowercase()) } else -> { a,b -> MicroFuzz.ratio(a,b) } } val results=choices.extractTop(safe,limit=6,scorer=scorer,cutoff=1) return "{\"results\":["+results.joinToString(",") { "{\"title\":"+quoted(it.referent)+",\"score\":"+it.score+"}" }+"],\"note\":"+quoted("${results.size} 条结果 · FuzzyKot 原算法评分")+"}" } fun runChecks():String=checks(listOf( "完全相等" to { MicroFuzz.ratio("hello","hello")==100 }, "忽略英文大小写" to { MicroFuzz.ratio("HELLO","hello")==99 }, "空查询原库边界" to { MicroFuzz.ratio("","hello")==100 }, "中文完全相等" to { MicroFuzz.ratio("鸿蒙平板","鸿蒙平板")==100 }, "Emoji 完全相等" to { Levenshtein.ratio("你好🙂","你好🙂")==100 }, "词序变化" to { Levenshtein.tokenSortRatio("word order","order word")==100 }, "重复词集合" to { Levenshtein.tokenSetRatio("fuzzy fuzzy matching","fuzzy matching")==100 }, "局部匹配" to { Levenshtein.partialRatio("sim","similar")==100 }, "集合最优项" to { listOf("Java","Kotlin","Swift").extractOne("Kotlin")?.referent=="Kotlin" }, "Top 0 边界" to { listOf("hello","world").extractTop("hello",limit=0).isEmpty() }, "界面空串不伪造结果" to { search("",0).contains("\"results\":[]") }, "所有返回分数在0到100" to { listOf("鸿蒙","HELLO","🙂","x").all { q -> choices.extractAll(q).all{it.score in 0..100} } } ))

这里还有一个容易误解的数字:页面显示零到一百的评分,不能读成“识别正确的概率”。同一策略、同一查询下,可以用它排序;换了策略,评分规则也跟着变化。进度条只是把数值画出来,业务含义仍需要由算法和场景共同决定。

英文大小写归一化在调用 Weighted Ratio、Token Sort 时完成。MicroFuzz 自己也有相应的归一化逻辑,设备检查保留了大小写不同得到九十九分的原库语义。中文查询使用当前标题集合测试。Emoji 检查只覆盖相等字符串;组合字符和字素簇还需要增加专门用例。

3. 空输入怎样处理,页面就怎样显示

删除搜索词之后应该看到什么?原 MicroFuzz 对空查询可以返回满分,直接把它接到列表上,会让没有输入的页面展示一批高分条目。本示例在入口检测到空字符串时返回空集合,并给出“输入关键词”的提示。原算法的行为仍留在检查函数里,应用层另外定义自己的空输入交互。

页面也保留了两种空状态:关键词为空时提示先输入;关键词不为空、返回集合为空时显示“这次没有合适的匹配”。HrmnyOS 在 SMART 下走后者,删除全部输入走前者。这样就能从界面区分尚未搜索和确实没有匹配,不必靠猜测。

输入变化后,ArkTS 把字符串与模式交给 Native,随后使用返回的标题和评分绘制列表。排序由 Kotlin 原库完成,结果区没有另做一次相似度计算。完整页面如下。

import native from 'libentry.so'; interface Match { title: string; score: number; } interface Response { results: Match[]; note: string; } interface Checks { passed: number; total: number; detail: string; } @Entry @Component struct Index { @State query: string = 'Kotlin'; @State mode: number = 0; @State matches: Match[] = []; @State note: string = ''; @State checks: string = '未运行'; @State details: string = ''; private modes: string[] = ['MicroFuzz', 'Weighted Ratio', 'Token Sort']; private examples: string[] = ['Kotlin', 'HrmnyOS', '鸿蒙', 'Native React']; aboutToAppear(): void { this.search(); } private search(): void { const response = JSON.parse(native.search(this.query, this.mode)) as Response; this.matches = response.results; this.note = response.note; } build() { Scroll() { Column({ space: 16 }) { Row() { Column({ space: 7 }) { Text('手滑打错字,搜索也能接住').fontSize(29).fontWeight(FontWeight.Bold).fontColor('#24223C') Text('FuzzyKot · 原 Kotlin 模糊匹配算法').fontSize(14).fontColor('#77718B') }.alignItems(HorizontalAlign.Start).layoutWeight(1) Text('FUZZY\nFINDER').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#7C3AED') }.width('100%') TextInput({ text: this.query, placeholder: '试试 Kotlin、鸿蒙,或者故意漏几个字母' }) .height(54).fontSize(18).fontColor('#27233D').placeholderColor('#77718B').backgroundColor(Color.White).borderRadius(14).maxLength(128) .onChange((value: string) => { this.query = value; this.search(); }) Row({ space: 8 }) { ForEach(this.examples, (example: string) => { Button(example).fontSize(13).backgroundColor('#EAE3FA').fontColor('#57369A') .onClick(() => { this.query = example; this.search(); }) }) }.width('100%') Row({ space: 10 }) { ForEach(this.modes, (title: string, index: number) => { Button(title).fontSize(14).backgroundColor(this.mode === index ? '#7C3AED' : '#E9EAF1') .fontColor(this.mode === index ? Color.White : '#4F4865') .onClick(() => { this.mode = index; this.search(); }) }) }.width('100%') Text(this.note).fontSize(14).fontColor('#77718B').width('100%') if (this.matches.length === 0) { Text(this.query.length === 0 ? '先输入关键词' : '这次没有合适的匹配') .fontSize(18).fontColor('#77718B').padding(35).width('100%') } ForEach(this.matches, (item: Match) => { Row({ space: 20 }) { Column({ space: 9 }) { Text(item.title).fontSize(19).fontWeight(FontWeight.Medium).fontColor('#27233D').width('100%') Progress({ value: item.score, total: 100, type: ProgressType.Linear }) .color('#8B5CF6').backgroundColor('#ECE5FC').width('100%') }.layoutWeight(1) Text(`${item.score}`).fontSize(28).fontWeight(FontWeight.Bold).fontColor('#7C3AED').width(60) }.padding(20).width('100%').backgroundColor(Color.White).borderRadius(16) }, (item: Match) => item.title) Row() { Text(`设备端检查:${this.checks}`).fontSize(14).fontColor('#4F4865').layoutWeight(1) Button('验证匹配边界').backgroundColor('#7C3AED').fontSize(14).onClick(() => { const result = JSON.parse(native.runChecks()) as Checks; this.checks = `${result.passed}/${result.total} 通过`; this.details = result.detail; }) }.width('100%') if (this.details.length > 0) { Text(this.details).fontSize(13).fontColor('#4F4865').fontFamily('monospace').lineHeight(21).width('100%') .padding(16).backgroundColor('#ECE8F5').borderRadius(12) } Text('0~100 是算法评分,不是识别概率。词序、漏字和大小写由所选策略处理。') .fontSize(13).fontColor('#77718B').width('100%') }.padding(28).width('100%').constraintSize({ maxWidth: 1000 }) }.width('100%').height('100%').backgroundColor('#F5F3FA') } }

当前只有十二条本地标题,所以输入变化时同步调用 Native,便于演示即时反馈。数据增长到数万条时,应该重新测量耗时,并考虑输入防抖、后台执行和丢弃过时结果。这个示例没有访问远程搜索服务,也没有测过大规模索引。

调用边界的代码负责检查参数、转换字符串和释放返回值。查询与评分模式传入 Kotlin 后,返回 JSON 字符串;C++ 创建 N-API 字符串后调用 DisposeString,页面再解析它。发生参数异常时先返回到调用方,避免继续用无效参数运行搜索。

#include "napi/native_api.h" #include "fuzzykot_api.h" #include <string> // The strings returned by Kotlin/Native have to be released by DisposeString. static napi_value ReturnString(napi_env env, const char* text) { napi_value value; napi_create_string_utf8(env,text?text:"",NAPI_AUTO_LENGTH,&value); if(text) fuzzykot_symbols()->DisposeString(text); return value; } static std::string ReadString(napi_env env, napi_value value) { size_t length=0; if(napi_get_value_string_utf8(env,value,nullptr,0,&length)!=napi_ok || length>4096) { napi_throw_type_error(env,nullptr,"Expected a string up to 4096 UTF-8 bytes"); return ""; } std::string result(length+1,'\0'); napi_get_value_string_utf8(env,value,result.data(),result.size(),&length); result.resize(length); return result; } static napi_value Run(napi_env env, napi_callback_info info) { size_t argc=2; napi_value args[2]; napi_get_cb_info(env,info,&argc,args,nullptr,nullptr); if(argc!=2) { napi_throw_type_error(env,nullptr,"Wrong number of arguments"); return nullptr; } auto query=ReadString(env,args[0]);int32_t mode=0; if(napi_get_value_int32(env,args[1],&mode)!=napi_ok){napi_throw_type_error(env,nullptr,"Expected numeric mode");return nullptr;} bool pending=false;napi_is_exception_pending(env,&pending);if(pending)return nullptr; return ReturnString(env,fuzzykot_symbols()->kotlin.root.harmony.search(query.c_str(),mode)); } static napi_value Check(napi_env env, napi_callback_info info) { return ReturnString(env,fuzzykot_symbols()->kotlin.root.harmony.runChecks()); } static napi_value Init(napi_env env, napi_value exports) { napi_property_descriptor methods[]={ {"search",nullptr,Run,nullptr,nullptr,nullptr,napi_default,nullptr}, {"runChecks",nullptr,Check,nullptr,nullptr,nullptr,napi_default,nullptr} }; napi_define_properties(env,exports,2,methods); return exports; } static napi_module module={1,0,nullptr,Init,"entry",nullptr,{0}}; extern "C" __attribute__((constructor)) void RegisterEntry(){ napi_module_register(&module); }

JSON 辅助函数处理了标题中的引号、换行、反斜杠等字符,也把设备检查包装成可以直接展示的结果。即便目前的标题固定,调用边界仍要把这些转义规则写清楚,后续更换标题来源时才容易核对。

package harmony internal fun quoted(s: String): String = "\"" + buildString { for (c in s) when(c) { '\\' -> append("\\\\") '"' -> append("\\\"") '\n' -> append("\\n") '\r' -> append("\\r") '\t' -> append("\\t") else -> if(c.code < 32) append(" ") else append(c) } } + "\"" internal fun checks(tests: List<Pair<String, () -> Boolean>>): String { val lines=tests.map { (name, test) -> val ok=try { test() } catch(e: Throwable) { false } name to ok } return "{\"passed\":"+lines.count{it.second}+",\"total\":"+lines.size+ ",\"detail\":"+quoted(lines.joinToString("\n") { (n,ok) -> (if(ok) "PASS " else "FAIL ")+n })+"}" }

4. 平台改动和最后一次复核

FuzzyKot 1.0.0 的算法集中在 fuzzykot/src/commonMain,库本身没有外部依赖。这次保留原共享源码,新增 harmony 来配置 ohosArm64、ohosX64 目标及调用入口;完整鸿蒙程序放在原仓的 example/harmony,方便从库目录直接找到页面和 N-API 桥。

平台环境按 CPF-KMP-CMP 官方快速开始准备。本次实际编译器为鸿蒙 Kotlin/Native 2.2.21-1.1.0,应用和设备验证为 API 24。下面的配置描述共享源码接入两种鸿蒙目标的关系,实际产物由命令行编译器生成,再参与 DevEco 的 HAP 构建;Gradle 发布流程尚未运行验证。

plugins { kotlin("multiplatform") version "2.2.21-1.1.0" } kotlin { val ohosTargets=listOf(ohosArm64(),ohosX64()) ohosTargets.forEach { target -> target.binaries.sharedLib { baseName="fuzzykot" } } sourceSets { commonMain { kotlin.srcDir("../fuzzykot/src/commonMain/kotlin") } val ohosMain by creating { dependsOn(commonMain.get()) kotlin.srcDir("src") } ohosArm64Main { dependsOn(ohosMain) } ohosX64Main { dependsOn(ohosMain) } commonTest { kotlin.srcDir("../fuzzykot/src/commonTest/kotlin") dependencies { implementation(kotlin("test")) } } } }

新增文件各自处理一件事:harmony/src/Demo.kt 选择策略并提取候选项,JsonAndChecks.kt 序列化结果和运行用例,example/harmony 中的 napi_init.cpp 建立 ArkTS 调用入口,Index.ets 负责搜索交互与结果显示。MicroFuzz 和 Levenshtein 的原实现没有改写,算法边界可以对照上游源码检查。

同一份最终构建在 x86_64 模拟器与 MatePad Edge arm64 真机上分别执行,均核对了 SMART 无结果、切换后的相关标题、中文查询和词序变化。两端设备的原生检查均返回十二项通过。下面是模拟器的实际操作与真机检查位置。

十二项用例除了相等字符串和分数范围,还包含原库空查询行为、示例空输入处理、大小写、中文、Emoji 相等、词序变化、重复词集合、局部匹配、最优项提取和返回数量为零。把原库空查询与页面空输入分开检查,正好记录了这一层应用包装的职责。

这次搜索页最有用的观察其实就是开头的空列表。它让人看到默认策略的边界,也让切换策略有了明确的理由。用于具体产品时,先收集用户常见的查询,再看是需要补全词首、容忍漏字,还是忽略词序;选择之后继续保留无结果状态和回归用例,才便于维护搜索体验。

库的 API、原实现与可交互 Web 示例见 FuzzyKot 上游仓库,鸿蒙目标和集成资料见 CPF-KMP-CMP。

项目源码:harmonyos-crossplatform-libs。本文示例位于 libraries/fuzzykot,构建步骤见 适配说明。

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

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

立即咨询