去年我接到一个内部需求:把公司已经跑了一年多的电子合同签署能力,搬一套到鸿蒙设备上。业务方说得轻巧,“就是再出个App嘛”,结果真动工才发现,这活儿完全不是“再来一遍”那么简单——合同签署链路里全是实名认证、模板渲染、签署任务、手写签名图片上传这些重交互逻辑,底层接的又是第三方电子签章服务商的API,客户端要重写、签名链路要重新适配、相册文件网络这些系统能力全得重新过一遍。
最后我们选定的方案是用 Flutter 来做客户端跨端层,跑在 OpenHarmony 上,把 API 集成这层彻底打通。这半年踩了不少坑,也沉淀了一套可以直接复用的套路。今天这篇就把整个实战过程拆开揉碎讲清楚,重点放在 API 集成实现上,从环境搭建、工程结构、Dio 封装、Token 刷新,到手写签名面板、平台通道适配,再到常见问题排查,一次讲透。适合已经有 Flutter 基础、正准备把业务搬到 OpenHarmony 上的团队参考。
1. 项目背景与整体技术方案
1.1 电子合同业务为什么需要跑在 OpenHarmony 上
电子合同签署这个业务,用户流程极重:注册登录、实名认证、查看合同模板、填写合同要素、手写签名、短信验证、签署完成回执。整套流程涉及到大量表单、图片、PDF 预览、网络请求,而且对交互流畅度要求很高——签署过程中手指写的每一个笔画都要实时反馈,延迟稍微大一点,用户体感就非常差。
之前我们只做了 Android 和 iOS 两端,但这两年 OpenHarmony 设备在政企采购、办公场景里越来越多,尤其是 RK3568 这类开发板和部分国产平板设备,客户明确要求合同签署能力必须在这些设备上可用。这类设备有几个特点:屏幕尺寸不统一、系统 API 有差异、WebView 能力相对弱、部分设备没有 GMS,Android 那套方案没法直接搬。
OpenHarmony 原生开发用的是 ArkTS 和 ArkUI,语言和生态跟 Android 完全隔离,如果单独维护一套原生实现,团队成本直接翻倍。所以我们把目光放回到了 Flutter 上——Flutter 的跨端能力在这两年对 OpenHarmony 的支持已经逐渐成熟,同一个 UI 层和业务层代码,可以同时跑在 Android、iOS、OpenHarmony 三端,对我们这种人力吃紧的小团队来说,几乎是最优解。
1.2 框架选型:为什么是 Flutter 而不是 ArkTS 或 uni-app
选型那阵子我们内部开过好几轮会,候选方案就这么几个:原生 ArkTS、uni-app、Flutter。
原生 ArkTS 最大的问题是生态割裂。合同签署里最核心的 PDF 渲染、手写签名采集、图片压缩这类能力,ArkUI 里虽然有基础组件,但成熟度离 Flutter 插件生态还是差了一截。更现实的问题是团队里没人正经写过 ArkTS,从零开始学一套新 UI 框架加新语言,再写完整个签署流程,排期至少多两个月。
uni-app 我们也试过 demo,跑通基本界面没太大问题,但一碰到底层能力,比如自定义高性能绘图(手写签名)、复杂 PDF 渲染、字节流分片上传,就绕不开自己写原生插件,插件的维护成本同样不低。
Flutter 的好处在于:UI 层完全自绘,不依赖系统组件,渲染表现三端一致;Dart 语言团队现成有基础;社区里跟签名、绘图、网络、存储相关的插件可以直接用或改造;再加上 OpenHarmony 官方这两年持续推进 Flutter 兼容层,平台通道(MethodChannel)已经能稳定工作,对上层业务来说基本是透明的。我们最后用一张表把关键维度拉了个对比:
| 对比项 | 原生 ArkTS | uni-app | Flutter |
|---|---|---|---|
| 团队上手成本 | 高,需学新语言新框架 | 低,类 Vue 语法 | 中,需有 Dart 基础 |
| 跨端复用 | 无,只能鸿蒙 | 可复用,但原生能力弱 | Android/iOS/OH 三端复用 |
| 自绘 UI 一致性 | 强但封闭 | 依赖系统渲染 | 完全自绘,三端一致 |
| 底层 API 扩展 | 直接调系统 API | 依赖原生插件 | MethodChannel 可扩展 |
| 合同业务适配 | 需全量重写 | 复杂绘图吃力 | 签名/绘图生态成熟 |
结论很明确:Flutter 是我们这种已有跨端业务、又要新增 OpenHarmony 设备支持的最佳平衡点。
1.3 签署链路与 API 调用全景
在动手写代码之前,我建议先把电子合同的签署链路彻底梳理清楚。别看业务叫“电子合同”,实际上整套流程是多个 API 接口的组合协作。
我们项目的签名服务用的是第三方电子签章服务商,对方提供了一套 RESTful API,客户端全程只做两件事:组装请求、渲染结果,核心的印章合法性、证书链验证、时间戳服务都在服务端完成。整个链路可以划分成五个节点:
第一个节点是实名认证,用户需要提交姓名、身份证号、手机号,服务端调公安库校验,这个环节客户端要处理 322 之类的人脸识别回调,比较复杂。
第二个节点是合同模板,服务商提供了一个模板列表接口,每个模板对应一类合同(比如劳动合同、租赁协议),客户端拿到模板 ID 后,再调一个详情接口,拿到合同要素字段定义(就是让用户填的那些空),动态渲染成表单。
第三个节点是创建签署任务,用户填完表单后,客户端把表单数据和签署位置信息(比如签名落在哪一页的哪个坐标)打包,POST 给服务端,服务端生成一个签署任务 ID。
第四个节点是发起签署,服务端生成一个短链接或取签 URL,通过短信发给其他签署方;当前用户如果就在本机操作,则直接进入签署页面。
第五个节点是签署完成回调,服务端验签通过后,回调我们自己的业务服务器,同时客户端可以轮询任务状态,拿到最终签好的 PDF 下载地址。
从 API 集成的视角看,客户端要对接的接口至少有:认证登录、模板列表、模板详情、创建签署任务、获取签署链接、查询任务状态、手写签名图片上传、合同文件下载,一共 8 个左右。下面所有技术方案都是围绕这 8 个接口展开的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 工程搭建与依赖准备
2.1 Flutter 鸿蒙开发环境配置(照着做一遍)
环境配置这块网上教程不少,但很多讲得含糊,我直接说我们团队实际用下来的方案。
OpenHarmony 侧的编译工具链需要先装 DevEco Studio(选择支持 API 版本较高的版本,因为我们目标 SDK 需要新一些的 API)。DevEco Studio 装完会自带 SDK 管理器,记得把 ohos-sdk 的 default 和 public 两个包都装上,很多人只装 default,结果后面编译 native 工程缺头文件。
Flutter 侧需要拉取支持 OpenHarmony 的 Flutter SDK,不能直接用官方 flutter 主干,因为官方 SDK 默认的 flutter doctor 不认识 OpenHarmony。我们在 Gitee 上维护了内部用的 flutter_flutter 分支,同步官方稳定版代码后,补了 OpenHarmony 平台的构建配置。具体命令:
bash复制git clone https://gitee.com/xxx/flutter_flutter.git -b oh-3.7.12
export PATH=$PATH:$(pwd)/flutter_flutter/bin
flutter doctor
flutter doctor 只要能识别到 OpenHarmony 平台即可。另外建议在 flutter config --enable-ohos 里打开对应的实验开关,不同版本开关名会有差别,可以用 flutter config --list 查一下当前版本支持哪些。这里有个容易踩的坑:环境变量和 DevEco Studio 里配置的 SDK 路径必须指向同一个 ohos-sdk 目录,否则后面创建工程时会报 SDK 版本对不上。
2.2 创建 Flutter + 鸿蒙混合工程
环境就绪后,创建工程的流程跟普通 Flutter 工程几乎一样,只是要额外指定 ohos 平台:
bash复制flutter create --platforms ohos,android,ios --org com.example contract_sign
命令跑完之后,工程根目录下会出现一个 ohos 文件夹,里面就是标准的 OpenHarmony 工程结构:entry/src/main/ets 是 ArkTS 层入口,entry/src/main/resources 放资源文件,build-profile.json5 负责模块和 SDK 版本配置。
我们实际的业务代码大部分放在 lib/ 目录下,纯 Dart 实现,不区分平台。只有跟系统能力强相关的功能(相册选择、文件路径、手写笔压感)才通过 MethodChannel 下沉到 ohos 工程里用 ArkTS 写。
首次运行的时候,需要先确保设备或模拟器连接正常,OpenHarmony 侧 Build 会自行触发,flutter run -d <device> 可以直接跑起来。有一点要特别提醒:OpenHarmony 设备上跑 Flutter debug 包,第一次启动会比较慢,因为要推送编译产物,后面增量构建就正常了。
2.3 网络权限与基础依赖配置
API 集成的第一步不是写请求代码,而是先把网络权限打开。OpenHarmony 的权限模型跟 Android 类似,需要在 ohos/entry/src/main/module.json5 里声明权限:
json复制{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
}
]
}
}
漏配这个权限的表现特别迷惑:Dio 请求发出去,不报 Dns 错误也不报超时,而是直接 Failed host lookup 或者干脆 Connection closed before full header was received,排查老半天最后发现是权限没加。
基础依赖我们选这几样:dio 做网络请求、hive 做本地缓存、provider 做状态管理、path_provider 获取目录。Dio 是目前 Dart 生态里最成熟的网络库,支持拦截器、FormData、下载进度,后面 API 封装全靠它。需要说明的是,OpenHarmony 兼容层对 sqflite 这类强原生依赖的插件支持参差不齐,所以本地存储我首选纯 Dart 实现的 hive,少碰原生插件就少踩一份坑。
3. 核心 API 集成实战
3.1 统一请求封装:Dio 拦截器与 Token 刷新机制
API 集成中最重要的一件事,是把所有请求收敛到一个统一入口。我见过很多团队的项目,网络请求散落在各个页面里,有的用 http 包,有的用 dio,有的直接 HttpClient,一旦服务端调整鉴权策略,全项目都得跟着改,非常痛苦。
我们这边用 Dio 做了统一封装,核心思路是把 baseUrl、超时时间、Token 注入、错误码处理全部收敛到一个 ApiClient 里,业务层只跟对应的 Repository 打交道。
dart复制class ApiClient {
ApiClient({required String baseUrl, required TokenStore tokenStore})
: _tokenStore = tokenStore {
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 30),
));
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await _tokenStore.getAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
options.headers['X-Device-Id'] = await _deviceIdProvider.getDeviceId();
handler.next(options);
},
onError: (error, handler) async {
// 统一处理 401,触发刷新逻辑
handler.next(error);
},
));
}
}
这里有几个细节值得展开。
第一,超时时间要区分接口类型设。合同签署里有个“查询任务状态”的轮询接口,短轮询 3 到 5 秒一次,超时时间设 15 秒就够了;但上传签名图、下载 PDF 这种大文件接口,要单独给长超时,我一般是基于同一个 Dio 实例 clone 一份自定义 Options 传给方法,不全局改。
第二,Token 刷新要考虑并发问题。真实场景下,App 启动后会同时发好几个请求(拉模板列表、拉用户信息、拉签署任务),如果这些请求同时收到 401,你不能让每个请求各刷一次 Token,那样服务端会被打崩溃。正确做法是做一个刷新锁——第一个请求触发刷新,其他请求等待刷新完成后再重放。我直接用 Dart 的 Future 缓存就搞定了:
dart复制Future<String?> _refreshFuture;
Future<String> _refreshToken() {
if (_refreshFuture == null) {
_refreshFuture = _doRefreshToken().whenComplete(() {
_refreshFuture = null;
});
}
return _refreshFuture;
}
第三,所有接口返回的数据结构必须统一。我们约定服务端返回格式是 { code, message, data },Dio 拦截器里先判断最外层 code,非 0 码直接抛业务异常,业务层写起来会清爽很多。
3.2 用户认证与签名鉴权
电子合同 App 的登录跟普通 App 不太一样,除了用户名密码,还必须绑定手机号并完成实名认证,因为合同签署的法律效力建立在签名人身份真实的基础上。
我们这个项目的认证画像是:App 端先调登录接口拿临时 Token,然后调用实名的预校验接口,校验通过后拉起人脸识别 SDK(这部分服务商提供的是 Web SDK,在 OpenHarmony 的 WebView 里跑),识别成功回调后服务端返回正式 Token。
API 集成层面要注意的是请求签名。第三方电子签章服务商为了安全,要求每个请求除了 Bearer Token,还要带上 X-Signature 头,签名算法是:把所有业务参数按 key 升序排列,拼成字符串,加上时间戳和密钥,做 HMAC-SHA256。这个逻辑必须收敛到 Dio 拦截器里,否则每个接口都要手动算一遍签名,又容易写错。
我在实际实现里发现一个坑:时间戳必须跟服务端保持一致,但 OpenHarmony 上有些开发板的系统时间没有同步(尤其是 RK3568 设备,经常是出厂时间),于是签名验不过,服务端返回 400。后来我们的方案是:登录接口成功后,从响应头里读服务端时间,跟本地时间算一个偏移量缓存起来,之后所有签名都基于校正后的时间。
dart复制DateTime get correctedNow => DateTime.now().add(_serverTimeOffset);
这个偏移量还能用来处理另一个问题:Token 过期时间判断。客户端本地不能用 DateTime.now() 直接判断 Token 是否快要过期,因为本地时间可能是错的,统一走校正时间就能避免很多莫名其妙的 401。
3.3 合同模板与详情接口对接
合同模板列表接口本身不复杂,就是一个分页 GET 请求。但它的返回值设计比较特别:模板详情里包含合同要素字段定义,是一个嵌套的 JSON schema,比如 {"fields": [{"key": "partyName", "label": "甲方名称", "type": "text", "required": true}]},客户端要拿着这个 schema 动态渲染表单。
Dart 里处理这种动态表单,我建议不要硬编码,而是写一个轻量的 schema 渲染器。后端返回什么字段类型,前端就渲染对应组件:text 渲染输入框、date 渲染日期选择器、select 渲染下拉选择、attachment 渲染文件上传。这样做的好处是,以后服务端新增合同模板,客户端不用发版就能支持。
接口对接的时候有几个字段需要特别关注:signPositions(签署位置)、signerType(签署方类型:个人/企业)、allowReplaceSigner(是否允许代签)。尤其是 signPositions,它的格式通常是相对 PDF 页面的坐标百分比,比如 {"page": 1, "x": 0.6, "y": 0.8},客户端在展示 PDF 时需要把百分比坐标换算成实际像素坐标,这个过程会用到 PDF 渲染组件,我们后面单独讲。
3.4 创建签署任务与获取签署链接
创建签署任务是整个链路里最核心的 POST 请求,它的 body 结构大致长这样:
json复制{
"templateId": "tpl_10001",
"businessId": "order_20250101_001",
"signers": [
{
"signerId": "user_2817",
"name": "张伟",
"identityNo": "110101199001011234",
"mobile": "13800138000",
"signPositions": [
{"page": 1, "x": 0.62, "y": 0.75}
]
}
],
"formData": {
"partyName": "北京某某科技有限公司",
"amount": "50000",
"date": "2025-06-01"
},
"notifyUrl": "https://our-server.com/notify/sign"
}
这里的 signers 是一个数组,意味着一个合同可以有多个签署方,每个签署方的签名位置可以不同。接口调用成功后会返回一个 signTaskId,后续所有操作(轮询状态、查询详情、撤销任务)都靠这个 ID。
我踩过的坑是:notifyUrl 必须配置在我们自己的业务服务器上,而且回调地址必须是公网能访问的 HTTPS 地址。开发初期我们图省事,本地起了一个内网服务,结果回调请求根本打不进来,导致服务端永远收不到签署完成的异步通知,状态一直停在“签署中”。
获取签署链接的接口相对简单,传 signTaskId,返回一个短链接。有意思的是,大多数第三方服务商的取签链接在 PC 浏览器、手机浏览器打开后的交互完全不同,手机浏览器打开会进入 H5 签署页,PC 打开会进入扫码页。我们的 App 里需要判断当前环境,如果在 App 内部打开,直接跳转到 Flutter 的 WebView 页面去加载这个链接;如果是在 OpenHarmony 的浏览器环境里打开,可以直接调起我们 App(通过 URL Scheme)。
3.5 手写签名面板与签名图片上传
手写签名是电子合同 App 的重头戏,也是我当时最担心的部分——担心 Flutter 在 OpenHarmony 上手写笔迹会有延迟,毕竟签名对跟手度要求极高。实际跑下来,Flutter 的 CustomPainter 在 OpenHarmony 上的渲染性能是够用的,基本能做到笔画即时响应。
我的实现思路是:用一个 GestureDetector 监听 onPanStart、onPanUpdate、onPanEnd,把手指移动的坐标点收集到一个 List<Offset> 里,每次收到新点就调用 setState 重绘。为了画出来平滑,我不直接用折线连接(那样会有明显折角),而是用二次贝塞尔曲线把相邻点之间做平滑:
dart复制class SignaturePainter extends CustomPainter {
SignaturePainter({required this.points, required this.color, required this.width});
final List<Offset> points;
final Color color;
final double width;
@override
void paint(Canvas canvas, Size size) {
if (points.isEmpty) return;
final paint = Paint()
..color = color
..strokeWidth = width
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke;
final path = Path();
path.moveTo(points.first.dx, points.first.dy);
for (int i = 1; i < points.length - 1; i++) {
final mid = Offset(
(points[i].dx + points[i + 1].dx) / 2,
(points[i].dy + points[i + 1].dy) / 2,
);
path.quadraticBezierTo(points[i].dx, points[i].dy, mid.dx, mid.dy);
}
if (points.length > 1) {
path.lineTo(points.last.dx, points.last.dy);
}
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant SignaturePainter oldDelegate) =>
oldDelegate.points != points || oldDelegate.color != color;
}
签名完成后,要导出成图片上传。这里用 RepaintBoundary 包住 CustomPaint,通过 boundary.toImage(pixelRatio: 2) 生成高清 PNG,再转成字节流上传。有一个现实问题:手写签名图片默认带白色背景,但合同签署系统要求的是透明背景 PNG,这样盖章才能叠印到 PDF 上。处理办法是拿到 ui.Image 后,用 Canvas 先画原图,再用 BlendMode.clear 把白色像素擦掉,具体代码略长,核心就是把白色像素点的 alpha 通道置 0。
图片上传我用 Dio 的 FormData:
dart复制Future<String> uploadSignatureImage(Uint8List bytes) async {
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(bytes, filename: 'signature.png'),
'signTaskId': signTaskId,
});
final resp = await _dio.post('/file/upload', data: formData);
return resp.data['fileUrl'];
}
这里有两个细节:一是 Content-Type 不要手动指定,让 Dio 自己生成带 boundary 的 multipart 头,手动指定会服务端解析失败;二是上传前一定要压缩,如果原始图片超过 2MB,服务端大概率会拒收,我一般在客户端先用 package:flutter_image_compress 压到 1MB 以内再传。
4. 平台通道与鸿蒙原生能力适配
4.1 MethodChannel 在鸿蒙端怎么注册
API 集成不光是网络请求,还涉及一堆系统能力调用。Flutter 官方插件生态在 OpenHarmony 上兼容度还不是 100%,很多插件要么没有鸿蒙实现,要么实现质量参差。这时候就需要自己通过 MethodChannel 写鸿蒙原生代码。
以获取应用版本为例,Flutter 侧的写法跟 Android/iOS 完全一致:
dart复制static const MethodChannel _channel = MethodChannel('com.contract.sign/device');
Future<String> getAppVersion() async {
final version = await _channel.invokeMethod('getAppVersion');
return version as String;
}
鸿蒙侧的写法是用 ArkTS 实现 MethodChannel 的 handler。在 entry/src/main/ets 里创建入口类,注册通道并实现方法:
typescript复制import { MethodChannel, MethodCall, MethodResult } from '@ohos/plugin';
export class DeviceChannel {
private channel: MethodChannel;
constructor(engine: any) {
this.channel = new MethodChannel(engine, 'com.contract.sign/device');
this.channel.setMethodCallHandler((call: MethodCall, result: MethodResult) => {
if (call.method === 'getAppVersion') {
result.success(device.getValueSync(device.DisplayId.APP_VERSION));
} else {
result.notImplemented();
}
});
}
}
一些细节需要注意:MethodChannel 的 name 必须跟 Flutter 侧完全一致,大小写都不能错,否则 invoke 时会报 MissingPluginException。还有 @ohos/plugin 这个包是 Flutter 鸿蒙适配层提供的,通过 DevEco Studio 的依赖管理引入,不同 Flutter 版本对应的包版本不一样,最稳妥的做法是参考 ohos 工程模板里自带的版本。
4.2 文件选择、相册与系统目录适配
合同签署里需要用到合同附件上传(比如用户上传身份证照片、营业执照扫描件),这就要调系统相册或文件管理器。
在 Android 上大家习惯用 image_picker、file_picker 这类插件,但在 OpenHarmony 上,这两个插件不一定有可用的原生实现。我们把文件选择封装到了鸿蒙侧:Flutter 调 MethodChannel,ArkTS 侧用系统意图拉起相册,选完后把文件的沙箱路径返回给 Flutter,Flutter 再通过路径读取字节流上传。
值得注意的是,OpenHarmony 的文件权限管控比较严格。如果只是选一张相册图片并用到当前应用沙箱内,通常不需要额外申请存储权限;但如果要读取其他应用的私有目录,就必须走 requestPermissions 申请 ohos.permission.READ_MEDIA 或者文档类权限,否则返回的 URI 直接用 File 读取会抛 Permission denied。
另外,path_provider 插件在 OpenHarmony 上的实现目前还比较初级,获取到的缓存目录可能是空路径。我们内部的做法是:不依赖 path_provider,而是在鸿蒙侧用系统 API 拿到 App 沙箱目录后,通过 MethodChannel 一次性传给 Dart 层,缓存成一个全局单例,后续所有文件读写都基于这个路径拼。
4.3 事件回调:签署状态实时监听
合同签署到一半,用户可能会切到系统浏览器去接收短信验证码,再切回来的时候,签署状态可能已经从“签署中”变成了“已完成”。如果客户端一直靠轮询去拉状态,不仅浪费流量,还会有几秒到十几秒的延迟。
更好的方案是让服务端主动推。第三方服务商会把签署完成事件通过 notifyUrl 推给我们自己的业务服务器,业务服务器再把事件通过 WebSocket 或者 Server-Sent Events 推到客户端。客户端在 Flutter 层用 web_socket_channel 包接到消息后,直接刷新当前页面状态,体验非常顺滑。
有些场景下服务端的推送链路可能不可用(比如业务服务器宕了),离线兜底还是得要轮询。我们实现了一套组合策略:WebSocket 连通时,收到推送立刻刷新;WebSocket 断开时,退化为每 10 秒轮询一次任务状态。两套逻辑都收敛在同一个 SignStatusRepository 里,UI 层只关心 Stream<SignStatus> 的变化。
这个方案的代价是:需要同时维护 WebSocket 的连续性和轮询的定时器生命周期,dispose 处理不当会有内存泄漏。我在页面销毁时一定会取消订阅、关闭 socket:
dart复制@override
void dispose() {
_statusSubscription?.cancel();
_timer?.cancel();
_wsChannel?.sink.close();
super.dispose();
}
因为 OpenHarmony 上应用退到后台后,系统可能很快挂起进程,Socket 断开的时机跟 Android 有差异,所以心跳检测(心跳包超时就重连)必须做,不然用户切后台再回来后,状态推送早就断了。
5. 常见问题排查与性能优化
5.1 API 对接高频报错速查表
API 集成过程中遇到的报错,五花八门,但大部分都能归结到下面这几类。我整理了一份速查表,按现象对号入座,排查效率会高很多:
| 现象 | 大概率原因 | 排查方向 |
|---|---|---|
| 请求发出去收不到响应,直接超时 | OpenHarmony 设备网络权限未声明 | 检查 module.json5 是否有 INTERNET 权限 |
| 服务端返回 400 Bad Request | 请求签名校验失败,或参数格式不对 | 检查时间戳偏移量、签名算法、JSON 字段名 |
| 偶发 401,刷新 Token 后又好 | Token 并发刷新竞争 | 检查刷新逻辑是否有 Future 缓存锁 |
| 上传文件一直 503 | 文件过大或图片含 EXIF 信息异常 | 先压缩,再看服务端文件大小限制 |
| WebSocket 频繁断连重连 | 心跳包机制缺失或心跳频率不对 | 添加应用级心跳,间隔建议 20-30 秒 |
| 下载 PDF 到本地后打不开 | 路径拼接错误,或 Context 目录不对 | 用鸿蒙侧返回的沙箱路径,不要手拼路径 |
| 签名图片上传后背景不透明 | 图片导出时没做 alpha 通道处理 | 用 BlendMode.clear 擦除白色背景 |
这些坑一半以上都是我们真实踩过的。尤其是那个 400 签名错误,排查过程最折磨人,因为服务端返回的 message 只有“sign invalid”几个字,根本不知道是参数排序错了还是时间戳偏移算错了。后来我在拦截器里加了一个 debug 模式,把完整签名串、时间戳、参数列表全部打印出来,才定位到是时间偏移的问题。
5.2 Flutter + OpenHarmony 运行时坑点
运行时的问题往往比 API 层更隐蔽。我挑三个印象最深的说。
第一个是文本输入框的输入法弹层问题。OpenHarmony 上 Flutter 的 TextField 聚焦时,自绘 UI 和输入法面板之间的联动偶尔会失灵,表现为键盘弹起来把页面顶上去之后又立刻弹回来,导致输入框被遮挡。我们后面统一改用 Scaffold 的 resizeToAvoidBottomInset 配合手动 MediaQuery 调整,在 OpenHarmony 上最稳定。
第二个是 WebView 的兼容性。第三方实名人脸识别用的是 H5 方案,在 OpenHarmony 的 WebView 里跑,getUserMedia(摄像头权限)的适配各家服务商不一样,有些识别人脸的 SDK 直接无法在鸿蒙 WebView 里完成摄像头采集。这个坑我们绕了挺久,最后只能换路线:在鸿蒙侧用原生相机权限拍一张人脸照片,上传给服务端走“照片核身”模式,绕开 WebView 的摄像头限制。
第三个是内存抖动。OpenHarmony 设备(尤其是 RK3568 开发板)内存普遍偏小,Flutter 的 debug 模式跑起来非常卡,release 模式会好很多。但 PDF 预览组件加载大文件时,仍然会偶发 OOM。我们的做法是把 PDF 转成图片列表后分页加载,而不是一次把所有页面全部渲染出来,内存占用能降一半以上。
5.3 编译产物与包体积优化
OpenHarmony 上 Flutter App 的编译产物比 Android 大不少。我们第一次打出 release 安装包是 180MB,对一台只有 32GB 存储的开发板来说很不友好。
优化最有效的是这几招:第一,LLVM 编译开关加上,能省下大约 15% 的产物体积;第二,--split-debug-info 把 debug symbol 分离出去,这部分体积在 release 包里本来就不该有;第三,去除用不到的 Flutter 字体和国际化 locale,我们只保留中英文;第四,图片资源全部走 WebP 格式。
优化后的安装包大概 120MB,虽然还是不小,但已经能接受了。顺带提醒一句:OpenHarmony 的安装包在系统里会占用约两倍空间解压,所以实际占用的存储远大于安装包体积,给用户文档里要写清楚最低存储要求。
5.4 一个容易被忽略的细节:返回键和路由栈处理
电子合同签署流程是多步操作:模板选择 → 表单填写 → 签名 → 短信验证 → 完成页。用户在第三步签名的时候按了系统返回键,不能直接退出整个 App,而是应该回到表单页,但 FormData 不能丢。这块在 Flutter 里实现不复杂,用 PopScope 控制返回行为就行,但 OpenHarmony 的系统返回键事件在某些设备上会有延迟触发的问题,导致快速连按时路由栈跳乱了。
我们的解法是在 Flutter 层用一个全局的 RouteGuard 拦截返回事件,加了 300ms 的去抖,避免连按触发两次 pop。实测下来,在 RK3568 这类性能不太强的设备上,这个保护非常有效。
6. 写在最后:一些个人体会
这套电子合同签署的 Flutter + OpenHarmony 方案从立项到上线,前后大概花了四个月。如果让我重新做一次,有几个决定会做得更早一点:一是环境搭建阶段就应该把 Flutter 鸿蒙分支锁定成固定版本号,不要老是升版,中间因为 Flutter SDK 自动升级导致的适配问题,浪费了差不多一周;二是签名图片的透明背景处理方案应该更早定下来,早期我们传白底 PNG,服务端验签后虽然在 PDF 里能显示,但盖到合同文字上会遮住内容,后来才改的透明通道。
对于想在 OpenHarmony 上做 Flutter 业务的团队,我的建议是:先把 API 集成这层做深做透,网络封装、Token 刷新、统一错误处理这些基础能力一定要扎实,因为后面所有业务页面都依赖这层。至于那些花哨的 UI 效果,反而可以放一放,等主流程通了再慢慢补。
还有一个小技巧:开发阶段一定保留一份“无网络依赖”的 Mock 数据层,把所有接口响应都做成可切换的假数据。这样 UI 开发和 API 联调可以完全并行,而且遇到第三方服务商挂掉的时候,开发节奏也不会被阻断。我们团队靠这个 Mock 层,在测试环境不稳定的那两周,照样把签署流程的 UI 迭代全部做完了。
