很多新手朋友一碰到稍复杂一点的UI效果,第一反应是去网上找现成的第三方库,找不到就到处问"这个效果怎么做"。问了一圈发现答案都是"用自定义View"的时候,就又开始发怵,觉得这是大佬才会的东西。其实自定义View没有想象中那么高不可攀,它本质上就是一个普通的Java类,只不过系统给了一个机会,让你把自己的绘制逻辑塞进Android的渲染管线里。我把这几年写自定义View踩过的坑、总结出来的套路,以及从"能画出来"到"画得流畅、能复用、好维护"的完整进阶路径整理成这篇指南,希望能帮你把这层窗户纸捅破。
这篇文章的内容涵盖了:什么时候该用自定义View、测量与绘制的基本功、触摸事件怎么和绘制联动、性能优化怎么做、以及自定义View怎么封装成可以被团队其他人放心使用的组件。无论你是刚接触自定义View的入门者,还是已经能画点东西但不知道怎么做得更专业的进阶者,都能在这里找到对应的内容。
1. 自定义View不是炫技,而是"被逼无奈"的选择
1.1 为什么要自己动手画UI
先搞清楚一个根本问题:什么时候需要自定义View?我在实际项目里总结下来,基本逃不出下面这几类场景。
第一类是系统控件满足不了需求。比如产品要一个带渐变色和阴影的圆环进度条,你用ProgressBar加上layer-list可能勉强能做,但一旦涉及动画、交互、多状态切换,系统控件就会让你写得非常别扭。再比如要做类似随手记的环形账本、仿Keep的运动圆环,这些本质上就是"画弧线+动画",用系统控件的思路去拼,几乎拼不出来。
第二类是布局效率问题。复杂的嵌套布局会带来严重的性能损耗,一个界面里线性布局套相对布局再套帧布局,层级深了之后,measure和layout阶段会做大量重复计算。如果把这个复杂的UI画在一个自定义View里,一次绘制就能搞定,性能提升立竿见影。
第三类是交互方式的定制。系统控件提供的事件处理逻辑是固定的,你要做一个可以拖拽排序的宫格,或者可以滑动删除的列表项,在已有控件上改造的成本非常高,不如直接在自定义View里从底层控制触摸反馈。
所以,自定义View不是一种炫技,更多时候是产品需求倒逼出来的技术方案。判断一个需求要不要用自定义View,我会先问自己三个问题:系统控件能不能做?如果能做,性能上是不是可以接受?改动成本是不是比自定义View更低?如果前两个问题的答案都是否定,或者第三个问题的答案是否定,那就放心大胆地上自定义View。
1.2 自定义View的三种形态,先分清再动手
自定义View不是只有一个形态。很多人提到自定义View就想到"继承View然后重写onDraw",其实这只是其中一种。从工程角度来说,常见的自定义UI有三种写法:
- 继承View/View的子类,重写核心方法(onMeasure、onDraw、onTouchEvent)。这是最纯粹的自定义View,适合"无中生有"地绘制新图形。
- 继承ViewGroup/现有布局容器,重写onMeasure和onLayout。这类适合做"组合型"的自定义组件,比如自定义流式布局、环形菜单。它的核心不在于绘制,而在于测量和排版子View。
- 自定义Drawable。这类介于两者之间,不继承View,而是直接写一个Drawable,可以应用到任意View的背景上。好处是逻辑独立、方便复用,很多复杂的渐变、圆角、占位图都适合用Drawable实现。
在我带过的团队里,新人对这三种形态的区分经常是模糊的。如果你要做的组件内部包含多个可独立交互的子元素(比如带图标的按钮),优先考虑继承ViewGroup组合子View;如果你要做的组件本质上是"一张动态变化的画"(比如仪表盘、图表),那继承View直接绘制更合适;如果你只是想做一个装饰性的背景效果,独立Drawable是最优雅的方案。
这三种形态没有优劣之分,关键是适配场景。下面我会以最核心的"继承View重写核心方法"为主线来讲,因为这个路线把绘制、测量、事件、动画、性能这些基本功全部覆盖到了,学会了之后再去看其他两种形态,会发现很多机制是互通的。
1.3 第一个自定义View:从最简单的圆环开始
理论说再多,不如动手写一行代码。后面所有章节的讲解都会围绕一个贯穿全文的案例——圆形进度环,我会带着它从"能显示"一步一步走到"生产级可用"。
先看最基础的版本:
public class CircleProgressView extends View { private Paint mPaint; private float mProgress = 0f; public CircleProgressView(Context context) { this(context, null); } public CircleProgressView(Context context, @Nullable AttributeSet attrs) { this(context, attrs, 0); } public CircleProgressView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(); } private void init() { mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mPaint.setStyle(Paint.Style.STROKE); mPaint.setStrokeWidth(20f); mPaint.setStrokeCap(Paint.Cap.ROUND); mPaint.setColor(0xFF4A90E2); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); float cx = getWidth() / 2f; float cy = getHeight() / 2f; float radius = getWidth() / 2f - mPaint.getStrokeWidth() / 2f; canvas.drawCircle(cx, cy, radius, mPaint); } }这段代码能在View的中心画一个圆环。注意几个细节:Paint.ANTI_ALIAS_FLAG一定要加上,不然边缘会有锯齿;圆的半径要减去strokeWidth的一半,否则笔画会超出View边界,边缘被裁切掉。这都是新手最容易忽略的地方。
在XML里直接使用:
<com.example.demo.CircleProgressView android:layout_width="100dp" android:layout_height="100dp" />这样你就在屏幕上看到了一个圆环。但距离"进度环"还差两步:一是只画了一个完整的圆,没有"进度缺口";二是进度值还没法从外部设置。后面我会一步步补全。
2. 测量与绘制:摸清View从出生到上屏的完整路径
2.1 构造函数:为什么有四个重载,该重写哪个
写自定义View的第一个坑就是构造函数。View有四个构造函数,很多新手分不清该重写哪个,干脆全部重写,或者在错误的构造函数里做初始化,导致属性不生效。
四个构造函数从左到右分别对应四种使用场景:
// XML加载时调用,属性集不为空 public CircleProgressView(Context context, @Nullable AttributeSet attrs) // XML加载且布局中有style引用时调用 public CircleProgressView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) // API 21+,支持defStyleRes public CircleProgressView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes)一个最稳妥的写法是:默认只重写第一个或第三个构造函数,然后在内部统一转发到一个私有init方法里。我个人的习惯是重写(Context, AttributeSet)和(Context, AttributeSet, int)这两个,然后分别调用init(attrs, defStyleAttr)。这样既支持XML创建,也支持代码里创建,属性解析逻辑只写一份。
这里有个重要的点:不要在构造函数里读取getWidth()和getHeight(),因为这个时候View还没有被添加到窗口,宽高都是0。如果你需要根据宽高做初始化,比如创建Bitmap或者计算圆心,应该在onSizeChanged里做,这是很多人容易踩的坑。
2.2 onMeasure:理解MeasureSpec才能掌控尺寸
measure阶段大概是自定义View里最劝退新人的部分。但只要搞清楚一个核心概念——MeasureSpec,事情就简单了。
每一个View的测量结果,本质上是一个"期望尺寸+约束条件"的组合,这个组合叫MeasureSpec。它是一个32位的int值,高2位代表模式,低30位代表尺寸。三种模式:
UNSPECIFIED:父容器不限制尺寸,View想要多大就给多大。ScrollView、RecyclerView在测量子项时经常用这种模式。EXACTLY:父容器已经确定了精确尺寸,比如match_parent或者固定值。这时View不需要自己算,measSize里的值就是最终大小。AT_MOST:父容器给了一个上限,View的尺寸不能超过这个值,但具体是多少可以由View自己算。wrap_content对应这种模式。
默认情况下,如果你不重写onMeasure,View的行为是:match_parent和固定值都满足EXACTLY,没问题;但wrap_content会被当成match_parent处理,直接填满父容器。这是一个反直觉的坑——很多人自定义View里用wrap_content结果发现撑满了全屏,就是因为没重写onMeasure。
所以我需要重写onMeasure,给wrap_content一个合理的默认大小:
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int defaultSize = dp2px(100); int width = resolveSize(defaultSize, widthMeasureSpec); int height = resolveSize(defaultSize, heightMeasureSpec); // 进度环是圆形,取宽高较小值,保证是正方形 int squareSize = Math.min(width, height); setMeasuredDimension(squareSize, squareSize); }resolveSize(int size, int measureSpec)是系统提供的一个工具方法,它内部帮你处理了三种模式的逻辑:如果是AT_MOST,就取min(size, specSize);如果是EXACTLY,就取specSize;如果是UNSPECIFIED,就取size。这样写下来,代码既简洁又健壮。
我见过不少自定义View代码里自己写if-else去判断MeasureSpec模式,其实完全没必要,resolveSize就是系统给的标准答案。真正需要手动处理的场景很少,比如你要确保View是正方形、保持宽高比,或者根据子View的测量结果决定自己的尺寸,这些才需要深入解析MeasureSpec。
2.3 onDraw:Canvas与Paint的配合艺术
onDraw是自定义View最核心的舞台。很多人以为onDraw就是把图形画出来,但真正到了复杂场景,Canvas和Paint的配合才是拉开差距的地方。
先说Paint。Paint是"画笔",决定了画出来的东西长什么样子:颜色、粗细、透明度、填充还是描边、字体风格、是否有抗锯齿。我初始化Paint时必开ANTI_ALIAS_FLAG,这个是基础操作。如果要做模糊效果,用setMaskFilter;如果要做渐变,用LinearGradient或者SweepGradient作为Shader。
再说Canvas。Canvas是"画布",决定了画在哪儿、怎么画。它提供的API看似很多,但底层可以归为几类:画几何图形(drawCircle、drawRect、drawArc)、画文字(drawText)、画图片(drawBitmap)、以及变换操作(translate、rotate、scale、save、restore)。
回到进度环案例,现在要画一个有缺口的进度弧:
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); // 背景圆环,浅灰色 mBackgroundPaint.setColor(0xFFE0E0E0); canvas.drawArc(mArcRectF, 0, 360, false, mBackgroundPaint); // 前景进度弧,从12点钟方向开始,顺时针画 canvas.drawArc(mArcRectF, -90, mProgress / 100f * 360, false, mProgressPaint); }drawArc(RectF oval, float startAngle, float sweepAngle, boolean useCenter, Paint paint)的参数非常容易被搞错。startAngle是起始角度,0度在时钟的三点钟方向,所以我们通常传-90让起始点在十二点钟方向。sweepAngle是扫过的角度,顺时针为正。useCenter决定要不要连接圆心,如果画的是环形进度条,它必须为false;如果画的是扇形统计图,它必须为true。
还有一点要注意:mArcRectF需要在哪里初始化?我前面说过不能在构造函数里读宽高,所以这里需要在onSizeChanged里计算:
@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); float strokeWidth = mProgressPaint.getStrokeWidth(); float left = strokeWidth / 2f; float top = strokeWidth / 2f; float right = w - strokeWidth / 2f; float bottom = h - strokeWidth / 2f; mArcRectF = new RectF(left, top, right, bottom); }这样确保圆弧的笔画不会在View边缘被裁掉一半。
2.4 自定义ViewGroup:测量与布局的进阶组合
继承ViewGroup和继承View有本质区别。继承View时,你只需要管好自己;继承ViewGroup时,你必须管好所有子View的测量和摆放。
核心方法就两个:onMeasure和onLayout。onMeasure负责测量所有子View并确定自己的尺寸,onLayout负责把每个子View放到对应的坐标位置上。
一个最简的自定义ViewGroup骨架:
public class SimpleContainer extends ViewGroup { public SimpleContainer(Context context) { super(context); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int maxWidth = 0; int totalHeight = 0; for (int i = 0; i < getChildCount(); i++) { View child = getChildAt(i); measureChild(child, widthMeasureSpec, heightMeasureSpec); maxWidth = Math.max(maxWidth, child.getMeasuredWidth()); totalHeight += child.getMeasuredHeight(); } setMeasuredDimension(resolveSize(maxWidth, widthMeasureSpec), resolveSize(totalHeight, heightMeasureSpec)); } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { int currentTop = t; for (int i = 0; i < getChildCount(); i++) { View child = getChildAt(i); child.layout(l, currentTop, l + child.getMeasuredWidth(), currentTop + child.getMeasuredHeight()); currentTop += child.getMeasuredHeight(); } } }这里最容易犯的错误是:在onLayout里直接用getWidth()而不是getMeasuredWidth()。虽然多数情况下两者相等,但在测量阶段和布局阶段之间存在时间差,任何时候都应该以getMeasuredWidth()为测量依据、以layout()传入的参数为最终位置依据。另外一点经验之谈:如果不打算支持子View滚动,不要随便重写getChildDrawingOrder、dispatchDraw这类方法,默认实现往往是最稳妥的。
3. 让进度环"活"起来:触摸事件与动画联动
3.1 onTouchEvent的事件处理逻辑
静态的进度环只是一个装饰,真正像样的组件都离不开交互。要让进度环支持拖动改变进度,需要重写onTouchEvent。
事件分发的核心流程是:ACTION_DOWN发生时,如果不返回true,后续的ACTION_MOVE、ACTION_UP都会被分发到别处去,而且你再也没有机会收到达个事件序列。所以,只要你想处理触摸,ACTION_DOWN必须返回true。
下面的代码实现了拖动进度环的效果:
@Override public boolean onTouchEvent(MotionEvent event) { float x = event.getX(); float y = event.getY(); switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: updateProgressByTouch(x, y); return true; case MotionEvent.ACTION_MOVE: updateProgressByTouch(x, y); return true; case MotionEvent.ACTION_UP: performClick(); return true; } return super.onTouchEvent(event); } private void updateProgressByTouch(float x, float y) { float cx = getWidth() / 2f; float cy = getHeight() / 2f; double angle = Math.toDegrees(Math.atan2(y - cy, x - cx)); // 角度从右侧0度开始,我们进度从12点开始,所以需要偏移 angle = (angle + 360) % 360; float progress = (float) ((angle + 90) % 360) / 360f * 100f; setProgress(progress); }这里用atan2通过触摸点和圆心的相对位置算出角度,再把角度映射成进度。有个细节:ACTION_UP时调用performClick()是为了通过无障碍测试。Android Lint会强制要求重写onTouchEvent的View必须调用performClick,这是为了屏幕阅读器能够触发点击事件。
3.2 setProgress方法:值与View的桥梁
上面的代码里已经出现了setProgress,这个方法是所有外部控制逻辑的入口。它会做两件事:更新内部进度值,并触发重绘。
public void setProgress(float progress) { float safeProgress = Math.max(0f, Math.min(100f, progress)); if (Math.abs(safeProgress - mProgress) < 0.01f) { return; } mProgress = safeProgress; invalidate(); }这里有个值得注意的细节:我在赋值前做了非法值钳制(clamp),并在变化量极小时直接返回,避免无意义的invalidate调用。这种做法在组件被高频调用时能省下不少绘制开销。invalidate()会在UI线程下一帧重新调用onDraw,它必须在主线程执行;如果你在子线程里改了进度值,需要调用postInvalidate()。
如果你希望进度变化是平滑的,而不是瞬间跳变,就需要引入动画。一种常见做法是把ValueAnimator封装到setProgress内部,让外部调用者无需关心动画细节:
public void setProgressWithAnimation(float targetProgress, long duration) { ValueAnimator animator = ValueAnimator.ofFloat(mProgress, targetProgress); animator.setDuration(duration); animator.setInterpolator(new DecelerateInterpolator()); animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { mProgress = (float) animation.getAnimatedValue(); invalidate(); } }); animator.start(); }加动画之后用户体验会好很多,从"数字跳变"变成"视觉过渡"。要注意长动画不要频繁在onDraw里打印Log或者做复杂运算,否则很容易卡顿。
3.3 状态保存与恢复:别让Activity重建毁掉组件状态
还有一个经常被忽略的问题:Activity旋转屏幕或系统回收Activity时,View的状态会丢失。系统提供了一套状态保存机制,对应的就是onSaveInstanceState和onRestoreInstanceState。
自定义View如果需要保存状态,需要先创建一个继承View.BaseSavedState的静态类:
public static class SavedState extends View.BaseSavedState { public float progress; public SavedState(Parcelable superState) { super(superState); } private SavedState(Parcel in) { super(in); progress = in.readFloat(); } @Override public void writeToParcel(Parcel out, int flags) { super.writeToParcel(out, flags); out.writeFloat(progress); } public static final Parcelable.Creator<SavedState> CREATOR = new Parcelable.Creator<SavedState>() { @Override public SavedState createFromParcel(Parcel in) { return new SavedState(in); } @Override public SavedState[] newArray(int size) { return new SavedState[size]; } }; } @Override protected Parcelable onSaveInstanceState() { Parcelable superState = super.onSaveInstanceState(); SavedState ss = new SavedState(superState); ss.progress = mProgress; return ss; } @Override protected void onRestoreInstanceState(Parcelable state) { SavedState ss = (SavedState) state; super.onRestoreInstanceState(ss.getSuperState()); mProgress = ss.progress; invalidate(); }这里有一个非常易错的细节:在onSaveInstanceState里一定要先调用super.onSaveInstanceState(),把父类的状态保存在SavedState里,否则父类保存的视图层级状态(比如焦点位置)会丢。在恢复时,也要先把superState传给父类,再恢复自己的字段。
4. 性能优化:自定义View的生死线
4.1 invalidate的局部刷新与绘制开销控制
自定义View画得不卡顿,是比画出来更重要的进阶能力。很多新手在onDraw里写太多耗时操作,导致滑动时掉帧明显。onDraw里的代码是每帧都要执行的,任何资源的创建、对象的分配、复杂的for循环、甚至是Log.d,都会直接影响帧率。
我见过最典型的反面案例是有人把Paint初始化写在了onDraw里,每帧都new一个Paint,结果一滑动就垃圾回收一次,掉帧掉到没法看。Paint、Path、RectF这些对象一定要在初始化阶段创建好,onDraw里只做绘制调用。
invalidate的刷新范围也值得优化。默认情况下,invalidate()会重绘整个View区域。如果View很大,而实际变化只发生在局部(比如进度环的一小段弧),可以调用invalidate(Rect dirty)或者invalidate(left, top, right, bottom),只更新变化区域。系统会把这个脏区域传给Canvas的clipBounds,渲染时裁剪掉不需要绘制的部分。
判断是否值得做局部刷新,我一般遵循这个原则:如果变化区域占View总面积的比例低于30%,值得做;如果变化基本覆盖全View,就别瞎折腾,直接全量刷新反而更省心。
4.2 过度绘制:肉眼可见的卡顿元凶
过度绘制(Overdraw)指的是同一像素点在一帧内被重复绘制多次。系统有个杀手锏工具:开发者选项里的"调试GPU过度绘制",打开后界面会用颜色标注过度绘制的程度,蓝色=1倍,绿色=2倍,粉色=3倍,红色=4倍以上。
自己写自定义View时,最容易产生过度绘制的点有三个:
- 先画了一个不透明的背景矩形,然后又画了一次半透明遮罩,接着又画内容。如果View自身的背景是不透明的,优先使用
canvas.clipRect裁剪,而不是反复画覆盖层。 - 绘制文字前没有考虑文字背景。如果文字背后不需要背景,就不要画那个背景矩形,而是直接drawText。
- 层级叠加。自定义ViewGroup里子View又套子View,深色背景层层叠加,导致同一区域被画了多次。
一个很实用的优化技巧是在onDraw里给不需要绘制的内容加"快速拒绝"逻辑:
if (mProgress <= 0f) { // 进度为0时,画一个空心圆即可,不需要画进度弧 canvas.drawCircle(cx, cy, radius, mBackgroundPaint); return; }这种提前return的写法不但能减少绘制指令,还能让代码更清晰。还有一种是利用Canvas的quickReject判断某个区域是否在可见范围内,如果不可见就跳过绘制。
4.3 硬件加速与Layer:什么时候不能用setLayerType
从Android 3.0开始,系统默认开启了硬件加速,Canvas上的绘制操作大多会转换为OpenGL指令,由GPU执行。大多数情况下这对自定义View是透明的,但也有几个特例。
Paint.setShadowLayer在关闭硬件加速时能用,但在开启硬件加速时会被忽略或抛异常(具体取决于Android版本)。所以如果你要在View上画阴影,需要调用setLayerType(View.LAYER_TYPE_SOFTWARE, null),强制让这个View用软件绘制。
不过,setLayerType(LAYER_TYPE_SOFTWARE)是一个全局开关,一旦开启,整个View的绘制都会走CPU。这个开销可能很大,尤其是View尺寸较大时。更精准的方案是只把这个View用一个独立的Layer绘制:
// 把View的渲染缓存到一个独立的缓冲层 setLayerType(View.LAYER_TYPE_HARDWARE, null); // 动画或属性更新后 invalidate(); // 动画结束清理 setLayerType(View.LAYER_TYPE_NONE, null);如果要做放大、旋转、透明度这类动画,可以用硬件层来避免每帧重绘。但要注意:Layer本身需要额外的内存,对于大尺寸View,一个Layer可能占几MB甚至更大,不能滥用。
4.4 Profile GPU Rendering与Systrace的分析思路
优化做完了,怎么验证?我常用的手段是Android Studio自带的Profile GPU Rendering(开发者选项里的"GPU呈现模式分析")。
在开发者选项里打开"GPU呈现模式分析"->"在屏幕上显示为条形图",会看到每个帧的柱状图。柱状图由三部分组成:Draw(绿色)、Prepare(蓝色)、Process(红色)。如果绿色柱经常突破16ms的水平线,说明onDraw里的绘制指令太多;如果红色柱高,说明主线程里的布局和计算占用过高。
如果屏幕柱状图看不出明显问题,就用Systrace(或者Android Studio的CPU Profiler)抓取一段操作的trace。看自定义View相关的线程里,View.draw下面的调用栈里有没有异常的耗时函数,比如Bitmap.create、Canvas.saveLayer这类高成本操作。
有一个排查工具可能很多人没注意到:Layout Inspector。它可以让你看到当前界面的视图层级和每个View的绘制属性。有些"自定义View很卡"的问题,其实根本不是自定义View本身的绘制问题,而是它被套在一个不断requestLayout的父容器里。Layout Inspector能帮你快速定位这类外层问题。
5. 从Demo到生产级组件:自定义View的工程化落地
5.1 自定义属性:别把参数写死在代码里
Demo阶段的进度环,进度初始值可以写死在代码里。但真正到了生产环境,一个组件要能配置初始进度、环的粗细、颜色、动画时长,这些都应该暴露成XML属性。
在res/values/attrs.xml里声明:
<resources> <declare-styleable name="CircleProgressView"> <attr name="progress" format="float" /> <attr name="ringWidth" format="dimension" /> <attr name="ringColor" format="color" /> <attr name="animateDuration" format="integer" /> </declare-styleable> </resources>然后在构造函数里读取:
private void init(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.CircleProgressView, defStyleAttr, 0); try { mProgress = ta.getFloat(R.styleable.CircleProgressView_progress, 0f); float ringWidth = ta.getDimension(R.styleable.CircleProgressView_ringWidth, dp2px(20)); mProgressPaint.setStrokeWidth(ringWidth); int ringColor = ta.getColor(R.styleable.CircleProgressView_ringColor, 0xFF4A90E2); mProgressPaint.setColor(ringColor); mAnimateDuration = ta.getInteger(R.styleable.CircleProgressView_animateDuration, 800); } finally { ta.recycle(); } }这里有个特别容易犯的错:TypedArray用完后必须调用recycle()。虽然在Android 5.0之后recycle的实现变成了no-op,但旧版本上不调用会导致资源泄漏。而且这个操作不需要在onDetachedFromWindow里做,直接放在构造函数里finally块就行。
还有一点经验:自定义属性命名时,我已经习惯给它们加上组件名前缀,比如progress改成progressValue,ringWidth就不会跟组件的其他属性冲突。虽然不会真的冲突,但可读性和搜索性都好很多。
5.2 接口回调与对外API设计
一个生产级组件,不可能只提供setter方法让外部主动拉状态,很多时候还需要把内部事件通知出去。进度环最常见的回调是进度变化监听。
定义接口:
public interface OnProgressChangedListener { void onProgressChanged(CircleProgressView view, float progress, boolean fromUser); }然后提供注册方法:
public void setOnProgressChangedListener(OnProgressChangedListener listener) { mListener = listener; }在onTouchEvent的ACTION_MOVE里,进度变化后调用:
if (mListener != null) { mListener.onProgressChanged(this, mProgress, true); }在setProgress里,如果是代码外部调用,则fromUser传false:
public void setProgress(float progress) { // ... 赋值和钳制逻辑 ... if (mListener != null) { mListener.onProgressChanged(this, mProgress, false); } invalidate(); }fromUser这个参数很重要,它让调用方能够区分"是用户触摸导致的进度变化"还是"代码主动设置的进度变化"。比如你在做播放器进度条,用户拖动的进度和音频播放器回调的进度是两回事,UI反馈逻辑完全不同。
对外API设计上,我坚持一个原则:只暴露必要的方法,其他一切私有。不要把内部的mPaint、mRectF这些实现细节用public暴露出去,否则组件后期重构时,外部代码全都是坑。
5.3 深色模式与主题适配
深色模式适配是现在所有App避不开的话题,自定义View里最容易出现的问题是:硬编码颜色。很多人在自定义View里直接写0xFF333333、0xFFFFFFFF,深色模式下就露馅了。
解决方案是优先从主题属性里取色。在colors.xml里定义两个希望跟随深色模式变化的颜色:
<color name="progress_track_dark">#4D000000</color> <color name="progress_track_light">#1A000000</color>然后声明在styleable里。但这还不够,更彻底的方式是使用Theme.Enforcement的机制,让CustomView支持主题属性的资源引用。大致思路是:
- 在attrs.xml里定义
attr/colorProgressTrack这类自定义主题属性。 - 组件初始化时,通过
context.theme.resolveAttribute(R.attr.colorProgressTrack, typedValue, true)读取颜色。 - 这样配置深色主题时,组件用深色配色的值;配浅色主题时,用浅色的值。
具体到进度环,背景环和进度弧的颜色都应该走这个机制,而不是直接写死。另外,如果组件内部绘制了文字,文字颜色也建议从android.R.attr.textColorPrimary这类系统主题属性里取。
5.4 自定义View的测试策略
很多人觉得View不好测试,其实只要设计得当,自定义View也可以做单元测试和UI测试。
先说单元测试。核心逻辑(进度计算、角度映射、状态保存)尽量不要写在onDraw或onTouchEvent里,而是抽象成纯Java的"逻辑类"。比如角度转进度这个计算,可以写成一个静态方法:
public static float angleToProgress(float angle, float cx, float cy, float touchX, float touchY) { double rad = Math.atan2(touchY - cy, touchX - cx); double deg = Math.toDegrees(rad); return (float) ((deg + 450) % 360) / 360f * 100f; }这个函数不依赖任何Android类,可以直接用JUnit测试各种边界值和异常值。
UI测试方面,Espresso配合自定义View的onView可以对View进行交互测试。但要注意:自定义View的无障碍描述很重要,如果View没有设置contentDescription,或者没有处理performClick,会有无障碍风险。我们在设计touch事件时,已经调用了performClick,这里再强调一次:任何重写onTouchEvent的View,都应该保证performClick()被调用,这既是无障碍的要求,也是Lint检查的硬性规则。
5.5 让组件易于调试:自定义View的Debug可视化
最后分享一个比较小众但很实用的小技巧:给自定义View增加debug模式。在日常开发中,想快速看自定义View的边界、绘制区域、触摸区域,最直接的办法是在开发者模式里用Layout Inspector,但那是外部分析工具。如果你的组件本身内置了debug绘制逻辑,调试起来会快得多。
我的做法是,在组件里定义一个setDebugMode(boolean)方法,开启时onDraw里额外绘制边界框、圆心坐标、关键点:
@Override protected void onDraw(Canvas canvas) { // ... 原有绘制逻辑 ... if (mDebugMode) { // 绘制边界框 mDebugPaint.setColor(0xFFFF0000); mDebugPaint.setStrokeWidth(2f); mDebugPaint.setStyle(Paint.Style.STROKE); canvas.drawRect(0, 0, getWidth(), getHeight(), mDebugPaint); // 绘制圆心 mDebugPaint.setColor(0xFF00FF00); canvas.drawCircle(getWidth() / 2f, getHeight() / 2f, 8f, mDebugPaint); } }这个模式在开发阶段打开,可以直观看到触摸事件的位置跟实际绘制区域是否匹配,圆心坐标有没有偏。等上线前记得关闭。
最后的实践建议
回到文章开头那个问题:自定义View难吗?我的答案是,它确实有一定的门槛,但这个门槛不在"理解代码"上,而在"动手之前有多少积累"上。绘制、测量、事件、性能、工程化这五个方向,任何一个都值得单独深入研究。如果你现在处于刚入门阶段,先别急着写复杂的效果,把Canvas的API和MeasureSpec的三种模式吃透,比什么都强。
在我自己的项目里,进度环这个组件经过两次重构才达到生产级标准。第一次重构把自定义属性补全,第二次重构解决了深色模式适配和性能问题。现在它被用在了好几个业务模块里,稳定性比第三方库还好。这说明一个道理:自定义View的"从入门到精通",从来不是一蹴而就的,它是一步步踩坑、重构、打磨出来的。希望这篇指南能帮你少走一些弯路。