news 2026/10/5 2:28:57

Android 自定义 View 从零到实战:手写 6 大高频组件,附绘制异常排查清单

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android 自定义 View 从零到实战:手写 6 大高频组件,附绘制异常排查清单

摘要:从零手写 Android 自定义 View 的实战指南。深入拆解 Measure、Layout、Draw 三大核心步骤,带你完成月份选择器、防滚动列表、动态饼图、广告轮播等 6 个高频组件,并附赠绘制异常排查清单。全程代码可复制、可直接落地。

目录

  • ① 视图构建核心三步骤解析
    • 1.1 onMeasure:确定 View 的尺寸
    • 1.2 onLayout:摆放子元素的位置
    • 1.3 onDraw:真正绘制内容
    • 1.4 完整示例:一个可复用的自定义 View
    • 1.5 三个步骤的协作关系
  • ② 改造现有控件实现月份选择器
    • 2.1 初始化两个 NumberPicker
    • 2.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 完整封装:一个可直接复用的 NonScrollableRecyclerView
    • 4.4 在布局中使用
    • 4.5 注意事项与性能优化
  • ⑤ 通知渠道配置与悬浮窗实现
    • 5.1 创建通知渠道
    • 5.2 申请悬浮窗权限
    • 5.3 构建并发送悬浮通知
    • 5.4 完整封装:一个可复用的悬浮通知工具类
    • 5.5 使用示例与注意事项
  • ⑥ 利用 Handler 机制驱动持续绘制
    • 6.1 理解 Handler 驱动绘制的原理
    • 6.2 控制帧率:选择合适的延迟时间
    • 6.3 生命周期管理:避免内存泄漏
    • 6.4 完整封装:一个可复用的持续绘制 View
    • 6.5 进阶:暂停与恢复
    • 6.6 使用示例与注意事项
  • ⑦ 自定义饼图动画代码全流程
    • 7.1 定义数据模型
    • 7.2 初始化数据与绘制工具
    • 7.3 核心绘制逻辑:动态裁剪扇形
    • 7.4 动画驱动:利用 Handler 实现平滑生长
    • 7.5 生命周期管理:避免内存泄漏
    • 7.6 完整封装:一个可复用的 PieChartView
    • 7.7 使用示例与进阶优化
  • ⑧ 广告轮播实战项目需求与设计
    • 8.1 需求梳理:一个轮播图要具备哪些能力
    • 8.2 技术选型:为什么选择 ViewPager2
    • 8.3 无限循环的核心设计:首尾副本策略
    • 8.4 自动播放与手势干预的设计
    • 8.5 指示器同步的设计
    • 8.6 整体架构:一个可复用的 BannerView
    • 8.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;i<childCount;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 初始化两个 NumberPicker

NumberPicker本质上是一个垂直滚动的选择器,我们可以通过反射或自定义属性来修改其内部的分隔线颜色、字体大小以及选中项的高亮样式。要实现一个月份选择器,首先需要初始化两个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
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 2:28:57

Python实战第14期:常用标准库

文章目录 引言:为什么需要标准库? 一、os模块 1. 导入os模块 2. 获取当前工作目录 3. 切换目录 4. 列出目录内容 5. 创建目录 6. 删除目录 7. 删除文件 8. 重命名文件或目录 9. 路径操作 10. 遍历目录树 11. 环境变量 12. 执行系统命令 二、datetime模块 1. 导入datetime模块…

作者头像 李华
网站建设 2026/10/5 2:28:28

Linux 权限别死记!一文搞懂 rwx、数字权限、目录权限坑点

这几天是国庆我久违的睡了一个大觉 目录 一、 root与普通用户的区分 二、Linux的权限管理 2.1 三种基本权限 2.2 权限的两种表示法&#xff1a;符号和数字 三、权限的修改与验证 四、三个关于权限的问题 4.1 进入一个目录需要哪些权限 4.2 读文件需要什么权限 4.3 写入…

作者头像 李华
网站建设 2026/10/5 2:28:07

交易最大的敌人,终于被机制打败

在金融交易市场中&#xff0c;绝大多数交易者的亏损&#xff0c;从来不是败给了行情&#xff0c;而是败给了自己。 无数交易数据和实战案例证明&#xff1a;市场的不确定性并不可怕&#xff0c;人性的确定性弱点才是交易亏损的核心根源。贪婪、恐惧、侥幸、犹豫、冲动、自负&am…

作者头像 李华