简介
在 Android 中,自定义 View 是指继承自系统 View(或其子类)并重写相关方法以实现特定外观和行为的组件。通过自定义 View,开发者可以突破系统自带组件的局限,满足复杂设计要求,提高 UI 重用性和代码封装性。下面将从概念、作用、实现方法和典型场景等方面,对自定义 View 的意义和价值进行详解。
1. 概念与定义
自定义 View 是在 Android 应用中通过继承系统 View 或其子类,来满足默认组件无法完成的特殊 UI 与交互需求的核心机制。它能够将复杂的界面与逻辑封装为可复用、可配置的组件,从而提高代码可维护性与一致性。通过自定义 View,我们可以精细地控制绘制流程、测量流程和事件处理,实现与众不同的用户体验,同时避免在多处重复编写相似布局或逻辑,遵循“Don’t Repeat Yourself”原则 。
2. 什么是自定义 View
- View 子类:如果预置的 Widget 或布局无法满足需求,可通过继承
View或其子类(如TextView、LinearLayout)来创建全新或复合的 UI 组件。 - Compound View(组合控件):将多个已有 View 组合成一个可在 XML 中直接使用的整体,如带图标和文本的标题控件等 。
3. 自定义 View 的作用
- 精细化外观与行为控制
- 自定义渲染:通过重写
onDraw(),可使用 Canvas APIs 绘制任意形状或动画,例如自定义刻度盘、进度环等。 - 测量与布局:重写
onMeasure()以实现特殊的宽高比例或自适应效果,例如始终保持宽高比 1.5:1 的 ImageView 。 - 事件拦截与分发:可在
onTouchEvent()或onKeyDown()中处理复杂触摸或按键逻辑,用于游戏手柄、手势识别等场景。
- 自定义渲染:通过重写
- 代码复用与模块化
- 封装复杂UI:将包含多种状态(加载、完成、失败等)的组件封装为单一 View,避免在不同页面重复实现相同状态机逻辑。
- 可配置属性:通过
declare-styleable在 XML 中暴露自定义属性,使组件更灵活,且使用者无需修改代码即可调节样式与行为 。
4. 自定义 View 的意义和优势
- 提供可维护性:将重复布局与逻辑集中管理,一处修改,全局生效;符合面向对象“高内聚、低耦合”设计思想 。
- 优化性能:通过精简绘制与测量逻辑,减少 View 层级与布局计算开销;在高频刷新场景(如游戏、实时数据可视化)尤为重要。
- 拓展框架能力:自定义 View 可与 Android 动画、手势、无障碍等框架无缝结合,允许开发者打破系统组件的限制,创造差异化体验 。
5. 何时使用自定义 View
- 默认组件无法满足设计需求:如需要复杂的交互、动画或特定的绘制效果时 。
- 多处重复统一交互模式:当相同 UI 模式(带状态、图标、动画等)在多个界面出现时,优先考虑封装为自定义 View。
- 性能要求高:避免多层布局嵌套,通过单一 View 实现所有逻辑,减少测量与渲染开销。
6. 如何创建自定义 View
-
继承合适的基类
选择
View、ViewGroup或现有控件子类,根据需求重写onDraw()、onMeasure()、onLayout()等方法。 -
提供必要的构造函数
至少实现
(Context, AttributeSet?)构造,以支持 XML 布局;可使用@JvmOverloads简化次级构造函数声明。 -
解析自定义属性
在构造函数中使用
context.obtainStyledAttributes()获取 XML 中定义的属性值,并在绘制或布局逻辑中应用。 -
实现测量与绘制
onMeasure():根据测量规范计算setMeasuredDimension()。onDraw():使用 Canvas 绘制自定义元素,并在必要时调用invalidate()触发重绘。
-
处理交互事件
根据需求重写
onTouchEvent()、onKeyDown()等方法,实现触摸、按键或手势响应。
7. 继承 View 和 ViewGroup 的区别
-
继承 View
-
基本定义:
View是 Android UI 的基石,任何可见且可交互的组件(如Button、TextView等)都直接或间接地继承自View。核心职责:
- 测量自身尺寸:在
onMeasure()中调用setMeasuredDimension(),按照父容器给定的测量规范计算自身宽高 。 - 绘制内容:在
onDraw(Canvas)中使用CanvasAPI 绘制形状、文本或位图等。 - 处理交互:重写
onTouchEvent()、onKeyDown()等方法,实现点击、按键或手势识别等逻辑 。
- 测量自身尺寸:在
-
-
继承 ViewGroup
-
基本定义:
ViewGroup是View的子类,专门用来 “*作为其它View或ViewGroup的容器*”,也是所有布局(如LinearLayout、ConstraintLayout)的基类 。核心职责:
-
测量并决定子 View 尺寸:在
onMeasure()中,遍历所有子 View,根据它们的布局参数和父容器约束分别调用measureChild()或measureChildWithMargins()。 -
对子 View 布局:在
onLayout()中,根据自定义算法(如线性排列、网格排列)调用每个子 View 的layout()方法放置位置。 -
事件分发与拦截:可在
dispatchTouchEvent()、onInterceptTouchEvent()中决定事件是交给自己处理还是交给子 View 处理,用于实现复杂的手势或滚动冲突解决 。
-
-
-
关键方法对比
方面 View ViewGroup 测量 ( onMeasure)计算自身尺寸,调用 setMeasuredDimension()遍历并测量所有子 View,再决定自身尺寸 布局 ( onLayout)通常不需重写(无子元素),只布局自身 必须重写,调用子 View 的 layout()放置位置绘制 ( onDraw)绘制自身图形与文本 可选重写(多数场景只需绘制背景),真正绘制往往由子 View 完成 事件处理 重写 onTouchEvent()处理自身触摸事件支持事件分发与拦截,通过 onInterceptTouchEvent()协调子 View 响应属性支持 可通过 declare-styleable暴露自定义属性同上,同时可管理子 View 的布局属性 ( LayoutParams)
8. 自定义View示例(画板)
-
Shapesealed class Shape { // 矩形形状数据类 data class Rect(val left: Float, val top: Float, val right: Float, val bottom: Float) : Shape() // 圆形形状数据类 data class Circle(val cx: Float, val cy: Float, val radius: Float) : Shape() // 直线形状数据类 data class Line(val startX: Float, val startY: Float, val endX: Float, val endY: Float) : Shape() } -
DrawActionsealed class DrawAction { // 路径类型动作(含路径数据和画笔状态) data class PathAction(val path: Path, val paint: Paint) : DrawAction() // 形状类型动作(含形状数据和画笔状态) data class ShapeAction(val shape: Shape, val paint: Paint) : DrawAction() } -
SimpleItemSelectedListenerclass SimpleItemSelectedListener( private val onSelected: (position: Int, value: String) -> Unit ) : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { onSelected(position, parent.getItemAtPosition(position).toString()) } override fun onNothingSelected(parent: AdapterView<*>) {} } -
res/values/
arrays.xml<?xml version="1.0" encoding="utf-8"?> <resources> <string-array name="brush_sizes"> <item>5</item> <item>10</item> <item>20</item> <item>40</item> </string-array> <string-array name="brush_colors"> <item>#000000</item> <item>#FF0000</item> <item>#00FF00</item> <item>#0000FF</item> </string-array> <string-array name="brush_styles"> <item>普通画笔</item> <item>空心矩形</item> <item>实心矩形</item> <item>空心圆</item> <item>实心圆</item> <item>直线</item> <item>虚线</item> </string-array> </resources> -
DrawingViewclass DrawingView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : View(context, attrs) { // region 核心绘图属性 // 当前使用的画笔配置(颜色/粗细/样式等) private var currentPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE // 默认描边模式 strokeWidth = 10f // 默认笔触粗细 color = Color.BLACK // 默认颜色 } // 已完成的所有绘图动作记录(用于重绘) private val actions = mutableListOf<DrawAction>() // 撤销栈(用于重做功能) private val undone = Stack<DrawAction>() // 临时路径记录(用于实时绘制预览) private var tempPath = Path() // 图形起始坐标(用于矩形/圆形等形状计算) private var startX = 0f private var startY = 0f // endregion // region 绘制模式配置 // 支持的绘图模式枚举 enum class Mode { PATH, // 自由路径 RECT, // 空心矩形 FILL_RECT, // 实心矩形 CIRCLE, // 空心圆形 FILL_CIRCLE,// 实心圆形 LINE, // 直线 DASH // 虚线路径 } // 当前绘图模式(属性设置器带重置逻辑) var mode = Mode.PATH set(v) { field = v resetTemp() // 切换模式时清空临时路径 } // endregion // region 画笔配置方法 // 设置笔触大小 fun setBrushSize(size: Float) { currentPaint.strokeWidth = size } // 设置画笔颜色 fun setBrushColor(color: Int) { currentPaint.color = color } // 重置画笔样式(清除混合模式,恢复描边) fun resetBrush() { currentPaint.xfermode = null currentPaint.style = Paint.Style.STROKE } // endregion // region 绘图操作控制 // 清空所有绘图记录 fun clearAll() { actions.clear(); undone.clear(); invalidate() } // 撤销操作:将最后一步移到撤销栈 fun undo() { if (actions.isNotEmpty()) undone.push(actions.removeAt(actions.lastIndex)) invalidate() } // 重做操作:从撤销栈恢复最后一步 fun redo() { if (undone.isNotEmpty()) actions.add(undone.pop()) invalidate() } // 重置临时绘图数据 private fun resetTemp() { tempPath.reset() startX = 0f startY = 0f } // endregion // region 绘制逻辑 override fun onDraw(canvas: Canvas) { super.onDraw(canvas) // 绘制历史记录 actions.forEach { act -> when (act) { // 路径类型:直接绘制路径 is DrawAction.PathAction -> canvas.drawPath(act.path, act.paint) // 形状类型:根据具体形状绘制 is DrawAction.ShapeAction -> { when (val s = act.shape) { is Shape.Rect -> canvas.drawRect( s.left, s.top, s.right, s.bottom, act.paint ) is Shape.Circle -> canvas.drawCircle( s.cx, s.cy, s.radius, act.paint ) is Shape.Line -> canvas.drawLine( s.startX, s.startY, s.endX, s.endY, act.paint ) } } } } // 绘制临时路径(预览效果) if (!tempPath.isEmpty) canvas.drawPath(tempPath, currentPaint) } // endregion // region 触摸事件处理 override fun onTouchEvent(ev: MotionEvent): Boolean { val x = ev.x val y = ev.y when (ev.action) { // 按下事件:记录起始坐标 MotionEvent.ACTION_DOWN -> { startX = x; startY = y // 路径类模式需要初始化移动点 if (mode == Mode.PATH || mode == Mode.DASH) { tempPath.moveTo(x, y) } } // 移动事件:实时更新路径(仅自由绘制模式) MotionEvent.ACTION_MOVE -> { if (mode == Mode.PATH) { tempPath.lineTo(x, y) } } // 抬起事件:完成绘制并保存记录 MotionEvent.ACTION_UP -> { // 克隆当前画笔状态(避免后续修改影响历史记录) val paintCopy = Paint(currentPaint) when (mode) { // 普通路径:直接保存路径 Mode.PATH -> actions.add(DrawAction.PathAction(Path(tempPath), paintCopy)) // 虚线路径:添加虚线效果后保存 Mode.DASH -> { paintCopy.pathEffect = DashPathEffect(floatArrayOf(20f, 10f), 0f) actions.add(DrawAction.PathAction(Path(tempPath), paintCopy)) } // 矩形类:根据填充模式保存形状 Mode.RECT, Mode.FILL_RECT -> { paintCopy.style = if (mode == Mode.FILL_RECT) Paint.Style.FILL else Paint.Style.STROKE actions.add( DrawAction.ShapeAction( Shape.Rect(startX, startY, x, y), paintCopy ) ) } // 圆形类:计算半径后保存 Mode.CIRCLE, Mode.FILL_CIRCLE -> { paintCopy.style = if (mode == Mode.FILL_CIRCLE) Paint.Style.FILL else Paint.Style.STROKE val r = hypot((x - startX), (y - startY)) // 计算半径 actions.add( DrawAction.ShapeAction( Shape.Circle(startX, startY, r), paintCopy ) ) } // 直线:直接保存起点终点 Mode.LINE -> { actions.add( DrawAction.ShapeAction( Shape.Line(startX, startY, x, y), paintCopy ) ) } } // 重置临时数据并清空撤销栈 tempPath.reset() undone.clear() } } invalidate() return true } } -
activity_main.xml<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent"> <!-- 工具栏 --> <HorizontalScrollView android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="8dp"> <LinearLayout android:orientation="horizontal" android:layout_width="wrap_content" android:layout_height="wrap_content" android:gravity="center_vertical"> <!-- 画笔大小 --> <Spinner android:id="@+id/spinner_size" android:layout_width="wrap_content" android:layout_height="wrap_content" android:entries="@array/brush_sizes" /> <!-- 颜色 --> <Spinner android:id="@+id/spinner_color" android:layout_width="wrap_content" android:layout_height="wrap_content" android:entries="@array/brush_colors" /> <!-- 绘制样式 --> <Spinner android:id="@+id/spinner_style" android:layout_width="wrap_content" android:layout_height="wrap_content" android:entries="@array/brush_styles" /> <!-- 重置 清空 --> <ImageButton android:id="@+id/btn_clear" android:src="@android:drawable/ic_menu_close_clear_cancel" android:layout_width="48dp" android:layout_height="48dp" android:contentDescription="Clear"/> <!-- 撤销 --> <ImageButton android:id="@+id/btn_undo" android:src="@android:drawable/ic_menu_revert" android:layout_width="48dp" android:layout_height="48dp" android:contentDescription="Undo"/> <!-- 重做 --> <ImageButton android:id="@+id/btn_redo" android:src="@android:drawable/ic_menu_rotate" android:layout_width="48dp" android:layout_height="48dp" android:contentDescription="Redo"/> </LinearLayout> </HorizontalScrollView> <!-- 画板区域 --> <com.example.studyviewexample.DrawingView android:id="@+id/drawing_view" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:background="#FFF"/> </LinearLayout> -
MainActivityclass MainActivity : AppCompatActivity() { private lateinit var drawingView: DrawingView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) drawingView = findViewById(R.id.drawing_view) // 画笔大小 val sizeSpinner: Spinner = findViewById(R.id.spinner_size) sizeSpinner.onItemSelectedListener = SimpleItemSelectedListener { pos, value -> drawingView.setBrushSize(value.toFloat()) drawingView.resetBrush() } // 画笔颜色 val colorSpinner: Spinner = findViewById(R.id.spinner_color) colorSpinner.onItemSelectedListener = SimpleItemSelectedListener { _, value -> drawingView.setBrushColor(Color.parseColor(value)) drawingView.resetBrush() } // 绘制样式 val styleSpinner: Spinner = findViewById(R.id.spinner_style) styleSpinner.onItemSelectedListener = SimpleItemSelectedListener { _, value -> drawingView.mode = when (value) { "普通画笔" -> DrawingView.Mode.PATH "空心矩形" -> DrawingView.Mode.RECT "实心矩形" -> DrawingView.Mode.FILL_RECT "空心圆" -> DrawingView.Mode.CIRCLE "实心圆" -> DrawingView.Mode.FILL_CIRCLE "直线" -> DrawingView.Mode.LINE "虚线" -> DrawingView.Mode.DASH else -> DrawingView.Mode.PATH } drawingView.resetBrush() } // 清空 findViewById<ImageButton>(R.id.btn_clear).setOnClickListener { drawingView.clearAll() } // 撤销 findViewById<ImageButton>(R.id.btn_undo).setOnClickListener { drawingView.undo() } // 重做 findViewById<ImageButton>(R.id.btn_redo).setOnClickListener { drawingView.redo() } } }
9. 自定义 ViewGroup示例(流式布局)
以下示例演示了如何实现一个简单的 FlowLayout(流式布局)——一个继承自 ViewGroup 的自定义容器,它会将子视图按行从左到右排列,遇到边界自动换行。在这个示例中,你将看到:如何重写 onMeasure(),遍历并测量所有子视图以确定自身尺寸;如何重写 onLayout(),根据测量结果为每个子视图分配位置,实现流式换行效果;在 XML 中直接使用自定义 FlowLayout 并动态添加子视图 。
-
res/values/attrs.xml<resources> <declare-styleable name="FlowLayout"> <!-- 子 View 之间的水平间距,默认 0dp --> <attr name="horizontalSpacing" format="dimension" /> <!-- 子 View 之间的垂直间距,默认 0dp --> <attr name="verticalSpacing" format="dimension" /> </declare-styleable> </resources> -
FlowLayoutpackage com.example.studyviewexample import android.content.Context import android.util.AttributeSet import android.view.View import android.view.ViewGroup class FlowLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : ViewGroup(context, attrs) { // 默认间距都为 0 private var horizontalSpacing = 0 private var verticalSpacing = 0 init { attrs?.let { val ta = context.obtainStyledAttributes(it, R.styleable.FlowLayout) horizontalSpacing = ta.getDimensionPixelSize( R.styleable.FlowLayout_horizontalSpacing, 0 ) verticalSpacing = ta.getDimensionPixelSize( R.styleable.FlowLayout_verticalSpacing, 0 ) ta.recycle() } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val widthSize = MeasureSpec.getSize(widthMeasureSpec) var lineWidth = 0 var lineHeight = 0 var totalWidth = 0 var totalHeight = 0 for (i in 0 until childCount) { val child = getChildAt(i) if (child.visibility == View.GONE) continue measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0) val lp = child.layoutParams as MarginLayoutParams val childW = child.measuredWidth + lp.leftMargin + lp.rightMargin val childH = child.measuredHeight + lp.topMargin + lp.bottomMargin // 如果加上一个水平间距后超出宽度,需要换行 val spaceNeeded = if (lineWidth == 0) childW else lineWidth + horizontalSpacing + childW if (spaceNeeded > widthSize - paddingLeft - paddingRight) { // 记录当前行 totalWidth = maxOf(totalWidth, lineWidth) totalHeight += lineHeight // 新行开始 lineWidth = childW lineHeight = childH } else { // 同一行继续放置 if (lineWidth == 0) { lineWidth = childW } else { lineWidth += horizontalSpacing + childW } lineHeight = maxOf(lineHeight, childH) } } // 处理最后一行 totalWidth = maxOf(totalWidth, lineWidth) totalHeight += lineHeight // 加上 padding 及垂直间距 totalHeight += paddingTop + paddingBottom + verticalSpacing * (lineCount() - 1) totalWidth += paddingLeft + paddingRight setMeasuredDimension( resolveSize(totalWidth, widthMeasureSpec), resolveSize(totalHeight, heightMeasureSpec) ) } override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { val parentWidth = r - l var x = paddingLeft var y = paddingTop var lineHeight = 0 for (i in 0 until childCount) { val child = getChildAt(i) if (child.visibility == View.GONE) continue val lp = child.layoutParams as MarginLayoutParams val cw = child.measuredWidth val ch = child.measuredHeight // 换行判断 if (x != paddingLeft && x + lp.leftMargin + cw + lp.rightMargin > parentWidth - paddingRight) { x = paddingLeft y += lineHeight + verticalSpacing lineHeight = 0 } val left = x + lp.leftMargin val top = y + lp.topMargin child.layout(left, top, left + cw, top + ch) // 更新 x、lineHeight,下一个子 View 位置预留水平间距 x = left + cw + lp.rightMargin + horizontalSpacing lineHeight = maxOf(lineHeight, ch + lp.topMargin + lp.bottomMargin) } } // 必须提供 MarginLayoutParams 支持 override fun generateLayoutParams(attrs: AttributeSet) = MarginLayoutParams(context, attrs) override fun generateDefaultLayoutParams() = MarginLayoutParams( LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT ) // 辅助:计算行数(用于总高度的垂直间距计算) private fun lineCount(): Int { var lines = 1 var lineWidth = 0 val widthLimit = measuredWidth - paddingLeft - paddingRight for (i in 0 until childCount) { val child = getChildAt(i) if (child.visibility == View.GONE) continue val lp = child.layoutParams as MarginLayoutParams val cw = child.measuredWidth + lp.leftMargin + lp.rightMargin if (lineWidth == 0) { lineWidth = cw } else { if (lineWidth + horizontalSpacing + cw > widthLimit) { lines++ lineWidth = cw } else { lineWidth += horizontalSpacing + cw } } } return lines } } -
activity_main.xml<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" xmlns:app="http://schemas.android.com/apk/res-auto"> <com.example.studyviewexample.FlowLayout android:id="@+id/flow_layout" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="#f0f0f0" android:padding="8dp" app:horizontalSpacing="12dp" app:verticalSpacing="16dp" /> </FrameLayout> -
res/drawable/tag_background.xml<?xml version="1.0" encoding="utf-8"?> <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> <solid android:color="#FFCC80"/> <corners android:radius="8dp"/> </shape> -
MainActivityclass MainActivity : AppCompatActivity() { private lateinit var flowLayout: FlowLayout override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) flowLayout = findViewById(R.id.flow_layout) // 模拟一组长度不一的标签文本 val tags = listOf( "Android", "Kotlin", "FlowLayout", "自定义布局", "示例", "流式换行", "ViewGroup", "动态添加", "Margin", "Padding", "WrapContent", "FrameLayout", "布局演示", "多行显示", "Tag", "生活","就像是海洋","只有意志坚强的人","才能到达彼岸","我爱我的国", "我爱我的家","犯我中华者虽远必诛","天天写代码","为了做牛马" ) for (text in tags) { // 创建 TextView 并设置样式 val tv = TextView(this).apply { this.text = text setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) setBackgroundResource(R.drawable.tag_backgrouond) setTextColor(Color.BLACK) setPadding( dp(12), dp(8), dp(12), dp(8) ) } // 设置 MarginLayoutParams:所有标签左右和上下各留 8dp 空隙 val lp = ViewGroup.MarginLayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT ) lp.setMargins(dp(8), dp(8), dp(8), dp(8)) flowLayout.addView(tv, lp) } } // 辅助:dp 转 px private fun dp(value: Int): Int = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), resources.displayMetrics ).toInt() }
10. 自定义 View 的常见面试问题
-
自定义 View 为什么要实现多个构造函数?它们有什么区别?
-
Android 在不同场景下创建 View 时调用不同构造函数:
View(Context context)用于在代码中直接 new 时。View(Context context, AttributeSet attrs)用于从 XML 布局中 inflate 时 。View(Context context, AttributeSet attrs, int defStyleAttr)用于同时应用 XML 属性和主题默认样式时。
-
若仅实现最简构造,会导致 inflate 或样式应用失败,甚至运行时崩溃。
-
-
请详细描述自定义 View 的测量流程及 onMeasure() 的作用。
-
测量流程分为两阶段:父 ViewGroup 在
onMeasure()中通过measureChild()或measureChildWithMargins()给子 View 传递 MeasureSpec(模式+大小)。 -
子 View 接收 MeasureSpec 后,在自身
onMeasure(widthMeasureSpec, heightMeasureSpec)中:-
解析 MeasureSpec 模式(
EXACTLY、AT_MOST、UNSPECIFIED)和尺寸; -
结合内容或期望
wrap_content逻辑,计算期望宽高; -
调用
setMeasuredDimension(width, height)通知测量结果。
-
-
最终,父容器根据子 View 的测量结果,决定布局阶段位置与大小。
-
-
如何在自定义 View 中暴露并读取自定义属性?
-
在
res/values/attrs.xml中使用<declare-styleable>定义属性,例如:<declare-styleable name="MyView"> <attr name="strokeColor" format="color"/> <attr name="strokeWidth" format="dimension"/> </declare-styleable> -
在 View 构造函数中调用
context.obtainStyledAttributes(attrs, R.styleable.MyView),通过getColor、getDimension等方法读取属性值。 -
最后记得
typedArray.recycle()释放资源,避免内存泄漏。
-
-
自定义 View 如何处理复杂触摸事件?请说明 dispatchTouchEvent()、onInterceptTouchEvent() 与 onTouchEvent() 的职责与调用关系。
-
dispatchTouchEvent():事件分发入口,负责将事件传递给子 View 或自身处理;可重写做全局拦截或统计。 -
onInterceptTouchEvent()(仅在 ViewGroup 中):决定是否拦截当前事件流,返回true则后续事件交由自身onTouchEvent()处理,否则继续分发给子 View 。 -
onTouchEvent():真正处理触摸交互,在此方法中实现拖拽、点击等逻辑,并根据处理结果返回true/false表示是否消费事件。
-
-
在自定义 View 中如何优化性能并避免内存泄漏?请至少列举三种手段。
- 局部刷新:在
onDraw()中只调用invalidate(Rect),只重绘需要更新的区域,减少 GPU 开销。 - 对象复用:在绘制循环中复用
Paint、Path、Rect等对象,避免在onDraw()里频繁 new,降低 GC 压力 。 - 硬件加速:在需求场景允许下开启硬件加速或使用
setLayerType(LAYER_TYPE_HARDWARE, paint),利用 GPU 加速渲染 。 - 资源回收:在
onDetachedFromWindow()中及时释放持有的 Bitmap 或其他大资源,避免 Activity/Fragment 退出后泄漏。 - 避免匿名内部类:使用静态内部类或弱引用避免持有外部上下文导致的内存泄漏 。
- 局部刷新:在