把 Flutter 应用跑到 OpenHarmony 设备上,这个动作已经淘汰掉一批准备不足的团队;而要在电子合同签署App里把合同搜索做到又快又准,又会淘汰掉一批只会写列表页的开发者。我上个月刚完成公司“电子合同签署App”的 OpenHarmony 适配,第一模块就是合同搜索。解决好它,再在 OpenHarmony 上做其他跨端业务,心里都会踏实不少。
这篇文章我会把整个搜索模块拆开讲:为什么在 OpenHarmony 上选 Flutter、工程怎么搭、合同数据模型怎么建、搜索匹配和状态过滤怎么写、结果页怎么高亮、2 万条数据下怎么保证不卡。如果你刚把 Flutter 跑上 OpenHarmony,或者正在做某个行业 App 的跨端改造,这篇可以直接照着落地。
1. 为什么在 OpenHarmony 上选 Flutter:两条路线的实际对比
1.1 当前 OpenHarmony 应用开发的四条主流路线
OpenHarmony 生态发展到现在,应用开发路线基本分成四类:ArkUI 原生开发、Flutter 适配、RN 适配、H5/WebView 套壳。我最后选了 Flutter 适配版 SDK(也就是社区常说的 flutter_ohos),不是因为赶潮流,而是被团队现状和业务场景逼出来的。
先说 ArkUI 原生。如果是从零起步、只做 OpenHarmony 单端,ArkUI 确实是最稳妥的选择,官方支持力度最大,组件和动画性能也最直接。但问题是,我们团队已经有一套完整的 Flutter 代码库,里面包含合同模块、审批流模块、电子签名板、文件预览等业务代码,重新用 ArkUI 写一遍,等于把整个业务复盘,成本不是翻倍,是直接翻三四倍。
RN 那边我也调研过。RN 的社区适配在 OpenHarmony 上确实在推进,但三方原生库的适配进度差别很大,很多常用的原生模块要么自己写桥接,要么等社区维护。到了合同这种对权限、文件、签名板依赖很重的场景,我实在不想赌每个依赖都有人持续适配。
H5/WebView 套壳是最省事的,但只适合内容展示型应用。电子合同的搜索要求离线可用、快速过滤、多条件组合,而且用户会输入合同编号、相对方公司名、签署状态,这些在 WebView 里做交互,手感和性能都压不住。
四条路线的对比我整理成了一张表,方便你根据自己团队情况判断:
| 路线 | 投入成本 | 性能上限 | 三方库生态 | 适合场景 |
|---|---|---|---|---|
| ArkUI 原生 | 高,业务重写 | 高 | 持续增长 | 只做 OpenHarmony 单端、有原生团队 |
| Flutter 适配 | 中,保留业务层 | 中高 | 纯 Dart 包可用,原生包需确认适配 | 已有 Flutter 代码库、重视端侧一致性 |
| RN 适配 | 中 | 中 | 社区维护强度不一 | 已有 RN 代码库、以内容展示为主 |
| H5/WebView | 低 | 中低 | 强依赖浏览器内核 | 轻交互、弱离线、快速上线 |
1.2 电子合同搜索场景对框架的三个硬要求
电子合同 App 的搜索和资讯类 App 的搜索完全不同。资讯搜索的核心是“召回更多内容”,合同搜索的核心是“精确找到某一笔业务”。这个差异决定了框架选型和技术方案。
第一个硬要求是离线优先。很多用户在出差路上、地下车库、电梯里翻合同,网络环境根本不稳定。搜索必须基于本地已经落地的合同数据完成,不能每次都去请求后端接口。Flutter 的内存对象模型和本地存储配合得很好,我可以把合同列表项常驻内存,搜索就是一次内存过滤。
第二个硬要求是高频且不可停顿。合同签署人一天说“找一下那份合同”好几次,搜索首帧超过 300 毫秒,用户就会明显感觉卡。Flutter 的 UI 线程模型只要你不乱写同步计算,保持流畅是没问题的。
第三个硬要求是多端一致。同一套 Flutter 代码要在手机、平板上保证筛选、高亮、排序行为完全一致。Flutter 在这方面天然占优,因为 UI 和业务逻辑都是跨端共享的,不需要我为不同屏幕尺寸写两套搜索页。
2. 先把开发环境跑通:Flutter for OpenHarmony 工程搭建
2.1 工具链准备与版本搭配
环境搭建是很多人的第一个坎。OpenHarmony 的 Flutter 适配版并不是 Flutter 官方主分支直接支持的,你需要拿到对应的适配 SDK,并配合官方 IDE 来完成签名、编译、安装。
我的建议是分成三步走:
- 安装官方配套 IDE,用它完成 OpenHarmony SDK 管理。OpenHarmony 的 SDK 组件、工具链、签名配置都在这个 IDE 里统一管,不要自己手工下载碎片化工具。
- 准备 Flutter 稳定版和 flutter_ohos 适配版。实际项目中我直接使用三方库中心托管的 flutter_ohos SDK,这是 Flutter 代码库在 OpenHarmony 上的适配分支。版本选型以适配版官方 release 页的配套矩阵为准,不要盲目追最新的 Flutter 版本,因为新版本引入的渲染引擎改动可能还没合入适配分支。
- 配置完成后,在 IDE 里创建一个空的 OpenHarmony 工程,先把签名和实机安装流程跑通。这一步绝对不能跳过,如果你等到 Flutter 工程跑起来再去配签名,遇到问题时很难分辨是 Flutter 适配的问题还是签名的问题。
跑完这三步,再用flutter doctor -v看输出,如果能识别出 OpenHarmony 相关条目,说明工具链已经接上了。
2.2 创建一个带 ohos 平台的 Flutter 工程
Flutter 工程支持 OpenHarmony 平台之后,创建方式和传统 Flutter 工程基本一致。我是用命令行生成的:
flutter create --platforms=ohos --org com.example.signing .生成后的工程目录会多出一个ohos目录,和android、ios平级。这个ohos目录就是 OpenHarmony 原生工程的入口,里面有 entry 模块、build-profile.json5等原生工程文件。后续如果要对鸿蒙设备做原生定制,比如调用系统签名板、接入相机,都要在这个目录里动手。
如果你用的是旧版本 Flutter 工程,命令行不支持--platforms=ohos,也不用慌。最简单的方式是从官方模板里拷贝一份ohos目录到现有工程,手动改一下包名和应用 ID,再重新构建。两种方式我都试过,新工程直接用命令行最省事,老工程手工拷贝也能跑。
2.3 跑通 hello world 之后先验证的三件事
很多人在 OpenHarmony 上写完一个 hello world 就觉得环境没问题了,等到接真实业务才发现处处是坑。我跑通之后先做了三个专项验证:
第一个验证是 hot reload 是否真的可靠。OpenHarmony 适配版的热重载在首次连接后通常会慢几秒,这是正常的。但如果每次改代码都要等十秒以上,甚至直接断连,就要检查 USB 调试模式和 IDE 里的连接状态。热重载是 Flutter 开发效率的命根子,这个不靠谱,后面调搜索 UI 会让你崩溃。
第二个验证是三方库是否真的可用。我跑了一个flutter pub add path_provider,然后调用getApplicationDocumentsDirectory(),看能不能拿到目录。很多纯 Dart 包在 OpenHarmony 上直接就能用,但依赖原生通道的插件就不一定了。如果你一调用原生能力就报MissingPluginException,说明这个插件还没有适配 OpenHarmony,需要另找方案或者自己写桥接。
第三个验证是 EventChannel 是否打通。我写了一个小的 demo,从 Dart 侧调用原生返回一个设备型号字符串。电子合同签署场景后面要调原生签名板、拍照、文件预览,这些都得靠原生能力通道。通道早一点确认可用,后面接业务就不会被卡住。
3. 合同数据模型与搜索空间设计
3.1 一个够用的 ContractListItem 模型
搜索功能的地基是数据模型。第一版我图省事,直接搜数据库表,结果每次输入一个关键字都要重新查库,又慢又容易出并发问题。后来改成把搜索需要的字段全部放进一个轻量列表模型,常驻内存,搜索就变成一次内存过滤。
enum ContractStatus { draft, // 草稿 pending, // 待签署 signing, // 签署中 completed, // 已完成 expired, // 已过期 revoked, // 已撤销 } class ContractListItem { final String id; final String contractNo; final String title; final String counterparty; final int amountInFen; // 金额单位用分,避免浮点误差 final ContractStatus status; final DateTime signTime; final DateTime updateTime; const ContractListItem({ required this.id, required this.contractNo, required this.title, required this.counterparty, required this.amountInFen, required this.status, required this.signTime, required this.updateTime, }); }这里有几个细节值得说一下。
合同编号和标题一定要分开存。编号是唯一键,常用来做精确匹配;标题是用户记忆里的“南京那份设备采购合同”,需要模糊匹配。相对方公司名也是高频搜索项,用户经常说“找一下某某公司的合同”,所以我把它也作为一个独立字段冗余进来。
金额我用int存分,不用double。合同金额涉及到财务对账,浮点数运算的误差在电子合同场景是不能接受的。存分的方式在 UI 层展示时再换算成元,搜索排序时也能避免浮点比较的坑。
合同分页存储是另一个关键决策。搜索时只需要这个列表项模型,不需要加载完整合同正文。我把合同正文和签名记录存在本地存储里,按id懒加载。这样即使本地有 2 万份合同,搜索时内存里也只保留列表项,不会把几 GB 的正文全部拖进内存。
3.2 状态枚举与搜索条件的组合
合同搜索很少只靠一个关键字搞定。用户的真实操作习惯是:输入“南京”,再勾选“待签署”和“签署中”,有时候还会限定签署时间范围。所以搜索条件不能只是一个字符串,我封装了一个查询参数类:
class ContractSearchQuery { final String keyword; final Set<ContractStatus> statuses; final ContractSort sort; final DateTime? startTime; final DateTime? endTime; const ContractSearchQuery({ this.keyword = '', this.statuses = const {}, this.sort = ContractSort.signTimeDesc, this.startTime, this.endTime, }); bool get hasFilter => keyword.isNotEmpty || statuses.isNotEmpty || startTime != null || endTime != null; }状态用Set<ContractStatus>而不是单个枚举,是因为用户经常同时勾选多个状态,比如“待签署 + 签署中”一起搜。如果你用单个字段,就只能让用户在“待签署”和“签署中”之间二选一,这个产品就废了。
3.3 生成两万条模拟合同的土办法
做搜索功能最怕的是拿 30 条数据调 UI,一上线就卡。我压测时直接造了 2 万条合同数据。为什么是 2 万条?一般企业的合同归档量在 1 万到 3 万条之间,2 万是一个比较有代表性的压测水位。
生成数据的核心是让标题、编号、相对方公司名有一定的规律和重复度,这样才能模拟真实用户输入关键词的场景。
List<ContractListItem> generateContracts(int count) { final random = Random(42); final cityPool = ['南京', '上海', '北京', '深圳', '杭州']; final typePool = ['设备采购', '技术服务', '框架协议', '劳动合同', '保密协议']; return List.generate(count, (i) { final city = cityPool[random.nextInt(cityPool.length)]; final type = typePool[random.nextInt(typePool.length)]; return ContractListItem( id: 'c_$i', contractNo: 'HT2024${i.toString().padLeft(6, '0')}', title: '$city$type合同', counterparty: '$city示例科技有限公司', amountInFen: random.nextInt(100000) * 100, status: ContractStatus.values[random.nextInt(ContractStatus.values.length)], signTime: DateTime(2023, 1, 1).add(Duration(days: random.nextInt(700))), updateTime: DateTime(2023, 1, 1).add(Duration(days: random.nextInt(700))), ); }); }这个生成器最大的价值是让搜索场景有“可预期性”。比如我知道合同编号前缀是HT2024,那我搜ht2024应该命中全部合同;搜南京应该命中一部分;搜南京 设备采购应该命中交叉部分。有了这些预期,测试搜索逻辑就非常方便。
4. 合同搜索核心实现:三个维度层层递进
4.1 搜索入口:独立路由而不是 SearchDelegate
搜索入口我放在了合同列表页的顶栏,点击后进入一个独立的SearchPage。我没有用 Flutter 自带的SearchDelegate,原因有三点:
第一,SearchDelegate的内部样式在 OpenHarmony 的字体渲染下需要大量覆盖,它的搜索框状态也不是完全可控。第二,我需要一个“最近搜索”区块,方便用户一键复用历史关键词,SearchDelegate那套流程塞进去很别扭。第三,电子合同搜索经常会搭配状态筛选和时间筛选,独立页面能加载更复杂的过滤 UI。
跳转方式很简单:
Future<void> _openSearch(BuildContext context) { Navigator.of(context).push( MaterialPageRoute(builder: (_) => const SearchPage()), ); }4.2 文本匹配:归一化、模糊匹配、多关键词
搜索匹配是整个模块的地基。我踩过一个大坑:用户输入HT2024,数据库里存的是ht2024,如果直接String.contains做比较,大小写不一致就漏数据。所以第一步必须是归一化。
String _normalize(String input) { return input .toLowerCase() .trim() .replaceAll(RegExp(r'\s+'), ' '); }归一化之后再做多关键词匹配。用户经常会输入“南京 设备”这种带空格的搜索词,意思是这些词都要出现在合同信息里。我实现的是 AND 逻辑:
bool _matchContract(ContractListItem item, String normalizedKeyword) { if (normalizedKeyword.isEmpty) return true; final keys = normalizedKeyword.split(' '); final haystack = _normalize('${item.title} ${item.contractNo} ${item.counterparty}'); return keys.every(haystack.contains); }把标题、编号、相对方拼成一个 haystack,好处是两个关键词可以跨字段命中。比如用户输入“南京 HT2024”,第一条命中的可能就是“南京设备采购合同”,而编号是HT2024123456,这种跨字段的 AND 匹配特别符合真实搜索习惯。
这里有一个很常见的 bug:如果关键词之间包含中文全角空格,split(' ')切不开。我归一化时用RegExp(r'\s+')把连续任意空白字符压成半角空格,就是为了处理这个场景。
4.3 状态过滤与排序:查询条件如何组合
文本匹配只是第一层,真正的搜索体验在于状态过滤和排序。我的搜索函数是一个纯 Dart 函数,不依赖任何 Flutter 上下文,方便单元测试:
List<ContractListItem> _search( List<ContractListItem> source, ContractSearchQuery query, ) { final keyword = _normalize(query.keyword); final filtered = source.where((item) { if (!_matchContract(item, keyword)) return false; if (query.statuses.isNotEmpty && !query.statuses.contains(item.status)) { return false; } if (query.startTime != null && item.signTime.isBefore(query.startTime!)) { return false; } if (query.endTime != null && item.signTime.isAfter(query.endTime!)) { return false; } return true; }).toList(); filtered.sort((a, b) { final exactA = a.contractNo == query.keyword; final exactB = b.contractNo == query.keyword; if (exactA != exactB) return exactA ? -1 : 1; return b.updateTime.compareTo(a.updateTime); }); return filtered; }排序里最值得学习的是“精确匹配优先”。当用户输入一个完整的合同编号时,搜索意图非常明确,就是要找那一份合同。这个时候如果还按签署时间倒序,用户可能要在第一屏翻半天。所以我把“合同编号完全等于关键字”的记录提到最前面。
这样做还有一个隐藏好处:用户从历史记录里点了一个“HT2024123456”,搜索页回到列表页时,那份合同一定排在首位,产品体验非常顺。
4.4 300ms 防抖与异步竞态
搜索最忌讳每敲一个字符就跑一次全量检索。用户在中文输入法下打“南京”,键盘联想会触发一长串 onChange,如果每次都搜索,300ms 内能同时发起五六次查询,不仅浪费性能,还会出现结果乱跳。
我的做法是 300ms 防抖:
Timer? _debounce; void _onKeywordChanged(String value) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 300), () { _loadResults(value); }); }为什么是 300ms 而不是 100ms?我测试过,中文输入法在 100ms 量级还在联想过程中,过早触发会把用户没打完的词拿去搜索,结果当然不对。300ms 是比较稳妥的平衡点。
防抖之外还要处理一个问题:异步请求的竞态。用户输入“南京”,触发了一次 300ms 后的查询 A,但在查询 A 返回前,用户又输入了“南京设备”,触发了查询 B。如果 A 比 B 晚返回,结果就会用旧关键词覆盖新关键词。我用了序列号机制来丢弃过期结果:
int _querySeq = 0; Future<void> _loadResults(String keyword) async { final seq = ++_querySeq; final result = await _searchAsync(keyword); if (seq != _querySeq) return; // 过期结果直接丢弃 setState(() => _results = result); }这个序列号方案在搜索场景里足够用,不需要引入复杂的状态管理库。
5. 搜索结果页 UI:高亮、空态和 OpenHarmony 输入法适配
5.1 关键词高亮:逐词切分 TextSpan
搜索结果如果没有高亮,用户就得在列表里肉眼找关键词,体验大打折扣。高亮的实现并不复杂,核心是把一句话按关键词切分成多个片段,命中片段用高亮样式,未命中用普通样式。
List<TextSpan> _highlightSpans( String text, Set<String> keys, TextStyle base, TextStyle highlight, ) { final spans = <TextSpan>[]; final lower = text.toLowerCase(); final sortedKeys = keys.toList()..sort((a, b) => b.length - a.length); int start = 0; while (start < text.length) { int? hitIndex; String? hitKey; for (final key in sortedKeys) { final idx = lower.indexOf(key.toLowerCase(), start); if (idx >= 0 && (hitIndex == null || idx < hitIndex)) { hitIndex = idx; hitKey = key; } } if (hitIndex == null || hitKey == null) { spans.add(TextSpan(text: text.substring(start), style: base)); break; } if (hitIndex > start) { spans.add(TextSpan(text: text.substring(start, hitIndex), style: base)); } spans.add(TextSpan( text: text.substring(hitIndex, hitIndex + hitKey.length), style: highlight, )); start = hitIndex + hitKey.length; } return spans; }这里有三点容易踩坑。
排序关键词按长度倒序是必须的。比如用户搜“公”,但相对方公司名是“南京示例科技有限公司”,如果处理不好会把“公司”里的“公”高亮得七零八落。按长词优先匹配,可以避免短词把长词内部结构切开。
indexOf的起始位置必须是start,否则会死循环。我之前犯过一个错误,每次从头找同一个关键词,start永远前进不了,结果while循环把内存拖爆。
高亮样式要克制。我用的是主题色加粗,不加背景色。电子合同场景偏商务专业,大红大绿的背景色会让结果页显得很不正式。
5.2 空状态与最近搜索
无结果时,最差的做法是只显示一个“暂无搜索结果”的文案。用户心里会想:“我明明记得有这份合同,怎么搜不到?”这时候要给用户出口。
我做了两个按钮:“清空筛选条件”和“查看全部合同”。“清空筛选条件”解决的是“筛选条件太严格导致无结果”的问题;“查看全部合同”解决的是“搜索词不对但合同确实存在”的问题。
最近搜索是搜索体验的重要一环。我把它存在本地存储里,最多保留 10 条:
Future<void> _saveRecent(String keyword) async { final box = await Hive.openBox<String>('recent_search'); final list = box.get('keywords') ?? []; list.remove(keyword); list.insert(0, keyword); if (list.length > 10) list.removeRange(10, list.length); await box.put('keywords', list); }这里有一个安全上的细节我要特别提醒:合同数据是敏感业务数据,搜索结果为空时,绝对不要做网络联想,也不要调用任何线上接口去“猜用户想搜什么”。否则用户输入的合同关键词可能会被第三方接口记录,这在电子合同场景里是不可接受的风险。
5.3 OpenHarmony 适配中的三个输入法细节
OpenHarmony 设备的输入法和标准 Android 有差异,我在适配时踩了三个坑。
第一个是键盘遮挡问题。OpenHarmony 自带输入法在部分版本上会直接盖住 TextField 下方的“最近搜索”区块。解决办法是在 build 时读取MediaQuery.of(context).viewInsets.bottom,动态调整底部 padding。这个值在键盘弹出时会变化,所以必须放在build方法里读,不能缓存。
第二个是中英文切换的匹配问题。输入法从英文切到中文时,搜索框的controller.text本身不会出问题,但如果你监听了onChanged,搜索词的归一化就很重要。我前面写的_normalize会把所有字符转小写,同时处理空白字符,这样“HT 2024”和“ht2024”才能命中同样的数据。
第三个是搜索动作的提交。在 OpenHarmony 上,TextField 的默认动作在不同输入法里差异很大。我显式指定:
TextField( textInputAction: TextInputAction.search, onSubmitted: (value) => _onKeywordChanged(value), )指定TextInputAction.search后,键盘上的搜索按钮才会比较稳定地触达 Flutter 的提交回调。如果省略这一步,部分输入法只在失去焦点时才触发提交,用户会以为搜索按钮没反应。
6. 性能优化:从 300 条到 2 万条合同的实测记录
6.1 首帧卡顿的根因是我自己写的同步搜索
第一版在 300 条测试数据上跑得飞快,把数据量加到 2 万条之后,每次输入一个字符都会卡一会儿,输入法都跟不上。定位后发现,罪魁祸首是我在 UI 线程同步执行了_search和_highlightSpans。
Flutter 的 UI 线程要负责渲染、动画、输入法响应,当主线程被一个耗时的sort或大量字符串操作塞满,用户看到的就直接是卡顿和掉帧。所以第一步优化很明确:把“搜索 + 排序”移出主线程,UI 线程只负责接收结果和渲染高亮。
6.2 compute 不是万能的,常驻 isolate 才是
很多 Flutter 开发者会第一时间想到compute函数,因为它在 Flutter 里用起来最简单。但我很快发现,在 2 万条数据的搜索场景下,compute反而更慢。
原因在于compute的隔离模型。每次搜索都要新建一个 isolate,把 2 万条合同对象拷贝进去,搜索完还要把结果拷贝回主线程。isolate 的创建、预热、销毁,这些开销叠加起来比搜索本身还大,尤其在低端 OpenHarmony 设备上表现更明显。
正确的做法是启动一个常驻后台 isolate,让它一直活着,随时接收搜索请求:
void searchIsolateMain(SendPort sendPort) { final receivePort = ReceivePort(); sendPort.send(receivePort.sendPort); receivePort.listen((message) { final request = message as SearchRequest; final result = _search(request.source, request.query); request.replyPort.send(result); }); }主 isolate 侧通过SendPort发请求,后台 isolate 处理完通过replyPort把结果传回来。这样避免了两万条数据反复拷贝的 overhead,实测下来 2 万条合同的搜索加排序稳定在 60ms 以内。
需要提醒的是,常驻 isolate 的数据来源必须在启动后一次性下发并缓存在 isolate 内部。如果每次请求都通过SendPort传 source,那等于又把拷贝成本捡回来了。
6.3 预计算拼音与首字母索引
中文场景最麻烦的是拼音搜索。用户记不住合同全称,只会输“南京那笔服务合同”或者直接打首字母“njfw”。纯中文contains匹配完全处理不了这些输入。
我的方案是为每个合同预计算一份拼音索引,搜索时同时匹配原始中文、完整拼音、首字母缩写:
class ContractSearchIndex { final String id; final String fullPinyin; // "nanjingshishebeicaigouhetong" final String abbrPinyin; // "njsbcght" }拼音转换我用的是 pub 上的纯 Dart 包。选择纯 Dart 的原因很明确:很多拼音包依赖原生 ICU 库,而 ICU 在 OpenHarmony 原生侧的支持情况不完全一致,一旦遇到NotImplementedError就很难排查。纯 Dart 方案虽然在转换速度上略慢,但兼容性可控,预计算本来就在后台 isolate 里做,慢一点无感。
预计算不是边搜索边算。我在 App 冷启动后用一个后台任务生成全量索引,生成期间不阻塞用户操作。1 万条合同的预计算时间大约在 1.5 秒左右,完全可接受。
6.4 缓存搜索结果与滚动位置
用户输入“南京”得到结果,点击进入详情,再返回搜索页,这时候如果因为状态重建把结果清空,用户会非常恼火。我在SearchPage的状态里保存了_lastQuery和_lastResults,页面恢复时直接重新渲染,不需要重新搜索。
滚动位置丢失是另一个隐蔽问题。搜索结果列表很长的场景下,用户滑到了第 30 条,去详情页看了一份合同,退回来发现又从头开始了。这个问题的解法是给ListView一个稳定的 key:
ListView.builder( key: const PageStorageKey<String>('search_result_list'), itemBuilder: ..., )PageStorageKey本质是给列表一个持久化身份,Flutter 的PageStorage会自动保存它的滚动偏移量。这个方案成本极低,效果却很直接。
7. 组件通信与状态优化:搜索页、列表页、详情页怎么联动
7.1 用同一个 Cubit 管理搜索条件和列表页数据
合同搜索不是一个孤立的页面,它和列表页、详情页之间有很强的联动。最差的做法是用Navigator.pop的返回值把搜索条件传回列表页。这样做的坏处是:列表页无法感知搜索页内部的实时状态,而且回调层层嵌套很难维护。
我的方案是用同一个ContractListCubit管理两边的状态:
class ContractListCubit extends Cubit<ContractListState> { ContractListCubit(this.repository) : super(const ContractListState()); void updateQuery(ContractSearchQuery query) { emit(state.copyWith(query: query, dirty: true)); } Future<void> refresh() async { final list = await repository.fetchContractList(); emit(state.copyWith(list: list, dirty: false)); } }搜索页只负责把ContractSearchQuery发布到 Cubit,列表页监听 Cubit 状态自动刷新。这样有一个非常关键的收益:从搜索页返回列表页时,列表数据和筛选条件永远是一致的,不会出现“搜索结果显示 3 条,回到列表页却看到全部合同”的割裂感。
7.2 为什么用 Cubit 而不是 ChangeNotifier
我在这个项目里最终选了 flutter_bloc 全家桶里的 Cubit,而不是一开始用的 ChangeNotifier,原因是在合同搜索这种多状态场景里,Cubit 的状态机边界更清晰。
ChangeNotifier的问题是通知粒度太粗。它只负责“告诉所有监听者我变了”,但监听者不知道到底哪个字段变了。搜索场景里有 keyword、statuses、startTime、endTime、loading、dirty 多个状态维度,ChangeNotifier 的模型很难区分“筛选条件变化”和“列表刷新完成”。
Cubit 强制你定义完整的ContractListState,每次 emit 一个新状态对象,所有变化都是可追溯的。调试时我只要看状态对象,就知道搜索页当前处于什么阶段。在 OpenHarmony 上跨设备调试本来就比 Android 麻烦,状态管理越可追溯,问题定位越快。
7.3 搜索过程中新增合同怎么办
电子合同场景有一个很现实的并发问题:用户正在搜索,后台推送了一条新的待签署合同。如果搜索结果是纯快照,这条新合同就漏掉了,用户可能会错过重要的签署任务。
我的处理思路是引入版本号。底层 Repository 维护一个version字段,每次合同列表刷新后version++。搜索页拿到结果时检查版本号,如果发现已经变化,就自动发起一次重新搜索:
final currentVersion = _repository.version; final result = await _searchAsync(request); if (currentVersion != _repository.version) { _loadResults(state.keyword); // 数据源已变化,重新查一次 return; } setState(() => _results = result);这个方案不追求实时同步,但能保证搜索结果的“最终一致性”。用户不会在搜索页看到一条已经被签完但状态还停留在“待签署”的合同,体验是符合业务预期的。
8. 一块合同搜索模块做完之后的复盘建议
整个模块做下来,我最想分享的是这几个实操层面的建议。
第一,用三条数据量先调业务逻辑,再用两万条数据做压测。不要一上来就追求极限优化,否则你在 300 条数据上改的高亮逻辑,可能在 2 万条量级又变成性能瓶颈。
第二,把_matchContract、_highlightSpans、_search这些函数全部设计成纯函数。纯函数不依赖 Flutter 上下文,可以直接写单元测试覆盖大小写、空格、多关键词、编号精确命中这些边界情况。电子合同这种业务,测试覆盖率比普通 App 重要得多。
第三,多设备适配不能只在模拟器上看。我在 OpenHarmony 手机和开发板上都跑了搜索模块,重点看输入法弹出、系统字体缩放、返回手势这三个点。模拟器上正常不等于真机正常,尤其是输入法和滚动手感这种强交互体验,必须真机验证。
第四,如果合同量后续突破二十万,本地内存过滤就该让位于服务端检索。我在设计 Repository 接口时预留了扩展位,后续切换数据源不用动搜索 UI。这个扩展点建议大家都留上,谁也不知道业务量什么时候会翻倍。
这块搜索模块做完之后,我对 Flutter 在 OpenHarmony 上的落地又多了一层信心。框架本身能做的事其实比想象中多,真正决定体验高度的,还是数据模型和检索策略这些基本功。