先说下背景。我和团队最近在做一个面向政务和中小企业的电子合同签署App,目标平台从Android和iOS扩展到了OpenHarmony设备上。技术栈选了Flutter,核心诉求是快速签署:用户打开一份合同,看完内容,手写签名,几秒内完成,然后生成一份带签名的PDF回传。整个链路听起来不复杂,但真正把Flutter跑在OpenHarmony设备上、再把合同渲染、签名采集、签名合成这几件事串起来时,踩坑数量远超预期。这篇文章把我这一路的环境配置、技术选型、核心模块实现和排错过程都记下来,给准备在这个组合上做应用的人趟个路。
1. 为什么这个时间点可以做OpenHarmony上的签名应用
1.1 Flutter与OpenHarmony的适配现状
先说结论:OpenHarmony官方目前对Flutter的支持,来自OpenAtom基金会和华为共同维护的flutter_flutter分支,对应OpenHarmony主版本持续迭代。当前做应用选型时,我建议直接使用为OpenHarmony适配的Flutter SDK,而不是从原生Flutter SDK随便拉个版本。原因很简单:OpenHarmony的图形栈、Ability生命周期、插件注册机制都和Android有差异,官方适配分支针对这些差异做了大量patch,比如渲染层对接了OHOS的Surface、gesture事件适配了鸿蒙的输入分发通道。
我实测下来,适配分支提供的Flutter版本目前主要围绕3.7.x和3.22.x等几个序列在滚动。3.7.x胜在稳定、插件兼容性好,但Dart语言特性旧一些;3.22.x版本新,对OpenHarmony API 11+的适配更完整,如果你的合同中涉及大量Canvas绘制和协程异步,建议直接上3.22.x。我们最终定了3.22.x这条线,原因是手写签名需要高频的PointerEvent处理,新版Dart和Flutter在事件合并和手势竞技场上的优化更明显。
另外一个关键点是插件机制。OpenHarmony的Flutter插件不能直接复用Android的AAR,需要编译成ohos的HAR包。很多常用插件在OpenHarmony上都有对应的适配版本,比如shared_preferences、path_provider、permission_handler,但如果某个Android插件没有OpenHarmony实现,flutter pub get时插件解析会直接报错,需要你拿到源码自行适配。这是整个项目选型时最大的变数,建议动手前先梳理一遍依赖树。
1.2 电子合同签署场景对技术栈的真实要求
回到我们具体的业务。电子合同签署App在客户端的核心链路是:合同内容的远端拉取、本地展示、签名采集、签名合成、文件回传。这条链路对技术栈有几个硬性要求。
第一是文档渲染能力。合同文件最常见的格式是PDF和富文本HTML。PDF在Flutter端需要能多页滚动、缩放查看;HTML则要求不乱版、图片和字体能正常加载。这两项在Android上都有成熟方案,但在OpenHarmony上,部分底层依赖了原生渲染的插件会失效。
第二是手写输入的流畅度。签名是高频采集过程,手写面板要支持连续笔画、笔锋效果、撤销清空和延迟低。Flutter的GestureDetector和CustomPainter可以完成这一目标,但需要处理好采样频率和重绘范围。
第三是签名与合同内容的合成。快速签署场景下,我们不能让用户等太久。方案需要保证签名位置相对合同不变,导出图片或PDF时清晰度达标。
第四是工作流状态管理。快速签署不只是画一个签名,还包括合同草稿、签署位置模板、多人签署顺序、签署完成状态等。这些用Flutter的状态管理框架做起来不复杂,但需要协议先定清楚。
这些要求放在Android上没什么难度,但叠加了OpenHarmony后,每个环节都要做一轮兼容性验证。下面我按项目推进的先后顺序,把每个模块的实践细节和踩坑记录写下来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境搭建:版本、镜像、Gradle和hdc这一堆看似简单的事
2.1 OpenHarmony版Flutter SDK的安装与版本对应
环境准备是第一个坑。OpenHarmony的Flutter SDK不是通过flutter sdk直接下载的,而是需要从flutter_flutter分支的对应OpenHarmony版本拉取。具体操作上,你所在的团队如果已经有OpenHarmony SDK,建议直接找一个和当前SDK版本匹配的flutter_flutter分支。这里有个要点:分支的版本和OpenHarmony系统版本是强对应的,比如默认用OpenHarmony API 11的设备,Flutter分支版本需要在支持API 11的适配分支上,否则编译出来的hap包在真机上运行时会出现渲染白屏或者无法拉起Ability。
环境变量配置好后,flutter doctor要看是否识别Ohos平台。如果命令里没有Ohos相关输出,多半是Flutter SDK分支不对或者环境变量指向了原生Flutter SDK。别在这个问题上死磕,直接检查Git分支和PATH。
另外一个容易忽略的点是镜像仓库。由于OpenHarmony的Flutter引擎和插件包都放在特定仓库,很多依赖下载会走存储地址。热词里提到"storage.flutter-io.cn"这类国内镜像,如果在网络环境受限的办公网里,需要把PUB_HOSTED_URL和FLUTTER_STORAGE_BASE_URL都指到国内镜像,否则pub get会卡死。
2.2 从Android项目迁移时的Gradle问题
我们是先有了Android版本,再往OpenHarmony迁移的。迁移过程中最常见的一类报错就是热词里的那句:"You are applying Flutter's main Gradle plugin imperatively using the apply script",以及"Error resolving plugin [id: 'dev.flutter.flutter-plugin-loader', version: ...]"。
这类问题的根因是Android项目里settings.gradle和build.gradle对Flutter插件的加载方式,和OpenHarmony工程的声明式加载方式不一致。OpenHarmony适配后,Flutter插件通过依赖管理加载,不再执行apply script脚本。解决办法是打开项目中的settings.gradle和根build.gradle,找到apply script相关的行,删除或注释掉,然后改用flutter plugin-loader的声明式依赖。如果是全新创建的OpenHarmony Flutter工程,一般不会遇到这个报错;但凡是从已有的Android工程改造过来的,基本都要动这一刀。
同样常见的还有插件解析失败:某个Flutter插件在pub.dev上存在,但OpenHarmony工程里没有对应实现。这时候构建日志会在"flutter-plugin-loader"的报错前提示缺少哪个插件。我们遇到的是某个定位插件没有ohos实现,最后用源码方式把插件的android目录替换为ohos目录并实现PlatformInterface才通过。
2.3 hdc、DEVUdid与真机签名配置
真机调试这一环,OpenHarmony用的是hdc而不是adb。热词里专门提到"hdc 查看 openharmony 系统版本 param get",这条命令非常实用。获取设备信息可以用:
code复制hdc shell param get const.product.name
hdc shell param get const.product.model
hdc shell param get const.product.version
我在RK3568和RK3588两套开发板上都跑过这套流程,hdc的稳定性比早期强不少,但偶尔会遇到设备离线。离线时先执行hdc list targets确认连接状态,再执行hdc kill和hdc start重启服务端,基本能解决。
签名问题同样需要注意。OpenHarmony的hap包支持自动签名和手动签名。用DevEco Studio开发时,会自动配置签名证书,生成deveco.p12、deveco.cer和deveco.p7b。命令行构建时,需要把signingConfigs放进项目build-profile.json5里。如果签名的profile文件过期或证书不匹配,hdc install时会直接失败,并且报错信息不会特别明显,我一度以为是包没打出来。
3. 合同展示层:PDF渲染与富文本HTML的适配实录
3.1 合同文件在App里的两条呈现路径
电子合同的格式现在基本是两派。一派是PDF,适合正式协议的定版归档;另一派是富文本HTML,适合动态生成的合同模板,比如企业信息填充、表单联动。
PDF的优点是排版不会乱,缺点是在Flutter生态里可用的插件有限。富文本HTML则反过来,样式灵活但渲染引擎的兼容性要求高。我们在OpenHarmony上的策略是双轨并行,根据合同类型在打开时选择对应的渲染组件。
这里特别提醒一点:不要试图用WebView组件去渲染PDF或长合同。OpenHarmony的Web组件虽然存在,但Flutter插件与WebView的通信桥接在部分设备上有内存抖动问题,合同内容多时会明显卡顿。我们的实测结论是,能用原生渲染尽量用原生渲染,不要绕道WebView。
3.2 PDF插件在OpenHarmony上的可用性评估
我先后试了syncfusion_flutter_pdf、pdfrx和原生的pdf_render三套方案。
syncfusion_flutter_pdf的PDF查看器组件功能最全,支持手势缩放、文本选择和页面缩略图,但它的依赖里有些代码路径会走Android的PdfRenderer或iOS的PDFKit,迁移到OpenHarmony后虽然有部分实现,但稳定性还在打磨。我们在真机上用它的PdfViewer预览一份3MB的合同,翻页时偶尔出现灰色块,需要手动缩放触发重绘。
pdfrx是基于Pdfium二次封装的库,渲染性能非常不错,但依赖的是so库,OpenHarmony上则需要单独编译ohos版本的so。如果你们团队有NDK或Native编译经验,可以走这条路;否则不建议在快速出活的项目里碰。
我们最终采用的是自研加轻量依赖结合的方式:合同预览用某个支持OpenHarmony的PDF渲染组件,如果后端能把合同转成图片流,就直接用PageView加载图片,省掉PDF渲染的兼容成本。这个方案对快速签署场景性价比最高,因为合同预览的主要目的是让用户确认内容,而不是做PDF编辑器。
3.3 富文本合同的渲染与字体处理
富文本HTML在客户端渲染,我用的方案是flutter_widget_from_html_core。它在OpenHarmony上适配得不错,因为它最终是把HTML解析成Widget树,不走WebView,所以天然跨平台。
但有三个细节需要处理。第一是字体:HTML里定义的font-family在OpenHarmony上不一定存在,需要把常用字体文件注册到Flutter的FontLoader里。我们踩到过一个坑,合同模板里用了楷体,在Android上是正常显示的,到了OpenHarmony设备上全部变成系统默认字体,公章样子都变了。第二是富文本里的图片,加载路径是外网时要注意网络权限和HTTP明文限制;第三是列表和表格样式,部分CSS属性在core版本里不支持,需要提前让后端同事把合同模板改成内联样式,不要引外部CSS文件。
章节号、段落缩进这些细节,在HTML渲染时也容易错位。建议在测试阶段就把常用合同模板跑一遍,和后端确认哪些标签是禁用的。
4. 手写签名面板:从画一条线到真正能用的签名板
4.1 GestureDetector加CustomPainter的底层实现
手写签名面板是快速签署的核心交互。实现上,我用GestureDetector的onPanStart、onPanUpdate和onPanEnd来采集手指或手写笔移动轨迹,用CustomPainter绘制笔画。
核心数据结构是List<List
dart复制class SignaturePainter extends CustomPainter {
SignaturePainter({required this.strokes, required this.penColor})
: super(repaint: strokes);
final ValueNotifier<List<List<Offset>>> strokes;
final Color penColor;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = penColor
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke
..strokeWidth = 4.0;
for (final stroke in strokes.value) {
if (stroke.length < 2) {
continue;
}
final path = Path()..moveTo(stroke.first.dx, stroke.first.dy);
for (int i = 1; i < stroke.length - 1; i++) {
final mid = Offset(
(stroke[i].dx + stroke[i + 1].dx) / 2,
(stroke[i].dy + stroke[i + 1].dy) / 2,
);
path.quadraticBezierTo(
stroke[i].dx,
stroke[i].dy,
mid.dx,
mid.dy,
);
}
canvas.drawPath(path, paint);
}
}
@override
bool shouldRepaint(covariant SignaturePainter oldDelegate) =>
oldDelegate.strokes != strokes;
}
4.2 笔锋优化:用速度和压力模拟真实手写
如果只是简单地在每个触点之间连直线,用户写出来的字会显得生硬。我在项目里做了两个层面的优化:一是二次贝塞尔曲线平滑,让线段转折处不那么突出;二是笔画宽度随速度变化,模拟真实笔锋。
具体思路是:记录最近两三帧的采样点,计算点间距离和时间差,得到移动速度。速度快时笔画细,速度慢时笔画粗。这个逻辑需要放在手势回调里,不能用自定义Painter的paint方法去计算,因为paint方法在每帧都会执行,而手势回调才有事件时间戳。
代码大致是这样:
dart复制void _handlePanUpdate(DragUpdateDetails details) {
final local = details.localPosition;
final now = DateTime.now().microsecondsSinceEpoch;
if (_lastTime != 0) {
final dt = now - _lastTime;
final distance = (local - _lastPoint).distance;
final velocity = distance / dt; // 单位:像素/微秒
_currentWidth = (4.0 - velocity * 2000).clamp(1.5, 6.0);
}
_lastPoint = local;
_lastTime = now;
_currentStroke!.add(_lastPoint);
_strokes.value = [..._strokes.value];
}
注意限制笔锋宽度的上下界,太细的线条在低分辨率设备上看不清,太粗又不像签字。1.5到6.0像素这个区间,在多数设备上视觉上比较舒服。如果用户用触控笔,可以进一步读取pressure值来调整宽度,但需要引入更多原生侧的信息,价值与成本要权衡。
4.3 手势冲突、重绘性能与签名清晰度
手势冲突是另一个容易踩的坑。签名面板如果放在ScrollView或PageView里,onPanUpdate会被父级手势竞技场拦截,出现"第一笔总是画不上去"的问题。解决办法是把签名面板包在GestureDetector里,设置behavior: HitTestBehavior.opaque,必要时给父级滚动组件的physics设置为NeverScrollableScrollPhysics,当签名区域激活时禁用滚动。
重绘性能上,之前提到用ValueNotifier来局部刷新,但这还不够。触控屏的采样率普遍是120Hz到240Hz,onPanUpdate回调频率极高,每帧都触发一次通知和重绘,低端设备会发热。我做了降采样:每收到3到5个点才触发一次重绘,中间的点只追加到数据里,绘制时统一处理。肉眼基本看不出差异,帧率却稳定了很多。
签名清晰度方面,最后导出签名图时,不要直接用画布的逻辑分辨率。我用RepaintBoundary的toImage,把pixelRatio设为3.0甚至4.0,这样签名图在合同PDF里放大也不会发虚。这部分的详细合成方案放在下一节说。
5. 签名合成与合同输出:UI层叠加和数据层合成怎么选
5.1 两种合成方案的技术对比
拿到用户签名之后,要解决的问题是:把签名放到合同的指定位置,并导出一份带签名的合同文件。
方案A是UI层叠加法:在合同预览上方叠加一个Positioned的签名图层,然后对整个合同区域截图,得到一张带签名的图片。方案B是数据层合成法:记录签名在合同上的坐标和页码,提交给后端或本地PDF库,在PDF上绘制签名并导出新PDF文件。
| 对比项 | UI层叠加截图 | 数据层PDF合成 |
|---|---|---|
| 实现成本 | 低,主要用Stack和RepaintBoundary | 高,需要操作PDF对象 |
| 所见即所得 | 强,用户看到什么导出什么 | 弱,需要校验坐标映射 |
| 清晰度 | 受截图倍率影响,可调 | 高,矢量绘制 |
| 后端合规 | 需要图片和坐标一起回传 | 可直接回传PDF |
| OpenHarmony适配 | 纯Flutter,适配风险低 | 依赖PDF库,适配风险高 |
在快速签署场景下,我最终选择了方案A为主、方案B为辅。方案A的好处是纯Flutter实现,OpenHarmony上不会有插件适配问题,且用户体验一致:用户预览时看到的就是最终效果。方案B用于服务端归档:客户端生成一张签名图片和一套坐标数据,由服务端把签名合成到原始PDF上,保证原始文件的完整性和防篡改能力。
5.2 RepaintBoundary截图的关键实现
UI层叠加的方案里,最关键的一段代码是用RepaintBoundary包住整个合同展示区和签名层,然后通过GlobalKey拿到RenderRepaintBoundary对象,调用toImage导出图片。
实现要点有两个。第一是pixelRatio设置,我在导出时用MediaQuery.devicePixelRatio的三倍值,确保截图在各端都清晰。第二是要处理滚动偏移:如果合同内容在SingleChildScrollView里,RepaintBoundary只能截取当前可见区域,还是整个内容?这里需要注意,RepaintBoundary渲染的是其boundary内的完整内容,不受父级滚动影响,但需要确保boundary本身没有被clip。操作时,我把RepaintBoundary放在了ScrollView内部,这样导出时需要注意屏幕外的内容可能因为懒加载没有被绘制,所以导出前要触发一次完整的布局和绘制,必要时用ScrollController把内容滚动一遍后再导出。如果你用的是PageView逐页加载合同图片,建议按页导出,再拼接成一张长图或提交服务端合成。
签名坐标的映射也一样要注意。签名图片在屏幕上的位置,要除以合同的缩放比例,再换算成合同原始坐标。我在服务端协议里定义了一个signatureInfo对象,包含页码、x、y、width、height、签名图base64。所有坐标都以合同原始像素为准,客户端只负责转换,服务端不关心屏幕分辨率。
5.3 多签署区坐标映射与合同页面滚动冲突
真实合同不止一个签署位,经常是乙方签章一个位置,日期一个位置,甚至有骑缝章。快速签署场景要处理的是"一次签名,多个签署位复用",或者"多个签署位,顺序批量签"。
多签署位定位我采用模板匹配:每个合同模板里预埋了签署区的相对坐标,合同加载时,根据当前展示的页码和缩放比例,把签署区渲染到页面上。签名完成后,分别记录每个签署区对应的签名图坐标和原始合同坐标。这里最复杂的地方在于坐标基准:屏幕上的Point是一个逻辑分辨率坐标,而合同原始坐标是PDF设备无关坐标。转换公式如下:
dart复制// 获取合同原始尺寸与显示尺寸的比例
final scaleX = originalWidth / renderedWidth;
final scaleY = originalHeight / renderedHeight;
// 屏幕坐标换算为合同原始坐标
final originalX = screenOffset.dx * scaleX;
final originalY = screenOffset.dy * scaleY;
如果在合同页面上做了缩放,需要再把缩放比例乘进去。我在这个环节踩过一次坑:合同渲染组件内部默认做了fit缩放,我在转换坐标时忘记了这层缩放,导致服务端把签名贴到了完全错误的位置。后来统一封装了一个CoordinateConverter,所有坐标转换都走它,问题彻底解决。
6. 快速签署的流程设计:模板、草稿、并发三件套
6.1 签署位置模板化,大幅减少用户操作
快速签署的"快"不只是靠流畅的手写面板,更要靠流程优化。我们做的最有价值的一步是签署位置模板化。
合同类型在上传时就会被识别并打上模板标签,比如"采购合同""劳动合同""保密协议"。每个模板在服务端维护一个签署位置集合,包含甲方签字、乙方签字、签署日期等字段的位置。客户端渲染合同时,直接根据模板把签署区域标注出来,用户可以点击任意空位进入签名。
这个设计让用户不用来回缩放找签字位置,打开合同后直接看到"待签署"的标识,点一下、写一笔、自动保存,整个流程稳定控制在10秒以内。对高频用户来说,这个体验提升是立竿见影的。
6.2 草稿续签与签署流程状态机
快速签署还有一个容易被忽略的需求:用户写到一半被电话打断,或者发现签名写歪了想重来。我们的处理是引入草稿机制和流程状态机。
签名过程中,每一笔的stroke数据都会缓存到内存中,并在签名完成时异步保存到本地文件。如果用户在签署流程中被中断,重启App后可以从草稿箱恢复,笔画数据重新渲染到签名面板。这比只能重新画一遍的体验好太多。
流程状态机我定义了几个状态:DRAFT_MARKING(合同模板解析中)、READY_TO_SIGN(待签署)、SIGNING(签名中)、SIGNED_AND_UPLOADING(签署完成上传中)、UPLOAD_FAILED_RETRYABLE(上传失败可重试)。每个状态都绑定了对应的交互约束,比如SIGNING状态下禁止退出页面,UPLOAD_FAILED状态下提供重试按钮而不是回退到上一页。状态管理我用了Riverpod,它对这类状态机的表达力很强,和OpenHarmony的适配也没有额外问题。
6.3 并发批签与本地资源释放
政务场景里经常出现一个用户同时签好几份合同的情况,快速签署更要支持"签完一份,立刻下一份"的连续操作。我们做了并发批签:用户在一份合同上签好名后,同一个签名图片会缓存在内存里,用户切到下一份合同时,可以直接调用签名图库复用,不需要再写一遍触控笔。
这里要注意并发带来的内存问题。签名图我们同时保留多份清晰度和压缩率不同的产物:内存里保留低分辨率预览图,上传时生成高分辨率图,磁盘里保存原始坐标数据。当批签数量超过20份时,自动清掉最早的低分辨率预览图,避免低端设备内存溢出。
另外,上传模块用队列串行处理。保证只有前一个合同上传完整"签名图加坐标加签署区信息",才会发下一个请求。这样可以避免服务端因为并发请求导致合同和签名错配。
7. 真机调试与部署中的hdc、hap和杂七杂八的坑
7.1 hdc常用命令与设备信息获取
OpenHarmony开发过程中,和设备的交互基本靠hdc。以下是这份工作里最常用的一组命令:
code复制hdc list targets # 查看设备是否连接
hdc shell param get const.product.name # 获取产品名
hdc shell param get const.product.model # 获取型号
hdc shell param get const.product.version # 获取系统版本
hdc install com.example.contract_sign.hap # 安装hap包
hdc uninstall com.example.contract_sign # 卸载应用
hdc file send local.hap /data/local/tmp/ # 推送文件到设备
hdc shell aa start -a MainAbility -b com.example.contract_sign # 拉起应用能力
热词里提到的"devudid和serial"也很关键。开发者在申请证书或做设备白名单时,需要获取设备的唯一标识,可以用:
code复制hdc shell bm get -udid
真机调试中如果发现应用安装后启动不了,先用上述aa start命令手动拉起,看日志里是否有JS或C++层崩溃。OpenHarmony的崩溃日志一般会输出到hilog,过滤关键字可以用:
code复制hdc shell hilog | grep "FATAL\|ERROR"
我对开发团队的建议是,把这一套命令封装成脚本,放在项目tools目录下,新成员入职半天就能上手。
7.2 RK3568和RK3588开发板上的运行表现
我们在RK3568和RK3588都做了联调。RK3568性能偏弱,Flutter渲染引擎和手写事件处理同时进行的压力下,偶尔会出现首帧延迟。优化手段是减少首页非必要Widget的构建,把合同列表改成懒加载,并且在打开签名面板前预热RepaintBoundary。RK3588的表现则明显从容,复杂合同页面滚动和手写签名同时进行的帧率能稳定在50帧以上。
如果你的目标设备里包含RK3568这类中低配开发板,建议在Profile模式下跑一遍性能面板,重点关注ContractPreview的build耗时和SignaturePainter的paint耗时。一旦这两个方法出现超过16毫秒的耗时任务,优先考虑降级合同渲染方案,比如改成图片流。
7.3 插件解析错误与构建处理的兜底方案
最后一类问题是构建期的顽固错误。前面提到的"flutter-plugin-loader"版本解析失败,绝大多数情况下是因为Flutter SDK分支、项目依赖的Flutter版本以及OpenHarmony SDK三者版本不匹配。解决办法不是手动改版本号,而是严格对照华为和OpenAtom社区维护的版本矩阵。如果某个插件始终无法解析,还有一个兜底方案:在pubspec.yaml中把该插件的依赖路径改成git地址,指向已经适配ohos的fork版本。
另一个构建常见坑是OpenHarmony的hap包默认不支持动态权限声明。如果你的应用在签署过程中需要存储权限读取合同,必须在module.json5里显式声明,否则真机上运行时会静默失败。排查这类问题时,用hilog过滤permission关键字比断点调试快得多。
如果你不是从零创建工程,而是从Android迁移过来的项目,还要记得把OpenHarmony工程里resources/base/profile/main_pages.json里的页面配置与Flutter route匹配好。曾经有一次,我们改了首页路由,但hap包里main_pages.json忘同步,导致启动后卡在品牌闪屏位置,排查了一下午。
最后说一点个人体会:Flutter在OpenHarmony上的生态虽然还在成长期,但得益于Flutter渲染层的高度自绘特性,像电子合同签署这种偏文档与手写交互的App,反而是适配成功率比较高的场景。核心思路是:能用Flutter自绘解决的就不要依赖原生插件,必须依赖原生能力的要提前做适配调研。手写签名、合同截图、状态管理这些模块全部用纯Dart实现后,我们的应用在OpenHarmony上的表现已经和Android端非常接近了。这套组合目前已经具备支撑真实业务落地的条件,接下来如果PDF原生渲染和更多系统能力接入能补齐,应用空间会大得多。
