学习Android(八)自定义 View

377 阅读14分钟

简介

在 Android 中,自定义 View 是指继承自系统 View(或其子类)并重写相关方法以实现特定外观和行为的组件。通过自定义 View,开发者可以突破系统自带组件的局限,满足复杂设计要求,提高 UI 重用性和代码封装性。下面将从概念、作用、实现方法和典型场景等方面,对自定义 View 的意义和价值进行详解。

1. 概念与定义

自定义 View 是在 Android 应用中通过继承系统 View 或其子类,来满足默认组件无法完成的特殊 UI 与交互需求的核心机制。它能够将复杂的界面与逻辑封装为可复用、可配置的组件,从而提高代码可维护性与一致性。通过自定义 View,我们可以精细地控制绘制流程、测量流程和事件处理,实现与众不同的用户体验,同时避免在多处重复编写相似布局或逻辑,遵循“Don’t Repeat Yourself”原则 。

2. 什么是自定义 View

  • View 子类:如果预置的 Widget 或布局无法满足需求,可通过继承 View 或其子类(如 TextViewLinearLayout)来创建全新或复合的 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

  • 继承合适的基类

    选择 ViewViewGroup 或现有控件子类,根据需求重写 onDraw()onMeasure()onLayout() 等方法。

  • 提供必要的构造函数

    至少实现 (Context, AttributeSet?) 构造,以支持 XML 布局;可使用 @JvmOverloads 简化次级构造函数声明。

  • 解析自定义属性

    在构造函数中使用 context.obtainStyledAttributes() 获取 XML 中定义的属性值,并在绘制或布局逻辑中应用。

  • 实现测量与绘制

    • onMeasure():根据测量规范计算 setMeasuredDimension()
    • onDraw():使用 Canvas 绘制自定义元素,并在必要时调用 invalidate() 触发重绘。
  • 处理交互事件

    根据需求重写 onTouchEvent()onKeyDown() 等方法,实现触摸、按键或手势响应。

7. 继承 View 和 ViewGroup 的区别

  • 继承 View

    • 基本定义View 是 Android UI 的基石,任何可见且可交互的组件(如 ButtonTextView 等)都直接或间接地继承自 View

      核心职责

      • 测量自身尺寸:在 onMeasure() 中调用 setMeasuredDimension(),按照父容器给定的测量规范计算自身宽高 。
      • 绘制内容:在 onDraw(Canvas) 中使用 Canvas API 绘制形状、文本或位图等。
      • 处理交互:重写 onTouchEvent()onKeyDown() 等方法,实现点击、按键或手势识别等逻辑 。
  • 继承 ViewGroup

    • 基本定义ViewGroupView 的子类,专门用来 “*作为其它 ViewViewGroup 的容器*,也是所有布局(如 LinearLayoutConstraintLayout)的基类 。

      核心职责

      • 测量并决定子 View 尺寸:在 onMeasure() 中,遍历所有子 View,根据它们的布局参数和父容器约束分别调用 measureChild()measureChildWithMargins()

      • 对子 View 布局:在 onLayout() 中,根据自定义算法(如线性排列、网格排列)调用每个子 View 的 layout() 方法放置位置。

      • 事件分发与拦截:可在 dispatchTouchEvent()onInterceptTouchEvent() 中决定事件是交给自己处理还是交给子 View 处理,用于实现复杂的手势或滚动冲突解决 。

  • 关键方法对比

    方面ViewViewGroup
    测量 (onMeasure)计算自身尺寸,调用 setMeasuredDimension()遍历并测量所有子 View,再决定自身尺寸
    布局 (onLayout)通常不需重写(无子元素),只布局自身必须重写,调用子 View 的 layout() 放置位置
    绘制 (onDraw)绘制自身图形与文本可选重写(多数场景只需绘制背景),真正绘制往往由子 View 完成
    事件处理重写 onTouchEvent() 处理自身触摸事件支持事件分发与拦截,通过 onInterceptTouchEvent() 协调子 View 响应
    属性支持可通过 declare-styleable 暴露自定义属性同上,同时可管理子 View 的布局属性 (LayoutParams)

8. 自定义View示例(画板)

  • Shape

    sealed 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()
    }
    
  • DrawAction

    sealed class DrawAction {
        // 路径类型动作(含路径数据和画笔状态)
        data class PathAction(val path: Path, val paint: Paint) : DrawAction()
        // 形状类型动作(含形状数据和画笔状态)
        data class ShapeAction(val shape: Shape, val paint: Paint) : DrawAction()
    }
    
  • SimpleItemSelectedListener

    class 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>
    
  • DrawingView

    class 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>
    
  • MainActivity

    class 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>
    
  • FlowLayout

    package 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>
    
  • MainActivity

    class 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 模式(EXACTLYAT_MOSTUNSPECIFIED)和尺寸;

      • 结合内容或期望 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),通过 getColorgetDimension 等方法读取属性值。

    • 最后记得 typedArray.recycle() 释放资源,避免内存泄漏。

  • 自定义 View 如何处理复杂触摸事件?请说明 dispatchTouchEvent()、onInterceptTouchEvent() 与 onTouchEvent() 的职责与调用关系。

    • dispatchTouchEvent():事件分发入口,负责将事件传递给子 View 或自身处理;可重写做全局拦截或统计。

    • onInterceptTouchEvent()(仅在 ViewGroup 中):决定是否拦截当前事件流,返回 true 则后续事件交由自身 onTouchEvent() 处理,否则继续分发给子 View 。

    • onTouchEvent():真正处理触摸交互,在此方法中实现拖拽、点击等逻辑,并根据处理结果返回 true/false 表示是否消费事件。

  • 在自定义 View 中如何优化性能并避免内存泄漏?请至少列举三种手段。

    • 局部刷新:在 onDraw() 中只调用 invalidate(Rect),只重绘需要更新的区域,减少 GPU 开销。
    • 对象复用:在绘制循环中复用 PaintPathRect 等对象,避免在 onDraw() 里频繁 new,降低 GC 压力 。
    • 硬件加速:在需求场景允许下开启硬件加速或使用 setLayerType(LAYER_TYPE_HARDWARE, paint),利用 GPU 加速渲染 。
    • 资源回收:在 onDetachedFromWindow() 中及时释放持有的 Bitmap 或其他大资源,避免 Activity/Fragment 退出后泄漏。
    • 避免匿名内部类:使用静态内部类或弱引用避免持有外部上下文导致的内存泄漏 。