尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Android自定义View进阶指南:从绘制到性能优化

Android自定义View进阶指南:从绘制到性能优化 很多新手朋友一碰到稍复杂一点的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_width100dp android:layout_height100dp /这样你就在屏幕上看到了一个圆环。但距离进度环还差两步一是只画了一个完整的圆没有进度缺口二是进度值还没法从外部设置。后面我会一步步补全。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对应这种模式。默认情况下如果你不重写onMeasureView的行为是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 onDrawCanvas与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.CreatorSavedState CREATOR new Parcelable.CreatorSavedState() { 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 nameCircleProgressView attr nameprogress formatfloat / attr nameringWidth formatdimension / attr nameringColor formatcolor / attr nameanimateDuration formatinteger / /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改成progressValueringWidth就不会跟组件的其他属性冲突。虽然不会真的冲突但可读性和搜索性都好很多。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传falsepublic 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 nameprogress_track_dark#4D000000/color color nameprogress_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的从入门到精通从来不是一蹴而就的它是一步步踩坑、重构、打磨出来的。希望这篇指南能帮你少走一些弯路。
返回列表