这段时间我一直在折腾一个比较偏门的组合:用 Flutter 开发 OpenHarmony 上的电子合同签署 App。听上去好像没啥特别的,但真正动手做 API 集成的时候才发现,这里面的坑远比想象中多。这篇文章把我在实战中踩过的坑、验证过的方案、以及最终跑通的实现路径整理出来,特别是 API 集成这一块,给后面想做同类跨端 App 的朋友一个参考。
开头先交代一下背景。电子合同签署这个业务,核心流程无非是:用户注册实名认证、发起签署、查看合同、确认签署、签署完成。但放到 OpenHarmony 这个新生态里,事情就没那么简单了。OpenHarmony 起步阶段,原生生态的第三方 SDK 数量和成熟度比不上 Android,而 Flutter 虽然能做到跨平台,但要调用鸿蒙系统的底层能力,就必须走 platform channel 桥接。这个项目的核心难点,就是在 Flutter 层把业务逻辑跟 UI 全部跑通,同时通过桥接层调起鸿蒙底层的能力,再跟后端的合同服务 API 做数据交换。
如果你正准备在 OpenHarmony 上做 Flutter 开发,特别是涉及用户登录、实名认证、合同文件上传下载这类重度依赖 API 和系统能力的场景,这篇文章值得收藏。
1. 内容整体设计与思路拆解
1.1 为什么在 OpenHarmony 上选 Flutter 而不是 ArkTS
这个项目最早做技术选型的时候,团队内部是有过激烈讨论的。OpenHarmony 官方主推的是 ArkTS + ArkUI 原生开发,理论上性能和系统能力调用最直接。但问题在于,团队里大部分人之前都是做 Flutter 的,如果全部转向 ArkTS,学习成本是一方面,最要命的是时间成本——原本两周能交付的东西,可能要拖到一个半月。
用 Flutter 的另一个决定性因素,是业务形态。电子合同签名这个 App,未来大概率要覆盖 Android、iOS、Windows、OpenHarmony 多个平台,不可能每个平台都搞一套原生实现。用 Flutter 做跨端,UI 层和业务逻辑层可以一套代码到处跑,只需要针对 OpenHarmony 做平台桥接适配。
从实际验证结果看,Flutter 在 OpenHarmony 上的表现是够用的。我用的是 Flutter 3.7.12 版本搭配 OpenHarmony 4.1 Release,开发框架选的是 OpenHarmony Flutter SDK 社区维护版,日常操作和页面跳转的流畅度跟原生差距不大。当然,如果你做的App对性能要求极其苛刻,比如大量 3D 渲染,那 Flutter 方案可能不是最优解,但对于合同签署这种偏表单和文档操作的业务,Flutter 完全能扛住。
1.2 API 集成方案的选型逻辑
API 集成这块,我一开始想过用官方推荐的 http 包,简单直接。但合同签署这个业务它不是简单的增删改查,涉及到的 API 调用场景非常复杂,比如:
- 实名认证需要上传身份证正反面照片,文件流上传;
- 合同发起需要提交模板 ID 和签署方信息,同时关联多个参与方;
- 签署状态需要轮询或者长连接实时刷新;
- 下载合同文件可能面对几百兆的大文件;
- 所有请求都要带 token,且 token 过期需要自动刷新。
这些需求叠加在一起,http 包的抽象层级太低,做拦截器、全局错误处理、token 刷新这些功能都得自己造轮子。我最终选了 dio,它内置了拦截器、请求取消、文件上传下载进度、连接超时控制,做这种重 API 依赖的业务能省掉一大半的底层工作。
数据格式方面,后端接口统一走 RESTful JSON。关于这一点我多说一句:如果你能控制后端接口设计,强烈建议所有接口的 response 都包装成统一格式,比如 { code, message, data },这样前端做全局拦截和错误提示会非常顺手。我这个项目的后端虽然也是自研的,但一开始没做统一包装,导致我在前端处理了三种不同的返回结构,浪费了不少时间去兼容。
1.3 整体架构分层与模块划分
这是我最想强调的部分。用 Flutter 开发 OpenHarmony 应用,架构分层是否清晰,直接决定你在 API 集成阶段是轻松还是崩溃。
我的项目架构长这样:
- UI 层:Flutter Widget 构建,负责合同列表、签署页面、实名认证页面的展示;
- 业务逻辑层:用 Provider 做状态管理,处理登录态、签署流程状态、轮询逻辑;
- API 服务层:dio 实例封装,统一的请求入口,处理 token 注入和错误码映射;
- 数据模型层:JSON 序列化和反序列化,
fromJson/toJson手动写,或者用json_serializable生成; - 平台桥接层:MethodChannel 调鸿蒙原生能力,比如获取设备唯一标识、调用系统相机拍照、使用安全存储。
每个模块职责单一,层与层之间通过接口通信。这样做的好处是,API 集成的时候你只需要关注 API 服务层和数据模型层,UI 和业务逻辑基本不用动。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 合同签署 API 的数据模型设计
电子合同签署,核心对象就是"合同"和"签署行为"。我在设计数据模型的时候,参照了行业内比较成熟的电子合同平台的数据结构,再针对自己的业务做了一些裁剪。
合同(Contract)模型的字段设计:
contractId:合同唯一标识,后端生成,UUID 格式;contractName:合同名称,用户可读;templateId:模板ID,对应后端的合同模板;signStatus:签署状态(见下文状态机);initiatorId:发起人ID;signers:签署方列表,数组类型,每个元素包含signerId、signerName、signerType(个人/企业)、signStatus;createdAt和updatedAt:时间戳;fileUrl:签署完成的合同文件下载地址;expireTime:合同过期时间,一般会设置一个有效期。
这些字段在设计的时候有两点要注意:第一,signers 必须是数组而不是单个对象,因为一份合同可能有多个签署方,虽然 MVP 版本只需要双方签署,但为了后续扩展,数据结构上要一步到位;第二,fileUrl 和 expireTime 看似简单,但实际上牵扯到 CDN 地址有效期和自动过期,前端要根据 expireTime 决定是否需要重新获取下载链接,不然用户在下个月点下载却拿到失效链接,体验很糟糕。
签署状态(SignStatus)我设计了以下状态机,待签署、签署中、部分签署、已完成、已拒绝、已过期。
| 状态 | 枚举值 | 含义 |
|---|---|---|
| 待签署 | PENDING |
合同已生成,等待签署方处理 |
| 签署中 | IN_PROGRESS |
部分签署方已完成,还有未完成 |
| 部分签署 | PARTIAL |
部分签署方已完成签署动作 |
| 已完成 | COMPLETED |
所有签署方均已完成签署 |
| 已拒绝 | REJECTED |
某个签署方拒绝签署 |
| 已过期 | EXPIRED |
超过有效期未完成签署 |
前端拿到这个状态值,直接映射到 UI 上展示对应文案和操作按钮。比如 PENDING 状态显示"去签署"按钮,COMPLETED 状态显示"查看合同"和"下载"按钮。
2.2 实名认证与电子签名 API 的安全处理
电子合同的法律效力,前提是签署人身份真实有效。所以实名认证这个环节,绝对不能是走个形式。这块的 API 集成主要涉及两端:
后端接口层面,实名认证通常是调用权威数据源做四要素校验:姓名、身份证号、手机号、银行卡号。前端需要做的,是把用户填写的信息加密传输,避免中间人窃取。我们这里做的是 RSA 非对称加密,后端把公钥下发给前端,前端用公钥加密,后端用私钥解密。
前端代码大致长这样:
dart复制String encryptByPublicKey(String plainText, String publicKey) {
final rsa = RSAEngine();
final publicKeyParser = RSAPublicKeyParser();
final parsedKey = publicKeyParser.parse(publicKey);
rsa
..init(true, PublicKeyParameter(parsedKey));
final cipherText = rsa.process(utf8.encode(plainText));
return base64Encode(cipherText);
}
注意:RSA 加密有长度限制,1024 位密钥最多只能加密 117 字节,所以如果用户输入的明文信息比较长,要先做分段加密或者改用"对称加密 + 非对称加密交换密钥"的方案。
电子签名这块,一般不是要求用户在屏幕上手写签名图片,而是用户通过短信验证码确认意愿之后,后端在合同文件上盖上"等同于手写签名"的电子签章。前端要做的,是调用一个"签署确认"API,把签署人的验证码凭证、合同 ID、签署位置坐标传过去。
2.3 合同文件的上传与下载优化
合同签署过程中涉及两类文件操作:发起合同时上传合同模板,签署完成后下载最终合同。这部分 API 集成的体验优化特别重要,我踩过几个比较明显的坑。
先讲上传。合同模板的源文件,可能是 PDF、Word,也可能是扫描件图片。文件体积从几 MB 到几十 MB 不等。如果直接用普通的 POST 表单上传,在大文件场景下很容易超时。我的做法是改成分片上传:前端把文件切成 2MB 一片,顺序上传,全部上传完成后通知后端合并。
分片上传的核心逻辑:
dart复制Future<void> uploadContractFile({
required String filePath,
required String contractId,
required String uploadUrl,
}) async {
final file = File(filePath);
final fileLength = await file.length();
const chunkSize = 2 * 1024 * 1024; // 2MB
final totalChunks = (fileLength / chunkSize).ceil();
for (var index = 0; index < totalChunks; index++) {
final start = index * chunkSize;
final end = (index + 1) * chunkSize > fileLength
? fileLength
: (index + 1) * chunkSize;
final chunkBytes = await file.readAsBytesSync().then((bytes) {
return bytes.sublist(start, end);
});
final formData = FormData.fromMap({
'chunkIndex': index,
'totalChunks': totalChunks,
'file': MultipartFile.fromBytes(chunkBytes, filename: 'contract.pdf'),
});
await _dio.post(uploadUrl, data: formData);
}
// 通知后端合并分片
await _dio.post('/api/contract/merge', queryParameters: {
'contractId': contractId,
'totalChunks': totalChunks,
});
}
这里有个容易忽略的点:readAsBytesSync 是同步读取整个文件到内存,对大文件来说非常危险,容易 OOM。我建议你用 RandomAccessFile 分段读取,虽然代码会多一点,但内存占用能控制在一个分片大小以内。
下载端我用的也是 dio,直接开下载流写到本地文件,实时刷新进度条。下载完成后用 path_provider 拿到应用文档目录存储,同时写入一条本地记录,下次打开直接从本地读取,不用重复下载。
3. 实操过程与核心环节实现
3.1 开发环境:从模拟器到 RK3568 真机
先介绍我这边的开发环境,方便大家对齐版本:
- 操作系统:Ubuntu 20.04 LTS(macOS 也可以,但 Linux 下适配问题少一些)
- Flutter SDK:Flutter 3.7.12
- OpenHarmony SDK:API 10(4.1 Release)
- Flutter for OpenHarmony:社区版 SDK(基于 Flutter 3.7 fork)
- 开发工具:DevEco Studio 4.1 + Visual Studio Code(Flutter 插件)
- 测试设备:RK3568 开发板
这里多说一句 RK3568 开发板的设备树问题。很多刚接触 OpenHarmony 的朋友会被板子上一堆 dtb 文件整懵。我的经验是:你要先用 dmesg | grep -i model 看清楚你的板子型号对应的芯片配置,然后到 /vendor/etc 确认系统加载的是哪一个 dtb,不要凭感觉选。在 DevEco Studio 里配置设备连接时,用 hdc 命令先 hdc list targets 确认设备在线,再运行应用,可以省掉很多不必要的排查时间。
3.2 在 Flutter 工程中集成 OpenHarmony 平台通道
API 集成这块,纯 Flutter 代码可以跑 API 请求,但要调用系统能力必须走平台通道。我这里举一个调用系统相机的例子,在实名认证环节需要用户拍摄身份证照片。
OpenHarmony 侧代码写在 ets/entryability/EntryAbility.ets 里注册 MethodChannel:
typescript复制import { BusinessError } from '@kit.BasicServicesKit';
import { common } from '@kit.AbilityKit';
import { camera } from '@kit.CameraKit';
const CAMERA_CHANNEL = 'com.example.contract/camera';
registerCameraChannel(context: common.UIAbilityContext) {
this.cameraChannel = rpc.RemoteObject.create(CAMERA_CHANNEL, {
onRequest: (data: object) => {
const method = data['method'];
if (method === 'takePicture') {
// 调用鸿蒙相机能力
this.takePicture(context);
}
return true;
}
});
}
Flutter 侧调用:
dart复制import 'package:flutter/services.dart';
const platform = MethodChannel('com.example.contract/camera');
Future<String> takePicture() async {
try {
final String filePath = await platform.invokeMethod('takePicture');
return filePath;
} on PlatformException catch (e) {
print('调用相机失败: ${e.message}');
return '';
}
}
这里最容易踩的坑是 channel name 不一致。两边必须严格一致,大小写、分隔符都不能有偏差,否则运行时会报 MissingPluginException。我建议你把 channel name 定义成常量放在一个公共文件里,两边都引用它,而不是手动复制粘贴。
3.3 dio 网络层封装与 token 自动刷新
API 集成过程中,网络请求层的封装是整个项目的生命线。一个好的封装应该做到:
- 全局统一注入 token;
- 401 状态码自动触发 token 刷新;
- 业务错误码统一弹出提示;
- 网络异常统一转为友好提示。
我最终实现的 dio 拦截器长这样:
dart复制class TokenInterceptor extends Interceptor {
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
final token = await TokenStorage.getAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
super.onRequest(options, handler);
}
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
if (err.response?.statusCode == 401) {
final isRefreshed = await refreshToken();
if (isRefreshed) {
// 重新放行原请求
final opts = err.requestOptions;
final token = await TokenStorage.getAccessToken();
opts.headers['Authorization'] = 'Bearer $token';
final response = await _dio.fetch(opts);
return handler.resolve(response);
}
}
super.onError(err, handler);
}
}
refreshToken 的实现要注意三个细节:第一,刷新 token 用的不是旧 token 去换,而是用 refresh token 去换;第二,多个请求同时 401 时,要加一个"是否正在刷新"的互斥标志,避免同时发起多个刷新请求;第三,刷新失败要清空本地登录态,跳转到登录页,不能无限重试。
3.4 生成 8 位字符串 /** final code = List.generate(8, (_) => chars[Random().nextInt(chars.length)]).join(); */
我在页面里写了一段生成 8 位随机字符串的工具函数,作为"参会邀请码"或者是"本地操作验证码"使用。实现如下:
dart复制String generateCode({int length = 8}) {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
final random = Random.secure();
return List.generate(length, (_) => chars[random.nextInt(chars.length)]).join();
}
Random.secure() 在生成验证码等安全相关字符串时是必要的,它底层使用的是系统安全随机数源,比 Random() 默认的伪随机数更适合这类场景。把这个函数放在 utils/code_generator.dart 里,可以被业务层多个模块复用。为了防止随机码重复导致脏数据,我在后续调用时还会拼上当前时间的毫秒时间戳。
3.5 完整签署闭环的 API 调用流程
把前面各个模块串起来,一个完整的签署流程在 App 内的 API 调用时序是这样的:
- 用户登录,调用
/api/auth/login,拿到 accessToken 和 refreshToken,本地安全存储; - 进入发起签署页面,填写合同名称,上传模板文件,调用分片上传接口,得到 fileUrl;
- 填写签署方信息,调用
/api/contract/create,传入合同信息和签署方列表,后端生成合同记录,返回 contractId; - 界面跳转到签署详情页,前端轮询
/api/contract/status?contractId=xxx,实时刷新签署状态; - 当前用户确认签署,调
/api/contract/sign,传 contractId、签名验证码、签名位置; - 后端完成签署动作,更新状态为 COMPLETED,返回合同最终文件地址;
- 前端收到完成状态,调用下载接口,把签署后的合同文件存到本地,同时展示签署完成页面。
整个流程走下来,前端需要对接的 API 接口大约 8 个。你要保证每个接口的参数、返回结构、异常分支在联调之前都提前定义清楚,能省掉至少 40% 的联调返工时间。
4. 常见问题与排查技巧实录
4.1 OpenHarmony 上 Flutter 网络请求失败的排查
在 OpenHarmony 真机上跑 Flutter App,网络请求失败是高频问题。我遇到的情况可以归纳为三类:
第一类,请求直接超时。原因是真机上的网络环境跟模拟器不同,访问外网 API 需要走代理或者 HTTPS 证书校验。解决办法是先测试设备能否 ping 通 API 服务器,在手机或板子上用浏览器或命令行工具直接访问接口,排除设备网络问题。
第二类,HTTP 明文流量被拦截。OpenHarmony 对明文 HTTP 流量限制比较严格,如果是调试环境用了 http:// 的接口地址,需要在 module.json5 里配置网路安全策略,允许明文流量。
第三类,证书校验失败。如果 API 服务器用的是自签名证书,Flutter 层的 HttpClient 默认会拒绝。这种情况要么让后端换正式 CA 签发的证书,要么在调试阶段临时跳过证书校验。
注意:生产环境绝对不能关闭证书校验,否则任何中间人都能冒充你的服务器,用户数据等于裸奔。
4.2 轮询接口在 TaskPool 中的调度问题
合同签署状态刷新,我最初是用 Timer.periodic 在 Flutter isolate 里轮询接口。但跑到后面发现,当 App 切到后台再回前台,轮询经常停止,或者偶发崩溃。
排查发现这是因为 Flutter engine 的 isolate 调度和 OpenHarmony 自身的 TaskPool 机制存在冲突。解决办法是:不用 Timer.periodic 做轮询,而是改成"前沿触发",在页面进入前台时立即请求一次,然后刷新倒计时,配合手势下拉刷新兜底。如果确实需要实时性强的推送,建议上 WebSocket 长连接,而不是高频轮询。
4.3 dio 在 OpenHarmony 上偶现的 DNS 解析异常
这个坑比较深。dio 在 OpenHarmony 上偶尔会报 Failed host lookup,但同样的代码在 Android 上没有这个问题。查了很久,发现是 dio 默认走的 dart:io HttpClient 在 OpenHarmony 上对 IPv6 和 DNS 解析的顺序处理跟其他平台不太一样。
我的解决办法是给 dio 底层指定自定义的 HttpClientAdapter,用 IOWebSocketChannel 的方式去适配,或者简单一点的方案是:在 API 服务器的地址配置上,优先使用 IP 直连 + 设置 HOST 头,避免依赖系统 DNS。
如果你不想搞这么复杂,还有一个更朴素的方案:在启动时先 ping 一下 API 地址,把解析出来的 IP 缓存到本地,后续请求全部用 IP += HOST 头 的方式访问。
4.4 本地持久化在 OpenHarmony 上的兼容处理
合同列表、签署状态这些数据,在 OpenHarmony 上做本地缓存,我用的是 shared_preferences 插件。但实际测试发现,shared_preferences 在 OpenHarmony 上的实现依赖的是平台侧 UserDefaults 的能力,签名周期和 Android 不完全一样,偶尔会出现读不到旧数据的情况。
如果你也遇到这种问题,我的建议是:主数据不要只依赖 shared_preferences,合同列表、用户资料这种结构化的数据,优先用数据库,比如 sqflite 或者 drift。shared_preferences 只保存 token、用户 ID 这种轻量 KV 数据。
5. 一些额外想分享的经验
5.1 调试工具链的搭建
OpenHarmony 上调试 Flutter App,跟 Android 最大的区别是没有现成的 logcat 单一入口。我自己的调试工具链是这样的:
- Flutter 层日志:用
debugPrint,在 DevEco Studio 的 Log 窗口能看到; - OpenHarmony 原生层日志:用
hilog,在命令行用hdc hilog查看; - 网络请求日志:dio 的 LogInterceptor 开启后,所有请求和响应都会打到控制台;
- 真机 UI 调试:Flutter Inspector 在 DevEco Studio 里不是特别好使,我经常直接把
debugPaintSizeEnabled打开,在真机上直观地看布局边距。
这套工具链搭好之后,我调试一个 API 集成问题的时间从原来的半天缩短到半小时以内。
5.2 API 联调阶段的模拟数据策略
我强烈建议前端不要等后端全部就绪才开始写 API 集成代码。在联调之前,我做了两件事:
第一,用 json-server 起了一套假 API,数据结构和真实接口完全一致,前端先用假数据跑通整条业务流程;
第二,把所有的接口请求和响应用拦截器打点记录,存成 JSON 文件,方便复现问题。
这样做的效果很明显,真实联调阶段我们只用了两天,就完成所有接口的对接和异常场景覆盖,因为大部分坑在前面已经被提前排掉了。
6. 写在最后的实操心得
这个项目从立项到完成 API 集成,前前后后大概用了六周时间,其中有一半的时间花在"验证 OpenHarmony 和 Flutter 的兼容性"上。如果你也准备走这条路,我的建议是:先跑通最小闭环,不要一上来就设计几十个页面十几个接口;先做"列表页 + 详情页 + 一个完整的签署动作",把网络层、桥接层、安全存储这些基础设施全部打通,再往上面加业务。
API 集成的核心,不只是把接口调通,而是要保证异常分支的体验。网络超时、token 过期、签署状态冲突、大文件下载失败,这些场景如果不在前期就设计好,后期补起来极其痛苦。我在这篇文章里写的很多细节,都是反复踩坑之后才沉淀下来的,希望能帮你绕开这些坑。
