Android自定义View实战:onMeasure、onDraw与onTouchEvent深度解析
1. 复杂业务场景下自定义View的定位与整体设计思路
做过几年Android开发的人都有一个共同的感受:刚入行的时候觉得自定义View是个炫技的东西,无非就是画个圆、画个进度条、搞个雷达图。但真正进入业务一线,尤其是接手一些历史包袱重、交互逻辑绕、性能要求高的项目之后,你会发现自定义View根本不是“画图”那么简单,它更像是你在系统控件和业务需求之间硬生生凿出来的一条通道。
我最近刚交付的一个项目,里面有一个核心模块需要同时处理手势拖拽、惯性滑动、多指缩放、实时数据刷新和状态回显,系统自带的SeekBar、ScrollView、ViewPager全部试了一遍,没有一个能直接满足。最后只能自己写一个自定义View来统一收口。这篇文章就把我在这个过程中踩过的坑、想明白的原理、以及最终跑通的方案完整地分享出来。
这篇文章适合已经写过简单自定义View、但对onMeasure的测量逻辑一知半解、对onTouchEvent的事件分发心里没底、对onDraw的性能优化没有系统方法的Android开发者。如果你还在纠结“自定义View到底该继承View还是ViewGroup”,或者“为什么我的View在ScrollView里高度总是不对”,那这篇内容应该能帮你省下不少查资料的时间。
核心关键词先摆出来:自定义View、Android、onDraw、onMeasure、onTouchEvent。这五个词基本覆盖了自定义View从测量、布局、绘制到交互的完整生命周期。下面我会按照实际开发中的决策顺序,一层一层拆开来讲。
1.1 为什么系统控件在复杂业务里总是“差一点”
很多人一开始会有一个误区:觉得系统控件已经足够强大了,组合一下就能用。但实际业务场景里,系统控件的问题往往出在三个地方。
第一个是测量逻辑的刚性。比如你想做一个高度根据内容动态变化、但同时受父容器最大高度约束的卡片,系统控件的onMeasure要么完全按照父容器的建议值来,要么完全按照自己的内容来,很难做到“两者取小”这种灵活策略。你可能会说用ConstraintLayout的layout_constraintHeight_max,但那个在嵌套滚动场景下经常失效。
第二个是事件处理的冲突。当你的View需要同时响应点击、长按、拖拽、滑动,而且还要和父容器的滚动事件协调时,系统控件的onTouchEvent实现往往过于“霸道”或者过于“谦让”。比如SeekBar在ScrollView里滑动时,手指稍微有点垂直位移,事件就被ScrollView抢走了,用户体验非常割裂。
第三个是绘制性能的黑盒。系统控件的onDraw是封装好的,你没法控制它什么时候重绘、重绘哪些区域。当你的界面需要每秒刷新几十次数据时,系统控件可能会触发全量重绘,导致GPU过度绘制,帧率直接掉到30以下。
所以,自定义View在复杂业务中的核心价值,不是“画得好看”,而是把测量、布局、绘制、事件这四个环节的控制权拿回到自己手里,根据业务的实际约束来定制行为。
1.2 继承View还是ViewGroup:一个被低估的决策点
这个问题看起来很简单,但我见过太多项目在这里选错,导致后期改造成本极高。
如果你的控件内部没有子View,或者子View的位置和数量是固定的、不需要动态增删,那就继承View。比如一个带刻度的手势选择器、一个实时折线图、一个自定义进度条,这些都应该继承View。继承View的好处是测量逻辑简单,你只需要在onMeasure里根据自己的内容算出宽高就行,不需要考虑子View的测量和布局。
如果你的控件需要动态添加、删除、排列子View,或者子View的布局规则比较复杂(比如流式布局、瀑布流),那就继承ViewGroup。但继承ViewGroup的代价是你要自己实现onMeasure和onLayout,尤其是onMeasure里对每个子View的测量和二次测量,逻辑很容易写错。
我个人的经验是:能用View解决的,绝不用ViewGroup。因为ViewGroup的测量和布局链路更长,在嵌套滚动场景下更容易出问题。我这次的项目里,虽然界面上看起来有多个元素,但那些元素的位置和数量都是固定的,所以我最终选择了继承View,把所有元素都通过Canvas直接绘制出来。这样不仅测量逻辑简单,而且绘制性能更好,因为我可以精确控制重绘区域。
1.3 整体架构:把测量、绘制、事件拆成三个独立模块
在动手写代码之前,我先画了一张架构草图。核心思路是把自定义View拆成三个相对独立的模块:
- 测量模块:负责根据业务约束计算View的最终宽高,处理
wrap_content、match_parent和精确值三种模式,同时考虑父容器的最大可用空间。 - 绘制模块:负责把数据模型渲染成视觉元素,包括背景、刻度、指示器、文字标签等,同时处理不同状态下的视觉差异(比如选中态、禁用态、加载态)。
- 事件模块:负责处理触摸事件,包括按下、移动、抬起、取消,以及多指手势的识别和冲突处理。
这三个模块之间通过一个状态模型来通信。状态模型里保存了当前的数据值、选中状态、缩放比例、滚动偏移量等。测量模块根据状态模型计算尺寸,绘制模块根据状态模型渲染画面,事件模块根据用户操作更新状态模型。这样拆的好处是,每个模块的职责单一,测试和调试都方便。比如我想验证测量逻辑是否正确,只需要构造不同的状态模型,看onMeasure的输出是否符合预期,不需要真的把View渲染出来。
2. onMeasure深度解析:从“为什么高度不对”到“精确控制每一像素”
onMeasure是自定义View里最容易出问题的地方,没有之一。我见过太多开发者在这里栽跟头,包括我自己早期也写过不少“在ScrollView里高度永远不对”的代码。这一章我会把onMeasure的测量逻辑彻底讲清楚,包括父容器传进来的MeasureSpec到底是什么意思、getDefaultSize和resolveSize的区别、以及如何处理wrap_content和match_parent的混合场景。
2.1 MeasureSpec的三种模式与实际含义
父容器在测量子View时,会通过measureChild或measureChildWithMargins方法传入两个参数:宽度MeasureSpec和高度MeasureSpec。每个MeasureSpec是一个32位的int值,高2位表示测量模式,低30位表示测量大小。
三种测量模式分别是:
| 模式 | 常量值 | 含义 | 典型场景 |
|---|---|---|---|
| EXACTLY | 0x00000000 | 父容器已经决定了子View的精确大小 | 子View的layout_width是match_parent或具体数值 |
| AT_MOST | 0x80000000 | 子View可以在指定大小内自由决定自己的大小 | 子View的layout_width是wrap_content |
| UNSPECIFIED | 0x00000000 | 父容器不对子View施加任何约束 | ScrollView测量子View时 |
这里有一个很容易混淆的点:EXACTLY和UNSPECIFIED的常量值都是0,但它们的区别在于高2位的具体取值。EXACTLY是00,UNSPECIFIED是00,实际上UNSPECIFIED的高2位是00,但低30位通常为0。更准确地说,MeasureSpec的mode是通过(measureSpec & MODE_MASK)来提取的,其中MODE_MASK = 0xC0000000。
在实际开发中,你不需要记住这些位运算的细节,只需要理解每种模式对应的业务含义:
- EXACTLY:父容器说“你就这么大”,你不需要自己算,直接用
MeasureSpec.getSize()就行。 - AT_MOST:父容器说“你最大只能这么大”,你需要根据自己的内容算一个不超过这个值的大小。
- UNSPECIFIED:父容器说“你随便多大”,你需要完全根据自己的内容来算。
2.2 getDefaultSize的陷阱与resolveSize的正确用法
很多初学者在onMeasure里会直接调用setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec), getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec))。这行代码看起来很方便,但它有一个致命的陷阱:当测量模式是AT_MOST时,getDefaultSize会直接返回父容器建议的最大值,而不是子View实际需要的大小。
举个例子:你在ScrollView里放了一个自定义View,layout_height是wrap_content。ScrollView测量子View时,传入的高度MeasureSpec是AT_MOST,大小是ScrollView的可见高度。如果你的自定义View实际只需要100px高度,但getDefaultSize会返回ScrollView的可见高度(比如800px),导致你的View在ScrollView里占了整整一屏,下面留出大片空白。
正确的做法是使用resolveSize方法:
resolveSize的逻辑是:如果是AT_MOST模式,返回min(size, measureSpecSize);如果是EXACTLY模式,返回measureSpecSize;如果是UNSPECIFIED模式,返回size。这样就能保证在wrap_content场景下,View的高度是实际内容高度,而不是父容器的最大可用高度。
但resolveSize也不是万能的。如果你的View内容高度是动态计算的,比如根据数据点的数量来算高度,那你就不能直接用getSuggestedMinimumHeight(),而是要先算出内容高度,再传给resolveSize。
2.3 实战:一个高度自适应且受最大高度约束的测量方案
我这次项目里的自定义View需要满足一个特殊需求:高度根据内容动态变化,但最大不能超过父容器可用高度的60%。这个需求用系统控件很难实现,因为系统控件的wrap_content要么完全自适应,要么完全填充,没有中间状态。
我的解决方案是在onMeasure里分三步走:
第一步,先计算内容的理想高度。这个高度是根据数据模型里的数据点数量、每个数据点的间距、以及上下内边距算出来的。比如有20个数据点,每个点间距30px,上下内边距各20px,那理想高度就是20 * 30 + 20 + 20 = 640px。
第二步,获取父容器允许的最大高度。如果是AT_MOST模式,最大高度就是MeasureSpec.getSize(heightMeasureSpec);如果是EXACTLY模式,最大高度就是精确值;如果是UNSPECIFIED模式,最大高度就是理想高度。
第三步,取两者中的较小值作为最终高度,但还要考虑一个业务约束:最大不能超过父容器可用高度的60%。所以最终高度是min(理想高度, 父容器最大高度 * 0.6)。
这段代码里有一个细节需要注意:当heightMode是EXACTLY时,我直接用了heightSize,没有乘以0.6。因为EXACTLY意味着父容器已经明确指定了高度,这时候再乘以0.6反而会导致View高度和父容器的预期不符,可能会出现布局错乱。只有在AT_MOST模式下,才需要应用60%的业务约束。
注意:在onMeasure里不要做耗时操作,比如读取数据库、网络请求、复杂的数学运算。onMeasure会被频繁调用,任何耗时操作都会导致界面卡顿。如果确实需要计算复杂的数据,应该在数据变化时提前算好,缓存起来,onMeasure里只做简单的取值和比较。
2.4 处理wrap_content与match_parent的混合场景
在实际项目中,自定义View的宽高很少是单一模式。常见的情况是宽度match_parent、高度wrap_content,或者宽度wrap_content、高度match_parent。这两种场景的测量逻辑完全不同。
对于宽度match_parent、高度wrap_content的场景,宽度MeasureSpec是EXACTLY,高度MeasureSpec是AT_MOST。你只需要在onMeasure里处理高度逻辑,宽度直接用MeasureSpec.getSize(widthMeasureSpec)就行。
对于宽度wrap_content、高度match_parent的场景,宽度MeasureSpec是AT_MOST,高度MeasureSpec是EXACTLY。这时候你需要根据内容算出理想宽度,然后用resolveSize来约束。
还有一种更复杂的场景:宽度和高度都是wrap_content,但宽度和高度之间存在比例关系。比如一个正方形的自定义View,宽度和高度必须相等。这时候你需要先测量宽度,然后根据宽度算出高度,或者反过来。
这段代码看起来有点啰嗦,但它是处理正方形自定义View最稳妥的方式。我试过用resolveSize来简化,但在某些父容器下会出现测量结果不稳定的情况,最后还是回到了这种显式判断的方式。
3. onDraw性能优化:从“能画出来”到“画得流畅”
onDraw是自定义View的另一个核心环节。很多人觉得onDraw就是把数据画到Canvas上,没什么技术含量。但实际项目中,onDraw的性能直接决定了界面的流畅度。我见过一个自定义折线图,数据量大的时候帧率直接掉到20以下,用户滑动的时候明显卡顿。这一章我会把onDraw的性能优化拆成几个可操作的层面来讲。
3.1 避免在onDraw里创建对象
这是onDraw性能优化的第一条铁律,也是最多人违反的一条。onDraw会被频繁调用,每次调用都创建新对象,会导致GC频繁触发,进而导致界面卡顿。
常见的错误包括:
- 在onDraw里
new Paint()、new Path()、new RectF() - 在onDraw里
new ArrayList<>()、new StringBuilder() - 在onDraw里做字符串拼接,比如
"当前值:" + value
正确的做法是把这些对象作为成员变量,在构造函数里初始化,在onDraw里复用。比如:
对于字符串拼接,如果必须在onDraw里做,可以用StringBuilder的成员变量,每次调用setLength(0)来清空,而不是创建新的StringBuilder。
3.2 使用clipRect精确控制重绘区域
默认情况下,invalidate()会触发整个View的重绘。如果你的View很大,但每次只有一小部分内容变化,全量重绘就是浪费。
invalidate(Rect dirty)方法可以指定只重绘某个区域。但需要注意的是,这个dirty区域是相对于View的坐标系的,而且系统不保证只重绘这个区域,它只是一个“建议”。
更可靠的方式是在onDraw里使用canvas.clipRect()来限制绘制区域:
但clipRect本身也有性能开销,如果dirty区域太小,clipRect的开销可能比直接绘制还大。所以这个优化要视情况而定,不能盲目使用。
我这次项目里的做法是:把View分成几个独立的图层,每个图层有自己的invalidate方法。比如背景层、刻度层、指示器层、文字层。当数据变化时,只invalidate指示器层和文字层,背景层和刻度层不动。这样重绘面积减少了70%以上,帧率从35提升到了58。
3.3 使用硬件加速与离屏缓存
Android从3.0开始支持硬件加速,默认是开启的。硬件加速会把Canvas的绘制操作转换成OpenGL指令,由GPU执行,性能比软件绘制高很多。
但硬件加速也不是万能的。有些绘制操作在硬件加速下反而更慢,比如canvas.drawText()在硬件加速下会有额外的纹理上传开销。对于频繁变化的文字,可以考虑用StaticLayout或者TextPaint来优化。
离屏缓存是另一个优化手段。如果你的View的某部分内容不经常变化,可以把这部分内容先绘制到一个Bitmap上,然后在onDraw里直接canvas.drawBitmap()。这样每次重绘时,只需要绘制Bitmap,不需要重新执行复杂的绘制逻辑。
需要注意的是,离屏缓存会占用额外的内存。一个全屏的ARGB_8888 Bitmap大约占用8MB内存(1920x1080x4字节),如果缓存多个Bitmap,内存压力会很大。所以离屏缓存只适合静态内容多、动态内容少的场景。
3.4 减少过度绘制:从“画了又被盖住”到“只画可见部分”
过度绘制是指同一个像素被多次绘制。比如你先画了一个背景色,又画了一个不透明的矩形盖在上面,那背景色的绘制就是浪费。
检测过度绘制的方法是:在开发者选项里打开“调试GPU过度绘制”,然后观察界面上的颜色。蓝色表示1次绘制,绿色表示2次,浅红色表示3次,深红色表示4次及以上。理想情况下,大部分区域应该是蓝色或绿色。
减少过度绘制的方法有:
- 移除不必要的背景。如果父容器和子View的背景颜色一样,子View的背景可以去掉。
- 使用
canvas.clipRect()限制绘制区域,避免绘制不可见的部分。 - 对于不透明的View,使用
setWillNotDraw(false)并确保onDraw里不绘制透明内容。 - 使用
View.setLayerType(LAYER_TYPE_HARDWARE, null)把View渲染到硬件层,减少重绘时的过度绘制。
我这次项目里,自定义View的过度绘制从深红色降到了浅蓝色,帧率提升了将近一倍。关键操作就是去掉了两层不必要的背景,并且在onDraw里用clipRect限制了绘制区域。
4. onTouchEvent与手势冲突:从“事件被抢”到“精确控制”
onTouchEvent是自定义View交互的核心。很多人写自定义View时,onDraw和onMeasure都处理得很好,但一到onTouchEvent就各种问题:滑动不跟手、事件被父容器抢走、多指手势识别错误。这一章我会把onTouchEvent的事件分发机制和常见冲突场景讲清楚。
4.1 事件分发的基本流程与三个关键方法
Android的事件分发涉及三个方法:
dispatchTouchEvent(MotionEvent ev):负责事件的分发,返回true表示事件被消费。onInterceptTouchEvent(MotionEvent ev):只有ViewGroup才有这个方法,返回true表示拦截事件,不再传递给子View。onTouchEvent(MotionEvent ev):负责事件的处理,返回true表示消费了事件。
对于自定义View(继承View),你只需要关心onTouchEvent。对于自定义ViewGroup,你需要同时关心onInterceptTouchEvent和onTouchEvent。
事件分发的核心规则是:如果子View的onTouchEvent返回false,事件会回传给父容器的onTouchEvent处理。这就是为什么你的自定义View在ScrollView里滑动时,事件经常被ScrollView抢走——因为你的onTouchEvent在某些情况下返回了false。
4.2 解决滑动冲突:requestDisallowInterceptTouchEvent的正确用法
requestDisallowInterceptTouchEvent(boolean disallowIntercept)是解决滑动冲突的利器。当你的自定义View需要处理滑动事件时,可以调用getParent().requestDisallowInterceptTouchEvent(true)来告诉父容器“不要拦截我的事件”。
但这个方法不能滥用。如果你在ACTION_DOWN时就调用,父容器永远不会有机会处理滑动事件,可能导致父容器的滚动功能失效。正确的做法是在ACTION_MOVE时判断滑动方向,如果滑动方向是你的View需要处理的,再调用requestDisallowInterceptTouchEvent(true)。
这段代码的关键点是:在ACTION_DOWN时不调用requestDisallowInterceptTouchEvent,而是等到ACTION_MOVE时根据滑动方向来决定。这样既能保证水平滑动时事件不被父容器抢走,又能保证垂直滑动时父容器可以正常滚动。
4.3 多指手势处理:ACTION_POINTER_DOWN与ACTION_POINTER_UP
当用户用两根手指操作时,事件序列会变成:
- ACTION_DOWN(第一根手指按下)
- ACTION_POINTER_DOWN(第二根手指按下)
- ACTION_MOVE(手指移动)
- ACTION_POINTER_UP(第二根手指抬起)
- ACTION_UP(第一根手指抬起)
处理多指手势时,需要注意getActionMasked()和getActionIndex()的配合使用。getActionMasked()返回的是去掉指针索引后的动作类型,getActionIndex()返回的是触发事件的指针索引。
这里有一个容易忽略的细节:当第二根手指抬起时,如果直接重置mInitialDistance,可能会导致缩放跳变。更好的做法是在ACTION_POINTER_UP时,把剩下的那根手指的位置作为新的基准点,重新计算初始距离。
4.4 惯性滑动与Scroller的配合使用
如果你的自定义View需要支持惯性滑动(比如手指快速滑动后,内容继续滑动一段距离再停下),就需要用到Scroller或OverScroller。
Scroller的工作原理是:你调用startScroll()方法传入起始位置、滑动距离和持续时间,然后Scroller会根据时间插值器计算出每一帧的位置。你需要在computeScroll()方法里不断调用scroller.computeScrollOffset()来获取当前位置,并调用invalidate()触发重绘。
Scroller的startScroll方法里的持续时间参数(500毫秒)需要根据实际体验调整。太短了感觉不到惯性,太长了感觉拖沓。我一般用300到500毫秒,具体看滑动距离和内容量。
注意:
computeScroll()方法不会自动被调用,它是在draw()方法里被调用的。所以如果你在自定义View里重写了draw()方法,记得调用super.draw(),否则computeScroll()不会被触发。
5. 常见问题与排查技巧实录
这一章我整理了几个在实际项目中遇到的高频问题,以及我的排查思路和解决方案。这些问题在官方文档里很少提到,但在一线开发中几乎每个人都会遇到。
5.1 自定义View在ScrollView里高度不对
问题现象:自定义View的layout_height设置为wrap_content,但在ScrollView里显示的高度要么是0,要么是满屏。
排查思路:首先检查onMeasure里是否使用了getDefaultSize。如果是,改成resolveSize。然后检查是否在onMeasure里正确计算了内容高度。如果内容高度是动态的,确保在数据变化时调用了requestLayout()。
解决方案:
5.2 onDraw里绘制的内容不显示
问题现象:onDraw方法被调用了,但Canvas上什么都没画出来。
排查思路:按以下顺序检查:
- 画笔的颜色是否和背景色一样?比如白色背景上画白色线条。
- 画笔的StrokeWidth是否为0?如果为0,线条不可见。
- 绘制坐标是否在View的可见区域外?比如View的宽高是100x100,但你在(200, 200)的位置绘制。
- 是否在onDraw里调用了
canvas.save()但没有调用canvas.restore()?这会导致后续的绘制被裁剪。 - 是否在onDraw里调用了
canvas.clipRect()裁剪了过小的区域?
解决方案:在onDraw里加一行canvas.drawColor(Color.RED),看看整个View是否被红色填充。如果是,说明onDraw被调用了,问题出在绘制内容上;如果不是,说明onDraw没有被调用,问题出在View的可见性或测量上。
5.3 自定义View的点击事件不响应
问题现象:给自定义View设置了setOnClickListener,但点击没有反应。
排查思路:首先检查onTouchEvent是否返回了true。如果返回false,事件不会被消费,点击事件也不会触发。然后检查是否在onTouchEvent里调用了getParent().requestDisallowInterceptTouchEvent(true),导致父容器无法处理事件。
解决方案:确保onTouchEvent在ACTION_DOWN时返回true。如果自定义View不需要处理触摸事件,只是想响应点击,可以不重写onTouchEvent,直接使用View默认的实现。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 高度为0 | onMeasure里没有调用setMeasuredDimension | 在onMeasure里加日志 | 确保调用了setMeasuredDimension |
| 高度满屏 | 使用了getDefaultSize | 检查onMeasure代码 | 改用resolveSize |
| 内容不显示 | 画笔颜色与背景相同 | 改画笔颜色为红色测试 | 调整画笔颜色 |
| 点击无响应 | onTouchEvent返回false | 在onTouchEvent里加日志 | 返回true |
| 滑动卡顿 | onDraw里创建对象 | 用Profiler检测GC | 把对象移到构造函数 |
| 事件被抢 | 没有调用requestDisallowInterceptTouchEvent | 在onTouchEvent里加日志 | 在ACTION_MOVE里根据方向调用 |
| 多指缩放跳变 | ACTION_POINTER_UP时重置了初始距离 | 检查多指处理逻辑 | 重新计算基准点 |
| 惯性滑动不生效 | 没有重写computeScroll | 检查是否重写了computeScroll | 重写并调用invalidate |
5.5 几个我踩过的坑
第一个坑是在onMeasure里调用invalidate。onMeasure是在布局阶段调用的,invalidate是在绘制阶段调用的。在onMeasure里调用invalidate会导致布局和绘制循环触发,界面直接卡死。正确的做法是在数据变化时调用requestLayout(),让系统重新走测量和布局流程。
第二个坑是在onDraw里做浮点数运算。浮点数运算比整数运算慢很多,尤其是在onDraw这种高频调用的方法里。我之前的做法是在onDraw里计算每个数据点的坐标,后来改成在数据变化时提前算好坐标数组,onDraw里只做简单的取值和绘制,帧率提升了20%左右。
第三个坑是忽略View的padding。自定义View在绘制内容时,需要考虑padding的影响。如果忽略了padding,内容会紧贴View的边缘,视觉上很难看。正确的做法是在onDraw里用getPaddingLeft()、getPaddingTop()等方法来偏移绘制坐标。
第四个坑是没有处理ACTION_CANCEL。当父容器拦截了事件时,子View会收到ACTION_CANCEL。如果不在onTouchEvent里处理ACTION_CANCEL,可能会导致状态残留,比如手指已经抬起了,但View还认为手指在按下状态。正确的做法是在ACTION_CANCEL里重置所有状态,和ACTION_UP的处理逻辑保持一致。
6. 从代码到体验:自定义View的调试与验证方法
写完自定义View的代码只是第一步,真正的挑战在于调试和验证。这一章我分享几个我在实际项目中常用的调试方法,以及如何验证自定义View在不同场景下的表现。
6.1 用开发者选项验证绘制性能
Android的开发者选项里有几个非常实用的工具:
- 调试GPU过度绘制:显示每个像素被绘制的次数,颜色越深表示过度绘制越严重。
- GPU渲染模式分析:显示每一帧的渲染时间,包括测量、布局、绘制、合成等阶段。
- 显示布局边界:显示每个View的边界,方便检查测量和布局是否正确。
我一般在自定义View开发阶段会同时打开这三个选项。过度绘制用来看onDraw是否有优化空间,渲染模式分析用来看onMeasure和onDraw的耗时,布局边界用来看测量结果是否符合预期。
6.2 用adb命令抓取布局层级
adb shell dumpsys activity top可以输出当前界面的View层级信息,包括每个View的宽高、位置、可见性等。这个命令在排查测量和布局问题时非常有用。
比如你想知道自定义View在ScrollView里的实际测量高度,可以在命令输出里搜索你的View的类名,看看mMeasuredHeight的值是多少。如果和预期不符,就可以顺着测量链路往上查,看是哪个父容器传入了错误的MeasureSpec。
6.3 用单元测试验证测量逻辑
自定义View的测量逻辑是纯计算,非常适合用单元测试来验证。你可以把测量逻辑抽成一个独立的方法,输入MeasureSpec和内容数据,输出期望的宽高,然后在单元测试里覆盖各种边界情况。
单元测试的好处是可以在不启动模拟器的情况下快速验证逻辑,而且可以覆盖很多手动测试难以触发的边界情况。
6.4 用多设备验证兼容性
自定义View在不同设备上的表现可能会有差异,尤其是不同屏幕密度、不同Android版本的设备。我一般会在以下设备上做验证:
- 低密度设备(ldpi/mdpi):检查绘制内容是否清晰,文字是否可读。
- 高密度设备(xxhdpi/xxxhdpi):检查绘制内容是否过大或过小。
- 不同Android版本:检查API兼容性,尤其是硬件加速和离屏缓存的差异。
- 不同厂商ROM:检查事件分发和测量逻辑是否有差异。
我这次项目里就遇到一个问题:在某个厂商的ROM上,ScrollView测量子View时传入的MeasureSpec模式是UNSPECIFIED而不是AT_MOST,导致我的自定义View高度计算错误。后来在onMeasure里加了UNSPECIFIED的处理逻辑才解决。
7. 自定义View的扩展方向与个人经验
自定义View写多了之后,你会发现它不仅仅是一个UI组件,更是一个可以复用的业务能力载体。我这次项目里的自定义View,后来被抽成了一个独立的模块,其他项目直接依赖就能用。这一章我分享几个扩展方向和个人经验。
7.1 把自定义View封装成独立模块
如果你的自定义View在多个项目中都有使用,建议把它抽成一个独立的模块。模块里包含View的代码、自定义属性、以及必要的资源文件。其他项目通过Gradle依赖引入,不需要复制粘贴代码。
封装模块时需要注意:
- 自定义属性的命名要加前缀,避免和宿主项目的属性冲突。
- 提供默认的样式和主题,方便宿主项目快速集成。
- 暴露必要的回调接口,让宿主项目可以监听状态变化。
- 提供详细的文档和示例代码,降低使用门槛。
7.2 支持动态配置与主题切换
自定义View的视觉样式不应该硬编码在代码里,而应该通过自定义属性来配置。比如线条颜色、文字大小、指示器样式等,都应该可以在XML里设置。
在构造函数里读取这些属性:
支持主题切换的话,可以在onDraw里根据当前主题动态选择颜色,或者监听主题变化事件,重新读取颜色值并调用invalidate()。
7.3 我个人的几点体会
第一,不要过度设计。我早期写自定义View时,总想把所有可能的需求都考虑进去,结果代码越来越复杂,维护成本越来越高。后来我学乖了,只实现当前业务需要的功能,等真的有新需求时再扩展。这样代码更简洁,也更容易测试。
第二,多写注释,尤其是测量和事件部分的注释。自定义View的测量逻辑和事件处理逻辑往往比较绕,过几个月再看可能就忘了当初为什么这么写。我现在的习惯是在每个关键判断上加注释,说明这个判断的业务背景和预期行为。
第三,性能优化要基于数据,不要凭感觉。我见过很多开发者一上来就做各种优化,结果代码复杂度上去了,性能却没提升多少。正确的做法是先用Profiler测出瓶颈在哪里,然后针对性地优化。我这次项目里,一开始以为onDraw是瓶颈,测了之后才发现onMeasure里的计算才是耗时大户,优化了测量逻辑之后帧率直接上了一个台阶。
第四,多设备测试是必须的。自定义View在不同设备上的表现差异比系统控件大得多,尤其是涉及触摸事件和绘制性能的场景。我现在的习惯是至少在三台不同品牌、不同Android版本的设备上跑一遍,确认没有兼容性问题再交付。
这个自定义View的代码后来我又迭代了几个版本,增加了动画支持和无障碍适配。动画部分主要是用ValueAnimator来驱动状态变化,然后在onDraw里根据当前动画进度来绘制。无障碍适配主要是重写onInitializeAccessibilityNodeInfo方法,把自定义View的状态暴露给无障碍服务。这两部分内容展开的话又是一大篇,后面有机会再单独写。