
摘要:从零手写 Android 自定义 View 的实战指南。深入拆解 Measure、Layout、Draw 三大核心步骤,带你完成月份选择器、防滚动列表、动态饼图、广告轮播等 6 个高频组件,并附赠绘制异常排查清单。全程代码可复制、可直接落地。目录① 视图构建核心三步骤解析1.1 onMeasure:确定 View 的尺寸1.2 onLayout:摆放子元素的位置1.3 onDraw:真正绘制内容1.4 完整示例:一个可复用的自定义 View1.5 三个步骤的协作关系② 改造现有控件实现月份选择器2.1 初始化两个 NumberPicker2.2 格式化显示:让数字变成“1 月”“2 月”2.3 联动逻辑:切换年份时动态调整2.4 样式定制:修改分隔线与高亮2.5 手感优化:调整滚动阻尼2.6 完整示例:一个可复用的 MonthPicker③ 为翻页标签栏扩展专属属性3.1 在 attrs.xml 中定义自定义属性3.2 创建自定义 TabLayout 子类并读取属性3.3 重写指示器绘制:圆角 + 跟随文字宽度3.4 选中文字渐变效果3.5 在布局中使用自定义属性④ 打造不滚动的列表视图组件4.1 拦截触摸事件:让列表不再消费滑动4.2 测量高度:让列表完全展开4.3 完整封装:一个可直接复用的 NonScrollableRecyclerView4.4 在布局中使用4.5 注意事项与性能优化⑤ 通知渠道配置与悬浮窗实现5.1 创建通知渠道5.2 申请悬浮窗权限5.3 构建并发送悬浮通知5.4 完整封装:一个可复用的悬浮通知工具类5.5 使用示例与注意事项⑥ 利用 Handler 机制驱动持续绘制6.1 理解 Handler 驱动绘制的原理6.2 控制帧率:选择合适的延迟时间6.3 生命周期管理:避免内存泄漏6.4 完整封装:一个可复用的持续绘制 View6.5 进阶:暂停与恢复6.6 使用示例与注意事项⑦ 自定义饼图动画代码全流程7.1 定义数据模型7.2 初始化数据与绘制工具7.3 核心绘制逻辑:动态裁剪扇形7.4 动画驱动:利用 Handler 实现平滑生长7.5 生命周期管理:避免内存泄漏7.6 完整封装:一个可复用的 PieChartView7.7 使用示例与进阶优化⑧ 广告轮播实战项目需求与设计8.1 需求梳理:一个轮播图要具备哪些能力8.2 技术选型:为什么选择 ViewPager28.3 无限循环的核心设计:首尾副本策略8.4 自动播放与手势干预的设计8.5 指示器同步的设计8.6 整体架构:一个可复用的 BannerView8.7 使用示例与注意事项⑨ 轮播图关键逻辑代码落地9.1 无限循环跳转:首尾副本的正确姿势9.2 自动播放与手势协同:用标志位避免冲突9.3 指示器同步:取模映射真实位置9.4 图片加载与复用安全:防止错位和内存溢出9.5 生命周期安全:页面不可见时停止轮播9.6 完整落地:把关键逻辑串进 BannerView⑩ 常见绘制异常排查与优化技巧10.1 黑屏:内容没画出来或全被裁掉10.2 闪烁:重绘范围过大或背景重复绘制10.3 ANR:主线程被耗时操作阻塞10.4 过度绘制:用 GPU 调试工具定位10.5 常见坑位速查表10.6 小结:从「能画」到「画得稳、画得快」10.7 常见异常排查清单⑪ 总结与参考资料11.1 三大核心步骤的关键要点11.2 6 个实战组件的核心收获11.3 参考资料在 Android 开发中,我们常常会遇到原生控件无法满足特定 UI 需求的情况。比如,产品经理突然要求做一个"只能滑动不能滚动"的列表,或者需要一个带有复杂入场动画的自定义饼图,甚至是一个自动轮播且支持手势干预的广告位。面对这些需求,如果仅仅依赖系统自带的 Widget,往往显得力不从心,代码写起来也是处处受限。很多新手在这一阶段容易陷入"找第三方库"的误区,却忽略了 Android 最核心的 View 绘制体系其实提供了足够的自由度来亲手打造这些组件。别担心,自定义 View 并没有想象中那么难——只要掌握了 Measure、Layout、Draw 这条主线,再配合触摸事件拦截和 Handler 消息驱动,你就能逐步解锁各种炫酷的交互效果。亲手实现自定义视图不仅能让应用的性能更可控,避免引入冗余的依赖包,更重要的是,它能让你真正理解从 Measure 到 Layout 再到 Draw 的完整生命周期。当你掌握了如何拦截触摸事件、如何利用 Handler 驱动动画、以及如何安全地管理绘图资源时,你会发现那些曾经看似复杂的交互效果,拆解开来不过是几个核心步骤的组合。这种对底层机制的掌控感,是单纯调用 API 无法带来的。本文将深入实战场景,从最基础的视图构建三步骤开始,逐步带你完成月份选择器、防滚动列表、悬浮窗通知以及动态饼图等高频难点功能的开发。我们会重点剖析如何通过扩展属性来增强控件的灵活性,如何利用消息机制实现流畅的持续绘制,并在最后分享一些在实际项目中遇到的绘制异常排查技巧。无论你是想优化现有项目的交互体验,还是希望夯实自定义 View 的基本功,接下来的内容都将提供可直接落地的代码思路与解决方案。阅读建议:如果你是第一次接触自定义 View,建议按顺序阅读,先跑通 ① 的三步骤,再逐步深入;如果你已有一定基础,可以直接跳到感兴趣的章节。每节都配有完整可运行的代码示例,建议边读边敲,效果最佳。① 视图构建核心三步骤解析自定义 View 的基石在于正确处理测量、布局和绘制这三个阶段。很多初学者在编写自定义控件时,经常遇到文字被截断、图片显示不全或者在不同屏幕上比例失调的问题,其根源大多是对onMeasure、onLayout和onDraw的理解不够透彻。下面我们逐一拆解,并配上一个完整的自定义 View 示例,让你能跟着一步步跑通。1.1 onMeasure:确定 View 的尺寸首先是onMeasure,它的核心任务是确定 View 的大小。系统会传入两个参数widthMeasureSpec和heightMeasureSpec,这两个整数封装了模式(Mode)和大小(Size)。我们需要通过MeasureSpec.getMode()判断当前是精确模式(EXACTLY)、最大模式(AT_MOST)还是未指定模式(UNSPECIFIED)。EXACTLY:父容器已经确定了精确尺寸,通常对应match_parent或具体的 dp 值,此时直接使用传入的 size 即可。AT_MOST:通常对应wrap_content,View 可以自由决定大小,但不能超过父容器给定的上限。UNSPECIFIED:父容器对子 View 没有任何限制,常见于 ScrollView 或 ListView 内部,此时可以按内容实际大小来测量。特别是在 AT_MOST 模式下,如果不进行特殊处理,View 往往会占满父容器。因此,我们需要根据内容实际需要的尺寸,与父容器提供的最大限制进行比较,取较小值作为最终尺寸,并调用setMeasuredDimension上报。@OverrideprotectedvoidonMeasure(intwidthMeasureSpec,intheightMeasureSpec){// 1. 获取父容器传入的宽高模式与大小intwidthMode=MeasureSpec.getMode(widthMeasureSpec);intwidthSize=MeasureSpec.getSize(widthMeasureSpec);intheightMode=MeasureSpec.getMode(heightMeasureSpec);intheightSize=MeasureSpec.getSize(heightMeasureSpec);// 2. 计算内容本身需要的尺寸(这里以文字宽度为例)intcontentWidth=(int)(mPaint.measureText("Hello")+getPaddingLeft()+getPaddingRight());intcontentHeight=(int)(mPaint.getTextSize()+getPaddingTop()+getPaddingBottom());// 3. 根据模式决定最终尺寸intfinalWidth=(widthMode==MeasureSpec.EXACTLY)?widthSize:Math.min(contentWidth,widthSize);intfinalHeight=(heightMode==MeasureSpec.EXACTLY)?heightSize:Math.min(contentHeight,heightSize);// 4. 上报最终尺寸setMeasuredDimension(finalWidth,finalHeight);}1.2 onLayout:摆放子元素的位置其次是onLayout,这一步决定了子元素的位置。对于继承自View的单元素控件,这一步通常不需要重写;但对于继承自ViewGroup的容器类控件,必须在此方法中遍历所有子 View,计算它们的 left、top、right、bottom 坐标,并调用child.layout()进行定位。这是实现复杂排版逻辑的关键所在。@OverrideprotectedvoidonLayout(booleanchanged,intl,intt,intr,intb){// 遍历所有子 View,为它们计算位置intchildCount=getChildCount();inttop=getPaddingTop();for(inti=0;ichildCount;i++){Viewchild=getChildAt(i);intchildWidth=child.getMeasuredWidth();intchildHeight=child.getMeasuredHeight();// 这里以垂直排列为例,每个子 View 依次向下摆放child.layout(getPaddingLeft(),top,getPaddingLeft()+childWidth,top+childHeight);top+=childHeight;}}1.3 onDraw:真正绘制内容最后是onDraw,这是真正“画”出内容的地方。Canvas 对象提供了丰富的绘图 API,如drawCircle、drawText、drawPath等。需要注意的是,onDraw会被频繁调用,因此应避免在此方法中创建新的对象(如 Paint、Rect 等),否则会导致频繁的 GC,引发界面卡顿。正确的做法是在构造函数或初始化阶段预先创建好绘图工具,仅在onDraw中复用。@OverrideprotectedvoidonDraw(Canvascanvas){super.onDraw(canvas);// 复用成员变量中的 Paint,避免在 onDraw 中创建新对象canvas.drawText("Hello Custom View",getPaddingLeft(),getPaddingTop()+mPaint.getTextSize(),mPaint);}1.4 完整示例:一个可复用的自定义 View把上面三步串起来,就是一个完整的自定义 View。下面是一个简单的CustomTextView,它支持wrap_content时按文字内容自适应大小,并绘制一段居中的文字:publicclassCustomTextViewextendsView{privatePaintmPaint;privateStringmText="Hello Custom View";publicCustomTextView(Contextcontext){this(context,null);}publicCustomTextView(Contextcontext,AttributeSetattrs){super(context,attrs);// 在构造函数中初始化 Paint,避免在 onDraw 中重复创建mPaint=newPaint(Paint.ANTI_ALIAS_FLAG);mPaint.setTextSize(48f);mPaint.setColor(Color.BLACK);}@OverrideprotectedvoidonMeasure(intwidthMeasureSpec,intheightMeasureSpec){intwidthMode=MeasureSpec.getMode(widthMeasureSpec);intwidthSize=MeasureSpec.getSize(widthMeasureSpec);intheightMode=MeasureSpec.getMode(heightMeasureSpec);intheightSize=MeasureSpec.getSize(heightMeasureSpec);// 计算内容尺寸floattextWidth=mPaint.measureText(mText);intcontentWidth=(int)(textWidth+getPaddingLeft()+getPaddingRight());intcontentHeight=(int)(mPaint.getTextSize()+getPaddingTop()+getPaddingBottom());// 根据模式决定最终尺寸intfinalWidth=(widthMode==MeasureSpec.EXACTLY)?widthSize:Math.min(contentWidth,widthSize);intfinalHeight=(heightMode==MeasureSpec.EXACTLY)?heightSize:Math.min(contentHeight,heightSize);setMeasuredDimension(finalWidth,finalHeight);}@OverrideprotectedvoidonDraw(Canvascanvas){super.onDraw(canvas);// 计算文字基线位置,实现垂直居中floatbaseline=(getHeight()-mPaint.getFontMetrics().bottom-mPaint.getFontMetrics().top)/2f;canvas.drawText(mText,getPaddingLeft(),baseline,mPaint);}}1.5 三个步骤的协作关系理解三个步骤的协作关系也很关键:系统先调用onMeasure确定尺寸,再调用onLayout确定位置,最后才调用onDraw进行绘制。如果尺寸测量不准确,绘制出来的内容就会错位或截断;如果布局计算有误,子 View 就会重叠或超出边界。因此,测量是基础,布局是骨架,绘制是最终呈现,三者环环相扣,缺一不可。在实际开发中,建议先用一个简单的自定义 View 跑通这三个步骤,再逐步叠加复杂逻辑。这样既能快速定位问题,也能加深对 View 绘制机制的理解。下面是这三个步骤的调用顺序与协作关系流程图:是(内容变化)否系统发起绘制请求onMeasure 测量尺寸onLayout 确定位置onDraw 绘制内容是否需要重绘?完成渲染从图中可以看出,系统会按照「测量 → 布局 → 绘制」的顺序依次调用onMeasure、onLayout和onDraw。当 View 的内容发生变化(如文字更新、尺寸调整)时,会重新触发这一流程,从而保证界面始终与最新状态保持一致。理解这条链路,是排查自定义 View 显示异常的基础。② 改造现有控件实现月份选择器原生的 DatePicker 虽然功能完备,但在某些追求极致 UI 一致性的场景中,其样式可能显得过于厚重或与整体设计语言格格不入。此时,基于NumberPicker进行改造是一个高效的方案。下面我们一步步拆解,从初始化到联动逻辑,再到手感优化,让你能跟着完整跑通。2.1 初始化两个 NumberPickerNumberPicker本质上是一个垂直滚动的选择器,我们可以通过反射或自定义属性来修改其内部的分隔线颜色、字体大小以及选中项的高亮样式。要实现一个月份选择器,首先需要初始化两个NumberPicker实例,分别用于选择年和月。对于年份,我们可以设置最小值和最大值为当前年份前后各 50 年;对于月份,则固定为 1 到 12。// 在布局中获取两个 NumberPickerNumberPickeryearPicker=findViewById(R.id.year_picker);NumberPickermonthPicker=findViewById(R.id.month_picker);// 年份:当前年份前后各 50 年intcurrentYear=Calendar.getInstance().get(Calendar.YEAR);yearPicker.setMinValue(currentYear-50);yearPicker.setMaxValue(currentYear+50);yearPicker.setValue(currentYear);// 月份:固定 1 到 12monthPicker.setMinValue(1);monthPicker.setMaxValue(12);monthPicker.setValue(1);2.2 格式化显示:让数字变成“1 月”“2 月”NumberPicker默认显示数字,若需显示“1 月”“2 月”这样的中文格式,可以通过设置setFormatter方法,传入一个 lambda 表达式或匿名内部类,将输入的数字转换为带单位的字符串。monthPicker.setFormatter(newNumberPicker.Formatter(){@OverridepublicStringformat(intvalue){returnvalue+" 月";}});// 也可以使用 lambda 简化yearPicker.setFormatter(value-value+" 年");2.3 联动逻辑:切换年份时动态调整关键的改造点在于联动逻辑。当用户切换年份时,如果是闰年和平年的区别,虽然月份总数不变,但如果涉及到日期选择(如扩展为日期选择器),则需要动态调整日的最大值。而在纯月份选择器中,我们更需要关注的是格式化显示。// 监听年份变化,如果是日期选择器,则动态调整日的最大值yearPicker.setOnValueChangedListener(newNumberPicker.OnValueChangeListener(){@OverridepublicvoidonValueChange(NumberPickerpicker,intoldVal,intnewVal){// 如果是纯月份选择器,这里可以留空或做其他联动// 如果是日期选择器,则需要根据年份和月份计算当月最大天数intmaxDay=getDaysInMonth(newVal,monthPicker.getValue());dayPicker.setMaxValue(maxDay);}});// 监听月份变化,同样需要联动monthPicker.setOnValueChangedListener(newNumberPicker.OnValueChangeListener(){@OverridepublicvoidonValueChange(NumberPickerpicker,intoldVal,intnewVal){intmaxDay=getDaysInMonth(yearPicker.getValue(),newVal);dayPicker.setMaxValue(maxDay);}});// 计算某年某月的最大天数privateintgetDaysInMonth(intyear,intmonth){Calendarcalendar=Calendar.getInstance();calendar.set(year,month-1,1);returncalendar.getActualMaximum(Calendar.DAY_OF_MONTH);}2.4 样式定制:修改分隔线与高亮为了让选择器更贴合整体设计语言,我们可以通过反射或自定义属性来修改内部的分隔线颜色、字体大小以及选中项的高亮样式。下面是一个通过反射修改分隔线颜色的示例:privatevoidcustomizeNumberPicker(NumberPickerpicker){try{// 通过反射获取内部的分隔线字段FielddividerField=NumberPicker.class.getDeclaredField("mSelectionDivider");dividerField.setAccessible(true);dividerField.set(picker,newColorDrawable(Color.parseColor("#FF6B35")));// 修改选中项的文字颜色FieldtextColorField=NumberPicker.class.getDeclaredField("mTextColor");textColorField.setAccessible(true);textColorField.set(picker,Color.parseColor("#FF6B35"));}catch(Exceptione){e.printStackTrace();}}2.5 手感优化:调整滚动阻尼此外,为了提升手感,可以移除默认的长按加速滚动行为,或者调整mScrollFrame的相关参数,使得滑动阻尼感更符合应用的整体调性。通过这种方式,我们既保留了系统控件的稳定性,又实现了高度定制化的视觉效果。// 移除长按加速滚动(通过反射设置)try{FieldlongPressField=NumberPicker.class.getDeclaredField("mLongPressUpdateInterval");longPressField.setAccessible(true);longPressField.setLong(picker,Long.MAX_VALUE);}catch(Exceptione){e.printStackTrace();}2.6 完整示例:一个可复用的 MonthPicker把上面的步骤串起来,就是一个完整的月份选择器。下面是一个封装好的MonthPicker自定义控件,它同时包含年和月两个NumberPicker,并支持中文格式化显示:publicclassMonthPickerextends