最近在做内部合同审批流程的移动化改造,需要支持用户在手机和办公平板上在线签署意见,最终定了 Flutter 来做跨平台 UI,顺手把鸿蒙设备的适配也打通了。整个项目最核心的一个模块就是手写签名板,说白了就是一个能拿手指或触控笔写字画画的画布,配合撤销、清除、导出图片这些能力,把用户的手写笔迹沉淀成可归档的图片或数据。这篇文章不打算讲一堆思想层面的东西,就围绕“Flutter 跨平台 + 鸿蒙适配 + 手写签名板”这条线,把我从需求拆解到实际编码、再到鸿蒙设备跑通的完整过程分享一下。想自己写签名板组件、或者正在做 Flutter 鸿蒙化改造的朋友,可以直接拿走里面的思路和代码。
1. 项目启动前,先想清楚签名板的架构和边界
先别急着写代码。签名板看似简单,但“能在屏幕上画出线条”和“能作为正式签核组件”是两个量级。我在前期把需求拆成了两件事:第一,手写体验必须接近纸笔,不能出现断线、抖线、延迟高的问题;第二,最终产物必须能导出成图片或矢量数据,方便后续归档、比对、存证。这个定位决定了后面所有代码的写法。
1.1 为什么这个场景适合 Flutter 跨平台
如果只做单一平台,原生是首选,但我们的使用场景是:领导在 Android 手机上签、业务员在 Windows 平板上签、部分办公区用鸿蒙设备,还要考虑后续把签名能力嵌到 Web 端 OA 里。这种情况下用 Flutter 一套代码覆盖全端,比维护三套原生组件划算得多。
手写签名板恰好是 Flutter 很擅长的场景:它是轻交互、重绘制的 UI 组件,不涉及复杂系统 API,主要工作都在渲染层完成。Flutter 的 CustomPaint 直接对接 Skia 渲染引擎,在 Android、Windows、Web 上都能保持一致的绘制行为,这对“签名笔迹必须标准化”的合同场景来说非常重要。如果是 ArkUI 或原生 View 各自实现一套,光是抗锯齿和曲线算法在每个平台上调统一就是一笔不小的成本。
1.2 签名板的整体功能清单与实现分层
在动手前我列了一份最小可用功能清单,只保留签名场景真正会用到的东西:
- 手写绘制:支持手指、电容笔、鼠标三种输入方式。
- 清除画布:一键清空所有笔迹。
- 撤销上一步:误操作时回退一个笔画。
- 导出图片:把画布区域渲染成 PNG 图片,可配置透明背景或白底。
- 轨迹数据版本:保存笔迹坐标序列,便于二次渲染或做动态重放。
这里我没有把“橡皮擦”放进第一版。原因很直接:签名场景不需要局部擦除,误签了用撤销就行;橡皮擦在 Flutter 上要配合 saveLayer 和 BlendMode.clear 使用,复杂度和性能成本都不低,签核场景的收益却很小。功能越克制,代码越稳。
在架构上,我把组件拆成四层:
- 输入层:负责接收 PointerEvent / Gesture 事件,区分手指、鼠标、笔,并提取坐标与压感。
- 数据层:把每一条笔画保存为独立的 Stroke 对象,存储点位数组、笔宽、颜色等属性,支撑撤销、重做、序列化。
- 渲染层:基于 CustomPaint + CustomPainter,把笔画数据转换为平滑的 Path 并绘制。
- 导出层:用 RepaintBoundary 把画布区域截图为位图数据,输出成 base64 或文件。
每层只做一件事,后续要扩展“笔迹加密”“PDF 印章合成”“多人会签”等功能时,直接替换对应层就行。
1.3 关于“鸿蒙适配”的三个前置认知
在真正碰鸿蒙之前,我踩过的坑基本都源于对“Flutter 鸿蒙适配”这几个字的误解,先敲三个认知:
第一,Flutter 官方仓库默认并不能直接构建鸿蒙 HAP,社区和硬件厂商维护的 ohos 分支补齐了这部分能力。也就是说,鸿蒙适配发生在 Flutter SDK 和 Engine 层,而不是业务代码层。
第二,在业务代码层面,签名板是纯 Dart 实现、只依赖 Flutter 自带的 rendering 能力,不依赖 Android 或 iOS 插件,这让它成为适配鸿蒙最顺利的一类项目。如果你的业务还用了 shared_preferences、path_provider 这些常用插件,就要额外确认是否有鸿蒙版本支持。
第三,鸿蒙设备和 Android 设备在触控事件、屏幕 density、字体渲染上存在细节差异,跨平台方案跑通很容易,但“跑得和纸笔一样顺手”需要单独调参。所以鸿蒙端既不是改几行代码就能搞定,也没有想象中那么可怕,核心工作集中在环境搭建和事件参数调整上。
如果一开始就把这三点想清楚,后面不会花太多时间在错误方向上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手写签名的核心交互:从手势到平滑笔迹
签名板的技术含量几乎全集中在“如何把触摸轨迹变成一条流畅、真实、贴近纸笔的曲线”。这一节我按从输入到渲染的链路,把每个环节的关键点拆开讲。
2.1 手势事件怎么接:GestureDetector 还是 Listener
很多人习惯直接用 GestureDetector 的 onPanStart/onPanUpdate/onPanEnd,但在手写签名场景里,我更推荐用 Listener 配合 PointerDownEvent / PointerMoveEvent / PointerUpEvent。原因是:
GestureDetector 的 pan 手势带有滑动阈值和手势竞技场判定,在某些情况下会出现“快速落笔后第一个点被吞掉”的现象;而 Listener 直接接收原始指针事件,每个触摸点都能拿到坐标和压力信息,更适合精密的绘制需求。不过 Listener 需要自己处理多点触控和事件类型判断,代码量会多一点。
实际代码中,我会在 PointerDownEvent 里判断 event.kind,分别处理 touch / stylus / mouse,然后记录起点;在 PointerMoveEvent 里不断追加点位;在 PointerUpEvent 里结束当前笔画并归档到 strokes 列表。如果担心多点触控导致串线,可以在记录笔画时检查 event.device,当前笔画只接受同一设备指针的事件。
2.2 让笔迹变平滑:二次贝塞尔曲线替代直线连接
拿到一系列点位后,最直接的做法是把每个点用 lineTo 连起来。这样画出来的线条会有明显的折角,尤其快速书写时像锯齿一样,非常影响“纸笔感”。
解决思路是用二次贝塞尔曲线做平滑。具体做法不是把每个点当作曲线端点,而是把相邻两个点之间的中点作为曲线端点,原有点作为控制点:
dart复制Path buildSmoothPath(List<Offset> points) {
if (points.isEmpty) return Path();
final path = Path()..moveTo(points.first.dx, points.first.dy);
if (points.length == 1) {
path.lineTo(points.first.dx + 0.1, points.first.dy + 0.1);
return path;
}
for (int i = 0; i < points.length - 1; i++) {
final p0 = points[i];
final p1 = points[i + 1];
final mid = Offset((p0.dx + p1.dx) / 2, (p0.dy + p1.dy) / 2);
if (i == 0) {
path.moveTo(p0.dx, p0.dy);
}
path.quadraticBezierTo(p0.dx, p0.dy, mid.dx, mid.dy);
}
path.lineTo(points.last.dx, points.last.dy);
return path;
}
这段代码是签名板平滑度提升的关键。为什么用中点而不是直接用原有点?因为二次贝塞尔曲线的端点就是路径经过的点,而控制点决定曲线弯曲的方向。把中点作为端点,曲线会自然经过每个原始点之间的“中间地带”,视觉上既不丢失书写轨迹,又不会产生突兀的折角。实测下来,这种方案对快速签名、连笔字的还原度比 lineTo 高一个档次,同时成本比三次贝塞尔低。
提示:如果追求更高的平滑效果,可以把二次贝塞尔换成 Catmull-Rom 样条,但这种场景下二次贝塞尔已经够用,而且计算量更小,对低端鸿蒙设备的压力也更低。
2.3 点序列的采样与去抖,防止 Path 膨胀
手写板接入后最容易出问题的不是画不出来,而是点位太密导致卡顿。手指在屏幕上滑动时,系统事件频率通常是 60Hz 到 120Hz,签名慢写十分钟可能产生上万的点位。如果不做采样,每次重绘都要遍历上万个点构建 Path,低端设备会明显掉帧。
我采用的策略是双条件采样:只有当新点与上一个采样点之间的距离大于 1.5 逻辑像素,并且时间间隔超过 8 毫秒时,才追加到当前笔画。这样既能保留笔迹细节,又能把点位数量控制在合理范围。实际使用中,一页签名从落笔到收笔通常只有几百个点,绘制开销完全可以忽略。
去抖则是在输入层加一个防抖逻辑:如果两次 PointerMoveEvent 的坐标完全一致且间隔极短,多半是设备上报的冗余事件,直接丢弃。这个细节在鸿蒙触控较密的设备上经常遇到,不加的话画出来的线偶尔会出现“微抖动”。
dart复制bool shouldAppend(SignPoint point, SignPoint last) {
final dist = (point.offset - last.offset).distance;
final dt = point.timestamp - last.timestamp;
return dist >= 1.5 || dt >= 8;
}
2.4 撤销、重做、橡皮擦:按笔画管理而不是按帧管理
很多初版签名板把画布状态设计成“一张位图”,清除和撤销都靠保存位图快照实现。这种做法在小画布上能跑,但画布一大,内存占用和快照开销都很难看。我选择按笔画(Stroke)管理,每个笔画包含点位数组、颜色、笔宽、输入设备类型等元数据,撤销就是移除最后一个 Stroke,重做就是重新放回,操作成本都是 O(1)。
如果以后要加橡皮擦,本质上也是新增一个类型为 eraser 的 Stroke,渲染时对该笔画所在区域使用 BlendMode.clear 清除像素。需要注意的是,Flutter 的 BlendMode.clear 必须配合 canvas.saveLayer 使用,否则会直接把整块画布的透明像素清掉,效果会出乎意料。签名场景用不到可以先不实现,但要留好这个扩展点。
dart复制void undo() {
if (_strokes.isEmpty) return;
setState(() => _strokes.removeLast());
}
2.5 压感与笔锋:手写体验的分水岭
用手指签名和用电容笔签名是完全不同的体验。若想让签名板更像纸笔,必须处理压感:设备上报的 pressure 值在 0 到 1 之间,笔尖用力越大,笔画就应该越粗。
等宽的整条 Path 无法表达粗细变化。我的做法是逐段绘制:把当前笔画按相邻两个点拆成小线段,每段线宽由两端点的 pressure 插值决定,再用 StrokeCap.round 让线段衔接处圆润。
dart复制for (int i = 0; i < points.length - 1; i++) {
final p0 = points[i];
final p1 = points[i + 1];
final w0 = math.max(p0.pressure * strokeWidth, minWidth);
final w1 = math.max(p1.pressure * strokeWidth, minWidth);
paint
..strokeWidth = (w0 + w1) / 2
..strokeCap = StrokeCap.round;
canvas.drawLine(p0.offset, p1.offset, paint);
}
这里对手指输入要做一个保护,因为大部分设备的触摸 pressure 上报为 0,直接乘一个系数会出现“笔画宽度为 0”的问题。我的策略是:当 pressure == 0 时,统一使用默认笔宽;只有 stylus 类型的事件才启用压感变化。
3. 实操:签名板组件的完整实现与多端打包
理论说得再多,不如跑通一遍。这一节给出签名板组件从零到一的完整实现,以及鸿蒙端打包的实操步骤。
3.1 组件骨架:StatefulWidget + CustomPaint + RepaintBoundary
签名板本质是一个 StatefulWidget,维护两个核心状态:strokes 保存已完成笔画,currentPoints 保存正在绘制中的点序列,然后交给 CustomPaint 绘制。RepaintBoundary 包在外层,专门用于截取画布图片。
dart复制class SignatureBoard extends StatefulWidget {
final Color strokeColor;
final double strokeWidth;
final Color backgroundColor;
final ValueChanged<Uint8List?>? onExport;
...
}
class _SignatureBoardState extends State<SignatureBoard> {
final List<SignStroke> _strokes = [];
final List<SignPoint> _currentPoints = [];
final GlobalKey _boundaryKey = GlobalKey();
bool _isEmpty = true;
...
}
组件对外暴露的方法包括 clear()、undo()、exportPng()。这些方法通过 GlobalKey 在父组件中调用,或者包装成 controller 模式,避免高层组件直接操作内部状态。
3.2 核心代码:SignatureBoard 与 SignaturePainter
下面是绘制层的核心实现,我把上一节讲的平滑曲线、采样、撤销都整合到一个可用版本里。
dart复制class _SignatureBoardState extends State<SignatureBoard> {
List<SignStroke> _strokes = [];
List<SignPoint> _currentPoints = [];
final GlobalKey _boundaryKey = GlobalKey();
void _onPointerDown(PointerDownEvent event) {
final point = SignPoint(
offset: event.localPosition,
pressure: event.pressure,
timestamp: DateTime.now().millisecondsSinceEpoch,
kind: event.kind,
);
setState(() {
_currentPoints = [point];
});
}
void _onPointerMove(PointerMoveEvent event) {
final point = SignPoint(
offset: event.localPosition,
pressure: event.pressure,
timestamp: DateTime.now().millisecondsSinceEpoch,
kind: event.kind,
);
final last = _currentPoints.isNotEmpty ? _currentPoints.last : point;
if (!shouldAppend(point, last)) return;
setState(() {
_currentPoints.add(point);
});
}
void _onPointerUp(PointerUpEvent event) {
if (_currentPoints.isEmpty) return;
setState(() {
_strokes.add(SignStroke(
points: List.of(_currentPoints),
color: widget.strokeColor,
strokeWidth: widget.strokeWidth,
));
_currentPoints = [];
});
}
@override
Widget build(BuildContext context) {
return RepaintBoundary(
key: _boundaryKey,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Listener(
onPointerDown: _onPointerDown,
onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp,
child: Container(
color: widget.backgroundColor,
child: CustomPaint(
painter: SignaturePainter(
strokes: _strokes,
currentPoints: _currentPoints,
),
size: Size.infinite,
),
),
),
),
);
}
}
SignaturePainter 的 paint 方法负责把笔画数据渲染出来,逻辑就是 2.2 和 2.5 里讲的两段算法:等宽模式用贝塞尔 Path,压感模式用逐段圆头线。shouldRepaint 则通过比较对象引用判断是否需要重绘。
dart复制class SignaturePainter extends CustomPainter {
final List<SignStroke> strokes;
final List<SignPoint> currentPoints;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..isAntiAlias = true
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
canvas.saveLayer(Offset.zero & size, Paint());
for (final stroke in strokes) {
_drawStroke(canvas, stroke, paint);
}
if (currentPoints.isNotEmpty) {
_drawStroke(canvas,
SignStroke(points: currentPoints, color: const Color(0xFF000000), strokeWidth: 2),
paint);
}
canvas.restore();
}
@override
bool shouldRepaint(covariant SignaturePainter oldDelegate) {
return oldDelegate.strokes != strokes ||
oldDelegate.currentPoints != currentPoints;
}
}
注意:saveLayer 和 restore 在这里是为了隔离绘制区域,避免压感分段绘制时半透明像素互相叠加产生“黑边”。如果只是纯色不透明笔迹,可以去掉这一层,能省一些 GPU 开销。
3.3 签名导出:RepaintBoundary 转 PNG / base64
签名要进合同、要归档,必须能导出高清图片。我用的方案是 RepaintBoundary 的 toImage:
dart复制Future<Uint8List?> exportPng({double pixelRatio = 3.0}) async {
final boundary = _boundaryKey.currentContext?.findRenderObject()
as RenderRepaintBoundary?;
if (boundary == null) return null;
final image = await boundary.toImage(pixelRatio: pixelRatio);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
return byteData?.buffer.asUint8List();
}
pixelRatio 参数很关键。默认 1.0 导出的图片是和逻辑像素等宽的,在合同归档场景下不够清晰。设为 3.0 后,导出的 PNG 在屏幕上和打印场景都能满足需求。需要透明背景签名章时,把 Container 的 color 设为透明即可;合同背景通常要白底,可以在导出前把背景色临时改成白色,或者导出后由图像处理模块统一加背景。
导出的图片可以继续转成 base64 字符串,通过接口传给后端,或者用文件选择器写到本地。这部分逻辑跟业务强相关,组件层只负责给 byteData,具体怎么用由调用方决定。
3.4 鸿蒙端工程接入与 HAP 打包
签名板组件的鸿蒙化过程,我把工作分成三步。第一步准备鸿蒙可用的 Flutter SDK:当前 Flutter 官方仓库默认不能直接构建鸿蒙 HAP,我选用的是 OpenHarmony 社区维护的 flutter_flutter 和 flutter_engine 的 ohos 分支,版本对应关系在仓库的 README 里有明确说明,务必按说明拉取,不要凭感觉随便切版本。
第二步工程接入:在已有 Flutter 工程中,根据分支文档执行初始化命令,创建或引入 ohos 目录。这个目录是鸿蒙侧的原生工程外壳,业务代码仍然全部在 lib 下,Dart 层的 SignatureBoard 组件不需要做任何改动。
第三步用 DevEco Studio 打开 ohos 目录,配置签名证书后点击运行,就能在鸿蒙设备或模拟器上看到签名板页面。如果只需要 HAP 安装包,可以直接在 DevEco Studio 里构建产物。
这里我要强调一个点:纯 Dart 业务组件在鸿蒙上跑通很容易,但最好不要指望它和 Android 端的渲染结果一模一样。鸿蒙端偶发的字体基线差异、边缘抗锯齿差异,更多是 Skia 在不同平台上的实现差异导致的,属于可接受的跨端偏差,重点要保证笔迹坐标、笔画语义完全一致。
3.5 跨平台验证:Android、Web、Windows、鸿蒙四端对齐
组件写完以后,我在四个平台都跑了一遍冒烟测试:
- Android:真机手写流畅,触控笔压感正常,导出图片清晰。
- Web:鼠标书写正常,但 PointerEvent 的 pressure 始终为 0,笔迹统一走默认宽度。
- Windows:与鼠标体验基本一致,配合触屏电脑可以当白板签批工具。
- 鸿蒙:真机手写和平板手写基本流畅,但发现低端设备在压感逐段绘制时偶发掉帧,后来把采样距离阈值从 1.5 调到 2.0,情况明显改善。
四端对齐的核心不是“像素级一致”,而是“语义一致”。签署的笔迹坐标、笔画数量、导出图片的长宽比必须统一,这样后端拿到数据才能做统一归档和存证。这一点在设计阶段就要定好口径,否则后期调整成本很高。
4. 踩坑记录与高频问题排查
写签名板这两周,我遇到过的坑比预期多,这里整理一份速查表和几条代表性问题的排查过程,保准能帮各位少走弯路。
4.1 高频问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 快速落笔时第一笔缺失 | GestureDetector 手势竞技场吞掉了首个事件 | 改用 Listener 接收原始指针事件 |
| 笔画拐角出现明显锯齿 | 直接用 lineTo 连接点 | 改用二次贝塞尔平滑算法 |
| 长时间书写后界面卡顿 | 点序列未采样,Path 过大 | 增加距离、时间双重采样阈值 |
| 导出图片模糊 | toImage 默认 pixelRatio 为 1 | 导出时设置 pixelRatio 为 2~3 |
| 撤销后画布多出残留线 | 缓存了位图快照导致状态不同步 | 改为按 Stroke 列表重绘,移除末尾项 |
| 压感书写时笔画出现断裂 | 压感为 0 时使用了 0 宽度笔宽 | 对 pressure == 0 的回退到默认笔宽 |
| 鸿蒙设备光标消失 | 未处理 mouse 类型事件 | 在 PointerEvent 中判断 kind 并设置对应光标 |
| 透明背景导出后签名异常 | 未使用 saveLayer 直接用了 BlendMode.clear | 绘制层统一使用 saveLayer/restore |
这张表基本覆盖了从 Android 到鸿蒙的高频问题,碰到新问题建议优先从“事件来源、绘图状态、导出设置”三个维度排查。
4.2 鸿蒙适配的典型坑:引擎不匹配、插件缺失、签名配置
鸿蒙适配遇到的坑比预想中多一些,三个最有代表性:
第一个是引擎版本不匹配。Flutter 的 ohos 分支和官方 Flutter 版本号并不完全同步,如果工程里的 pub 依赖要求某个新版本的 Flutter API,但 ohos 分支引擎没跟上,编译时会报一堆奇怪错误。我的办法是在准备鸿蒙适配前把整个项目的 Flutter 版本锁定在 ohos 分支支持的版本,并让团队成员统一使用同一套 SDK。
第二个是插件缺失。项目里如果用了第三方插件,很多在鸿蒙端没有现成实现。签名板本身不依赖插件所以没踩到,但迁移其他页面时频繁遇到。稳妥的办法是先把项目里的插件列表梳理一遍,对照 ohos 社区支持的清单,缺哪个就替换或自己实现对应平台通道。
第三个是签名配置。DevEco Studio 打包 HAP 需要配置签名证书,这个和 Android 的签名逻辑不太一样,第一次接触容易在运行按钮上一直报错。这个流程纯靠 IDE 向导点不掉,建议认真读完官方文档里的签名章节,把证书文件、profile 文件配置好再构建。
4.3 体验优化:签名板还能怎么做得像纸笔
最后再分享一个体验层面的优化点。手写签名板能不能留住用户,关键看三点:延迟、抖动、笔锋。
延迟方面,可以从事件采样密度和绘制复杂度入手。我在导出层的 pixelRatio 设置成 3,但绘制层的 RepaintBoundary 不能跟着用高倍率,否则每一帧都要处理超大画布,压力全给到
