做电子合同签署类的App最容易被低估的功能就是搜索。界面画出来容易但等到合同量上了几百份、上千份用户要在一堆 PDF、审批流里找到一份特定合同的时候搜索做得好不好直接影响整个产品的可信度。我这次是在 OpenHarmony 上基于 Flutter 做电子合同签署App其中一个核心模块就是合同列表和搜索这里把合同搜索实现的全过程拆开讲一遍包括选型、代码结构、性能优化和踩过的坑。先说下场景。合同签署App里合同数据通常来自服务端本地会缓存一份元数据列表合同编号、合同名称、签约方、状态、签署时间等PDF 正文一般不会全量拉到本地。所以合同搜索本质上是对元数据的检索而不是全文检索。如果哪天产品经理提搜索合同正文里的条款内容那是另一个量级的需求得靠服务端 ElasticSearch 之类的方案不在这次讨论范围内。我这次实现的目标很明确在 OpenHarmony 设备上用 Flutter 实现合同搜索支持按合同编号精确匹配、按合同名称模糊匹配、按签约方关键字匹配交互上要求输入即搜、结果高亮、搜索历史可沉淀。在前端框架和原生能力之间做了一次比较彻底的工程化拆解下面讲实现。1. 项目背景与搜索需求拆解1.1 为什么在 OpenHarmony 上选 FlutterOpenHarmony 是一个独立于 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 APIOpenHarmony 上通过 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 启动后加载一次存到一个ListContractMeta里并维护一个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 上跑 FlutterImpeller 的支持情况要单独确认。如果 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 依赖 StateState 依赖 RepositoryRepository 依赖 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 非标准操作系统的组合里都有参考价值。核心一直是一件事用户找到他想找的东西花最少的时间。把握住这个技术选型不会跑偏。