漏字、换词序、空查询:用 FuzzyKot 做一次鸿蒙搜索交互排查

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,构建步骤见 适配说明。

相关推荐
anew___1 小时前
《从零手写操作系统 (41):用户态Shell与Init进程——从内核到交互界面》
交互
懿路向前1 小时前
HarmonyOS 粒子特效实战:Particle 挂载 ≠ 显示:emitRate/count 真实语义与同帧黑缝(HarmonyOS ArkUI)
华为·harmonyos
贾伟康2 小时前
【化学实验室|02】HarmonyOS ArkTS 实验结果载荷实战:用强类型模型隔离页面参数与结果展示
harmonyos·arkts·canvas·arkui·化学实验
万物智能信息科技3 小时前
气体传感器MQ-2芯片驱动开发—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
驱动开发·嵌入式硬件·华为·开源·harmonyos
懿路向前3 小时前
HarmonyOS 粒子特效实战:ArkTS V2 状态管理三坑:@Local 判据 / 引用替换 / 双通道更新(HarmonyOS)
华为·harmonyos
蒿豪郝灬号4 小时前
UniApp接入鸿蒙版阿里百川
华为·uni-app·harmonyos
垆边人似月.5 小时前
华为题:零钱兑换
算法·华为
云边有个稻草人6 小时前
鸿蒙远控电脑,ToDesk和向日葵各用一周,三个细节差距比想象中大
harmonyos·todesk
曦夜日长6 小时前
Git系统篇,Git远程仓库(三):本地仓库与远程仓库的交互
linux·服务器·网络·git·交互