其它平台阅读链接:https://juejin.cn/post/7067372570399473701
背景
最近在用Flutter汉字书写板的功能。需求很简单,只需要实现一个书写板,通过手指在书写板书写的过程中,记录书写的轨迹,最终通过汉字AI识别算法,给出汉字书写的评分。
首次实现
用flutter实现书写板并记录书写轨迹的组件实现非常简单,笔者当时也没有多想,很快就实现了第一版的需求,代码如下:
import 'package:flutter/cupertino.dart';
class DrawBoard extends StatefulWidget {
final double width; // 书写板宽度
final double height; // 书写板高度
final Color color; // 书写颜色
final double strokeWidth; // 笔画宽度
final DrawBoardState state = DrawBoardState();
DrawBoard({
required this.width,
required this.height,
required this.color,
required this.strokeWidth,
});
@override
DrawBoardState createState() => DrawBoardState();
}
class DrawBoardState extends State<DrawBoard> {
bool _begin = false; // 是否开始书写
List<Offset> _drawPoints = []; // 记录当前书写轨迹
List<List<Offset>> _draws = []; // 笔画书写轨迹
// 开始书写
void _onPanStart(details) {
setState(() {
_drawPoints = [];
_begin = true;
});
}
// 书写中
void _onPanUpdate(DragUpdateDetails details) {
if (_begin) {
Offset offset = details.localPosition;
_drawPoints.add(offset);
}
}
// 结束书写
void _onPanEnd(details) {
setState(() {
_draws.add(_drawPoints);
_begin = false;
});
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd,
child: Container(
height: widget.height,
width: widget.width,
child: CustomPaint(
size: Size(widget.width, widget.height),
painter: DrawBoardPainterWidget(
color: widget.color,
strokeWidth: widget.strokeWidth,
draws: _draws,
),
),
),
);
}
}
class DrawBoardPainterWidget extends CustomPainter {
final Color color;
final double strokeWidth;
final List<List<Offset>> draws;
DrawBoardPainterWidget({
required this.color,
required this.strokeWidth,
required this.draws,
});
@override
void paint(Canvas canvas, Size size) {
Paint paint = new Paint()
..style = PaintingStyle.stroke
..color = this.color
..strokeJoin = StrokeJoin.round
..strokeCap = StrokeCap.round
..strokeWidth = strokeWidth;
for (int i = 0; i < draws.length; i++) {
List<Offset> points = draws[i];
// 书写平滑优化
if (points.length >= 2) {
...
canvas.drawPath(path, paint);
}
}
}
@override
bool shouldRepaint(DrawBoardPainterWidget old) {
return true;
}
}
使用组件,效果如下图所示:
上线以后
通过书写埋点统计,发现汉字书写练习的功能正确率识别很低。
通过分析汉字书写识别错误的数据,发现书写轨迹正确的数据会被AI识别算法判断错误,如下图所示:
通过分析书写轨迹,发现收集了有很多错误的多余的无关的轨迹数据。如上图所示下面一坨无规则的白色区域轨迹,影响AI识别算法的判断。
分析原因
通过对用户操作行为的分析,我们发现在实际书写过程中,遇到支持多指触控的安卓设备单指书写的时候,其他手指可能误碰书写板,也会记录到其他手指误碰的轨迹,导致汉字书写轨迹AI识别算法判断有误。
如下图所示:
解决方案
如何屏蔽多指操作?
web端单指书写
写过web端开发的同学,应该知道web端对于多指事件,浏览器touch事件对象e.targetTouches会进行手指分组,如下:
// 绑定touchmove事件
document.body.ontouchmove = (e) => console.log(e);
Flutter端单指书写
所以浏览器实现单指书写非常简单,在Flutter中是否有类似的分组呢?
Flutter touch事件使用GestureDetector组件捕获触发回调函数,回调函数touch事件对象分别是DragStartDetails、DragUpdateDetails、DragEndDetails。
查看其中一个对象DragUpdateDetails源代码,发现此对象非常简单,并没有对多指触发的事件做分组,代码如下:
class DragUpdateDetails {
DragUpdateDetails({
this.sourceTimeStamp,
this.delta = Offset.zero,
this.primaryDelta,
required this.globalPosition,
Offset? localPosition,
})
final Offset delta;
final double? primaryDelta;
final Offset globalPosition;
final Offset localPosition;
@override
String toString() => '${objectRuntimeType(this, 'DragUpdateDetails')}($delta)';
}
如果Flutter在底层没有对多指事件做分组处理,上层应用是很难处理的,但也不是没有办法。
初步方案
笔者率先想到,在书写过程中,单指触发的事件是连续较为密集的点,是否可以通过计算前后两个点的距离是否在固定的范围内判断后一个点属不属于同一个手指触发呢?
- 如果在范围内,就添加点
- 如果不在范围内就,丢弃点
实现代码如下:
void _onPanUpdate(DragUpdateDetails details) {
if (_moving) {
setState(() {
Offset point = details.localPosition;
if (_drawPoints.length > 0) {
Offset beforePoint = ...;
if ((point.dx - beforePoint.dx).abs() < 10 && (point.dy - beforePoint.dy).abs() < 10) {
_drawPoints.add(point);
}
}
});
}
}
}
通过此方法,确实能解决缓慢单指书写的问题。在快速书写的时候,由于触发的touch事件点很稀疏,前后点直接的距离明显变大,超过固定距离导致点被丢弃,最终书写断层。效果如下图所示:
思考分析
固定的距离判断显然不行,那么动态的距离判断呢?
分析得出的方案如下:
- 1.依据书写移动的速度动态计算合适的距离
- 2.Flutter是否提供单指前后点偏移值
方案1显然会复杂许多,笔者都想去研究一下chromium是如何对多指事件分组的原理。
方案2如果Flutter能支持,自然就非常方便。
通过对Flutter源码的分析和调试,发现Flutter touch事件对象有属性delta,支持单指前后点偏移值。
最终通过属性delta解决单指书写问题
实现代码如下:
void _onPanUpdate(DragUpdateDetails details) {
if (_moving) {
setState(() {
Offset point = details.localPosition;
Offset delta = details.delta;
if (_drawPoints.length > 0) {
Offset beforePoint = ...;
if ((point.dx - beforePoint.dx - delta.dx).abs() <= 1 && (point.dy - beforePoint.dy - delta.dy).abs() <= 1) {
_drawPoints.add(point);
}
}
});
}
}
}
封装单指touch组件
import 'package:flutter/cupertino.dart';
class SingleTouchMove extends StatefulWidget {
final Widget? child;
final GestureDragStartCallback? onPanStart;
final GestureDragUpdateCallback? onPanUpdate;
final GestureDragEndCallback? onPanEnd;
const SingleTouchMove({
Key? key,
this.onPanStart,
this.onPanUpdate,
this.onPanEnd,
this.child,
}) : super(key: key);
@override
_SingleTouchMove createState() => _SingleTouchMove();
}
class _SingleTouchMove extends State<SingleTouchMove> {
bool _isStart = false;
List<Offset> _eventPoints = [];
void _onPanStart(DragStartDetails details) {
if (_isStart) return;
_isStart = true;
_eventPoints = [details.localPosition];
widget.onPanStart?.call(details);
}
void _onPanUpdate(DragUpdateDetails details) {
Offset currentPoint = details.localPosition;
Offset delta = details.delta;
Offset beforePoint = _eventPoints[_eventPoints.length - 1];
bool notOverX = (currentPoint.dx - beforePoint.dx - delta.dx).abs() <= 1;
bool notOverY = (currentPoint.dy - beforePoint.dy - delta.dy).abs() <= 1;
if (notOverX && notOverY) {
_eventPoints.add(currentPoint);
widget.onPanUpdate?.call(details);
}
}
void _onPanEnd(DragEndDetails details) {
_isStart = false;
widget.onPanEnd?.call(details);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd,
child: widget.child,
);
}
}
最后
文章到此就结束了,Flutter单指书写最终解决方案比较简单,主要是记录笔者在此过程中遇到的问题和思考,期望通过这篇文章,有相似需求的读者能少走弯路。