做自定义 View 这件事几乎每个 Android 开发都会遇到。早期我还在用 ProgressBar 硬凑需求直到产品经理拿着设计稿说要一个两端带渐变、中间显示百分比、还能根据进度变颜色的环形进度条系统控件当场就废了。后来老老实实把自定义 View 的全流程走通才发现很多看起来高深的东西拆开看无非就是测量、布局、绘制、触摸这几件事的组合。这篇文章就把我这些年写自定义 View 的完整思路、核心代码和踩过的坑一次性整理出来适合已经会写基础 Java 或 Kotlin、但没系统玩过自绘控件的开发者参考。1. 先分清你要做哪种自定义 View重绘、组合还是全新自绘很多人一提到自定义 View 就想到继承 View、重写 onDraw。实际上在日常项目里真正的从零自绘只占一小部分。动手前先分清楚你的需求到底属于哪一类可以少走很多弯路。1.1 继承已有控件改颜色改外观的轻量改法如果你只是想改个背景、改个圆角、改个默认文字颜色最轻量的方式就是继承已有的控件比如继承 TextView 重写 onDraw或者直接给现有控件写个 Drawable。举个例子产品想要一个左边图标、右边文字、整体像按钮的组件。继承 LinearLayout 塞两个子控件是常规操作但如果只是让某个文字颜色根据状态变化重写一个 AppCompatTextView 的子类在 onDraw 前后切换 Paint 颜色就够了。这种方式的好处是几乎不碰测量和布局系统帮你处理了绝大部分工作。1.2 组合控件把架子搭好再封装组合是业务开发里最常用的一种。比如常见的搜索框、角标容器、带清除按钮的输入框本质上是多个系统的控件叠加在一起然后对外暴露统一的设置方法。组合控件通常继承某个 ViewGroup在代码里 addView 或通过 XML inflate 子布局。它不涉及复杂的 Canvas 绘制但要注意测量模式、padding 的传递和子控件的布局位置。当时我做过一个带删除按钮的搜索框就是继承 LinearLayout动态 inflate 一个布局然后对外提供 getEditText()。这种方案开发效率高可维护性也不错。唯一的坑是如果父控件的 padding 没有传给 EditText输入文字会顶到边缘。这个问题的本质是组合控件也要遵循子控件应该避开 padding 区域的规矩。1.3 完全自绘从零开始画一个属于你的控件当你需要极度自由的外观、动画和交互时就会走上真正意义上的自定义 View。直接继承 View 或 ViewGroup重写 onMeasure、onDraw甚至自己处理触摸事件。完全自绘的优点是想要什么就画什么不受系统控件限制缺点也很明显所有事情都要自己管包括测量尺寸、padding 处理、状态保存、事件分发、性能优化。环形进度条、仪表盘、滑动刻度尺、签到日历这类组件一般都会走这条路。我的建议是能组合解决的别自绘需要自绘的一定要把测量和绘制流程弄透。接下来这块就是硬核内容。2. 把绘制流程吃透onMeasure、onLayout、onDraw 到底怎么配合很多新手第一次看自定义 View 的源码会觉得 onMeasure、onLayout、onDraw 之间的关系很模糊。其实可以这样理解measure 是爸妈问你你想要多大地方layout 是爸妈最终给你安排在哪draw 是你在这个地方怎么布置家具。三个步骤按严格顺序执行上一阶段的结果会成为下一阶段的输入。2.1 MeasureSpec 与三种测量模式UNSPECIFIED、EXACTLY、AT_MOSTonMeasure 里最重要的参数是 MeasureSpec它是一个 32 位整数高 2 位代表模式低 30 位代表尺寸。系统提供了三个模式EXACTLY父布局已经确定了精确尺寸比如你在 XML 里写死了 100dp或者用了 match_parent除了特殊情况。此时你应该直接使用 MeasureSpec 给的尺寸。AT_MOST父布局给你一个上限你最多不能超过这个值。wrap_content 就是这种模式。关键点来了系统自带的很多控件 wrap_content 时等价于 match_parent这是历史遗留问题自定义 View 时不处理的话你的控件 wrap_content 也会被撑满。UNSPECIFIED父布局没有限制常见于 ScrollView、RecyclerView 这种可滚动的容器。你可以自由决定尺寸。我在自定义 View 里通常这样写 onMeasureOverride protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthSize MeasureSpec.getSize(widthMeasureSpec); int widthMode MeasureSpec.getMode(widthMeasureSpec); int heightSize MeasureSpec.getSize(heightMeasureSpec); int heightMode MeasureSpec.getMode(heightMeasureSpec); int defaultSize dp2px(120); if (widthMode MeasureSpec.AT_MOST) { widthSize Math.min(widthSize, defaultSize); } if (heightMode MeasureSpec.AT_MOST) { heightSize Math.min(heightSize, defaultSize); } setMeasuredDimension(widthSize, heightSize); }这里默认宽度按 120dp 来如果你希望 wrap_content 是更大的尺寸可以改这个默认值。注意两个方向要分别判断经常有人只处理了宽度忘了高度结果子 View 高度塌陷。2.2 从 onMeasure 到 onDraw系统帮你走了哪几步完成 onMeasure 之后会依次执行 onSizeChanged、onLayoutView 默认空实现ViewGroup 需要实现、onDraw。onSizeChanged 在尺寸变化时回调用来初始化一些依赖尺寸的参数。我经常在这里计算文字的起始位置、弧形半径、渐变的 LinearGradient 范围避免每次 onDraw 都重复算。整个顺序是这样的父容器调用 child.measure()触发 onMeasure。测量完成后调用 child.layout()触发 onLayout记录 left/top/right/bottom。当 view 尺寸或位置改变时触发 onSizeChanged。最终调用 onDraw传入 Canvas 开始绘制。在实际调试中我会在 onDraw 里打一个 Log如果发现 onDraw 被频繁调用通常不是循环动画就是 invalidate 刷太猛后面会专门讲这块。2.3 坐标系、Canvas 层级与位图缓存的基本认知Canvas 的坐标系是 x 轴向右、y 轴向下原点在 View 的左上角。很多人一上手画圆弧就搞不清起始角度。以 drawArc 为例0 度是钟表三点方向顺时针是正角度。所以画一个从 12 点方向开始的进度条起始角度要写 -90。Canvas 的层级可以理解为后来者居上。你调用 save() 和 restore() 可以把 canvas 的状态如旋转、平移、透明度分层管理但要小心 save 和 restore 必须成对出现否则画布状态会错乱。另外一个容易被忽略的是图层缓存。如果你只是改变单个子 View 的属性做动画可以用 setLayerType(View.LAYER_TYPE_HARDWARE, null) 开启硬件层动画结束时再恢复。但如果你在 onDraw 里不断创建新位图那性能基本就废了。下面这个环形进度条的例子就是完全自绘的典型场景我们直接实战。3. 实战手写一个可定制的环形进度条完整 Java 示例环形进度条是自定义 View 的经典练手项目。它既有基础绘制圆环、弧线、文字又涉及尺寸适配、自定义属性和动画做完之后大部分自绘技巧都能串起来。3.1 先画底环和进度弧Canvas.drawArc 与 Paint 的度量问题核心逻辑不复杂底环是一个空心圆进度是一段圆弧。用 Paint.Style.STROKE 描边strokeWidth 就是圆环的厚度。drawArc 的 left/top/right/bottom 决定外接矩形。注意 STROKE 模式下弧线会以描边宽度为中心线绘制这意味着笔画的一半落在矩形外一半落在矩形内。如果想让进度弧完整地留在 View 边界内画弧的外接矩形必须向内缩进 strokeWidth / 2。下面给出一个完整可运行的基础版本只保留了最核心的绘制逻辑方便理解public class RingProgressView extends View { private Paint backgroundPaint; private Paint progressPaint; private float progressAngle; // 0-360 private int progressColor Color.parseColor(#3F7FFF); private float ringWidth dp2px(16f); private int startAngle -90; public RingProgressView(Context context) { this(context, null); } public RingProgressView(Context context, Nullable AttributeSet attrs) { super(context, attrs); init(); } private void init() { backgroundPaint new Paint(Paint.ANTI_ALIAS_FLAG); backgroundPaint.setStyle(Paint.Style.STROKE); backgroundPaint.setStrokeWidth(ringWidth); backgroundPaint.setStrokeCap(Paint.Cap.ROUND); backgroundPaint.setColor(Color.parseColor(#E8ECF4)); progressPaint new Paint(Paint.ANTI_ALIAS_FLAG); progressPaint.setStyle(Paint.Style.STROKE); progressPaint.setStrokeWidth(ringWidth); progressPaint.setStrokeCap(Paint.Cap.ROUND); progressPaint.setColor(progressColor); progressAngle 270f; // 默认 75% } Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int defaultSize dp2px(120); int width resolveSize(defaultSize, widthMeasureSpec); int height resolveSize(defaultSize, heightMeasureSpec); int minDim Math.min(width, height); setMeasuredDimension(minDim, minDim); } Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); float halfRing ringWidth / 2f; float left halfRing getPaddingLeft(); float top halfRing getPaddingTop(); float right getWidth() - halfRing - getPaddingRight(); float bottom getHeight() - halfRing - getPaddingBottom(); float diameter Math.min(right - left, bottom - top); // 底环 canvas.drawCircle(left diameter / 2f, top diameter / 2f, diameter / 2f, backgroundPaint); // 进度弧 RectF arcRect new RectF(left, top, left diameter, top diameter); canvas.drawArc(arcRect, startAngle, progressAngle, false, progressPaint); } public void setProgress(float progressPercent) { this.progressAngle progressPercent * 360f / 100f; invalidate(); } private int dp2px(int dp) { return Math.round(getResources().getDisplayMetrics().density * dp); } }由于进度弧使用 round cap首尾会有圆弧帽子这会让实际视觉长度超出你传入的 sweepAngle。实测下来进度 100% 时两根线会重叠看起来像多出来一截。网上很多文章没提这个真做起来会很困惑。3.2 加上中间百分比文字与颜色渐变进度的意义在于告诉用户当前状态所以中间通常要显示百分比。绘制文字时要注意文字居中不是简单的 setTextAlign(CENTER) 然后 drawText(text, centerX, centerY)。文字的基准线是 baseline不是文字的垂直中心。需要这样计算// 注意在 onSizeChanged 方法里初始化避免 onDraw 中频繁创建对象 private Paint textPaint new Paint(Paint.ANTI_ALIAS_FLAG); private Rect textBounds new Rect(); textPaint.setTextSize(sp2px(18f)); textPaint.setColor(Color.parseColor(#333333)); textPaint.setTextAlign(Paint.Align.CENTER); String text 75%; textPaint.getTextBounds(text, 0, text.length(), textBounds); float baseline centerY - (textBounds.top textBounds.bottom) / 2f; canvas.drawText(text, centerX, baseline, textPaint);getTextBounds 返回的 top 是负数bottom 可能是正数所以用这样的公式可以求出视觉上的垂直中心。渐变方面最简单的做法是给 progressPaint 设置一个 LinearGradient。但有个坑LinearGradient 是基于坐标的如果把 Shader 设置给 PaintView 尺寸一变渐变范围就错位了。所以要在 onSizeChanged 里重新创建 LinearGradient并调用 progressPaint.setShader(...)。我没有在 init 里直接 setShader就是为了避免尺寸变化时发色异常。3.3 动起来属性动画驱动进度与避免内存泄漏的细节让进度动起来很多人直接开一个线程循环 sleep(16)——这在小 demo 里没毛病但工程化之后容易在页面关闭后继续刷新甚至导致内存泄漏和 CPU 浪费。推荐的做法是用属性动画 ObjectAnimator 驱动进度值或者用 ValueAnimator 配合回调更新 View。ValueAnimator animator ValueAnimator.ofFloat(0f, 75f); animator.setDuration(1000); animator.setInterpolator(new DecelerateInterpolator()); animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { Override public void onAnimationUpdate(ValueAnimator animation) { float value (float) animation.getAnimatedValue(); ringProgressView.setProgress(value); } }); animator.start();ValueAnimator 本身是运行在 Choreographer 的帧回调里的不需要你管理线程。Activity 销毁时记得调用 animator.cancel()否则动画会继续跑一个周期。还有一个很容易踩的坑如果使用 ObjectAnimator.ofFloat(ring, progress, 0f, 75f)需要为控件提供 setProgress(float) 方法这里的 setProgress 我为了支持百分比内部用的是 progressPercent 参数但属性名progress也能映射成功因为它只找 setter 方法名。不过要小心 setter 里调用了 invalidate动画会每帧刷新这没问题。但如果你在 setter 里写了太重的计算确实会影响动画流畅度。4. 触摸反馈和事件冲突让自绘 View 响应用户操作画得再好看的控件如果不能响应点击、滑动、拖拽在实际业务里也活不下去。自定义 View 的触摸处理核心是事件分发链路和手势识别器。4.1 事件分发链路从 Activity 到 View 的完整路径触摸事件从底层进入系统后先发给 Activity再往下分发到 PhoneWindow、DecorView最后传到目标 ViewGroup 和 View。中间每个 ViewGroup 都经历三个方法dispatchTouchEvent负责任件分发onInterceptTouchEvent决定是否拦截当前手势序列onTouchEvent处理事件并决定返回值 true/false一个常见的误解是onInterceptTouchEvent 只对 DOWN 事件有效。实际上它对整个事件序列都会调用只不过如果在 DOWN 时返回 false后续事件可能继续分发也可能因为子 View 消费情况不同而改变。在自定义 View 中如果你只处理 DOWN 事件返回 true那么这个控件后续的 MOVE、UP 也会继续收到事件。如果你在 DOWN 时返回 false后续事件不会再传给你。4.2 用 GestureDetector 识别点击、滑动与长按自己写 ACTION_DOWN、ACTION_MOVE 的判定逻辑很容易出错尤其是区分点击和滑动需要定义阈值。强烈建议使用 GestureDetectorprivate GestureDetector gestureDetector; gestureDetector new GestureDetector(getContext(), new GestureDetector.SimpleOnGestureListener() { Override public boolean onDown(MotionEvent e) { return true; // 必须返回 true否则后续事件不会被识别 } Override public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { // 处理拖动逻辑 return true; } Override public boolean onSingleTapUp(MotionEvent e) { // 处理点击逻辑 return true; } }); Override public boolean onTouchEvent(MotionEvent event) { gestureDetector.onTouchEvent(event); return super.onTouchEvent(event); }这里特别要注意 onDown 的返回值。如果你希望接收后续 MOVE/UP 事件onDown 必须返回 true或者 onTouchEvent 返回 true。很多新手的控件点了没反应多半是 onDown 返回了 false。4.3 滑动冲突的经典解法父容器的 requestDisallowInterceptTouchEvent当你把自定义 View 放进 ScrollView 或 ViewPager 时垂直拖动手势经常被父容器抢走。最经典的处理方式是在子 View 的 ACTION_DOWN 里调用 parent.requestDisallowInterceptTouchEvent(true)并在合适时机恢复 false。Override public boolean onTouchEvent(MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: getParent().requestDisallowInterceptTouchEvent(true); break; case MotionEvent.ACTION_MOVE: // 根据业务逻辑判断是否要回交事件给父容器 if (shouldInterceptByParent(event)) { getParent().requestDisallowInterceptTouchEvent(false); } break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: getParent().requestDisallowInterceptTouchEvent(false); break; } return true; }为什么 DOWN 要请求禁止拦截因为很多父容器是在 DOWN 之后、MOVE 之前调用 onInterceptTouchEvent 来判断是否需要拦截的。你如果不在 DOWN 里提前声明我要自己处理一个快速的滑动可能还没到你的 MOVE 逻辑里就被父容器拦截了。5. 自定义属性与状态保存让你的 View 被 XML 和系统更好接纳写到这个阶段你的 View 已经能画、能动、能触摸了。但一个规范化的自定义 View 还要能像系统控件一样支持 XML 配置属性、Array 配置样式以及状态保存与恢复。5.1 在 attrs.xml 声明属性并获取 TypedArray自定义属性第一步是在 res/values/attrs.xml 里声明resources declare-styleable nameRingProgressView attr nameprogress formatfloat / attr nameringWidth formatdimension / attr nameprogressColor formatcolor / attr namebackgroundRingColor formatcolor / attr namestrokeCap formatenum enum namebutt value0 / enum nameround value1 / enum namesquare value2 / /attr /declare-styleable /resources然后在构造函数里读取private void init(Nullable AttributeSet attrs) { if (attrs ! null) { TypedArray ta getContext().obtainStyledAttributes(attrs, R.styleable.RingProgressView); progressPercent ta.getFloat(R.styleable.RingProgressView_progress, 0f); ringWidth ta.getDimension(R.styleable.RingProgressView_ringWidth, dp2px(16f)); progressColor ta.getColor(R.styleable.RingProgressView_progressColor, Color.parseColor(#3F7FFF)); backgroundRingColor ta.getColor(R.styleable.RingProgressView_backgroundRingColor, Color.parseColor(#E8ECF4)); int capIndex ta.getInt(R.styleable.RingProgressView_strokeCap, 1); ta.recycle(); // 必须回收 } ... }TypedArray 是资源池里的对象不调用 recycle() 虽然不太会崩但会影响后续资源的复用。这个是老生常谈但每次 review 都会看到有人漏掉。5.2 支持 View 状态保存横竖屏切了别丢进度如果你的 View 有动态数据比如进度值需要在 Activity 重建时保留。实现方式有两种让 Activity 在 onSaveInstanceState 里保存变量或者在 View 内部重写 onSaveInstanceState / onRestoreInstanceState。View 自带的可保存状态叫 SavedState是个 Parcelable。你可以在自定义 View 里这样写Override protected Parcelable onSaveInstanceState() { Parcelable superState super.onSaveInstanceState(); SavedState ss new SavedState(superState); ss.progress progressPercent; return ss; } Override protected void onRestoreInstanceState(Parcelable state) { SavedState ss (SavedState) state; super.onRestoreInstanceState(ss.getSuperState()); this.progressPercent ss.progress; invalidate(); }SavedState 的完整写法需要实现一个 Parcelable 内部类这里篇幅有限不再展开。核心思想是不要直接把 null 返回要把 superState 传下去否则系统控件的基础状态比如 id会丢失fragment 的恢复也可能出问题。5.3 结合 style 与主题让控件更规范一个更符合 Android 规范的做法是自定义 View 支持从 theme 里读取默认样式属性。比如style nameTheme.App parent... item nameringProgressStylestyle/Widget.App.RingProgress/item /style然后在构造函数里用第三参数的 defStyleAttr 传给 obtainStyledAttributespublic RingProgressView(Context context, Nullable AttributeSet attrs) { this(context, attrs, R.attr.ringProgressStyle); }这种方式适合团队内部做设计系统时使用。如果你只是个人项目直接在 XML 里设置属性就够了追求所有主题联动反而会让代码复杂。6. 性能优化与避坑自绘控件常见问题排查画得出来和画得流畅是两回事。我见过很多自定义 View 卡顿问题并不是 Canvas 绘制本身而是精心设计了一些性能陷阱。6.1 wrap_content 为什么突然失效一个典型排查链路这是我第一次写自定义 View 时最疑惑的问题。我在 XML 里写了com.demo.MyView android:layout_widthwrap_content android:layout_height100dp /结果 MyView 却占满了整个屏幕宽度。排查链路的起点是 onMeasure。系统默认的 View.onMeasure 在 EXACTLY 模式下没有问题但 AT_MOSTwrap_content模式下会直接使用父容器给的最大尺寸。如果不重写 onMeasure 并显式设置一个默认大小你的 View 和 match_parent 没有区别。我当时是直接在 onMeasure 里加了一个打印Log.d(MyView, widthMode MeasureSpec.getMode(widthMeasureSpec) widthSize MeasureSpec.getSize(widthMeasureSpec));打出来发现宽度的 mode 是 AT_MOSTsize 是屏幕宽度于是恍然大悟。这个问题的根因不是系统 bug而是 Google 的设计决策让 View 的开发者自己决定 wrap_content 的含义。解决方式Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int defaultWidth dp2px(200); int defaultHeight dp2px(120); setMeasuredDimension( MeasureSpec.getMode(widthMeasureSpec) MeasureSpec.AT_MOST ? defaultWidth : MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getMode(heightMeasureSpec) MeasureSpec.AT_MOST ? defaultHeight : MeasureSpec.getSize(heightMeasureSpec) ); }6.2 invalidate 和 postInvalidate主线程与子线程刷新的边界很多刚接触自定义 View 的人会问子线程里能不能调用 invalidate答案是绝对不能直接在子线程里调用 invalidate因为 invalidate 会触发 UI 线程的重绘子线程无法更新 UI。解决办法有两个在子线程中调用 postInvalidate()系统会通过 Handler 把刷新动作切回主线程。如果使用 ValueAnimator 或 ObjectAnimator不需要关心线程因为它们本身就是主线程回调的。关于 invalidate 还有两个重点invalidate() 不等于立即执行 onDraw它只是向系统请求尽快重新绘制。如果你在同一帧内连续调用多次 invalidate可能只会触发一次 onDraw。如果只想重绘局部区域可以使用 invalidate(Rect) 或 invalidate(left, top, right, bottom)。在自定义控件中只刷新矩形区域能显著减少重绘面积。实际调试时我习惯在 onDraw 里加一个 CountingIdlingResource 看不到 WebView 可以做累计计数如果 onDraw 的调用次数莫名其妙暴增就要注意是不是某个动画每帧都调用了多个 invalidate。6.3 绘制性能减少 overdraw、优化 Paint 与避免频繁分配对象自绘控件的卡顿大多不是因为 drawArc 本身慢而是因为过度绘制和频繁创建对象。过度绘制方面最常见的场景是先画了一个矩形背景再在上面画圆又把圆的部分再走一遍渐变。相关工具就是开发者选项里的显示布局边界和 GPU 渲染模式分析能看到哪里重绘得太狠。优化手段通常是在 onDraw 起始处使用 clipRect 裁剪掉不需要绘制的区域。不需要透明背景时在 XML 里设置 android:background 为不透明颜色避免系统默认图层叠加。静态内容尽量只绘制一次缓存到 Bitmap动态变化的部分再绘制在表面。避免频繁分配对象方面onDraw 每秒可能被调用 60 次以上如果在里面创建 Paint、RectF、Path就会触发频繁 GC导致掉帧。正确做法是在 init 或 onSizeChanged 中提前创建并复用这些对象。我写环形进度条时RectF 就是在 onSizeChanged 里算好onDraw 里直接 drawArc。另外还有硬件加速的坑。从 Android 4.0 开始硬件加速是默认开启的大部分 Canvas 绘制 API 都支持硬件加速但有几个方法在硬件加速下表现不同例如 clipPath 在某些版本上会有兼容问题。如果发现同样的代码在模拟器正常、真机花屏先考虑关闭该 View 的硬件加速试试view.setLayerType(View.LAYER_TYPE_SOFTWARE, null);不过这只是排查手段不要长期关闭否则 GPU 渲染的优势就没了。最后再分享一个我自己的习惯真正开发一个复杂自定义 View 之前我会先建一个空白的测试 Activity把这个 View 的所有状态、属性通过简单的方式暴露出来用一堆随手写的配置反复验证。因为项目里的布局嵌套和主题样式往往是隐藏的变量第一次跑往往和你设想的差很远。等画出来了再考虑规范封装。这种方式让我省下了大量联调时间也避开了不少本文里提到的坑。