Flutter for OpenHarmony 这个组合,我从 OpenHarmony 开始适配 Flutter 就在持续关注。这次把手语学习 App 完整跑起来,又把搜索功能做到了可用的程度,整个下来,算是把“跨平台”到“跨系统”这条路彻底走了一遍。这篇文章就围绕 Flutter for OpenHarmony 手语学习 App 的实战过程展开,重点讲清楚搜索模块是怎么从零到一实现的。如果你也在折腾 OpenHarmony 生态下的 Flutter 应用,或者正为手语类 / 内容检索型 App 的搜索功能发愁,下面这些记录应该能帮你少踩点坑。
1. 项目整体设计:为什么是 Flutter for OpenHarmony,为什么选手语学习
1.1 核心需求拆解
手语学习 App 看起来是个内容展示型项目,但实际上它的核心难点不在页面多漂亮,而在三个地方:手语内容怎么数字化、学习者怎么快速找到要学的手语词、以及这些内容在 OpenHarmony 设备上怎么流畅播放。
先拆第一层:手语内容的数字化。手语是动态的视觉语言,静态图片很难表达完整动作,所以主流方案一定是视频。但视频文件体积大、加载慢,如果做成在线播放,体验会很差。实际项目里我采用本地预置 + 有限缓存的方案:核心教学视频打包进 App,扩展内容走远程加载,这样首屏速度快,离线也能学。
第二层是检索。手语学习者的搜索习惯和普通词典用户不一样,他们可能只知道某个词的大概发音(拼音),也可能只记得一个分类(比如“日常问候”),甚至只记得视频里某个动作的关键标签。所以搜索不能只做简单的“名稱精确匹配”,得支持拼音匹配、模糊包含、标签检索,还要有足够的容错。
第三层是 OpenHarmony 平台适配。Flutter 在 OpenHarmony 上的支持还处于快速迭代期,很多 plugin 没有现成实现,必须自己做平台通道或者找社区适配方案。这个项目里视频播放、数据库存储、文件路径获取这几个模块,都涉及原生侧适配。
1.2 技术选型背后的取舍
为什么不用纯 OpenHarmony 原生开发?原因很实际:手语学习 App 将来大概率还要上别的平台,Flutter 一套 UI 代码多端复用的优势太明显了。而且 OpenHarmony 的 ArkUI 生态虽然发展快,但第三方组件数量、社区成熟度跟 Flutter 比还有差距,开发效率上 Flutter 更占优。
为什么选中 flutter_for_openharmony 而不是直接跑标准 Flutter?标准 Flutter 官方支持的是 Android / iOS / Web / Windows / macOS / Linux 这些平台,OpenHarmony 不在其中。flutter_for_openharmony 是社区主导的适配分支,把 Flutter 的 engine 部分移植到了 OpenHarmony 上,让 Flutter 工程能直接编译出 OpenHarmony 的 hap 包。
那为什么不直接搜在线接口、非要在本地做一套搜索?我实测过,在线搜索虽然能省本地存储,但手语学习的高频场景是“看一眼、搜一下、马上播”,网络往返一次至少几百毫秒,加上首帧加载,体感明显卡顿。本地搜索则稳定在几十毫秒以内,而且手语词库相对固定,更新频率低,本地完全能覆盖需求。
架构上我采用了干净的 MVVM 拆分:UI 层只负责渲染和交互,ViewModel 层处理搜索逻辑和状态管理,数据层封装数据库查询和文件缓存。不用 Provider 也不用 Riverpod,单独用 ChangeNotifier + 自写 Repository 做隔离,理由很简单——项目核心是搜索性能和内容展示,状态管理越轻越好,过度设计反而拖慢开发节奏。
2. 手语学习 App 的内容组织与页面设计
2.1 内容分类与词条结构
手语词条不能只存一个名字和一段视频,必须足够结构化。我的数据模型最终长这样:每个词条有一个主名称、一个拼音字段、一个分类字段、一组扩展标签、一个视频文件路径、一段文字说明。
拼音字段是搜索的关键。手语学习者搜“你好”时可能输“nihao”或“nh”,搜“谢谢”时可能输“xiexie”,如果只对名称做 LIKE 匹配,拼音输入就完全失效。所以我在建表时就给每个词条生成好全拼和首字母缩写两个带索引的字段。
分类上参考了手语教材的常见编排方式,分成“日常问候”“家庭称谓”“数字时间”“餐饮饮食”“交通出行”“情绪表达”“学校职场”“医疗急救”这几个大类。每个词条可以属于多个分类,用一张关联表维护,而不是在词条表里存一个字符串数组。这里明确是参照常见教学体系补充的,不是官方标准,手语本身有地域差异,分类表得留出扩展余地。
2.2 学习页面的交互设计
手语学习 App 的核心学习路径有三条:分类浏览、搜索直达、收藏复习。
分类浏览页面是一个纵向列表,每个分类左侧是图标,右侧是名称和词条数。点击进入分类详情后,词条卡片按拼音首字母分组排列,卡片上显示手势示意图、中文名称、拼音。这样即使没装视频,光看静态图也能完成部分学习。
搜索直达页面是独立的顶部搜索栏 + 下方结果列表结构。搜索栏自带清除按钮,输入时实时触发搜索,结果列表用卡片展示,命中关键词高亮。这里有一个交互细节:搜索结果列表里视频不会自动播放,必须点进去才加载,因为自动播放会瞬间拉起大量网络或解码任务,直接把列表帧率拖垮。
收藏复习模块用的是本地共享偏好存储,记录词条 id 列表,收藏页只查这些 id。为了性能,收藏查询走的是WHERE id IN (...)的预编译语句,避免逐条查询。页面右下角放了一个“开始今日练习”按钮,从收藏词条里随机抽固定数量生成循环播放列表,这个功能实测对巩固记忆很有用。
2.3 手语动作的辅助表现
并不是所有学习者一上来就能看懂视频里的手语动作,所以每个词条详情页都额外加入了动作拆解说明。视频上方显示三到四张关键帧静态图,分别代表动作起始、过渡、结束三个状态,下方再配文字说明,比如“左手平伸掌心向上,右手食指中指并拢,从左手掌心向胸口方向移动”。
这些关键帧图是从教学视频里按固定时间间隔抽取的,抽帧工作在内容准备阶段用离线脚本完成,不占用运行时开销。图片采用 WebP 格式压缩,一张图基本控制在十几 KB,详情页加载几乎无感知。
这里补充一个实测教训:手语动作的关键帧不能随便从中间抽几帧,必须选取动作状态发生质变的节点。我第一版是等间隔抽帧,结果很多图看起来差不多,后来改成人工标定关键帧时间点,效果好了不止一个量级。哪怕后续做成自动抽帧,也建议结合动作识别算法判断手型变化峰值,不要盲目均分。
3. 搜索功能的核心细节与难点拆解
3.1 搜索需求与难点分析
搜索功能是标题里明确点出来的重头戏,但搜索的价值不是简单做个查询接口,而是满足各种真实场景下的检索需求。我梳理出四类典型搜索行为:
- 按全称搜:输入“谢谢”,期望返回“谢谢”
- 按拼音首字母搜:输入“xx”,期望返回“谢谢”“小心”“休息”等
- 按全拼搜:输入“xiexie”,期望返回“谢谢”
- 按分类或标签搜:输入“问候”,期望返回所有属于“日常问候”分类的词条
这四类行为覆盖了手语学习者最主要的检索路径。但实现上有一个绕不开的坎——中文与拼音的匹配问题。SQLite 的默认 LIKE 查询不处理拼音,直接对“name LIKE '%xx%'”是无效的,因为中文字符串里根本没有“xx”这个子串。所以要么提前把拼音存进表里,要么分词扩展,没有捷径。
另一个难点是中文模糊查询的性能。手语词库如果做到上千条,直接对 name、pinyin、tags 三个字段做LIKE '%关键字%',每查一次都是全表扫描。用户每敲一个字符就触发查询,很快就会出现卡顿。这让我最终决定引入 SQLite 的 FTS5 全文检索,配合虚拟表实现索引查询。
3.2 数据模型与数据库设计
数据库选型上,我在 Room 和 sqflite 之间犹豫过,最后选了 sqflite 的 OpenHarmony 适配版本。原因有两方面:一是 flutter_for_openharmony 本身还在迭代,太多 ORM 依赖反而增加不确定性;二是手语词条查询逻辑不复杂,手写 SQL 更可控,也更容易针对 FTS5 调优。
核心表结构设计如下:
CREATE TABLE sign_word ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, full_pinyin TEXT, initial_pinyin TEXT, category TEXT, tags TEXT, video_path TEXT, description TEXT, created_at INTEGER ); CREATE TABLE sign_category ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, sort_order INTEGER ); CREATE TABLE sign_word_category ( word_id INTEGER, category_id INTEGER );full_pinyin存完整拼音,initial_pinyin存首字母缩写,这两个字段在写入时用拼音转换库一次性生成。标签字段用 JSON 数组字符串存储,查询时用LIKE '%"标签名"%'做粗匹配,因为标签数量少、值固定,不需要为此单建表。
全文索引表是关键:
CREATE VIRTUAL TABLE sign_word_fts USING fts5( name, full_pinyin, initial_pinyin, tags, content='sign_word', content_rowid='id' );FTS5 的content='sign_word'表示这是一个外部内容表,数据本体还在sign_word里,FTS 索引单独维护,这样避免了重复存储两遍正文内容。需要特别注意的是,FTS 索引不会自动跟随数据表更新,每次词条插入或修改后,必须手动同步:
INSERT INTO sign_word_fts(rowid, name, full_pinyin, initial_pinyin, tags) VALUES (newId, newName, newFullPinyin, newInitialPinyin, newTags);整个同步操作封装在 Repository 层里,词条新增、编辑、删除都会走统一入口,保证主表和索引表不脱节。
3.3 搜索接口设计:防抖、高亮、排序
搜索接口不能是简单的“用户输入什么就查什么”,我加入了三层过滤逻辑,避免无效查询导致性能损耗。
第一层是防抖。用户输入时,每个键盘事件都会触发控制器回调,但没必要每个字符都查数据库。我在输入框监听器里做了 300ms 的防抖计时器,只有用户停止输入 300ms 后才会真正发起查询。这样连续输入“xiexie”只会查最后一次,而不是查 x、xi、xie、... 共六次,实测大幅降低了数据库负载。
第二层是查询语义归一化。用户输入可能带空格、全角字符、大小写混写,查询前统一 trim、转小写、全角转半角。拼音检索也是同理,统一转成小写。这一步别集成到 SQL 里做,在 Dart 层先处理好再拼查询语句,逻辑更清晰。
第三层是排序权重。搜索结果不能只按 id 排,得按相关度排。FTS5 默认的bm25()函数能给出相关性分数,但手语场景还要叠加人工排序规则:名称精确匹配优先、名称前缀匹配其次、拼音精确匹配再次、标签匹配最后。具体操作是查询时额外算一个 rank 字段,然后按 rank 排序:
Future<List<SignWord>> search(String rawQuery) async { final keyword = normalize(rawQuery); if (keyword.isEmpty) return []; // 先查全文索引,拿到匹配的 rowid 和相关性 final ftsQuery = ''' SELECT rowid, bm25(sign_word_fts) AS rank FROM sign_word_fts WHERE sign_word_fts MATCH ? ORDER BY rank LIMIT 50 '''; // 再用 rowid 回表查详情 final detailQuery = ''' SELECT * FROM sign_word WHERE id IN (...) '''; }MATCH 查询的语法也踩过坑:FTS5 的 MATCH 默认把空格当作多个关键词的 AND 关系,所以“xie xie”会被解释成 xie AND xie,这没问题。但如果用户输入了特殊符号如-、*,MATCH 语法会直接报错。所以拼 MATCH 参数前,必须转义特殊字符,这个坑我在后面常见问题里详细说。
高亮方面,Dart 层用正则对渲染文本做匹配替换,把命中的字符段包上高亮样式,比如搜索结果里“谢”字加黄色背景。关键在于高亮逻辑必须作用在“用户实际输入的字符串”上,而不是作用在数据库返回的原始名称上,否则会出现用户输入“xx”但高亮“xx”字样而不是“谢谢”的情况。
3.4 搜索框的交互与状态管理
搜索框的交互状态我用了枚举管理:空闲态、输入态、加载态、无结果态、错误态。空闲态展示热搜词和最近搜索记录;输入态实时显示联想结果;加载态显示转圈;无结果态展示“未找到相关手语词”和推荐词条;错误态展示错误信息并提供重试。
最近搜索记录用共享偏好存列表,最多保留十条,按时间倒序,点击可直接再次搜索。热搜词是提前在本地配置的,不从服务端拉取,这样离线也有内容可展示。
这里分享一个实测经验:搜索联想结果和搜索历史要分开展示,别混在同一个列表里。用户看到“历史记录”时预期是点一下就能重新搜,看到“联想结果”时预期是精准匹配词条。混在一起会让用户困惑,而且从视觉上很难扫描。
4. 实操过程:从环境搭建到功能落地
4.1 环境准备与项目初始化
朝着实战走,第一步就是环境。flutter_for_openharmony 的完整开发链路跟标准 Flutter 稍有区别,需要安装对应分支的 Flutter SDK、配置 OpenHarmony SDK 路径、并确保本机有对应的编译工具链。
我这里给出一份可以直接照着做的初始化流程(以某个稳定版本为准):
# 1. 拉取 flutter_for_openharmony 分支 git clone -b ohos_flutter_main URL_OF_FLUTTER_FORK # 2. 配置环境变量 export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn export PUB_HOSTED_URL=https://pub.flutter-io.cn export OHOS_SDK_HOME=/path/to/your/ohos-sdk # 3. 把 flutter 命令接入 PATH export PATH="$PWD/flutter/bin:$PATH" # 4. 创建项目 flutter create --template=app sign_language_app cd sign_language_app # 5. 添加 OpenHarmony 平台支持 flutter create --platforms=ohos .创建项目后,工程目录里会多出一个ohos平台目录,这就是 OpenHarmony 的应用壳工程。所有原生侧适配代码都写在这里,Flutter 层的 Dart 代码则跟标准工程完全一致。
再强调一次版本对齐:flutter_for_openharmony 的分支版本必须跟 OpenHarmony SDK 版本严格匹配。我试过用较新的 Flutter 版本配旧版 SDK,编译报错反复折腾了一整天,最后发现是版本不匹配。建议拿到分支后先看仓库里示例工程的配置文件,里面一般会标记好已验证的 SDK 版本组合。
4.2 页面框架与导航
页面框架我采用底部导航加上四个主页面:首页、分类、搜索、我的。首页放今日推荐和最近学习记录,分类页放分类列表,搜索页独立承载搜索功能,我的页面放收藏和设置。
导航用标准的IndexedStack+BottomNavigationBar实现,这样四个页面会常驻内存,切换时不重新构建。手语学习 App 的场景里,用户会频繁在首页和搜索页之间来回,如果每次都重建页面,视频资源和搜索结果都会丢失,优化后体验好了很多。
搜索页的结构是标准的 Scaffold + Column:顶部搜索栏、中部状态区、底部结果列表。搜索栏使用TextField,配置textInputAction: TextInputAction.search,监听变化实时触发搜索;同时放一个清除按钮,点击后清空输入并恢复到空闲态。
这里有一个体验细节:搜索结果列表使用ListView.builder,不用默认的Column+ListView组合。builder 模式只构建当前可见的 item,词条多时滚动手感才顺滑。每个 item 内部还要限制视频封面图的缓存策略,不能一次性把所有图片加载进内存。
4.3 搜索核心代码实现
下面给出搜索功能的核心实现逻辑,完整简化为可直接参考的代码骨架。
首先是文本输入与防抖部分:
class SearchController extends ChangeNotifier { final TextEditingController textEditingController = TextEditingController(); Timer? _debounceTimer; SearchState _state = SearchState.idle; void onQueryChanged(String value) { _debounceTimer?.cancel(); if (value.trim().isEmpty) { _state = SearchState.idle; notifyListeners(); return; } _debounceTimer = Timer(const Duration(milliseconds: 300), () async { _state = SearchState.loading; notifyListeners(); final results = await repository.search(value); _results = results; _state = results.isEmpty ? SearchState.empty : SearchState.result; notifyListeners(); }); } }接着是 Repository 里的数据查询。FTS5 的 MATCH 查询要注意转义,这点我在代码里已经标明:
class SignWordRepository { Database? _db; Future<List<SignWord>> search(String keyword) async { final db = await _getDatabase(); final normalized = _normalizeKeyword(keyword); if (normalized.isEmpty) return []; // FTS5 的 MATCH 语法对特殊字符敏感,必须转义 final escaped = normalized .replaceAll('"', '""') .replaceAll('-', ' ') .replaceAll('*', ' ') .trim(); final matcher = '"$escaped"*'; final ftsResult = await db.rawQuery(''' SELECT rowid, bm25(sign_word_fts) AS rank FROM sign_word_fts WHERE sign_word_fts MATCH ? ORDER BY rank LIMIT 50 ''', [matcher]); if (ftsResult.isEmpty) return []; final ids = ftsResult.map((e) => e['rowid'] as int).toList(); final placeholders = List.filled(ids.length, '?').join(','); final detailResult = await db.rawQuery(''' SELECT * FROM sign_word WHERE id IN ($placeholders) ''', ids); return detailResult.map(SignWord.fromMap).toList(); } }这段代码解决了两个关键问题:一是把用户输入包装成 FTS5 的前缀匹配查询,比如输入“xie”会匹配到“xiexie”、“xiexie”等;二是先用 FTS 快速筛出相关 id,再回表查完整详情,避免每次查询都对全表做 LIKE。
4.4 视频播放与缓存优化
手语学习的核心载体是视频,但视频恰恰是性能和包体积的双重压力来源。我这边的策略是:常用教学视频匀到本地资源目录,按词条 id 命名,每次只加载当前正在播放的视频;扩展内容视频异步下载到应用缓存目录,用文件名当 key 做去重,下次播放前先查缓存。
视频播放器这里没有直接选官方 video_player,而是用了社区针对 OpenHarmony 适配过的播放插件,因为它基于系统原生播放能力封装,在 OpenHarmony 上的兼容性更好。使用上需要先初始化,再设置数据源:
final controller = VideoPlayerController.file( File( videoPlayerPlugin.getCachePath() + '/videos/${word.id}.mp4' ) ); await controller.initialize(); await controller.setLooping(false); await controller.play();踩坑点是:视频文件放在assets里以flutter: assets:方式引用后,Flutter 层拿到的路径是flutter://assets/...,OpenHarmony 的播放器插件不一定认这种路径。更稳妥的做法是把视频预先释放到应用私有目录,再拿本地文件路径去播放。首次进入详情页时会做一次“若文件不存在则从 assets 复制”的检查,并把释放后的路径缓存到一个映射表里,后续直接读映射,避免重复复制。
加载优化上,我实现了懒加载和预加载两套策略。首页词条列表滚动接近底部时,预加载下一页的视频头部帧;详情页真正打开时才初始化播放器。实测在低端设备上,同时预加载超过三个视频会出现解码延迟,所以控制预加载数量在 2 到 3 个以内比较稳妥。
另外强烈建议加上播放器释放逻辑:离开详情页时必须dispose()释放播放器,否则视频声音会在后台继续播放,而且内存里的解码器迟迟不回收,连续看完十几个视频后 App 会变得异常卡顿。我在页面退出时统一做了资源回收,这个动作不能省。
4.5 抓数据内容与词条导入
早期的词条数据是手工一条条录入的,太慢且容易出错。后来我写了一个离线元数据导入器,用 JSON 批量导入手语词条。JSON 单条结构如下:
{ "name": "谢谢", "full_pinyin": "xiexie", "initial_pinyin": "xx", "category": ["日常问候"], "tags": ["感谢", "礼貌", "回应"], "video": "xiexie.mp4", "description": "一手伸食指,指尖朝上,从额前向下一挥,表示致意" }导入器读取 JSON 后,按顺序写入 sign_word 表和 sign_word_fts 索引表。整个导入流程是幂等的,重复执行同一份 JSON 不会产生重复词条,靠名称唯一索引做去重。这样内容编辑就能完全脱离代码,交给内容运营人员维护 JSON 文件,做完一批直接导入,开发效率提升非常明显。
5. 常见问题与排查技巧实录
5.1 OpenHarmony 设备调试连接问题
OpenHarmony 设备调试和 Android 有很大差异,不能用adb直接连接,需要用到对应调试工具。我刚开始连不上设备时,第一反应是驱动问题,排查半天才发现是端口映射没配好。正确的做法是先确认设备已开启开发者模式,再用调试工具连接。
还有一个容易忽略的坑:Flutter 工程在 OpenHarmony 上运行时,热重载能力不如 Android 侧完善,经常出现热重载后页面状态丢失或者数据库连接未重新初始化。我的办法是大改后用冷启动重新编译,小改才用热重载,别浪费太多时间在热重载失效的排查上。
编译超时也是常见问题。OpenHarmony 工程首次编译非常慢,因为要生成并打包 so 库和资源。我首编跑了差不多十到二十分钟,对着屏幕一度以为卡死了。后来加大了构建内存和超时时间,并把后台杀毒软件暂时关掉,编译稳定多了。
5.2 中文搜索的坑与优化
中文搜索的坑,前面已经提过 FTS5 转义问题,这里再补充两个实测中印象深刻的。
第一个是输入法拼音联想导致的搜索“抖动”。用户在中文输入法下敲拼音时,TextField的监听器会被触发多次,而且某些输入法会临时上屏拼音字母再替换成汉字。这就导致搜索查询里混入拼音和汉字混合的中间态。处理方式是增加输入状态判断,只有当文本内容保持稳定且不再以拼音候选状态变化时才触发真正搜索,同时把防抖时长在中文输入法场景下调高一点。
第二个是 FTS5 对中文分词的支持几乎为零。FTS5 默认是按空格和标点做 token 切分的,中文句子没有空格,所以MATCH '谢谢'能匹配到“谢谢”作为整体,但MATCH '谢'也可能只返回包含“谢”字的词条,因为 FTS5 把连续中文字符当作一个 token。也就是说,对“谢谢你”这个过程,直接 MATCH 的效果不如预期理想。我目前的处理方案是仍然保留 FTS5 做主查,但兜底用一次name LIKE '%keyword%'查询来覆盖 FTS5 的分词盲区,两个结果集做合并去重。词条量不大的情况下,这个兜底查询的开销可以接受。
5.3 性能卡顿与内存问题
搜索列表滚动卡顿,十有八九是 item 构建太重。我第一版词条卡片里直接放了视频缩略图组件,滚动时每帧都要解码,卡到掉帧。后续改成分帧加载:列表里只显示静态图,点击才加载视频。缩略图统一用 16:9 的 WebP 图片,内存占用小了非常多。
数据库层面的性能问题也不能忽视。如果不加索引,WHERE full_pinyin = ?和WHERE initial_pinyin = ?都是全表扫;加了普通索引之后,拼音精确匹配基本是毫秒级返回。FTS5 索引表占不了多少存储,但必须注意同步更新,如果用户有新词条入库而 FTS 没更新,搜索就会漏结果,这种问题最难排查,因为数据表有数据、搜索索引没数据。
内存方面,视频播放器是重灾区。曾经连着看视频看半小时,内存从 150MB 涨到 400MB,因为每个页面退出时播放器没有销毁。后来在路由 observer 里统一监听页面销毁事件,主动调用播放器释放接口,内存曲线才稳定下来。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查/解决方式 |
|---|---|---|
| OpenHarmony 设备连不上 | 设备未开启调试模式 / 端口映射错误 | 开启开发者模式,检查调试工具端口,确认 SDK 版本匹配 |
| 编译非常慢 | 首次构建需要生成原生产物 | 增加构建内存和超时时间,必要时关闭占用资源的后台程序 |
| 搜索结果缺失某些词条 | FTS5 索引未同步更新 | 检查新增词条时是否同步写入了 sign_word_fts |
| 输入拼音但匹配不到中文 | FTS5 对中文分词效果差 | 增加 LIKE 兜底查询,合并结果集 |
| 输入特殊字符报错 | MATCH 语法遇到-、*、"等特殊符号 | 查询前统一转义并替换特殊字符 |
| 滚动列表卡顿 | 视频缩略图或视频组件随列表一起构建 | 列表只显示轻量静态图,点击后再加载视频 |
| 内存持续增长 | 视频播放器未释放 | 页面销毁时统一调用播放器的 release/dispose |
| 搜索每次点击明显延迟 | 无防抖导致频繁查数据库 | 增加 300ms 左右的防抖计时,减少无效查询 |
5.5 搜索权重调节的独家经验
搜索权重调节这块,文档里很难看到现成答案,我把自己的调整过程分享出来。第一版直接按 FTS5 的 bm25 排序,结果“谢谢”搜索时排在前面的居然是“歇歇”“谢谢你的爱”这些词,因为 bm25 偏向少见词。手语场景里用户的意图大概率是精确词条,而不是相关句子或长名称,所以查出来后必须人工调权重。
我的做法是把 name、full_pinyin、initial_pinyin、tags 四个字段的权重拆开:名称精确匹配权重最高,给 100;名称前缀匹配次之,给 50;拼音精确匹配给 30;标签匹配给 10。并不是所有字段都靠 bm25,而是先分桶判断命中类型,再按优先级排序。这样“谢谢”排第一位的永远是名称精确等于“谢谢”的词条,而不是 bm25 认为更稀有的其他词条。
还注意了排序稳定性:相同权重的词条按 id 升序排,这样用户看到的排序不会因为数据库内部存储变化而跳来跳去,学习类 App 的搜索结果排序稳定很重要,用户会记住“第一个就是我想找的”。
6. 后续还可以怎么扩展
前面把搜索主链路讲清楚了,但手语学习 App 还有几个方向值得接着往下做。
第一个是搜索联想的下拉补全。用户输入拼音前两三个字母时,可以实时展示候选词条列表,不占搜索结果页,体验会更顺滑。难点在于 FTS5 的前缀匹配只适合英文,对中文和拼音混排需要额外维护一个拼音前缀索引。
第二个是手语词条的“相似动作”关联推荐。同一只手型的不同方向、不同位置会产生不同含义,如果能为每个词条维护一个动作特征向量,搜索相关词条时也能做相似度推荐。不过这个依赖视频帧的动作特征提取,工程量和算力要求都会上一个台阶,适合有后续资源再推进。
第三个是学习进度的统计分析。目前收藏功能只记录词条 id,没有记录学习时长、播放次数、搜索次数。如果能把这些埋点数据汇总成图表,用户可以直观看到自己的学习轨迹,也方便后续推荐算法介入。数据量上来后,本地数据库可能要升级到更完整的持久化方案,搜索索引结构也需要跟着调整。
从 Flutter for OpenHarmony 到手语学习 App,再到搜索落地,整个过程比预想曲折,但也比预想值得。最深的体会是:跨系统的项目里,“兼容”永远排在“新潮”前面。确定核心功能级链路时,优先选择稳定成熟、有真实社区使用的方案;遇到平台差异时,不要硬套标准 Flutter 的逻辑,而是抓住数据层和交互层做充分解耦,这样后续平台适配反复调整时,业务代码基本不用大动。
如果你也想做类似的项目,建议从最小闭环开始:先确定一个垂直领域的内容模型,再落地搜索和展示,最后逐步增强体验。手语学习这个场景天然适合听障辅助、特殊教育、公益文化传播等多重价值方向,技术细节踩过的坑我已经记录在上文,照着走一遍,会比从零开始省下一大半时间。