我去年团队接到一个挺特殊的需求:把已经跑在Android和iOS上的电子合同签署App,做到OpenHarmony上去。业务方明确要求复用现有Flutter代码,不能重新写一套原生实现。项目落地之后,踩了不少坑,也沉淀了一些方法,今天把API集成这块的完整思路和实操过程整理出来分享给需要的朋友。如果你正准备做OpenHarmony上的Flutter应用,或者在做合同、金融类App的API对接,这篇文章应该能帮你省几天的调研时间。
先说清楚这个项目是做什么的。简单说,就是面向企业客户的电子合同签署工具,核心链路包括合同模板管理、创建合同、发起签署、实名认证、手写签名、签署完成归档。客户端主要承担三件事:展示合同内容、接收用户签署操作、与后端API同步状态。技术选型上,UI层用Flutter,跨端逻辑全部复用,OpenHarmony侧通过社区的flutter适配分支跑Dart代码,底层能力(比如文件存储、网络、安全元件调用)用自定义的平台通道补齐。
这篇文章的重点放在API集成实现上,包括协议怎么设计、网络层怎么封装、签名加密怎么做、文件上传下载怎么处理,以及我在OpenHarmony适配过程中遇到的几个典型问题。往下看。
1. 项目背景与技术选型:OpenHarmony生态下的Flutter实战思路
1.1 OpenHarmony的运行环境和Flutter适配现状
先说实话,OpenHarmony目前的应用生态相比Android和iOS还差不少,但企业端的国产化需求是实打实的,尤其是政务、金融、大型国央企这些行业,对鸿蒙生态的覆盖要求越来越高。我们接到需求的时候,评估过两条路线:一条是用ArkTS配合ArkUI从零开发,另一条就是坚持Flutter跨端复用。
结论很明确,选Flutter。原因有三点:第一,我们现有代码库90%以上是Dart写的,业务逻辑和UI基本可以平移到OpenHarmony;第二,电子合同这类业务,核心逻辑全在服务端,客户端偏重展示和交互,Flutter做展示层绰绰有余;第三,团队没人写过ArkTS,从头学成本太高,项目排期不允许。
目前社区主流的适配方案是基于OpenHarmony SIG组的flutter_flutter分支,配合对应的dart sdk和flutter engine,编译产物是HAP包(HarmonyOS Ability Package)。这里提醒一下,不要直接用官方Flutter SDK来编OpenHarmony目标,文件格式和构建链路都不一样,直接用会卡在gradle或native编译阶段,非常浪费时间。
1.2 电子合同业务链路拆解
电子合同表面上是个"签名"动作,实际上背后是一整条业务链路。我从后端的接口清单反推客户端需要做哪些事,大致分成五个环节:
- 模板与合同管理:企业管理员上传合同模板(PDF或者HTML转PDF),签署请求方根据模板填充参数创建合同实例。
- 签署流程编排:创建合同之后,需要指定签署方和签署顺序,每个签署方有独立的任务列表。
- 身份认证:签署前必须做实名认证,通常是对接权威数据源,比如手机号三要素、人脸识别,走的是服务端API,但客户端需要配合拉起认证页并回传认证结果。
- 签署动作:用户在合同PDF上确认阅读、完成手写签名或者输入签署验证码,客户端把签署坐标、签名图片、时间戳等信息提交给服务端。
- 签署完成与存证:服务端完成验签后归档,生成存证报告,客户端查询状态并展示给用户。
API集成要覆盖的就是这五条线。做之前,我建议把业务状态图画清楚,因为后面的接口设计、回调通知、异常处理,全都依赖对状态的理解。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体架构设计:客户端分层与核心模块划分
2.1 Flutter工程结构设计
项目采用标准的feature-first目录结构,没有用传统的by-type分层(即把widgets、models、services各自堆一层),因为合同签署过程涉及的功能页面太多,按业务模块划分代码会更清晰,后面加新功能也不用在全局目录里翻来翻去。
code复制lib/
core/ // 核心基础能力
network/ // 网络层封装
security/ // 加解密、签名
platform/ // 平台通道封装
storage/ // 本地存储
features/
contract_list/ // 合同列表
contract_detail/ // 合同详情
sign/
hand_sign/ // 手写签名
verify_code/ // 验证码签署
template/ // 模板浏览
user/ // 登录、认证
shared/
widgets/ // 公共组件
utils/ // 工具函数
这个结构对API集成最大的好处是,网络层、安全层被隔离在core里,业务页面只依赖清晰的接口定义,不关心HTTP细节。换签名算法、改baseUrl、加拦截器,都只动core层。
2.2 状态管理与路由设计
状态管理用的是Provider + ChangeNotifier,没有上bloc或者riverpod,原因是团队熟悉,且合同流程的状态流不算特别复杂。核心包括一个AuthState(登录态)、一个ContractState(合同列表和详情缓存)、一个SignState(签署流程当前步骤)。
路由用go_router,其中一个考虑是OpenHarmony上的深链跳转。电子合同业务里,用户从短信、邮件点击链接打开App,需要直接跳到某个具体合同详情页。go_router支持通过uri构造位置,配合服务端下发的link参数(例如gmapp://contract/detail?id=xxx),在Flutter入口处解析后路由到对应页面,这个能力在这里很好用。
2.3 平台通道设计思路
OpenHarmony上Flutter生态相对特殊,很多第三方的Flutter插件只实现了Android/iOS原生代码,不可能直接用在OpenHarmony上。比如我们需要的真机指纹校验、安全存储Keychain能力,OpenHarmony侧都没有现成插件。
我的做法是统一用MethodChannel封装:Dart侧定义抽象接口,OpenHarmony侧用ArkTS实现具体逻辑。比如安全存储模块,Android用EncryptedSharedPreferences,OpenHarmony用系统提供的Asset Store能力,两边实现不同但Dart侧API完全一致。这个抽象层是OpenHarmony适配中最值得投入的地方,后面接插件能力基本都靠它。
3. API集成实现:协议、网络层、安全与核心接口对接
3.1 API协议设计与响应格式规范
API协议走的是REST风格,要求客户端和服务端共同遵守一套响应封装格式。我们统一使用下面的JSON结构:
json复制{
"code": 0,
"message": "success",
"data": {}
}
code为0表示成功,非0表示业务失败。这里有个细节值得说:很多团队喜欢把HTTP状态码直接当作业务状态码用,比如返回200表示成功、400表示参数错误。这在页面少的小项目里问题不大,但合同签署这种强流程的业务,有些"成功响应但业务失败"的场景,比如"合同已被撤销""签署人身份不匹配",HTTP层面是200,但业务上必须明确告诉用户失败了。所以直接用嵌套code/message/data的结构,逻辑更清楚。
接口地址统一挂在一个前缀下,比如/api/v1/contract/sign,客户端和服务端都用相同的版本号控制,避免上线后接口地址混乱。
3.2 网络层封装:基于dio的实践
三端复用的场景下,网络层我直接用dio库,它是Flutter生态里最成熟的HTTP客户端,支持拦截器、自定义适配器、取消请求这些核心能力。OpenHarmony上只要Dart侧依赖能解析,dio底层走的是Dart的HttpClient或者平台自带的网络栈,基本不用改代码。
封装的核心内容包括四个拦截器:
dart复制class AuthInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestHandler handler) {
final token = AuthManager.instance.accessToken;
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
}
第一个是认证拦截器,统一给每次请求带上accessToken。第二个是签名拦截器,对关键请求做参数签名(详见3.3)。第三个是日志拦截器,debug模式下打印请求和响应详情,排查问题速度能快好几倍。第四个是重试拦截器,针对网络抖动做一次幂等的重试。
dio的baseOptions需要特别设置超时时长。电子合同App经常在弱网环境下操作,尤其PDF文件可能几MB甚至几十MB,连接超时设10秒、接收超时设30秒起步,不然用户在地铁里签个合同,一个超时直接中断,体验非常差。
3.3 签名与加密:敏感数据的安全通道
电子合同涉及法律效力,API通道上的数据安全性比普通App高一个等级。我们实际采用了"HTTPS + 请求签名 + 敏感字段加密"三重方案。
HTTPS是基础,不用多说。请求签名解决的是参数被篡改的问题,服务端和客户端约定一组密钥(AppKey/AppSecret),每次请求对query参数和body做哈希签名,服务端验签通过才处理。核心实现:
dart复制String generateSign(Map<String, dynamic> params, String secret) {
final sortedKeys = params.keys.toList()..sort();
final sb = StringBuffer();
for (final key in sortedKeys) {
sb.write('$key=${params[key]}&');
}
sb.write('key=$secret');
return md5(sb.toString());
}
注意点:参与签名的参数名要先按字典序排序,这能避免由于参数顺序不同导致签名不一致的问题。密钥不能硬编码在客户端代码里,要放到安全存储中,Android和OpenHarmony通过平台通道获取,不要为了图方便直接写在dart文件里,否则反编译一下就泄露了。
敏感字段加密针对的是身份证号、手机号、银行卡号这种数据。方案是客户端生成一个随机的AES会话密钥,用服务端的公钥(RSA)加密AES密钥,然后把加密后的密钥和加密后的业务数据一起传给服务端,服务端用私钥解开AES密钥,再解密业务数据。国密合规场景可以换成SM2/SM4,整体思想相同。
3.4 核心接口对接:合同创建、签署发起与状态查询
这里我按流程顺序把最核心的几个接口列出来,方便大家对照自己的后端设计。我们服务端接口用Spring Boot实现的,客户端用dio调用。
创建合同接口:
dart复制Future<ContractDetail> createContract({
required String templateId,
required Map<String, String> params,
required List<SignerInfo> signers,
}) async {
final response = await dio.post('/api/v1/contract/create', data: {
'templateId': templateId,
'params': params,
'signers': signers.map((s) => s.toJson()).toList(),
});
if (response.data['code'] == 0) {
return ContractDetail.fromJson(response.data['data']);
}
throw BusinessException(response.data['message']);
}
创建合同返回的是合同实例ID和初始状态。这里有个容易出问题的点:如果同时传了多个签署方,服务端可能要根据业务规则自动排序,比如"第一个签署人是企业经办人,第二个才是外部客户"。客户端不要在自己本地硬编码排序逻辑,以服务端下发的signOrder字段为准,否则容易出现界面显示顺序和真实签署流程不一致的bug。
发起签署接口:
创建完合同后,要推给签署者。这里的实现是服务端生成签署链接或者邀请码,客户端通过接口带出签署任务:
dart复制Future<List<SignTask>> fetchSignTasks() async {
final response = await dio.get('/api/v1/sign/task/list');
if (response.data['code'] == 0) {
return (response.data['data'] as List)
.map((e) => SignTask.fromJson(e))
.toList();
}
throw BusinessException(response.data['message']);
}
签署任务列表是App首页最核心的数据源,需要做下拉刷新和分页加载。注意状态标识不要只用一个字符串字段,建议用status枚举+statusDesc文案的组合,避免将来枚举值扩展时客户端需要反复发版适配文案。
提交换签署验证码:
签署动作本身,我们做了两种:一种是在PDF指定位置做手写签名图片上传,另一种是输入短信验证码确认意愿。验证码签署的接口设计是:
dart复制Future<void> submitSignVerifyCode({
required String signTaskId,
required String verifyCode,
}) async {
final response = await dio.post('/api/v1/sign/task/verify', data: {
'signTaskId': signTaskId,
'verifyCode': verifyCode,
// 时间戳用于服务端防重放攻击
'timestamp': DateTime.now().millisecondsSinceEpoch,
});
if (response.data['code'] != 0) {
throw BusinessException(response.data['message']);
}
}
这个接口在业务上非常敏感,服务端通常限定验证码有效期5分钟、错误次数5次以内锁定,客户端要做对应的倒计时显示和错误码文案映射。
签署状态查询:
合同状态用轮询还是推送,我在这里也纠结过。最终采用的是短轮询,频率控制在3秒一次,同时配合WebSocket做服务端主动通知。轮询接口很轻量,只返回状态码和签署进度:
dart复制Future<SignStatus> querySignStatus(String signTaskId) async {
final response = await dio.get(
'/api/v1/sign/task/status',
queryParameters: {'signTaskId': signTaskId},
);
return SignStatus.fromJson(response.data['data']);
}
合同签署的某个阶段,比如等待对端签署,可能持续几个小时甚至几天,客户端不可能一直处于前台。所以合同列表页用轮询没问题,但对于处于"用户正在签署中"的页面,可以进入页面时主动拉一次状态,配合WebSocket推送,做实时展示,效果更好。
3.5 文件上传与下载:合同PDF的可靠传输
合同签署流程中不可避免要处理PDF文件。这里最大的坑是:Flutter官方库只提供了dart:io的File接口,但OpenHarmony平台的沙箱路径和Android有差异,直接用path_provider插件在OpenHarmony上拿不到临时目录。我们的解决方式是:
- 在平台通道里暴露
getStoragePath方法,通过ArkTS获取应用沙箱路径。 - 上传采用
multipart/form-data,dio自带MultipartFile.fromFile支持。 - 大文件用分片上传,每片1MB,断点续传,服务端合并。
分片上传的核心代码:
dart复制Future<void> uploadContractFile({
required String contractId,
required String filePath,
}) async {
final file = File(filePath);
final totalSize = await file.length();
const chunkSize = 1024 * 1024;
final totalChunks = (totalSize / chunkSize).ceil();
for (var i = 0; i < totalChunks; i++) {
final start = i * chunkSize;
final end = min(start + chunkSize, totalSize);
final chunkData = await file.openRead(start, end).fold<List<int>>(
[],
(list, data) => list..addAll(data),
);
final formData = FormData.fromMap({
'contractId': contractId,
'chunkIndex': i,
'totalChunks': totalChunks,
'file': MultipartFile.fromBytes(chunkData, filename: 'contract_$i.part'),
});
await dio.post('/api/v1/contract/upload/chunk', data: formData);
}
await dio.post('/api/v1/contract/upload/complete', data: {
'contractId': contractId,
});
}
这里提醒一下,dart:io的File.openRead(start, end)在Flutter for OpenHarmony上如果底层文件系统能力没有完全打通,可能出现读取错位的问题。我当时排查到的原因是部分OpenHarmony版本上RandomAccessFile的seek实现有兼容性问题,后来改用File流分段读取,在前面加一个readAsBytes的起始偏移量计算,解决了问题。
下载方面,合同PDF通常需要支持离线查看(尤其是签署方在差旅途中签署),所以下载完成后要做本地缓存,下次直接读缓存文件。缓存Key建议用contractId + 版本号,合同文件更新后版本号变化,就不会命中旧缓存。
4. 常见问题与排查技巧实录
4.1 插件缺失和平台通道不生效
这是OpenHarmony上面最频繁的问题。Flutter生态里大量插件只写了Android和iOS的实现,在OpenHarmony上运行时,常表现为方法的result一直不回调,或者直接抛MissingPluginException。
排查思路是先在Dart侧确认插件的注册方式。如果是联邦插件(federated plugin),要检查它在OpenHarmony上有没有对应的endorsed实现,没有的话就得自己在项目里补一个平台通道实现。如果是普通插件,大概率不能用,直接自己用MethodChannel仿写一套同样的API,上层代码保持不变。
我当时遇到的是shared_preferences在OpenHarmony上不能正常工作,干脆自己写了一套基于文件存储的preference实现,接口命名和SharedPreferences保持一致,业务代码改动很小。
4.2 签名验签一直失败
签名验签失败是我们调试中次数最多的问题。绝大多数情况下不是算法本身有问题,而是参数排序或者编码问题。常见的场景:
- 参数里带着嵌套JSON对象,做签名时没有把嵌套对象
jsonEncode成字符串,导致签名串格式不一致。 - 服务端和客户端的
charset不一致,中文参数签名串编码不同,MD5结果不匹配。 - query参数和body参数混合时,漏掉了其中一部分参数。
我的建议是:服务端和客户端各自维护一个最小可复现的签名示例(比如固定参数、固定secret,打印签名串),联调时直接对比两端的签名串是否完全一致。这个"串比对"的方法能省掉大量抓包分析时间。
4.3 合同大文件导致内存暴涨
渲染PDF时,如果直接把整个文件加载进内存,几十MB的合同文件很轻松就把内存吃满了。我们项目里限制单份合同文件不超过20MB,超过则提示用户在PC端处理。同时PDF翻页渲染用懒加载模式,只预加载当前页和前后各一页。
用pdfx这类Flutter库渲染PDF时,OpenHarmony上需要确认原生渲染引擎是否支持。我们试过内置PDFium但OpenHarmony的so库兼容性不强,最后是服务端把PDF转成图片列表下发,客户端通过PageView滑动查看,性能和稳定度都好很多。
4.4 前后台切换导致网络请求中断
OpenHarmony对应用后台运行的网络限制和Android类似,进程被冻结后,未完成的网络请求会失败。针对签署这种重要操作,需要保证用户按Home键切到后台再回来,操作不丢。
做法是:所有涉及签名的请求都做成"提交后进入等待页",页面上显示"正在确认签署结果",如果请求失败则提供重试按钮。不要在用户点击"确认签署"后就直接返回上一页,那样一旦网络请求实际上已经到达服务端,用户在界面上却又看到了失败,会产生重复提交。这个需要服务端做幂等控制,客户端用signTaskId加本地标记位来去重。
5. 实操心得与后续扩展方向
这个项目做了两个多月,最深的体会是:OpenHarmony适配本身没有想象中那么可怕,真正的坑在全链路的工程化和对业务细节的把握上。Flutter for OpenHarmony现在虽然还算"青年期",但核心的Dart运行时、渲染管线已经能支撑生产级应用。团队如果想切入这个领域,建议从API集成这种和底层硬件打交道不太深的部分入手,逐步补齐平台能力。
最后分享一个调试的小技巧:在OpenHarmony上联调API时,用抓包工具如果发现HTTPS流量解不开,多半是系统级CA证书的问题。可以写一个debug专用的网络拦截器,将请求和响应的明文直接打印到日志里,配合日志定位,比抓包效率更高。上线时务必关掉这个拦截器并做混淆,避免敏感信息泄漏。
如果后续有机会,我打算再做两个方向:一个是把签署能力封装成OpenHarmony上的Ability可供其他应用拉起,另一个是把现有的手写签名接入OpenHarmony的触控笔SDK,提升手写体验。有相关实践的朋友,欢迎多交流。
