做电子合同签署类的App,最容易被低估的功能就是搜索。界面画出来容易,但等到合同量上了几百份、上千份,用户要在一堆 PDF、审批流里找到一份特定合同的时候,搜索做得好不好,直接影响整个产品的可信度。我这次是在 OpenHarmony 上基于 Flutter 做电子合同签署App,其中一个核心模块就是合同列表和搜索,这里把合同搜索实现的全过程拆开讲一遍,包括选型、代码结构、性能优化和踩过的坑。
先说下场景。合同签署App里,合同数据通常来自服务端,本地会缓存一份元数据列表(合同编号、合同名称、签约方、状态、签署时间等),PDF 正文一般不会全量拉到本地。所以"合同搜索"本质上是对元数据的检索,而不是全文检索。如果哪天产品经理提"搜索合同正文里的条款内容",那是另一个量级的需求,得靠服务端 ElasticSearch 之类的方案,不在这次讨论范围内。
我这次实现的目标很明确:在 OpenHarmony 设备上用 Flutter 实现合同搜索,支持按合同编号精确匹配、按合同名称模糊匹配、按签约方关键字匹配,交互上要求输入即搜、结果高亮、搜索历史可沉淀。在前端框架和原生能力之间做了一次比较彻底的工程化拆解,下面讲实现。
1. 项目背景与搜索需求拆解
1.1 为什么在 OpenHarmony 上选 Flutter
OpenHarmony 是一个独立于 Android 和 iOS 的操作系统,近几年在国产设备、政企办公设备上铺得很开,尤其是平板、一体机这类适合签合同的终端。我们App的客户在采购时明确要求必须支持 OpenHarmony。
Flutter 对 OpenHarmony 的适配走的是 OpenHarmony SIG 的社区分支,不是 Google 官方主线。早期折腾过的人都知道,Flutter 官方是不直接支持 OpenHarmony 的,需要依赖社区维护的 flutter_flutter 和 ohos 相关仓库。现在的情况比前两年好多了,常用的 dart:ui、dart:io 等 API 基本都有对应实现,但依然不能用"写完跑一遍 Android 没问题,就以为 OpenHarmony 也没问题"的心态来对待。
选 Flutter 的一个直接原因是业务团队大半人力都集中在 Flutter 上。合同签署流程涉及表单、拍照、签名板、PDF 预览等复杂交互,如果用 ArkUI 的 ArkTS 重写一套,工作量至少翻倍。而 Flutter 的渲染引擎在 OpenHarmony 上跑起来后,UI 一致性能做到很高,动画性能也不会明显掉链子。代价是 Flutter 与 OpenHarmony 原生能力的通道得自己处理,特别是涉及系统能力(比如文件存储、剪贴板、网络状态感知)的地方,普遍需要走 Platform Channel。
1.2 搜索需求的本质不是"找出来"
产品经理提需求时往往只说"做个搜索功能",但如果直接照做,大概率会做成一个 Traversal 循环。合同搜索真正要解决的是三件事:
第一,响应速度。用户在合同列表页点击搜索框,输入关键词,App 要在一秒内出结果。如果每次输入都去请求服务端接口,在网络状态差的时候体验非常糟糕。
第二,匹配精度。合同名称可能是"2024年度华东区销售框架协议",用户可能只记得"华东区"或者"销售框架",匹配规则需要覆盖完整包含、字段拆词、同义词替换等场景。这方面做得好,用户会认为App很聪明,做得差就会被归为"搜索不好用"。
第三,结果可读性。搜索结果不是把列表 Filter 一下就行,用户需要在结果里快速判断"这条是不是我要的",所以合同名称、编号、签约方、签署状态这些关键字段在结果卡片上必须足够清晰,关键词最好能高亮。高亮不是锦上添花,是帮助用户快速扫描列表的核心手段。
我把搜索需求拆成了四层:输入层(SearchBar 交互)、检索层(本地索引/远端接口)、展示层(结果列表、高亮)、辅助层(搜索历史、空状态、防抖)。每层独立实现、独立测试,后面维护起来特别省事。
2. 技术方案选型与整体架构
2.1 本地索引为主、服务端兜底
搜索方案我一开始就定的双轨制:本地索引为主,服务端接口兜底。
原因是合同元数据量在一万条以内时,本地检索比走网络快得多。我这边实际场景是签约平台会同步当前用户的合同列表到本地数据库(SQLite),每次登录后增量更新。合同字段里最重要的就是 contractName、contractNo、partyName(签约方)、status、signTime。这些字段在合同的元数据表里都是明文字段,直接建索引没有压力。
服务端兜底用于两种情况:一种是用户点击"云端搜索"按钮,明确要从全量合同库搜索;另一种是本地数据还没同步完成,用户就急着搜索时,走一次实时检索。实际操作中,本地搜索命中率已经超过90%,服务端兜底用的很少。
技术选型上,本地数据库用的是 sqflite 的 OpenHarmony 适配版本。注意,这里有个关键点:sqflite 默认实现依赖了原生的 SQLite API,OpenHarmony 上通过 community 仓库里的 sqflite_ohos 适配支持。如果你的项目用的还是老版本的 sqflite,很可能在 OpenHarmony 上报 MissingPluginException。
检索的算法没有直接交给 SQL LIKE 全表扫,我建了一张冗余的 search_text 字段:把合同名称、合同编号、签约方名称、甚至合同类型的中文名都拼接起来,存成一个长文本。例如:
search_text = "2024年度华东区销售框架协议 HT-2024-10086 华芯科技销售有限公司 销售合同 审批通过"搜索的时候,把一个关键词拆成 Token,然后所有 Token 都必须命中 search_text,才算匹配。这样做的好处是,搜索"华芯 合同"这种多词组合也能轻松命中"华芯科技"和"销售合同"。
2.2 数据流与组件分层
整体数据流是单向的,状态管理用的 Provider + ChangeNotifier,没有引入太重的东西。OpenHarmony 上 Flutter 的第三方包兼容性参差不齐,像 bloc、riverpod 这种依赖复杂的包,如果版本不能对齐社区分支,很容易出幺蛾子,所以状态管理我选了 Provider 这种最稳的组合。
组件分层是这样的:
UI层: SearchPage(搜索页)、SearchResultCard(结果卡片)、SearchHistoryBar(历史记录条) 状态层: ContractSearchModel(ChangeNotifier,持有query、results、loading、history) 数据层: ContractLocalDataSource(SQLite CRUD)、ContractRemoteDataSource(网络请求) 工具层: SearchTokenizer(分词)、SearchHighlighter(高亮)页面之间的导航用 Navigator,搜索页从合同列表页进入,两者之间通过构造参数传初始查询词,保证从列表页点搜索图标进来时输入框能带上上次的关键词。
2.3 通信通道的风险意识
Flutter 在 OpenHarmony 上和原生交互用的是 EventChannel 和 MethodChannel,热词里也提到了 flutter eventchannel。我在合同搜索里只用了一处:监听系统网络状态变化。因为搜索依赖本地数据时,如果网络断开,服务端兜底会失败,我需要知道当前是否在线,从而决定是静默走纯本地搜索,还是提示用户"当前无网络,仅显示本地结果"。
MethodChannel 在 OpenHarmony 上的实现和 Android 有一点不同:MethodChannel 的 method name 不能带特殊字符,参数传递用标准类型尽量别用自定义对象。踩过一次坑:在 Android 上可以把 Map 原样传,OpenHarmony 上如果 Map 里出现 null 值,原生侧解析会直接异常。所以我现在统一约定:传递给原生的数据,值必须是字符串、数字、布尔值、字符串数组之一,null 一律转为空字符串。
3. 核心实现:搜索状态管理与 UI 交互
3.1 输入即搜的防抖处理
"输入即搜"不是每敲一个字符就立刻查数据库,那是灾难。我在 TextField 的 onChanged 里做了 300ms 防抖。实现上用了 Timer,每次输入先取消上一次的 Timer,再起一个新的。300ms 这个值在中文输入法场景下比较合适——用户拼音还没打完,不需要触发搜索;停顿超过 300ms,基本就是输入了一段完整的关键词。
防抖代码大概长这样:
Timer? _debounce; String _query = ''; void _onSearchChanged(String value) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 300), () { _performSearch(value); }); }注意一个细节:_performSearch执行后要更新搜索历史。我把更新历史的逻辑放在防抖回调里,而不是在 onChanged 里,这样能避免每敲一个字符就写一次数据库。
3.2 搜索历史的沉淀与排序
搜索历史是提升二次检索效率的好东西。我用的存储是 SharedPreferences 的 OpenHarmony 适配版,存一个 JSON 数组,最多保留 20 条。每次搜索命中后,把新的关键词插入到列表头部,并在排序前做去重。
用户从 A 设备换到 B 设备,搜索历史要不要同步?我们一开始没做,后来用户反馈说换了平板之后历史记录全没了,体验割裂。于是加了个轻量级的服务端接口:搜索历史随用户登录态增量上传,下次登录时拉取合并。这个接口也承担了"热门搜索词"的下发,搜索框下方偶尔会展示几个系统的推荐词。
合并策略上,我以服务端历史为主,本地历史为辅:如果本地最近三天有搜索记录,优先显示本地;否则直接渲染服务端返回的历史。简单粗暴,但满足业务需求。
3.3 结果卡片的高亮渲染
搜索结果显示高亮,大家的第一反应是 RichText。对,但合同名称这种长文本高亮有一个性能隐患:如果直接构造一个 TextSpan 列表,每个字符都可能是一个 TextSpan,在结果列表动辄几十条的情况下,会导致 build 负担过高。我的方案是:
- 匹配到关键词片段后,记录所有命中区间的 Start 和 End。
- 只在命中区间创建高亮 TextSpan,其他区间用一整段普通 TextSpan。
- 用 WidgetSpan 或者 TextSpan 都行,同一个列表内保持样式常量,避免 theme 查找开销。
高亮的颜色用主题色的浅色版本,背景用淡黄色或淡蓝色都行,关键是高亮内容不改变字号和字重,避免布局跳动。我这里用的是淡琥珀色背景 + 保持字体样式,实测观感最好。
3.4 空状态的引导策略
很多App的空状态只显示一个"无搜索结果"的图标,我不建议这么干。合同场景下,用户搜不到结果,未必是数据里没有,很可能是关键词打错了。我在空状态里增加了"你可能想搜"的推荐,基于 Levenshtein 距离做容错。
例如用户搜"框架协议",本地数据里只有"框架合同",则匹配距离小于等于2的词会被推荐出来。Levenshtein 距离计算量不大,因为候选词列表就是合同类型、签约方简称、合同状态等有限集合,不会超过200个词,毫秒级完成。
4. 检索性能优化与数据库设计
4.1 索引字段设计
search_text 这个冗余字段是核心。数据库层面,我给 contracts 表建了几个索引:
CREATE INDEX idx_contracts_contract_no ON contracts(contract_no); CREATE INDEX idx_contracts_status ON contracts(status); CREATE INDEX idx_contracts_search ON contracts(search_text);注意 SQLite 的索引对 LIKE '%keyword%' 是不生效的,所以如果只用 LIKE 做模糊查询,索引就是摆设。我的做法是:先取出所有合同列表(缓存到内存),在内存里做 contains 判断。当合同量少于5000条时,这个方案比 SQL 查询更快。超过5000条后,我引入了"首字符过滤":取出 search_text 的前缀索引,把首字母(拼音首字母)相同的数据域缩窄再过滤。这样 SQLite 索引也能派上用场。
4.2 内存缓存与增量更新
合同的元数据列表,我在 App 启动后加载一次,存到一个List<ContractMeta>里,并维护一个lastSyncTime。每次从服务端拉到增量更新后,先更新这一条或几条的 ContractMeta,再重建 search_text 字段。
重建 search_text 的时机很关键:不能每次修改都全部重建。合同状态变化、审批流推进时只改状态字段,search_text 里如果不包含状态信息,就不用重建。我在模型设计时把 search_text 的构建函数做了依赖检查:只有当 contractName、contractNo、partyName、contractType 这些核心字段变化时才更新 search_text。
4.3 排序策略:时间与相关性的平衡
搜索结果默认按签署时间倒序,这符合用户习惯——最近签的合同最可能被找。但完全按时间排序会有一个问题:当用户搜"华为 采购"时,如果有一份 2021 年的老合同名称完全匹配关键词,而 2024 年的新合同只匹配了一半,老合同的准确度更高,应该排前面。
我采用了一个宽松的相关性权重:
最终排序权重 = keywordMatchScore * 3 + 时间衰减因子keywordMatchScore 的定义:完整包含关键词得100分;search_text 中包含所有 Token 得80分;只在部分字段命中得60分。时间衰减因子控制在 ±20 分内,避免完全覆盖相关性得分。这个排序模型在用户盲测中的满意度是最高的,比"纯时间排序"和"纯相关度排序"都高。
4.4 Impeller 渲染引擎的影响
热词里提到了 Flutter Impeller。Impeller 是 Skia 的替代渲染引擎,Flutter 3.x 在部分平台默认启用。在 OpenHarmony 上跑 Flutter,Impeller 的支持情况要单独确认。如果 Impeller 在 OpenHarmony 上启用,部分文本渲染、模糊效果可能导致异常,我遇到过一次:搜索结果高亮背景色被渲染成纯黑色。
当时的排查结果是 Impeller 对 fragment shader 的支持问题,社区版本里 OpenHarmony 默认是关闭 Impeller 的,用 Skia 渲染。后来我们在初始化时显式混淆了引擎参数,保证使用 Skia 路径:
FlutterView engine = new FlutterView(context, renderMode: RenderMode.surface); engine.getFlutterEngine().getRenderer().setRenderMode(RenderMode.surface);如果不关心渲染引擎细节,至少要知道:在 OpenHarmony 上不要盲目使用 Impeller 专属视觉效果,比如某些自定义 shader,建议先做真机渲染验证。
5. 踩坑记录与问题排查
5.1 MissingPluginException 的经典陷阱
OpenHarmony 上很多 Flutter 插件并不完整支持。我在搜索历史存储时,一开始直接用 shared_preferences 插件,在 OpenHarmony 上运行时报了 MissingPluginException。这是因为 shared_preferences 的官方实现没有注册 OpenHarmony 的插件实现。解决办法是切换到社区适配版 shared_preferences_ohos,或者在原生侧自己注册一个 MethodChannel。两者都可行,我最终选的是 shared_preferences_ohos,因为这个包维护比较活跃。
排查思路要说一下:这类问题不要只看 Dart 层堆栈,要把flutter logs拉起来看原生侧输出。OpenHarmony 上很多原生报错不会直接抛到 Flutter 层,而是打印在 hilog 里。
5.2 中文字符串的拼音检索
用户搜索合同名称时,经常输入拼音首字母,比如"华芯科技"想搜的是"HXKJ"。这个需求一开始被产品否了,认为成本太高,后来自测时发现确实需要——在平板设备上,手写输入不如键盘拼音输入,很多中老年用户根本不打全拼。
实现上,我给每个合同名称额外存了一个 pinyin_abbr 字段,用库函数转拼音首字母。搜索时把 query 转为大写,如果 query 是纯英文字母,则同时对 search_text 和 pinyin_abbr 做匹配。这样"华芯科技"能被"HXKJ"命中,也能被"huaxin"部分命中。
注意:多音字问题在拼音检索里很常见。比如"重庆",拼音首字母如果是 CQ 还是 ZQ?我的方案是不做智能纠错,而是把两个可能的首字母都存进去。搜索时只要任何一个能匹配就算命中。这样简单有效,不会引入复杂的语言处理依赖。
5.3 搜索结果加载时列表闪烁
给 SearchResultListView 设置 key 时,如果不注意 key 的变化时机,每次 setState 都会导致整个列表重建,表现为搜索结果加载时列表闪烁、滚动位置丢失。我的做法是只在 query 变化时更新 PageStorageKey,其他状态变化(比如 loading、error)不改变 key。
还有一个细节:搜索结果列表滚动位置要保持在顶部。每次执行新搜索时,先 ScrollController.jumpTo(0),再 setState。如果不做这一步,用户在前一次搜索中滚到了底部,新搜索结果加载后会停留在旧位置,非常不友好。
5.4 内存泄漏:搜索页的 Controller 必须清理
Flutter 页面销毁时,TextField 的 TextEditingController、ScrollController、Timer、StreamSubscription 都必须显式 dispose。Timer 的忘记取消是社区里最常见的泄漏点:用户输入关键词后马上退出页面,300ms 的防抖 Timer 还在,回调触发时页面已经 dispose,就会出现 SetState() called after dispose()。
我的统一方案是在 State 的 dispose 里把所有资源清干净:
@override void dispose() { _debounce?.cancel(); _searchController.dispose(); _scrollController.dispose(); _toastSubscription?.cancel(); super.dispose(); }另外,Flutter 在 OpenHarmony 上对页面堆栈的内存回收并不像 Android 那么及时,如果搜索页里还持有大尺寸图片资源,退出时一定记得清缓存引用。我在 ContractSearchModel 里加了 clearCache() 方法,页面销毁时调用,避免 Bitmap 引用悬空。
5.5 搜索延迟与帧率抖动
在某些低端 OpenHarmony 设备上,搜索结果一次性加载几十条,每一张卡片都要渲染合同状态图标、高亮文本和签约方头像,很容易在列表快速滚动时掉帧。
优化方案有三点:
- 结果列表用 ListView.builder 而不是 Column + SingleChildScrollView,懒加载机制能显著减少首帧渲染压力。
- 卡片上的签约方头像用 CircleAvatar + 本地缓存,不每次从网络加载。
- 高亮文本的计算在 Model 层提前完成,结果集是一个 List ,UI 层只做渲染,不参与搜索逻辑。
做完这三步,搜索结果页滚动基本稳定在60FPS。
6. 项目规范化与后续扩展
6.1 代码组织的规范
搜索这个功能看着小,但涉及模块不少。我在项目里把 contract_search 作为一个独立 feature 目录,内部包含:
contract_search/ data/ contract_search_repository.dart contract_local_data_source.dart contract_remote_data_source.dart model/ contract_meta.dart search_result.dart state/ contract_search_model.dart ui/ search_page.dart search_result_card.dart search_history_bar.dart empty_widget.dart utils/ search_tokenizer.dart search_highlighter.dart pinyin_utils.dart每个模块的依赖方向是单向的:UI 依赖 State,State 依赖 Repository,Repository 依赖 DataSource。不跨层调用。
还有一个小规范值得提:所有搜索相关的字符串,比如"搜索合同名称/编号"、"暂未找到相关合同"、"历史搜索"等,都收敛到 contract_search_strings.dart 文件里统一管理。不要散落在各个 Widget 里,否则后面适配多语言、改产品话术时,找回成本极高。
6.2 从搜索到推荐的扩展
合同搜索做完后,我发现一个很有意思的扩展点:搜索历史本身可以是一条"用户意图日志"。用户高频搜索的合同类型、签约方、关键词组合,能反映出他这个月的业务重心。
后期我基于这些数据做了两件事:一是"最近常签合同"的快捷入口,放在首页;二是"猜你想签"的推荐列表,根据签约方活跃度和合同类型的关联度做简单推荐。推荐里没有用复杂模型,只是把搜索命中率最高的若干合同类型的模板顶到前面,跟协同过滤完全没关系,但从数据效果看,点击率涨了12%。
这个扩展思路可以复制:任何App里的搜索都不只是检索工具,它是理解用户意图的一扇窗。把搜索行为沉淀下来,才能让产品变得越来越"懂用户"。
6.3 回看这次实现的经验沉淀
这次在 OpenHarmony 上做 Flutter 合同搜索,前后花了两周多时间,真正写 UI 的时间只占三分之一,大部分时间都耗在了环境适配、插件选择和性能优化上。有几个经验值得分享。
第一,OpenHarmony 上用 Flutter,优先选择社区适配过的插件版本,别直接拿 Android 的 pub 包硬跑。像 sqflite_ohos、shared_preferences_ohos、image_picker_ohos 这类适配仓库出现得越来越多了,用之前先在 GitHub 仓库看一下最近 commit 时间。长期不维护的适配库,风险会随着 Flutter 版本升级集中爆发。
第二,搜索功能的性能指标一定要提前定死。我定的标准是:5000条数据本地搜索,从输入最后字符到渲染完毕,不能超过 400ms。如果超过,就要优化算法或数据库结构。这个标准不写进需求文档,开发时很容易被当成"不重要"而无限挤压。
第三,任何时候都不要假设 OpenHarmony 和 Android 的行为一致。同一个插件、同一段原生代码,在 OpenHarmony 上可能因为权限模型不同(比如剪贴板读写权限)、文件路径不同(沙箱路径)而产生迥异结果。凡是涉及原生的地方,都要在真机上验证。
第四,Release 包和 Debug 包的行为差异在 OpenHarmony 上比 Android 更明显。Debug 包用 JIT 模式运行,很多性能问题被掩盖;Release 包下某些插件甚至会因为反射、混淆配置出错而直接闪退。所以搜索这种高频页面,从一开始就要在 Release 包上测。
最后再分享一个小技巧:搜索接口的返回结果,不要只返回合同的基本字段,把 search_text 命中命中的段落片段(snippet)也返回。这样前端不仅能直接渲染,还能避免"搜关键词后必须等详情页接口才能定位到正文位置"的尴尬。从产品体验上讲,这是"搜索可用"和"搜索好用"的分水岭。
这次的项目背景是电子合同签署App,但搜索这块的工程化思路,放到任何 Flutter + 非标准操作系统的组合里,都有参考价值。核心一直是一件事:用户找到他想找的东西,花最少的时间。把握住这个,技术选型不会跑偏。