电子合同签署这类需求,听起来不像一个“大项目”,但真正动手做的时候会发现:签个名只是最后一步,前面要搞定合同模板、签署流程、身份信息、文件存证、印章权限,后面还得接存证回调、验签、出证,整条链路全是API集成的活。这次我选的技术组合是Flutter + OpenHarmony,说句实话,刚开始心里也没底,因为OpenHarmony生态里很多Flutter第三方插件并没有现成的适配,但整个项目推进下来,收益非常明确:UI层和业务逻辑层可以跨端复用,系统能力通过平台通道补齐,一套代码跑通多类设备,尤其是国产终端和工业平板这类OpenHarmony主力设备。这篇就把从API集成设计、签名面板实现、文件上传下载到OpenHarmony真机适配的完整思路和踩坑记录都写出来。
1. 项目选型记录:为什么电子合同偏偏选中 Flutter + OpenHarmony
1.1 电子合同业务的三层核心诉求
第一次接触电子合同项目的人,容易把需求理解成“做一个能签字的画板”,然后生成一张图片上传。但实际上,一个能用于真实业务的电子合同签署App,至少要拆成三层:
- 业务层:合同模板管理、签署任务创建、签署方身份校验、签署顺序控制、合同状态流转。
- 能力层:手写签名采集、企业印章管理、文件上传下载、OCR识别证件、人脸核身调用。
- 合规层:签署时间授时、操作日志留痕、签名图片防篡改、证据链数据回传。
这三层落到技术上,几乎每一层都要和对端服务通过API交互。签名面板只是最前端的一小块,真正的核心工作量在API怎么编排、请求怎么鉴权、文件怎么传输、异常怎么兜底。
我们接的是某电子合同SaaS平台,它提供了一套标准RESTful API,请求返回JSON,文件走单独的文件服务接口。这种对接模式在行业里非常典型,所以下面这些设计和踩坑点,其实可以平移到任何一家电子合同服务商。
1.2 Flutter在OpenHarmony端的适配现状
Flutter适配OpenHarmony,现在已经不是“能不能跑”的问题,而是“插件够不够用”的问题。官方社区维护了OpenHarmony版本的Flutter SDK,Dart层代码基本可以复用,UI渲染也能正常跑。真正麻烦的是插件层:很多pub.dev上的插件默认只实现了Android和iOS的原生代码,OpenHarmony这边没有对应的ohos实现。
所以选型阶段就得定一个原则:能不依赖原生插件的功能,就尽量用Dart纯逻辑实现;实在绕不开系统能力,比如获取设备唯一标识、读写安全存储、调用系统相册,再走平台通道自己写一个薄封装。
这个原则在电子合同场景里特别合适。签名采集是纯手势识别加绘图,网络请求是纯Dart,文件读写可以通过path_provider的OpenHarmony适配版搞定。少数几个系统能力,比如安全存储密钥,直接在OpenHarmony侧用ArkTS写一个几十行的通道方法就行。
1.3 为什么还要加本地数据库加后端同步
电子合同签署有一个非常恶心的场景:人已经在设备前了,签也签完了,结果网络断了,提交失败。如果直接丢弃签名结果,用户得重新签一遍,体验极差。如果硬等网络恢复,用户可能直接放弃。
所以我在项目里引入了一层本地草稿机制,本质就是“本地数据库加后端同步”的思路。签名完成但提交失败的合同,先把签署数据落本地库,等网络恢复后自动补提。这个方案在移动端离线优先(offline-first)架构里很成熟,用到电子合同场景也完全成立。后面会细讲实现方式。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. API集成主链路设计:从创建合同到签署完成的请求编排
2.1 核心API的分工和调用顺序
电子合同签署的完整API链路,我按业务阶段拆成四段:
| 阶段 | 调用的API | 核心参数 | 返回关键字段 |
|---|---|---|---|
| 合同准备 | 模板列表/详情 | 商户ID、模板编码 | 模板ID、填写项定义 |
| 发起签署 | 创建签署任务 | 合同标题、签署方列表、合同文件ID | 签署任务ID、签署链接/短链 |
| 签署动作 | 提交手写签名/印章 | 签署任务ID、签署方身份标识、签名图片 | 签署记录ID、签署状态 |
| 结果获取 | 签署详情/存证回调 | 签署任务ID | 签署状态、存证报告编号 |
调用顺序不一定每次都一样,有些场景是模板生成合同文件,有些场景是用户直接上传PDF再创建签署任务。但整体编排逻辑是一致的:先拿到文件ID,再创建任务,再提交签署,最后轮询或等待回调确认状态。
我建议最开始在设计数据结构时就把这几个ID理清楚:模板ID(templateId)对应合同样式,文件ID(fileId)对应实际合同文件,任务ID(contractTaskId)对应一次签署流程,记录ID(signRecordId)对应某个签署方的一次签署操作。四个ID不要混。
2.2 参数签名和请求头防重放设计
电子合同API的鉴权,比普通业务API严格得多,因为牵涉到法律效力。我们对接的服务商要求每个请求必须带以下请求头:
- X-App-Key:商户应用标识。
- X-Timestamp:请求发起时的Unix毫秒时间戳。
- X-Nonce:一次性随机串,防止重放攻击。
- X-Sign:对以上参数加请求体做HMAC-SHA256后的签名值。
Dart端封装一个统一的签名函数,所有请求出口共用:
dart复制String buildSign({
required String appKey,
required String appSecret,
required String timestamp,
required String nonce,
required String bodyJson,
}) {
final params = [
'appKey=$appKey',
'timestamp=$timestamp',
'nonce=$nonce',
'body=$bodyJson',
];
final originString = params.join('&');
final hmac = Hmac(sha256, utf8.encode(appSecret));
final digest = hmac.convert(utf8.encode(originString));
return digest.toString();
}
有几个细节必须注意。
第一,请求体一定要用和实际发送一致的字符串,如果发送前做了字段排序,排序规则要在签名时同步。所以网络层和签名层必须共用同一个序列化方法,不要在业务代码里手动拼接JSON。
第二,时间戳一定要用设备当前时间,但设备时间可能不准,服务端会校验时间偏差。我遇到过测试机时间快了5分钟,导致所有请求全部返回“签名时间戳不合法”。后来在应用启动时通过NTP接口校准一次,再和本地时间做差值缓存。
第三,nonce要保证并发请求也不重复。我用的是UUID加自增计数器组合,确保同一个时间戳下多个并发请求nonce也不一样。
2.3 Token刷新和401自动重试
虽然业务API是签名鉴权,但部分接口还会附带OAuth2.0的accessToken,用于标识当前登录用户。OpenHarmony设备可能存在多用户或分时共用的情况,Token也会过期。我基于Dio的拦截器写了一套自动刷新逻辑:
dart复制_dio.interceptors.add(InterceptorsWrapper(
onError: (error, handler) async {
final response = error.response;
if (response?.statusCode == 401 &&
!error.requestOptions.extra['isRetry']) {
final success = await _refreshToken();
if (success) {
error.requestOptions.extra['isRetry'] = true;
final token = await _getToken();
error.requestOptions.headers['Authorization'] = 'Bearer $token';
final retryResponse = await _dio.fetch(error.requestOptions);
return handler.resolve(retryResponse);
}
}
handler.next(error);
},
));
核心就三点:只重试一次、重试请求要加标记防止循环、刷新Token的请求本身不能用同一个拦截器,否则会死循环。
2.4 本地草稿待提交队列
刚才提到的“本地数据库加后端同步”,我是用Hive做的实现,因为它轻量而且不依赖原生插件,OpenHarmony适配上没遇到障碍。每张表的主键就是contractTaskId加signerId的联合键,状态字段标记为pending、uploading、success、failed。
提交失败时把签名图片byte数组和请求参数完整存下来,网络恢复后用广播监听或者App回到前台时触发补提。补提成功后更新状态,并且删除本地图片,释放存储空间。
要注意的是,合同签署这种有法律效力的操作,本地草稿必须加密存储。Hive本身不加密,我用了AES对签名图片和关键参数加密后再存,密钥放在OpenHarmony安全存储里,不落明文。
3. 手写签名面板实现:手势采集、曲线平滑和图片导出
3.1 自定义绘制而不是用现成组件
pub.dev上确实有手写签名组件,比如signature、signature_view,但在OpenHarmony上适配不一定好,而且样式定制空间小。我最后决定用Flutter自带的CustomPaint加GestureDetector自己写,代码量不大,但可控性极强。
签名面板要管理的核心数据结构是笔画列表。每个笔画是一组坐标点,多个笔画组成一次完整签名:
dart复制class SignatureStroke {
final List<Offset> points;
SignatureStroke(this.points);
}
手势开始(onPanStart)时新建一个笔画,移动过程(onPanUpdate)往当前笔画追加坐标点,手势结束(onPanEnd)时把笔画放入历史列表。
3.2 曲线平滑和视觉优化
直接用线段把坐标点连起来,画出来会有棱角,笔迹看起来非常生硬。我改成用二次贝塞尔曲线连接相邻点,绘制出来的轨迹就平滑很多:
dart复制@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = _strokeColor
..strokeWidth = _strokeWidth
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke;
for (final stroke in strokes) {
if (stroke.points.isEmpty) continue;
final path = Path()
..moveTo(stroke.points.first.dx, stroke.points.first.dy);
if (stroke.points.length == 1) {
canvas.drawCircle(stroke.points.first, _strokeWidth / 2, paint);
continue;
}
for (int i = 1; i < stroke.points.length - 1; i++) {
final start = stroke.points[i];
final end = stroke.points[i + 1];
final mid = Offset((start.dx + end.dx) / 2, (start.dy + end.dy) / 2);
path.quadraticBezierTo(start.dx, start.dy, mid.dx, mid.dy);
}
path.lineTo(stroke.points.last.dx, stroke.points.last.dy);
canvas.drawPath(path, paint);
}
}
这里注意:不能用绘制整条path的方式处理单点,不然点一下屏幕只画出一个看不见的圆点,会被用户认为是Bug。
3.3 shouldRepaint和性能优化
签名过程是高频重绘场景,如果shouldRepaint处理不好,会有明显掉帧。我的做法是维护一个版本号或者直接用笔画列表的引用变化判断:
dart复制@override
bool shouldRepaint(covariant SignaturePainter oldDelegate) =>
oldDelegate.strokes != strokes || oldDelegate.signatureVersion != signatureVersion;
在真机调试中发现,OpenHarmony开发板上的GPU性能参差不齐,有些低成本设备绘制大量曲线时会卡顿。一个有效的优化是按需重绘,手势过程中只更新当前笔画所在的局部区域,手势结束后再全量绘制。实现上可以在paint方法里判断当前只有最后一笔变化,用canvas.saveLayer加clipRect限定重绘范围。
我把采样点也做了限制,每帧新增的坐标点超过一定数量就做稀疏采样,因为单指签名时手指移动的坐标点密集,全量保存会导致数据量膨胀,渲染压力也大。
3.4 签名图片导出为透明PNG
签名采集完成后,需要把整个画布内容导出成透明背景的PNG图片,提交给签章API。核心代码是:
dart复制Future<Uint8List> exportSignaturePng({
required int width,
required int height,
}) async {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
signaturePainter.paint(canvas, Size(width.toDouble(), height.toDouble()));
final image = await recorder.endRecording().toImage(width, height);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
return byteData!.buffer.asUint8List();
}
有几个细节非常重要:透明背景必须靠Canvas的透明底色保持,不要先填充白色;导出尺寸要用高点分辨率,我导出的是300dpi对应的像素尺寸,不然打印出来锯齿明显;导出前要把签名画布空白边裁掉,用图片的alpha通道计算有效签名区域,这个功能不复杂,但对手写签名规范很重要。
4. 文件API对接的边界情况:大合同上传下载与校验
4.1 Multipart上传还是分片上传
电子合同中的文件,大部分是PDF加扫描件,几MB到几十MB的都有,偶尔会遇到上百MB的合同附件。对接文件上传API时,我一开始用的就是最直接的MultipartFile。
dart复制final formData = FormData.fromMap({
'contractId': contractId,
'fileType': 'contract',
'file': await MultipartFile.fromFile(
filePath,
filename: 'contract.pdf',
contentType: DioMediaType('application', 'pdf'),
),
});
final response = await _dio.post('/file/upload', data: formData);
实践证明,普通PDF直接走Multipart上传没问题。但超过50MB的文件,直接Multipart会长时间占用网络连接,没有断点续传能力,一旦中断,用户只能重新选文件上传。
分片上传更稳妥,但实现成本高不少:前端切片、计算每片MD5、创建分片任务、逐个上传、合并文件。大多数电子合同服务商都支持分片API,对接也不难,如果合同库里有大量扫描件,建议直接上分片方案。
我给团队的建议是:先做Multipart上传,功能上线后统计文件大小分布,如果50MB以上文件占比超过5%,再做分片上传,不要一上来就把复杂度加满。
4.2 文件指纹校验
电子合同对文件完整性要求极高,上传和下载都要做指纹校验。上传前计算客户端文件的SHA256,放在请求参数里,服务端接收后重新计算比对,不一致直接拒绝。下载时同理,服务端返回Content-MD5或者下载地址后面带签名参数,客户端校验通过后再使用。
dart复制final sha256Digest = sha256.convert(bytes).toString();
这个字段看起来简单,但能挡掉很多极端场景:网络运营商劫持替换文件、传输过程中数据损坏、下载到一半被当成完整文件使用。电子合同后面如果要做司法出证,文件指纹是证据链非常关键的一环。
4.3 下载缓存和临时目录清理
合同文件下载后用path_provider写入临时目录,方便用户在弱网时预览。注意用合同ID加文件ID做缓存文件名,避免同名文件互相覆盖。App每次启动时清理超过7天的临时文件,避免存储膨胀。
这里踩过一个坑:OpenHarmony的文件系统路径和Android不完全一致,path_provider的getTemporaryDirectory在某些适配版本上返回的是应用沙箱内的cache路径,直接写入没有问题,但如果用绝对路径拼接字符串,可能导致文件路径错误。解决办法是始终通过path_provider提供的API获取目录,不要硬编码路径前缀。
5. OpenHarmony真机调试与平台通道:第三方插件缺失时的兜底方案
5.1 如何快速判断一个Flutter插件是否支持OpenHarmony
打开插件的pubspec.yaml文件,看有没有声明ohos的flutterPlugin实现。大部分主流插件后来都陆续加入了OpenHarmony支持,但这个支持往往不是在原包里面,而是在“Flutter社区OpenHarmony适配版本”分支里。实际使用有两种方式:
| 方式 | 优点 | 缺点 |
|---|---|---|
| 直接使用pub.dev已适配ohos的插件 | 省事,版本跟随上游 | 部分插件还是早期适配,API不完整 |
| 用Flutter的method channel自己写桥接 | 完全可控,不依赖插件进度 | 每个系统能力都要自己写原生代码 |
以文件路径能力为例,社区版本path_provider已经有ohos实现,直接用就行。但像拍照、相册选择这类能力,image_picker虽然也有适配,个别机型上返回图片角度异常。电子合同里经常要拍身份证和营业执照,如果遇到图片方向问题,可以用EXIF信息做旋转校正。
我最后的选择是:能上原版适配就用原版,确实没适配的系统能力自己封一层ArkTS实现。实测下来,平台通道的性能完全够用,一次同步调用耗时基本可以忽略。
5.2 MethodChannel封装设备信息服务
比如获取OpenHarmony设备唯一标识,用平台通道在ArkTS侧实现:
dart复制static const MethodChannel _deviceChannel = MethodChannel(
'com.example.esign/device',
);
Future<String> getDeviceUniqueId() async {
final result = await _deviceChannel.invokeMethod<String>('getDeviceUniqueId');
return result ?? '';
}
ArkTS那边注册同一个channel,然后调用系统能力获取设备ID返回。这个设备ID在电子合同场景里很有用,可以作为签署设备的标识记录在日志里,当用户后续做法律咨询时,可以证明当时是在哪台设备上完成的签署。
5.3 开发板到真机的设备差异
网上问“OpenHarmony的RK3568有许多设备树到底咋选”的人很多,这块我简单说下经验。设备树的选择要看固件构建目标,而不是应用层能决定的。签App的时候,最需要关心的是设备屏幕分辨率、触摸采样率、JavaSript引擎或者Canvas渲染能力这种运行时表现。
我们在RK3568开发板上调试时,签名画布没问题,但打开合同PDF预览页会明显卡顿。排查下来是PDF渲染组件在GPU比较弱的设备上用了过高的绘制精度。调低渲染分辨率后,流畅度好转但字体发虚。最终方案是根据屏幕尺寸动态计算渲染精度,只在设备DPR高于1.5时启用了高清模式。
这类问题其实不是OpenHarmony独有的,但在开发板上更容易暴露。我建议所有团队都准备一台低端设备作为基准测试机,性能优化都以它为准,否则真机上线时用户设备参差不齐,体验不可控。
5.4 UI适配的几个细节
OpenHarmony除了手机,还有大量平板、一体机、自助终端,屏幕比例千奇百怪。电子合同签署页面要重点适配横屏和分屏场景。
签名面板我做了自适应尺寸,横竖屏切换时保留已有笔迹,通过LayoutBuilder重新计算可绘制区域,不让签名内容因为排版变化而丢失或裁切。
签署页面的签署区域,建议不要写死在屏幕中间。合同在平板上预览时,实际签署区域是相对PDF页面的坐标位置,转换到屏幕坐标要乘以缩放比例。这里要预留出PDF工具栏高度和页面边距,否则会出现点对了位置但笔迹偏移的情况。
6. 安全和合规细节:电子合同不是随便画画就行
6.1 手写签名图片的防篡改处理
签名图片传到服务端后,服务端会做哈希指纹入库。客户端要配合做的一件事是:不要在签名图片上额外加文字水印。有些产品喜欢在签名图片上加水印和说明文字,这个在电子合同场景里是画蛇添足,反而会影响后续的笔迹鉴定。
如果确实需要在界面上展示“已签署”标识,用UI层叠加展示,不要合并到签名图片本身。签名图片一旦被篡改,哈希值就和存证记录对不上了。
6.2 签署时间授时
合同签署时间要采用可信时间源,客户端本地时间不能作为法律效力认可的时间。我这边是服务端在签署完成后统一授时,把可信时间戳回传并记录在签署结果里。客户端只需要展示服务端返回的时间,不要自己生成本地时间戳。
6.3 密钥存储和设备绑定
OpenHarmony支持安全存储区,可以将商户密钥和应用签名密钥保存在安全存储中,不直接落盘明文。虽然配置起来有点烦,但电子合同App涉及法律效力,密钥泄露不是小事。我在项目里把API签名用的appSecret和应用身份密钥都迁移到了安全存储,并通过设备唯一ID做了绑定,换设备后需要重新认证。
这些安全措施看起来增加了很多工作量,但对电子合同这个领域来说,每一层都是必要的。不是“想不想”做,而是“能不能不过审”的问题。
7. 一次线上问题复盘:签名提交成功但合同一直未更新状态
最后分享一个非常有价值的故障排查过程。
上线后遇到一个奇怪的问题:用户在第一台设备上完成了签名,API返回成功,但同一个合同在另一台设备上看到的签署状态仍然“待签署”。排查链路如下:
第一步,检查签署任务详情接口返回,发现服务端记录里“签署方列表”显示两个签署人的信息完全一样。原因是我们创建签署任务时,把一个签署方的手机号传成了另一个签署方的手机号,服务端默认两个不同身份,认为是两个人。
第二步,查本地草稿,发现第一台设备确实提交成功了,但UI刷新还是用的创建任务时缓存的旧状态,没有重新拉详情。修掉缓存逻辑,提交成功后强制刷新签署详情。
第三步,查服务端回调配置,发现回调地址还是测试地址,回调全部失败,状态更新逻辑依赖回调触发,所以一直卡在待签署。这个纯粹是配置遗漏。
这个问题跨了客户端、服务端配置、回调三个环节,单看任何一环都像“偶发问题”,但串起来其实是多层遗漏叠加。复盘下来,我的建议是:电子合同App一定要建立一个“合同状态流转对照表”,把所有状态变化触发的时机、调用的API、UI展示的条件都列清楚,上线前逐条核对。
这行的水其实很深。电子合同表面上是一个签名App,实际上是一个集成了身份认证、文件处理、安全存储、法律存证的服务端产品。API集成只是第一步,真正拉开差距的,是离线写法、错误恢复、设备适配、安全加固这些看着不起眼但直接影响使用体验的细节。如果你的项目也是类似场景,希望这篇能给你一些参考。
