Android 自定义 ViewGroup 综合学习笔记
| 方法 | 作用 | 注意事项 |
|---|---|---|
onMeasure() |
测量子View并确定自身尺寸 |
|
setMeasuredDimension() |
设置最终测量尺寸 |
|
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// 1. 测量子View
val childCount = childCount
for (i in 0 until childCount) {
measureChildWithMargins(childAt(i), widthMeasureSpec, 0, heightMeasureSpec, 0)
}
// 2. 计算自身尺寸
var totalWidth = paddingLeft + paddingRight
var totalHeight = paddingTop + paddingBottom
// 3. 调用setMeasuredDimension
val finalWidth = resolveSize(totalWidth, widthMeasureSpec)
val finalHeight = resolveSize(totalHeight, heightMeasureSpec)
setMeasuredDimension(finalWidth, finalHeight)
}
| 方法 | 作用 | 注意事项 |
|---|---|---|
onLayout() |
定位每个子View的位置 |
|
child.layout(l, t, r, b) |
设置子View的边界 |
|
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
val parentWidth = r - l
val parentHeight = b - t
// 遍历所有子View
var cumulativeHeight = paddingTop
for (i in 0 until childCount) {
val child = getChildAt(i)
if (child.visibility != VISIBLE) continue
// 获取 MarginLayoutParams
val lp = child.layoutParams as MarginLayoutParams
// 计算子View的边界(示例:垂直排列)
val childLeft = paddingLeft + lp.leftMargin
val childTop = cumulativeHeight + lp.topMargin
val childRight = childLeft + child.measuredWidth
val childBottom = childTop + child.measuredHeight
child.layout(childLeft, childTop, childRight, childBottom)
cumulativeHeight = childBottom + lp.bottomMargin
}
}
| 方法 | 作用 | 注意事项 |
|---|---|---|
onDraw() |
绘制自身背景 |
|
dispatchDraw() |
绘制子View |
|
draw() |
完整绘制流程 |
|
override fun dispatchDraw(canvas: Canvas) {
super.dispatchDraw(canvas)
// 在子View绘制后添加分隔线
if (showDivider) {
val paint = Paint().apply {
color = dividerColor
strokeWidth = dividerHeight.toFloat()
}
var y = paddingTop.toFloat()
for (i in 0 until childCount - 1) {
val child = getChildAt(i)
if (child.visibility == VISIBLE) {
y += child.measuredHeight + (child.layoutParams as MarginLayoutParams).bottomMargin
canvas.drawLine(paddingLeft.toFloat(), y, (width - paddingRight).toFloat(), y, paint)
y += dividerHeight
}
}
}
}
res/values/attrs.xml:
<declare-styleable name="MyVerticalLayout">
<attr name="dividerColor" format="color" />
<attr name="dividerHeight" format="dimension" />
<attr name="showDivider" format="boolean" />
</declare-styleable>
Kotlin 最佳实践:
class MyVerticalLayout(context: Context, attrs: AttributeSet?) : ViewGroup(context, attrs) {
private var dividerColor = Color.GRAY
private var dividerHeight = 2
private var showDivider = true
init {
// 使用KTX的withStyledAttributes自动回收TypedArray
context.withStyledAttributes(attrs, R.styleable.MyVerticalLayout) {
dividerColor = getColor(R.styleable.MyVerticalLayout_dividerColor, Color.GRAY)
dividerHeight = getDimensionPixelSize(R.styleable.MyVerticalLayout_dividerHeight, 2)
showDivider = getBoolean(R.styleable.MyVerticalLayout_showDivider, true)
}
setWillNotDraw(false) // 启用onDraw
}
}
<com.example.MyVerticalLayout
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:dividerColor="#FF0000"
app:showDivider="true"
app:dividerHeight="1dp">
<!-- 子View -->
</com.example.MyVerticalLayout>
必须重写三个方法:
class MyVerticalLayout : ViewGroup {
// 支持Margin的关键三方法
override fun generateLayoutParams(attrs: AttributeSet?) = MarginLayoutParams(context, attrs)
override fun generateDefaultLayoutParams() = MarginLayoutParams(WRAP_CONTENT, WRAP_CONTENT)
override fun checkLayoutParams(p: LayoutParams): Boolean = p is MarginLayoutParams
// 测量子View时必须使用带margin的方法
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val childCount = childCount
for (i in 0 until childCount) {
val child = getChildAt(i)
val lp = child.layoutParams as MarginLayoutParams
measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0)
}
// 计算自身尺寸(需包含所有子View的margin)
}
}
onMeasure/onLayout中创建对象(如 Paint、Rect)ViewGroup.resolveSize()正确处理父约束class MyVerticalLayout : ViewGroup {
// 缓存Paint对象
private val backgroundPaint = Paint().apply {
color = Color.GRAY
style = Paint.Style.STROKE
strokeWidth = 2f
}
// 复用Rect对象
private val tempRect = Rect()
override fun onDraw(canvas: Canvas) {
// 使用缓存的Paint和Rect对象
tempRect.set(0, 0, width, height)
canvas.drawRect(tempRect, backgroundPaint)
}
}
class ScrollingLayout : ViewGroup {
private var scrollY = 0
private var lastY = 0f
override fun dispatchDraw(canvas: Canvas) {
// 先滚动画布,再绘制子View
canvas.translate(0f, scrollY.toFloat())
super.dispatchDraw(canvas)
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> lastY = event.y
MotionEvent.ACTION_MOVE -> {
val dy = lastY - event.y
scrollY += dy.toInt()
// 限制滚动范围
scrollY = Math.max(Math.min(scrollY, maxScrollY), 0)
invalidate() // 请求重绘
}
}
lastY = event.y
return true
}
}
// 需要配合Scroller实现fling效果
private val scroller = Scroller(context)
override fun computeScroll() {
if (scroller.computeScrollOffset()) {
scrollY = scroller.currY
invalidate() // 更新滚动位置
}
}
// 处理fling事件
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
if (ev.actionMasked == MotionEvent.ACTION_MOVE) {
// 检测是否需要滚动
return shouldIntercept
}
return false
}
class MyVerticalLayout : ViewGroup {
override fun onSaveInstanceState(): Parcelable {
val superState = super.onSaveInstanceState()
return SavedState(superState).apply {
// 保存自定义状态
currentScrollY = scrollY
}
}
override fun onRestoreInstanceState(state: Parcelable?) {
val savedState = state as? SavedState ?: return super.onRestoreInstanceState(state)
super.onRestoreInstanceState(savedState.superState)
// 恢复自定义状态
scrollY = savedState.currentScrollY
invalidate() // 应用新状态
}
// 自定义状态类
class SavedState : BaseSavedState {
var currentScrollY = 0
constructor(superState: Parcelable?) : super(superState)
private constructor(parcel: Parcel) : super(parcel) {
currentScrollY = parcel.readInt()
}
override fun writeToParcel(out: Parcel, flags: Int) {
super.writeToParcel(out, flags)
out.writeInt(currentScrollY)
}
companion object {
@JvmField val CREATOR = object : Parcelable.Creator {
override fun createFromParcel(parcel: Parcel) = SavedState(parcel)
override fun newArray(size: Int) = arrayOfNulls(size)
}
}
}
}
| 对象类型 | 回收方法 | 最佳实践 |
|---|---|---|
| TypedArray | recycle() |
使用KTX的 .use { }自动回收 |
| 自建 Bitmap | recycle() |
在 onDetachedFromWindow()中回收 |
| Handler 回调 | removeCallbacks() |
在 onDetachedFromWindow()中移除 |
| Animator | cancel() |
在 onDetachedFromWindow()中取消 |
// 错误写法(可能导致资源泄漏)
val ta = context.obtainStyledAttributes(attrs, R.styleable.MyView)
val color = ta.getColor(R.styleable.MyView_color, Color.BLACK)
// 忘记recycle() → Lint警告 + 潜在泄漏
// 正确写法(推荐KTX)
context.obtainStyledAttributes(attrs, R.styleable.MyView).use { ta ->
val color = ta.getColor(R.styleable.MyView_color, Color.BLACK)
// 自动调用ta.recycle()
}
class MyView : View {
private var customBitmap: Bitmap? = null
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
// 回收自建的Bitmap
customBitmap?.recycle()
customBitmap = null
}
}
| 对象类型 | 复用策略 | 为什么重要 |
|---|---|---|
| Paint | 定义为成员变量并复用 | 频繁创建消耗内存,Paint 内部有原生资源 |
| Rect | 定义为成员变量并复用 | 高频绘制场景(如 onDraw)减少GC开销 |
| Matrix | 定义为成员变量并复用 | 避免频繁创建原生资源,提升性能 |
class MyView : View {
// 缓存Paint对象
private val textPaint = Paint().apply {
color = Color.RED
strokeWidth = 2f
textSize = 16f * resources.displayMetrics.scaledDensity
}
override fun onDraw(canvas: Canvas) {
// 直接使用缓存的Paint对象
canvas.drawText("Hello", 0f, 0f, textPaint)
// 不要在这里创建新的Paint对象!
}
}
class MyView : View {
// 定义成员变量
private val tempRect = Rect()
override fun onDraw(canvas: Canvas) {
// 复用tempRect,而不是每次新建
tempRect.set(0, 0, width, height)
canvas.drawRect(tempRect, paint)
// 下次使用时会重用同一个Rect对象
}
}
| 方法 | 作用 | 注意事项 |
|---|---|---|
onInitializeAccessibilityNodeInfo() |
初始化无障碍节点信息 | 必须设置contentDescription、setBoundsInScreen()、setClassName() |
onInitializeAccessibilityEvent() |
初始化无障碍事件 | 必须反映组件的可见性、处理焦点状态 |
class CustomViewGroup : ViewGroup {
override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
super.onInitializeAccessibilityNodeInfo(info)
// 设置内容描述
info.contentDescription = "自定义垂直布局"
info.setBoundsInScreen(Rect())
info.className = javaClass.name
// 添加点击操作支持
info.addAction(AccessibilityNodeInfo.ACTION_CLICK)
info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)
}
override fun onInitializeAccessibilityEvent(event: AccessibilityEvent) {
super.onInitializeAccessibilityEvent(event)
// 确保事件包含正确的信息
event.className = javaClass.name
event.text.add("自定义垂直布局")
}
// 为可聚焦子View设置焦点
override fun addFocusables(views: ArrayList, direction: Int, previouslyHasFocus: Boolean) {
// 遍历子View并添加可聚焦的子View
for (i in 0 until childCount) {
val child = getChildAt(i)
if (child.isFocusable) {
views.add(child)
}
}
}
}
<com.example.MyVerticalLayout
android:animateLayoutChanges="true"
... other attributes ...>
</com.example.MyVerticalLayout>
// 或在代码中启用
layoutTransition = LayoutTransition()
val transition = LayoutTransition()
// 设置入场动画
transition.setAnimator(LayoutTransition.APPEARING, ObjectAnimator.ofFloat(null, "alpha", 0f, 1f))
// 设置消失动画
transition.setAnimator(LayoutTransition.DISAPPEARING, ObjectAnimator.ofFloat(null, "alpha", 1f, 0f))
// 设置布局改变时的动画
transition.setAnimator(LayoutTransition.CHANGE_APPEARING, ObjectAnimator.ofFloat(null, "scaleX", 1f, 1.1f, 1f))
transition.setAnimator(LayoutTransition.CHANGE_DISAPPEARING, ObjectAnimator.ofFloat(null, "scaleX", 1f, 0.9f, 1f))
// 应用到ViewGroup
this.layoutTransition = transition
class MyVerticalLayout : ViewGroup {
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
super.onLayout(changed, l, t, r, b)
if (changed) {
// 遍历所有子View并添加入场动画
for (i in 0 until childCount) {
val child = getChildAt(i)
if (child.visibility == View.VISIBLE) {
child.translationY = child.measuredHeight.toFloat()
child.alpha = 0f
ObjectAnimator.ofFloat(child, "translationY", 0f).apply {
duration = 300
interpolator = AccelerateDecelerateInterpolator()
start()
}
ObjectAnimator.ofFloat(child, "alpha", 1f).apply {
duration = 300
interpolator = AccelerateDecelerateInterpolator()
start()
}
}
}
}
}
}
// 使用Robolectric测试onMeasure逻辑
@Test
fun testOnMeasureWithWrapContent() {
val layout = MyVerticalLayout(ApplicationProvider.getApplicationContext())
val childView = TextView(ApplicationProvider.getApplicationContext())
childView.layoutParams = MarginLayoutParams(100, 200)
layout.addView(childView)
layout.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED)
assertEquals(100 + layout.paddingLeft + layout.paddingRight, layout.measuredWidth)
assertEquals(200 + layout.paddingTop + layout.paddingBottom, layout.measuredHeight)
}
// 使用Espresso验证分隔线显示
@Test
fun testDividerVisibility() {
launchActivity()
// 确保分隔线可见
onView(withId(R.id.divider)).check(matches(isDisplayed()))
// 验证分隔线颜色
onView(withId(R.id.divider)).check(matches(hasBackgroundColor(dividerColor)))
}
| 源码类 | 学习重点 | 关键实现 |
|---|---|---|
LinearLayout |
权重分配、baseline对齐、margin处理 | weightSum的计算、measureWithBaseline的实现、computeChildWidth的逻辑 |
FrameLayout |
简单叠加、foreground支持 | 子View按Gravity对齐、foreground的绘制时机、setForeground的实现 |
CoordinatorLayout |
Behavior机制、交互解耦 | Behavior接口设计、layoutChild的实现、onInterceptTouchEvent的逻辑 |
Ctrl + 左键跳转到源码onMeasure、onLayout、onDraw的实现| 问题 | 解决方案 | 影响 |
|---|---|---|
| onMeasure中高度计算错误 | 使用 resolveSize(totalHeight, heightMeasureSpec) |
布局错乱,尺寸不符合预期 |
| ViewGroup默认不调用onDraw | 在构造函数中调用 setWillNotDraw(false) |
背景无法绘制 |
| 忘记TypedArray recycle() | 使用KTX的 .use { }或 try-finally 回收 |
资源泄漏,导致OOM或崩溃 |
| 不支持子View的margin | 重写 generateLayoutParams并返回 MarginLayoutParams |
margin属性无效 |
| 滚动内容无法显示全 | 计算 maxScrollY并限制滚动范围 |
内容被截断,无法滑动到底部 |
| 问题 | 解决方案 | 影响 |
|---|---|---|
| 在onDraw中频繁创建Paint对象 | 定义为成员变量并复用 | 增加GC压力,可能导致卡顿 |
| 未复用Rect等临时对象 | 定义为成员变量并重用 | 增加GC压力,导致内存波动 |
| 未处理GONE状态的子View | 在onLayout中跳过GONE子View | 错误计算尺寸,导致布局错乱 |
| 未正确处理padding | 在测量和布局时考虑padding | 内容被padding遮挡,无法正确显示 |
// 自定义垂直布局,支持分隔线和滚动
class VerticalLayout(context: Context, attrs: AttributeSet?) : ViewGroup(context, attrs) {
// 自定义属性
private var dividerColor = Color.GRAY
private var dividerHeight = 2
private var showDivider = true
private val dividerPaint = Paint()
// 滚动相关变量
private var scrollY = 0
private var lastY = 0f
private var contentHeight = 0
private val maxScrollY get() = Math.max(0, contentHeight - height)
// 临时对象复用
private val tempRect = Rect()
init {
// 解析自定义属性
context.withStyledAttributes(attrs, R.styleable.VerticalLayout) {
dividerColor = getColor(R.styleable.VerticalLayout_dividerColor, Color.GRAY)
dividerHeight = getDimensionPixelSize(R.styleable.VerticalLayout_dividerHeight, 2)
showDivider = getBoolean(R.styleable.VerticalLayout_showDivider, true)
}
// 初始化画笔
dividerPaint.color = dividerColor
dividerPaint.strokeWidth = dividerHeight.toFloat()
// 启用滚动
setWillNotDraw(false) // 启用onDraw
setClipChildren(false) // 允许内容超出边界
// 启用布局动画
layoutTransition = LayoutTransition()
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val childCount = childCount
var totalWidth = paddingLeft + paddingRight
var totalHeight = paddingTop + paddingBottom
// 测量子View
for (i in 0 until childCount) {
val child = getChildAt(i)
val lp = child.layoutParams as MarginLayoutParams
measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0)
val childWidth = child.measuredWidth + lp.leftMargin + lp.rightMargin
val childHeight = child.measuredHeight + lp.topMargin + lp.bottomMargin
totalWidth = Math.max(totalWidth, childWidth)
totalHeight += childHeight
}
// 计算自身尺寸
val finalWidth = resolveSize(totalWidth, widthMeasureSpec)
val finalHeight = resolveSize(totalHeight, heightMeasureSpec)
setMeasuredDimension(finalWidth, finalHeight)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
var currentY = paddingTop
for (i in 0 until childCount) {
val child = getChildAt(i)
val lp = child.layoutParams as MarginLayoutParams
if (child.visibility != View.VISIBLE) continue
// 计算子View位置
val childLeft = paddingLeft + lp.leftMargin
val childTop = currentY + lp.topMargin
val childRight = childLeft + child.measuredWidth
val childBottom = childTop + child.measuredHeight
// 应用布局(考虑滚动偏移)
child.layout(childLeft, childTop - scrollY, childRight, childBottom - scrollY)
// 更新累计高度
currentY += child.measuredHeight + lp.topMargin + lp.bottomMargin
}
// 记录内容高度
contentHeight = currentY
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 绘制分隔线
if (showDivider) {
var y = paddingTop.toFloat()
for (i in 0 until childCount - 1) {
val child = getChildAt(i)
if (child.visibility == View.VISIBLE) {
val lp = child.layoutParams as MarginLayoutParams
y += child.measuredHeight + lp.topMargin + lp.bottomMargin
canvas.drawLine(paddingLeft.toFloat(), y, (width - paddingRight).toFloat(), y, dividerPaint)
}
}
}
}
override fun dispatchDraw(canvas: Canvas) {
// 先滚动画布
canvas.translate(0f, scrollY.toFloat())
super.dispatchDraw(canvas)
}
// 支持Margin的关键方法
override fun generateLayoutParams(attrs: AttributeSet?) = MarginLayoutParams(context, attrs)
override fun generateDefaultLayoutParams() = MarginLayoutParams(WRAP_CONTENT, WRAP_CONTENT)
override fun checkLayoutParams(p: LayoutParams): Boolean = p is MarginLayoutParams
// 处理触摸事件
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> lastY = event.y
MotionEvent.ACTION_MOVE -> {
val dy = lastY - event.y
scrollY += dy.toInt()
// 限制滚动范围
scrollY = Math.max(Math.min(scrollY, maxScrollY), 0)
invalidate() // 请求重绘
}
}
lastY = event.y
return true
}
// 无障碍支持
override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
super.onInitializeAccessibilityNodeInfo(info)
info.contentDescription = "垂直布局"
info.className = javaClass.name
info.isVisibleToUser = true
info.setBoundsInScreen(Rect())
}
// 资源回收
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
// 取消所有动画
layoutTransition?.cancel()
}
}
onMeasure测量子View并确定自身尺寸onLayout定位所有子ViewonDraw绘制自身背景generateLayoutParams)LayoutTransition或自定义动画)onInitializeAccessibilityNodeInfo)onDetachedFromWindow中释放资源)onMeasure/onLayout中避免创建对象,使用缓存机制onMeasure/onLayout)的简洁性,复杂逻辑拆分到辅助函数onMeasure/onLayout是入门,能写出媲美系统 ViewGroup 的容器,才是高手。