ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android原笔迹手写:从触摸事件到贝塞尔曲线平滑绘制

Android原笔迹手写:从触摸事件到贝塞尔曲线平滑绘制 简介一份面向Android开发者的手写功能实现源码仿照有道云笔记的原笔迹效果重点解决触摸轨迹采集、平滑曲线绘制与笔锋还原等核心问题。资源共92个文件压缩包1.4MB其中12个Java文件承载主要逻辑26个XML用于界面布局与配置36张PNG图片提供示例素材另有PSD设计稿和说明文档便于对照学习。完整演示了从ACTION_DOWN到ACTION_UP的笔迹路径构建结合贝塞尔曲线优化线条平滑度并实现颜色笔宽切换、橡皮擦、撤销重做、笔记保存与回放等功能。通过分析源码可以理解Canvas与Paint的配合方式、MotionEvent的分发处理流程以及多页管理和缩放平移等交互细节。项目包含工程配置文件、构建脚本和说明文档目录结构清晰方便直接导入Android Studio运行调试。已有322人学习下载适合正在做笔记类应用或对手写绘图感兴趣的初中级开发者。1. 原笔迹手写的核心难点触控采样、绘制链路与源码切入点在 Android 上复刻有道云笔记的“原笔迹手写”功能最直观的感受是手指明明已经划过屏幕屏幕上出现的却是一段段僵硬折线或者起笔、收笔处出现不自然的断点。原因在于手写并不只是监听 MotionEvent 然后 lineTo而是要在每帧 16ms 内完成“采样点收集 → 抖动过滤 → 曲线拟合 → 局部重绘”四个动作。这份仿有道云笔记的源码正好把整条链路拆开包括 Path 的构建、Paint 的动态属性、笔画序列化和回放很适合在 Android Studio 里对照调参。下面会沿着这条链路逐层拆解并给出可以直接落地的代码和参数建议无论你是做签字板、批注 SDK 还是笔记 App都能从中找到对应模块的起点。2. 触摸事件到 Path 的映射ACTION_DOWN/MOVE/UP 与触摸点过滤手写的第一步是把触摸事件转换成绘图坐标。Android 中 View 的所有触摸都投递到onTouchEvent(MotionEvent event)这里有个容易踩的坑如果返回值不是 true后续的 MOVE 和 UP 事件不会被当前 View 收到笔迹自然断裂。源码里在 DOWN 分支直接返回 true 是正确的但很多新手经常忽略这一点。2.1 自定义 View 的 onTouchEvent 事件流先看核心的事件处理代码。下面是一个简化的HandwritingViewpublic class HandwritingView extends View { private Path currentPath; private Paint paint; private ListPointF currentPoints new ArrayList(); public HandwritingView(Context context, Nullable AttributeSet attrs) { super(context, attrs); paint new Paint(Paint.ANTI_ALIAS_FLAG); paint.setStyle(Paint.Style.STROKE); paint.setStrokeCap(Paint.Cap.ROUND); paint.setStrokeJoin(Paint.Join.ROUND); paint.setStrokeWidth(dpToPx(4)); // 初始笔宽按屏幕密度换算 currentPath new Path(); } Override public boolean onTouchEvent(MotionEvent event) { float x event.getX(); float y event.getY(); switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: // 新一笔重置路径和点集 currentPath.reset(); currentPath.moveTo(x, y); currentPoints.clear(); currentPoints.add(new PointF(x, y)); return true; case MotionEvent.ACTION_MOVE: if (shouldAddPoint(x, y)) { currentPoints.add(new PointF(x, y)); invalidate(); } return true; case MotionEvent.ACTION_UP: currentPoints.add(new PointF(x, y)); commitStroke(); // 交给笔迹管理器保存 return true; } return super.onTouchEvent(event); } Override protected void onDraw(Canvas canvas) { canvas.drawPath(currentPath, paint); } }这里的逻辑是DOWN 时创建新PathMOVE 时不断扩点并触发invalidate()UP 时把当前笔画提交给外部管理器。参数说明Paint.ANTI_ALIAS_FLAG是抗锯齿开关不打开的情况下斜线会有明显毛边setStrokeCap和setStrokeJoin使用 ROUND 是为了让笔迹的端点与转角更圆润dpToPx()需要自己实现因为不同设备密度下 4dp 对应的像素不同写死 4f 在低密度设备上过粗、高密度设备上过细。2.2 路径构建与抖动点过滤直接从屏幕上拿到的坐标点并不是稳定的。触摸屏的采样芯片会带来随机抖动尤其是指甲较长、手指停留或慢速书写时坐标会在两三个像素之间跳变。如果不处理原样扔进Path最终结果就是一条锯齿明显的折线。常见做法是在ACTION_MOVE采样后做距离过滤只有新点和上一个采样点之间的距离大于阈值时才认为这是一个有效的“书写点”。2.2.1 最小距离阈值与动态调整private static final float MIN_DISTANCE 2.0f; private boolean shouldAddPoint(float x, float y) { if (currentPoints.isEmpty()) return true; PointF last currentPoints.get(currentPoints.size() - 1); float dx x - last.x; float dy y - last.y; return (dx * dx dy * dy) MIN_DISTANCE * MIN_DISTANCE; }MIN_DISTANCE的单位是像素我一般会把它设为dpToPx(0.8f)效果比较稳。阈值太大快速书写时的细节会被吞掉阈值太小抖动点全部保留。更合理的方案是速度自适应计算本次采样点与前一次的距离除以时间差速度越快阈值越大速度越慢阈值越小这样既过滤慢速抖动又保留高速轨迹。这个逻辑在源码中做得不复杂但却是手写手感差异的重要来源。2.3 在 onDraw 中绘制路径Canvas.drawPath()是 Android 提供的高效路径绘制接口但如果不做控制每次 MOVE 都invalidate()会导致整个 View 重绘。对于单条笔画问题不大如果屏幕上已经有多条笔画全量重绘的代价会随笔画数量线性增长。优化方式是使用脏区域在 MOVE 事件里记录手势边界调用invalidate(left, top, right, bottom)只重绘发生变化的矩形区域。注意这里的边界要留出笔宽余量否则笔迹会被截断。当一条笔画完成后Path对象不能直接塞进集合供后续onDraw使用因为Path是可变的下次复用同一个对象会改掉已提交的数据。最稳妥的做法是让每条笔画保存自己的采样点数组在重绘时由点数组重新生成Path。虽然看起来多了一次构建但换来了稳定的数据模型为后面的撤销重做和保存回放打下基础。3. 使用二次贝塞尔曲线拟合手写笔迹从多段线到平滑曲线如果只做第 2 章的 lineTo书写速度稍微快一点线条就会出现明显的折角。因为触摸采样是离散的两点之间最短连线是一条直线而人类手写轨迹本质上是光滑曲线。采样点间距越大折线感越强。要解决这个问题通常引入二次贝塞尔曲线做拟合。3.1 为什么折线会出现明显锯齿在快速书写时相邻两个采样点之间可能相隔 510 个像素直线连接会让轨迹呈多边形。尤其是写“撇”“捺”等有弧形变化的笔画时折角会被视觉放大。有人尝试在 onDraw 里把Paint的setStrokeWidth调粗来掩盖锯齿但这会让字迹严重变形。正确的思路是保留原始点集但在点与点之间用贝塞尔曲线“补中间态”。3.2 中点插值的贝塞尔逼近最经典的算法是“中点二次贝塞尔”不是直接用quadTo把当前点作为控制点、下一个点作为终点而是取相邻三个点p0、p1、p2用p1作为控制点(p1p2)/2作为终点连接p0到中点。这样生成的曲线经过所有相邻点的中点整体更平滑。private Path buildSmoothPath(ListPointF points) { Path path new Path(); if (points.size() 3) { // 点太少时退化为折线 path.moveTo(points.get(0).x, points.get(0).y); for (int i 1; i points.size(); i) { path.lineTo(points.get(i).x, points.get(i).y); } return path; } path.moveTo(points.get(0).x, points.get(0).y); for (int i 1; i points.size() - 1; i) { PointF p1 points.get(i); PointF p2 points.get(i 1); float midX (p1.x p2.x) / 2; float midY (p1.y p2.y) / 2; // p1 作为控制点终点取中点降低曲线过冲 path.quadTo(p1.x, p1.y, midX, midY); } // 最后一段直接连接到末尾避免尾部缺失 PointF last points.get(points.size() - 1); path.lineTo(last.x, last.y); return path; }这段代码的关键在quadTo(p1.x, p1.y, midX, midY)当控制点p1与当前位置不重叠时曲线不会生硬地转弯而是沿着控制点方向“滑”过去。为什么不直接用quadTo(p0, p1)因为那样会让曲线在采样点处折角仍然有棱角。参数说明循环从i 1开始最后一个点用lineTo收尾否则最后的弧度会被丢弃如果下一步要做笔锋这里也可以换成cubicTo来获得更自由的形状控制但计算量会翻倍对普通手写没有必要。3.3 笔锋与压感模拟的实现思路原笔迹手写不只是平滑还需要有笔压反馈。硬件压感可以用MotionEvent.getPressure()获取但很多 Android 设备返回的是恒定值或者数值抖动剧烈。在这份源码里更实用的方案是“速度模拟笔锋”采样点之间的移动速度快模拟笔尖抬起路径变细移动速度慢模拟笔尖按压路径变粗。private float calculateWidth(float speed) { float maxSpeed 1.5f; // 像素/毫秒可调 float ratio Math.min(1f, speed / maxSpeed); return baseWidth * (1.0f - 0.5f * ratio); }使用时需要在每次 MOVE 时根据时间戳计算speed并在绘制当前段之前更新Paint.setStrokeWidth()。这里有个关键如果整条Path绘制时只用一个Paint宽度变化无法体现。常见做法是把路径切成多段每一段用当时的笔宽绘制到离屏缓存中。这样虽然增加了绘制调用次数但笔画更自然。性能方面可以利用Canvas.saveLayerAlpha来控制段与段之间的覆盖边界避免透明层重叠出黑边。顺带一提在 AndroidManifest 里开启硬件加速对手写有帮助application android:hardwareAcceleratedtrue ...但注意硬件加速下Paint.setMaskFilter的某些模糊模式不支持所以笔锋不要用模糊去伪装而是靠宽度渐变。这个坑在迁移到 Android 5.0 设备时会特别明显。4. Paint 画笔参数控制与橡皮擦的边界条件笔迹的视觉样式由Paint控制但很多开发者只把它当作一个“画笔盒”改改颜色和宽度就完事。手写场景下还需要考虑笔尖圆角、抗锯齿、透明度以及与底层 Bitmap 的混合模式。这里把画笔参数与擦除方案放在一起因为它们都影响最终像素的颜色合成。4.1 动态调整颜色、宽度与抗锯齿一份完整的画笔参数保存在自定义类里方便撤销和重算public class StrokeStyle { public int color; public float strokeWidth; public boolean antiAlias true; public Paint.Cap cap Paint.Cap.ROUND; public Paint.Join join Paint.Join.ROUND; }在onDraw里每次遍历笔画时根据样式构建独立的Paint而不是复用同一个Paint。否则在绘制多笔画时上一次调用残留的setColor会影响后续笔画。参数说明antiAlias开启后像素边缘会有一个渐变的过渡在已保存的 Bitmap 上二次绘制时透明度混合会产生叠加黑边所以需要配合Paint.setXfermode处理。下面是一个常见的属性对照表可以按需调整参数建议值影响antialiastrue消除边缘锯齿但会增加混合计算strokeCapROUND笔画起止点呈圆弧避免尖刺strokeJoinROUND转折处圆润避免折角strokeWidth2dp ~ 8dp过细视觉弱过粗丢细节alpha200 ~ 255过透明会导致叠加重影4.2 橡皮擦的两种实现方式对比橡皮擦的实现直接影响撤销逻辑和性能。第一种方式是使用PorterDuff.Mode.CLEAR把绘制内容从当前图层中擦除paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));这样写在Canvas上的像素会变透明在白底下看起来是白色。但问题在于如果Canvas是硬件加速的且叠在一张 Bitmap 上CLEAR 会直接挖空 Bitmap后续无法恢复。第二种方式是不修改像素而是维护一个“笔迹对象列表”橡皮擦本身也是一条“路径”绘制时通过Path.Op.DIFFERENCE计算剩余笔迹Path result new Path(); result.op(strokePath, eraserPath, Path.Op.DIFFERENCE);这种方式在逻辑上干净撤销只需删除或恢复路径对象不需要保存大块像素数据。对比来说特性PorterDuff CLEARPath 差集内存占用低但撤销需存像素稍高存路径是否可撤销难需快照容易边缘质量与路径一致与路径一致计算后可能产生额外分片对于笔记类应用推荐使用路径差集。要注意的是Path.Op在部分低端设备上的性能不太稳定尤其是笔画路径很复杂的时候。一个折中方案是橡皮擦用粗线条的“覆盖绘制”也就是用背景色画一遍但这种方法在背景不是纯色时无法用。4.3 擦除操作与撤销重做数据模型无论是画笔还是橡皮擦在源码里都应该作为一条Stroke记录。这样撤销就变成移除最后一条记录重做则把移除的Stroke放回去。实现层面上Stroke至少包含以下字段public class Stroke { public int id; public StrokeStyle style; public ListPointF points; public boolean isEraser; public long timestamp; }在onDraw里遍历ListStroke遇到isEraser的条目就用Paint.setXfermode(PorterDuff.Mode.CLEAR)绘制其中点集生成的Path注意绘制顺序不能颠倒后画的画笔会覆盖先画的橡皮擦所以需要把橡皮擦按照时间戳与画笔统一排序。实际使用中很多开发者会把橡皮擦分成两类对象擦除删除整条笔画和像素擦除模仿真实橡皮。前者数据模型最简单后者必须在笔画对象里记录被擦除的区段复杂度高但更接近有道云笔记的手感。5. 手写笔记的序列化与回放路径数据如何落盘与恢复手写应用最终需要保存用户数据。如果只是保存一张位图那以后无法重新编辑、缩放或改变颜色。所以源码级的解法是把笔迹保存为矢量结构再在需要时重建Path并绘制。真正的坑在于不能把Path对象直接序列化因为Path内部是 NDK 维护的字节结构跨版本不可靠。5.1 路径数据结构设计JSON 还是自描述结构JSON 足够方便但路径点可能成千上万全部 load 到内存再解析会导致卡顿。我一般会优先选择 JSON 而不是自描述二进制除非单篇笔记超过 10MB。常见做法是把点集压缩成数组用 org.json 或者 Gson 直接序列化。这里以 JSON 为例因为它能直观地和业务字段混用。public class StrokeData { public int color; public float width; public boolean isEraser; public float[] points; // [x0, y0, x1, y1, ...] }用扁平浮点数组比ListPointF在序列化时更节省空间也更容易流式读取。如果你要存储大量笔画还可以考虑ByteBuffer二进制格式但可读性会差很多。从维护角度看JSON 优先后续再针对大文件替换成SQLite或二进制。5.2 数据存储与读取实现写入端public void saveStrokes(ListStroke strokes, File file) throws Exception { JSONArray array new JSONArray(); for (Stroke stroke : strokes) { JSONObject obj new JSONObject(); obj.put(color, stroke.style.color); obj.put(width, stroke.style.strokeWidth); obj.put(eraser, stroke.isEraser); JSONArray points new JSONArray(); for (PointF point : stroke.points) { points.put(point.x); points.put(point.y); } obj.put(points, points); array.put(obj); } FileWriter writer new FileWriter(file); writer.write(array.toString()); writer.flush(); writer.close(); }这里的要点是只用原始采样点不保存插值后的贝塞尔控制点。原因是曲线拟合算法以后可能要改如果保存的是平滑后的控制点升级版本就无法迁移数据。读取时反序列化点数组后重新套用第 3 章的buildSmoothPath()方法保证绘制效果一致。注意 JSON 数组里x和y间隔存储读取时按2*i与2*i1取下标避免额外创建对象。提示写入文件不要在主线程里执行。手写活性数据可能达到几百 KB一旦磁盘 IO 卡顿立刻会体现为掉笔。5.3 回放动画的实现回放是一个常见的“演示笔记”需求。最简单的实现是把每条笔画的点序列转换为带时间戳的事件列表然后用ValueAnimator逐帧推进每帧把当前时间之前的点都加入一个临时Path并invalidate()。public void startPlayback(ListStrokeData data, long duration) { ValueAnimator animator ValueAnimator.ofFloat(0f, 1f); animator.setDuration(duration); animator.setInterpolator(new LinearInterpolator()); animator.addUpdateListener(animation - { float progress animation.getAnimatedFraction(); // 按 progress 计算当前该显示到哪个点 rebuildPlaybackPath(data, progress); invalidate(); }); animator.start(); }rebuildPlaybackPath()内部需要根据总点数乘以progress得出endPointCount然后从每次笔画的点列表里截断重建。这样做的优点是动画流畅且速度可调缺点是对点数特别多的笔画每帧重建 Path 会成为性能瓶颈。优化方案是只累加新增的点段而不是整体重建同时把已经完成的笔画缓存到 Bitmap只有当前回放到那条线才走矢量 Path。在 Android Studio 的 Profiler 里可以直观看到这种做法把 CPU 时间从 8ms 左右降到 1.5ms 以下。6. 手写性能优化与 Android Studio 调试技巧手写视图如果直接全量绘制很容易掉帧。比较好的结构是“已提交笔画画到离屏 Bitmap当前笔画用 Path 实时绘制”。在ACTION_UP时将currentPath绘制到一张和 View 同尺寸的缓存 Bitmap 上然后清除currentPath。之后onDraw只需要drawBitmap(cacheBitmap, ...)再加当前 Path。private Bitmap cacheBitmap; private Canvas cacheCanvas; Override protected void onDraw(Canvas canvas) { canvas.drawBitmap(cacheBitmap, 0, 0, null); canvas.drawPath(currentPath, paint); }6.1 硬件加速与绘制缓存划分cacheBitmap要在onSizeChanged创建一次不要每次执行都Bitmap.createBitmap。Android 的 Canvas 对 Bitmap 的绘制是硬件加速友好的因此这里能获得明显性能提升。还有一个细节在支持局部重绘的设备上切换LayerType会影响硬件层需根据Build.VERSION判断。6.2 在 Android Studio 中监控触摸与帧率手写卡顿问题最好用轨迹数据说话而不是凭感觉。在 Android Studio 和 Android SDK 的工具链里可以通过 Profiler 的 CPU 和 GPU 面板查看绘制掉帧也可以临时在onTouchEvent里把每个采样点的时间戳和坐标输出到 logcat再用 Python 脚本计算相邻事件间隔。如果 MOVE 事件之间的间隔超过 16ms说明主线程被耗时的invalidate或 Path 构建卡住。此时优先检查是否在绘制过程中调用了Bitmap.createBitmap、Paint.measureText或字符串拼接这类耗时操作。6.3 缩放平移与手写数据的一致性最后一个建议手写笔迹在缩放时不要直接对路径点做矩阵变换。正确做法是在onDraw开头canvas.concat(viewMatrix)让 View 的坐标空间和原始笔迹数据空间分离。这样缩放后笔画宽度不会跟着放大撤销重做和落盘保存的数据也不会因为视图操作而改变。常见做法是用ScaleGestureDetector维护一个Matrix在onDraw里应用同时把触摸坐标通过mapPoints反向映射到原始空间再进行笔迹追加。这样用户在手写放大状态下写下的字也正好落在纸面应有的位置。本文还有配套的精品资源点击获取
返回列表