
前两天一个做产品的朋友问我电子签名板能不能用 EditText 做我听完愣了一下然后果断告诉他这种手写需求老老实实走 Android 自定义 View 才是正路。Android 自定义 View 是 UI 开发里绕不开的一座山头平时你以为只是画个圆、写个字但真到做手写板、画板、签名板这类应用时你会发现它需要你把测量、绘制、触摸事件、性能优化、事件分发这些知识点全部打通。这篇文章就以一个手写板 View 为案子从 Paint 配置到触摸采集、从 Path 平滑到离屏缓存把自定义 View 的核心流程完整拆开讲一遍既适合刚学完四大组件准备进阶 UI 的同学也适合被各种花式交互需求反复折腾的开发者。我尽量用“写代码时真实遇到什么就讲什么”的方式来说不列空概念每个结论背后都有你在自己项目里能复用的理由。1. 内容整体设计与思路拆解1.1 三种自定义 View 的玩法先分清再动手自定义 View 并不是只有“重写 onDraw 画图”这一种形式很多新人一上来就陷入“必须从零画”的误区结果把简单需求做复杂了。实际上日常开发中自定义 View 主要分三类类型实现方式典型场景难度自绘型继承 View 或 ViewGroup重写 onDraw 用 Canvas 画内容仪表盘、走势图、签名板、刮刮乐高组合型用 XML 把多个系统控件拼起来封装成一个自定义控件搜索框、Item 布局、带标题的输入框低继承型继承某个系统控件在其基础上增加逻辑或修改表现自动清空输入框、带删除键的 EditText中手写板属于典型的自绘型 View。为什么不用 EditText 或者 ImageView 拼因为手写笔迹本质上是“实时轨迹的集合”系统控件没有给你暴露这种“用手指画任意曲线”的接口。你就算用 EditText 模拟输入法弹出来那一瞬间体验就崩了。更不要说签名场景往往还要做压感、笔锋、清空、保存图片这些需求只有从 Canvas 层面自己画才是可控的。1.2 签名板需求拆解先想清楚四个技术决策任何自定义 View 动手前我都建议把需求翻译成技术问题。拿手写板来说核心需求就一句话“手指划过屏幕留下对应的笔迹并且可以清空和保存。”听起来简单但落到技术层面要回答四个问题笔迹用什么数据结构存用 Bitmap 存实时画面还是用 Path 存轨迹每帧都画哪些东西是全量重画还是只重画变化的局部如何让曲线不“折”不“抖”这里涉及触摸点采样的处理。如何避免写满之后内存爆炸这和 Bitmap 的尺寸、复用策略有关。这四个问题你只要在编码前想清楚后面基本不会返工。很多人写手写板写到一半发现越来越卡就是因为一开始没想好“每帧画什么”和“画在哪”这两个根本问题。我的方案是用 Path 记录当前这笔画迹同时用一块离屏 Bitmap 保存之前已经画完的所有内容onDraw 时先画 Bitmap 再画当前 Path。这样既保证了无限笔迹的持续可画又避免了每帧把历史 Path 全部重放一遍的性能浪费。1.3 自定义 View 的生命周期入口从构造到触摸的顺序自绘型 View 的核心入口有五个构造方法、onMeasure、onSizeChanged、onDraw、onTouchEvent。它们不是随便写的每个入口都有明确职责。构造方法初始化 Paint、Path、画笔参数只做一次。onMeasure决定 View 的宽高尤其是 wrap_content 时要有默认尺寸。onSizeChanged当 View 尺寸确定后回调这里适合创建和尺寸强相关的对象比如离屏 Bitmap。onDraw把当前状态画到 Canvas 上。onTouchEvent接收手指事件把触摸坐标转换成笔迹数据。调用顺序上第一次布局时是“构造 - onMeasure - onSizeChanged - onDraw”之后每次手指移动触发 invalidate系统会重新调用 onDraw但不会再走 onSizeChanged。所以你要分清“哪些初始化只做一次”“哪些和尺寸相关”“哪些每帧都变”这是自定义 View 编码是否成熟的分水岭。2. Canvas 与 Paint 的核心参数手写板 View 的骨架2.1 Paint 的配置决定笔迹质感抗锯齿、StrokeCap 和 StrokeJoin手写板画出来的线好不好看很大程度上不是 Canvas 的锅而是 Paint 的锅。我见过很多人画出的线又糙又有毛刺原因就是没开抗锯齿。初始化 Paint 时下面几个参数几乎是手写场景的标配mPaint new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG); mPaint.setStyle(Paint.Style.STROKE); mPaint.setStrokeWidth(6f); mPaint.setStrokeCap(Paint.Cap.ROUND); mPaint.setStrokeJoin(Paint.Join.ROUND); mPaint.setColor(Color.BLACK);ANTI_ALIAS_FLAG 是抗锯齿开关不开的话斜线边缘全是锯齿。DITHER_FLAG 是抖动开关主要用于色彩过渡在低色彩深度的设备上能让渐变更平滑画笔绘制时顺手打开没坏处。Style.STROKE 表示只画轮廓、不填充。写手写板时如果你忘记设成 STROKE默认的 FILL 会把一个个 Path 围成的闭合区域涂黑画面会变成一坨黑色墨迹。StrokeCap.ROUND 让线条端点变成圆头。你可以想象一下没有这个参数时手指抬起瞬间线条末端是截断的看起来像用刀切了一下很生硬设成 ROUND 后端点是圆弧笔迹的收笔非常自然。StrokeJoin.ROUND 则保证折线拐角处是圆滑过渡的画“Z”字形或快速转弯时不会出现明显的尖角。2.2 坐标系与 padding触摸坐标不是直接就能用自定义 View 里最容易出 bug 的细节是坐标系混用。onDraw 的 Canvas 默认起点是 View 左上角onTouchEvent 的 getX()/getY() 也是相对 View 左上角的坐标这俩坐标系一致所以你直接拿手指坐标去画一般是没问题的。但如果你在布局里给手写板设置了 padding比如内边距 20px那么 onDraw 时 Canvas 不会自动帮你避让 padding它默认从 (0,0) 开始画。如果你的 View 背景上有边框、占位文字之类的装饰层笔记会把装饰遮住。解决方式有两种在 onDraw 里把 Canvas 整体平移canvas.translate(getPaddingLeft(), getPaddingTop())。在触摸事件里做坐标偏移x - getPaddingLeft()y - getPaddingTop()。我自己写签名板时一般不带 padding因为签名区域需要最大化利用空间。但如果你要做的是“画板里套一个固定边框”的 UI这个坑一定要提前想到否则上线后总有人反馈“字写到边框上去了”。2.3 onMeasure 与 onSizeChanged为什么你的 View 尺寸总不对默认的 View 在 XML 里设置 wrap_content 时效果其实和 match_parent 一样会把父容器剩余空间全占满。这是因为 View 的默认 onMeasure 没有处理 MeasureSpec.AT_MOST 的情况。写自绘型 View 时我基本都会重写 onMeasure 来保证 wrap_content 有一个合理的默认尺寸。Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int defaultWidth dp2px(300); int defaultHeight dp2px(200); int width resolveSize(defaultWidth, widthMeasureSpec); int height resolveSize(defaultHeight, heightMeasureSpec); setMeasuredDimension(width, height); }resolveSize 是系统提供的工具方法它会根据 MeasureSpec 的模式决定返回默认值、父容器指定值还是父容器最大值。这样写之后wrap_content 就是 300x200dp 的合理区域match_parent 依然撑满。为什么 onSizeChanged 里创建 Bitmap 而不是在 init 里创建因为 View 在 init 阶段还不知道自己最终有多大。如果宽高为 0 时就去创建 Bitmap后面必然报异常。onSizeChanged 在任何尺寸变化时都会回调包括第一次布局和屏幕旋转是创建离屏缓存最安全的位置。3. 完整实现从手指触摸到笔迹落屏3.1 触摸事件的三阶段处理DOWN、MOVE、UP 各干一件事手写板的事件处理逻辑很简单但每一步都有讲究。ACTION_DOWN 是落笔点ACTION_MOVE 是移动绘制ACTION_UP 是收笔。对应的代码如下Override public boolean onTouchEvent(MotionEvent event) { float x event.getX(); float y event.getY(); switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: mPath.reset(); mPath.moveTo(x, y); mLastX x; mLastY y; isDrawing true; getParent().requestDisallowInterceptTouchEvent(true); break; case MotionEvent.ACTION_MOVE: float dx Math.abs(x - mLastX); float dy Math.abs(y - mLastY); if (dx 1.0f dy 1.0f) { break; } mPath.quadraticBezierTo(mLastX, mLastY, (x mLastX) / 2, (y mLastY) / 2); mLastX x; mLastY y; invalidate(); break; case MotionEvent.ACTION_UP: mPath.lineTo(x, y); mBitmapCanvas.drawPath(mPath, mPaint); isDrawing false; invalidate(); break; } return true; }DOWN 里做两件事重置 Path并把起点移动到手指位置。这里要特别注意的是“重置”和“新建”的区别。有人习惯在 MOVE 里 new 一个 Path这会造成频繁创建对象、触发 GC手写场景不推荐。正确做法是复用一个 Path每次 DOWN 时 reset。MOVE 里先判断位移小于 1px 的移动直接忽略。这个阈值很重要因为有些设备的触摸采样会抖手指几乎没动也触发一连串事件如果不做过滤画出来的线会出现小毛刺。UP 里把最后一个点用 lineTo 连上然后立刻把整条 Path 画到离屏 Bitmap 上最后 invalidate。这一步是把“当前笔画”固化为“历史笔迹”的关键后面 onDraw 只需要画 Bitmap 和当前正在画的 Path 即可。3.2 quadraticBezierTo为什么 lineTo 画出来的线是折的直接拿点串画线最常见的问题是“线不够顺滑”。你快速画一个圆弧结果出来的是多边形折线。原因很好理解触摸事件的采样频率有限两个相邻采样点之间并不是连续的弧线如果你直接用 lineTo 连接Canvas 只能画直线无数段短线拼起来自然有棱角。解决方法是把直线换成一阶连续的曲线。我用的是 quadraticBezierTo代码里的技巧是把上一个触摸点作为控制点把上一个点和当前点的中点作为终点。mPath.quadraticBezierTo(mLastX, mLastY, (x mLastX) / 2, (y mLastY) / 2);这个写法的原理是一系列二次贝塞尔曲线首尾相连时只要控制点取的是两段曲线的共享端点曲线就会保持切线方向连续。视觉上的效果是笔迹平滑圆润没有尖锐折角。相比 cubicTo二次贝塞尔计算量更小而且只需要一个控制点对手写这种实时绘制场景完全够用。3.3 onDraw 的双层绘制策略Bitmap 加 PathonDraw 是整个 View 的最终呈现出口。手写板的 onDraw 只有两件事先画离屏 Bitmap再画正在绘制中的 Path。Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (mBitmap ! null) { canvas.drawBitmap(mBitmap, 0, 0, null); } if (isDrawing mPath ! null) { canvas.drawPath(mPath, mPaint); } }为什么需要离屏 Bitmap你可以想象一个极端场景用户在签名板上写了一百个字Path 有上千条。如果 onDraw 每次把上千条 Path 全部重画一遍加上手指高速移动时每秒可能刷新 60 帧性能撑不住。离屏 Bitmap 的思想是把已经完成的笔迹“固化”成一张位图每帧只画一张图和当前正在画的那条 Path工作量小了不止一个量级。onSizeChanged 里创建 Bitmap 和对应的 CanvasOverride protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); if (w 0 h 0) { if (mBitmap ! null) { mBitmap.recycle(); } mBitmap Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); mBitmapCanvas new Canvas(mBitmap); } }这里有一个很容易踩的坑如果 View 的宽高在屏幕旋转或者分屏等场景下发生变化onSizeChanged 会被触发多次。如果你每次直接 createBitmap旧 Bitmap 没回收内存就泄漏了。我一般先 recycle 再重建。注意 Bitmap.recycle() 之后不能再使用旧引用所以必须重新赋值。3.4 清空、保存、获取 Bitmap 三个扩展功能基础画线通了产品大概率还会要这三个功能清空、保存到相册、拿到 Bitmap 用于上传。实现都不难但细节值得说。清空最直接的方式是用 eraseColorpublic void clear() { if (mBitmap ! null) { mBitmap.eraseColor(Color.TRANSPARENT); } invalidate(); }保存图片时要注意签名板通常需要白底而 Canvas 透明区域存成 PNG 后是透明的直接拿给后端或 UI 看会“没背景”。稳妥做法是创建一个 A4 比例的白色 Bitmap把当前内容画上去再压缩public Bitmap getSignatureBitmap() { if (mBitmap null) { return null; } Bitmap result Bitmap.createBitmap(mBitmap.getWidth(), mBitmap.getHeight(), Bitmap.Config.ARGB_8888); Canvas canvas new Canvas(result); canvas.drawColor(Color.WHITE); canvas.drawBitmap(mBitmap, 0, 0, null); return result; }然后把 Bitmap 通过 compress 写入文件即可。保存后别忘了 recycle 临时 Bitmap尤其是大尺寸签名图一次两次无所谓频繁保存时内存会明显上涨。另外文件写入一定要在子线程做别在主线程直接 I/O否则用户画完点保存界面卡一下体验很糟糕。4. 性能优化与交互体验提升4.1 局部刷新代替全屏刷新invalidate(Rect) 的数学计算上面的基础版MOVE 事件里直接调用 invalidate()这会导致整个 View 重绘。手写板如果只是一个小签名区域问题不大但如果你把它放在全屏画板场景全屏重绘会肉眼可见地掉帧。优化思路是只刷新“发生变化的区域”。Canvas 的 invalidate(Rect) 允许你只重绘指定矩形区域。手写场景里变化区域就是上一事件点和当前事件点之间形成的矩形再向外扩展画笔宽度的一半加上一点余量int padding (int) (mPaint.getStrokeWidth() / 2) 2; int left (int) Math.min(x, mLastX) - padding; int top (int) Math.min(y, mLastY) - padding; int right (int) Math.max(x, mLastX) padding; int bottom (int) Math.max(y, mLastY) padding; invalidate(left, top, right, bottom);这段代码的关键是 padding 那一项。如果不加扩张重绘区域刚好贴着轨迹边缘由于抗锯齿和线帽的圆头效果重绘边界处经常出现“缺一块”或者“残影”的问题。向外多扩几像素视觉上就干净了。实测下来局部刷新在笔画密集、View 面积大的场景下绘制耗时能降低一个量级。4.2 与 ScrollView、ViewPager 的滑动冲突处理手写板放在可滚动容器里时会遇到一个经典问题手指一画线页面跟着滚动了。原因很简单父容器在拦截触摸事件时发现垂直位移大于水平位移判定为滑动直接把事件抢走了。处理方案是事件分发里的 requestDisallowInterceptTouchEvent。在 ACTION_DOWN 时请求父容器不要拦截getParent().requestDisallowInterceptTouchEvent(true);但这招比较“粗暴”。如果手写板嵌在竖向列表里用户想上下滚动列表时触碰到手写板就被锁死了。更精细的做法是设置一个阈值当水平位移大于垂直位移时认为是画线锁定父容器当垂直位移占主导时主动放行让列表滚动。case MotionEvent.ACTION_MOVE: float dx Math.abs(x - mLastX); float dy Math.abs(y - mLastY); if (dx dy !mParentInterceptLocked) { getParent().requestDisallowInterceptTouchEvent(true); mParentInterceptLocked true; } break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: getParent().requestDisallowInterceptTouchEvent(false); mParentInterceptLocked false; break;这里还要注意 ACTION_CANCEL 的处理。系统在父容器决定拦截时会给子 View 发一个 CANCEL 事件如果不在这个分支里做状态清理会出现“笔画画一半消失了下次落笔起点错乱”的 bug。我的习惯是 CANCEL 和 UP 做同样的收尾处理把 Path 画到 Bitmap 上只是不触发保存逻辑。4.3 硬件加速与 Bitmap 使用的注意事项Android 3.0 之后的设备默认开启硬件加速Canvas 绘制会走 GPU。这个开关对手写板是有利有弊的。好的地方是画 Bitmap、画 Path 这类基础操作速度很快坏的地方是某些 Canvas API 在硬件加速下不受支持比如 clipPath 在某些系统版本上表现不稳定还有离屏 Bitmap 的尺寸限制。离屏 Bitmap 的坑比较隐蔽。在硬件加速下Canvas 绘制 Bitmap 时如果 Bitmap 尺寸过大超过硬件纹理上限常见的是 4096x4096 或者 8192x8192不同 GPU 不同会直接抛异常或者什么都不画。我的经验是手写板尺寸尽量不要超过屏幕大小如果是平板上的全屏画板建议把 Bitmap 控制在 2048x2048 以内超出就分块或者降采样。另外一个经常被忽略的点是 View 的 layerType。有人为了性能给手写板设置 setLayerType(LAYER_TYPE_HARDWARE)这确实能让 onDraw 的输出缓存在 GPU 上但如果你在代码里动态修改 Paint 的 strokeWidth 或颜色可能会导致缓存更新不及时画面出现残影。手写板场景我一般不加硬件层让系统按普通 View 走局部刷新已经足够流畅。5. 常见问题与排查技巧实录5.1 手写板开发高频问题速查表问题现象根本原因解决方案画出来的线有毛刺没开抗锯齿Paint 初始化加 ANTI_ALIAS_FLAG线是折线不够圆滑用了 lineTo 连接采样点改用 quadraticBezierTo控制点取上一次点画完一笔下一笔开始点连到上一笔末尾收到 ACTION_CANCEL 时没处理 PathCANCEL 分支里 reset Path笔迹消失或残影局部刷新矩形边界没包含线条invalidate 矩形的 padding 扩展至少 strokeWidth/2页面跟着手指滚动父容器拦截了触摸事件requestDisallowInterceptTouchEvent(true)屏幕旋转后笔迹丢失onSizeChanged 重建 Bitmap 时没保留旧图先把旧 Bitmap 绘制到新 Bitmap 上保存的图片背景透明Canvas 默认透明背景先 drawColor(Color.WHITE) 再画笔迹大屏上画满后卡顿全屏 invalidate 或 Path 数量过多改成局部刷新配合离屏 Bitmap快速画线时出现断点事件采样间隔大前后点距离远用二次贝塞尔平滑连接内存持续上涨频繁创建 Bitmap 且不回收统一复用 Bitmap重建前先 recycle这个表里的每一条基本都对应我在真实项目里帮别人排查过的 case。尤其是“笔迹丢失”和“透明背景”这两条看起来很基础但踩的人非常多。5.2 两个排查手写问题的实用调试经验排查自定义 View 问题时我一般分两步走。第一步是打开开发者选项里的“显示布局边界”先看 View 的实际绘制区域是不是你预期的大小。很多“画不出来”“画了一半”的问题根因其实是 View 只有一丁点大或者被父布局裁剪了而不是绘图逻辑写错了。第二步是用 Log 打印触摸坐标。在 onTouchEvent 里打一行 Log记录 event 的 action、x、y。如果你发现手指移动时事件没有连续到达或者是跳到某个奇怪坐标多半是父容器在跟你抢事件如果事件正常但画面没变问题就在 invalidate 或 onDraw。先用这个方法把“事件层”和“绘制层”分开定位比盲改代码快得多。还有一个我多次踩坑的细节自定义 View 的构造方法。如果你只在代码里写了 new HandWriteView(context)而 XML 里用的是 com.xx.HandWriteView /系统会调用带 AttributeSet 的构造方法。如果你只实现了无参构造XML 布局直接崩溃。保险写法是在无参构造里 this(context, null)然后把初始化逻辑全部放在两个参数的构造里最后再补一个三参数的构造兜底。5.3 进阶扩展压感、笔锋和撤销功能的实现思路基础手写板做完之后如果想再往上走一层有几个方向很值得加。第一个是笔锋效果原理是计算当前触摸点的移动速度速度越快画笔越细速度越慢画笔越粗模拟真实笔迹的轻重变化。计算速度可以用两次事件之间的距离除以时间差但直接拿瞬时速度去改 strokeWidth 会非常抖我的经验是先对最近几次速度求平均再做一次滑动窗口平滑效果才自然。第二个是压感。硬件支持的话MotionEvent.getPressure() 能拿到按压力度把压力映射到 strokeWidth 即可。不支持的设备上可以退化为速度模拟。第三个是撤销。最稳妥且简单的实现是维护一个 List每次 ACTION_UP 把当前 Path 入栈撤销时弹出最后一条路径然后清空 Bitmap 把所有路径按顺序重新绘制一遍最后 invalidate。这个方案在路径数量不多时完全没有性能问题但如果用户画了几百笔重绘的耗时就会显现这时候可以改用 Bitmap 快照栈每完成一笔保存一份 Bitmap内存换时间看场景取舍。这些进阶功能我实际做签名板时只用了笔锋和撤销压感在大多数 Android 设备上并不普及做成可选配置就好。开发时不要把功能做满优先保障主流程稳定再按用户反馈逐步加。最后再分享一个我在项目里的习惯自定义 View 的代码写完尽量抽出独立的公共类Paint、Path、Bitmap 这些成员变量不要散落在 Activity 里。这样后面接业务层时Activity 只需要调用 setPenColor、setStrokeWidth、clear、getSignatureBitmap 这几个方法整个控件像黑盒一样可用也不容易因为业务逻辑污染绘图状态。手写板这个案例跑通之后你会发现自定义 View 的核心套路是通用的——测量、绘制、触摸、缓存这套组合拳打熟了其他自绘控件基本都能举一反三。