【Android】从复用到重绘的控件定制化方式

自定义 View 通常分为三类:组合控件、继承控件和重绘控件。

类型 定义
组合控件 基于已有系统控件,通过布局文件或代码组合得到的新控件,适合快速复用。
继承控件 通过继承系统控件类,保留或扩展其功能,适合对单一控件进行定制化。
重绘控件 直接继承自 View,依靠 Canvas 与 Paint 完全自绘,灵活性最高。

组合控件

组合控件的实现思路是:将多个现有系统控件组织到一个布局文件中,再通过继承布局类(如 FrameLayout、LinearLayout 等)包装为一个独立的自定义控件。这样可以对内部控件的样式和交互进行统一管理。

XML 布局示例:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/title"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="#FEFEFE">
​
    <Button
        android:id="@+id/button_left"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:layout_gravity="center_vertical"
        android:backgroundTint="@android:color/transparent"
        android:text="Button"
        android:textColor="@color/black" />
​
    <TextView
        android:id="@+id/title_text"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="center"
        android:gravity="center"
        android:text="TextView"
        android:textColor="@color/black" />
​
</LinearLayout>
java 复制代码
public class TitleView extends FrameLayout {
 
    private Button leftButton;
    private TextView titleText;
 
    public TitleView(Context context, AttributeSet attrs) {
        super(context, attrs);
        LayoutInflater.from(context).inflate(R.layout.title, this);
        titleText = findViewById(R.id.title_text);
        leftButton = findViewById(R.id.button_left);
    }
 
    public void setTitleText(String text) {
        titleText.setText(text);
    }
 
    public void setLeftButtonText(String text) {
        leftButton.setText(text);
    }
 
    public void setLeftButtonListener(OnClickListener listener) {
        leftButton.setOnClickListener(listener);
    }
}

在使用时,只需要像调用系统控件一样,在 XML 中通过包名+类名引入:

xml 复制代码
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
 
    <com.example.android.TitleView
        android:id="@+id/title_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>
</FrameLayout>

自定义属性

系统控件的属性分为 android: 与 app: 两类,开发者也可以通过定义 declare-styleable 来添加属于自定义控件的 app: 属性。

定义属性

res/values/attrs.xml

xml 复制代码
<resources>
    <declare-styleable name="TitleView">
        <attr name="titleText" format="string" />
        <attr name="leftButtonText" format="string" />
        <attr name="titleColor" format="color" />
    </declare-styleable>
</resources>

declare-styleable 的 name 对应控件类名,内部的 <attr> 用于声明属性名和可接受的数据类型。

使用属性

在布局根节点引入命名空间:

xml 复制代码
xmlns:app="http://schemas.android.com/apk/res-auto"

命名空间的 schemas.android.com 作为占位符用于放置 res-auto 自动检测的包名,所以想要引入 app: 作用域,在根布局加入固定语句即可,然后在自定义控件中使用:

xml 复制代码
<com.example.myapp.TitleView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:titleText="首页"
    app:leftButtonText="返回"
    app:titleColor="@color/black"/>

解析属性

在控件的构造方法中使用 TypedArray 解析属性值:

java 复制代码
public class TitleView extends FrameLayout {
 
    private Button leftButton;
    private TextView titleText;
 
    public TitleView(Context context, AttributeSet attrs) {
        super(context, attrs);
        LayoutInflater.from(context).inflate(R.layout.title, this);
        titleText = findViewById(R.id.title_text);
        leftButton = findViewById(R.id.button_left);
​
        TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.TitleView);
​
        String textTitle = ta.getString(R.styleable.TitleView_titleText);
        String textLeftButton = ta.getString(R.styleable.TitleView_leftButtonText);
        int color = ta.getColor(R.styleable.TitleView_titleColor, Color.BLACK);
​
        ta.recycle(); // 回收TypedArray的方法,避免内存泄漏
​
        titleText.setText(textTitle);
        leftButton.setText(textLeftButton);
        titleText.setTextColor(color);
    }
}

因为控件类实例化时调用构造方法来读取 XML 的属性资源,所以这种方式称为 静态声明 ;而在 Java 代码中通过调用方法设置属性称为 动态声明。


继承控件

继承控件通过扩展现有控件类,在保留其基础功能的同时,加入额外逻辑。例如通过重写 onDraw,在 TextView 上绘制辅助线。

关键回调方法:

  • 构造方法:初始化对象并读取 XML 属性
  • onMeasure(int widthMeasureSpec, int heightMeasureSpec):确定控件尺寸
  • onSizeChanged(int w, int h, int old, int oldh):控件大小首次确定或发生变化时触发
  • onDraw(Canvas canvas):实际的绘制逻辑
  • onLayout(boolean changed, int left, int top, int right, int bottom):View 内用于确定控件本身在父布局中的位置;ViewGroup 则用于计算和设置所有子控件的位置

因为大多数情况下我们使用继承控件的形式都是希望复用系统的 onMeasure 和 onLayout 流程,所以我们只需要重写 onDraw 方法。实现非常简单:

java 复制代码
public class LineTextView extends androidx.appcompat.widget.AppCompatTextView {
​
    private Paint mPaint;
​
    public LineTextView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }
​
    private void init() {
        mPaint = new Paint();
        mPaint.setColor(Color.BLACK);
    }
​
    @Override
    protected void onDraw(Canvas canvas) {
        int width = getWidth();
        int height = getHeight();
​
        // 背景
        mPaint.setColor(Color.parseColor("#FEFEFE"));
        RectF rectF = new RectF(0, 0, width, height);
        canvas.drawRect(rectF, mPaint);
​
        // 中线
        mPaint.setColor(Color.BLACK);
        mPaint.setStrokeWidth(5);
        canvas.drawLine(0, height / 2, width, height / 2, mPaint);
​
        super.onDraw(canvas); // 保持原有文字绘制
    }
}

重绘控件

重绘控件是完全基于 View 的高定制化自定义控件实现,适合从零绘制特殊效果。相比继承控件,它需要开发者自己实现 onMeasure 和 onDraw,从而精确控制尺寸和外观。

测量逻辑

MeasureSpec 提供三种模式:

  • UNSPECIFIED:父容器对子控件大小不做限制
  • EXACTLY:精确值(对应 match_parent 或固定值)
  • AT_MOST:最大值(对应 wrap_content)

默认实现 getDefaultSize 中,AT_MOST 和 EXACTLY 被同等处理,这就是为什么如果不重写 onMeasure,wrap_content 会表现为填满父容器,所以我们重绘控件时必须要重写 onMeasure 方法。

java 复制代码
public static int getDefaultSize(int size, int measureSpec) {
    int result = size;
    int specMode = MeasureSpec.getMode(measureSpec);
    int specSize = MeasureSpec.getSize(measureSpe);
    
    switch (specMode) {
    	case MeasureSpec.UNSPECIFIED:
            result = size;
        	break;
    	case MeasureSpec.AT_MOST:
    	case MeasureSpec.EXACTLY:
        	result = specSize; // here
        	break;
    }
    return result;
}

示例:

java 复制代码
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);

    if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
        setMeasuredDimension(300, 300);
    } else if (widthMode == MeasureSpec.AT_MOST) {
        setMeasuredDimension(300, heightSize);
    } else if (heightMode == MeasureSpec.AT_MOST) {
        setMeasuredDimension(widthSize, 300);
    }
}

绘制逻辑

java 复制代码
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    int paddingLeft = getPaddingLeft();
    int paddingRight = getPaddingRight();
    int paddingTop = getPaddingTop();
    int paddingBottom = getPaddingBottom();

    int width = getWidth() - paddingLeft - paddingRight;
    int height = getHeight() - paddingTop - paddingBottom;

    paint.setColor(Color.parseColor("#FEFEFE"));
    canvas.drawRect(paddingLeft, paddingTop, width + paddingLeft, height + paddingTop, paint);
}

这样我们得到了一个矩形空白内容的控件。


渐变程度条实践

在天气应用中,用于表示指数程度的控件往往需要比系统控件更灵活。以下是一个重绘控件示例:

java 复制代码
public class RainbowLineView extends View {

    private Paint linePaint;
    private Paint circlePaint;
    private float[] dataPoints = {0.2f};

    public RainbowLineView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    private void init() {
        linePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        linePaint.setStyle(Paint.Style.STROKE);
        linePaint.setStrokeWidth(13);

        circlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        circlePaint.setStyle(Paint.Style.FILL);
        circlePaint.setColor(Color.parseColor("#FEFEFE"));
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        int width = getWidth();
        int centerY = getHeight() / 2;

        LinearGradient gradient = new LinearGradient( // 颜色渐变过渡器
                0, centerY, width, centerY,
                new int[]{
                        Color.parseColor("#009FFB"), // 蓝
                        Color.parseColor("#55D2CC"), // 青
                        Color.parseColor("#FBD449"), // 黄
                        Color.parseColor("#FEA736"), // 橙
                        Color.parseColor("#FE3F4F")  // 红
                },
                null, Shader.TileMode.CLAMP
        );
        linePaint.setShader(gradient);

        // 渐变线
        canvas.drawLine(0, centerY, width, centerY, linePaint);

        // 标记点
        for (float value : dataPoints) {
            float x = value * width;
            canvas.drawCircle(x, centerY, 15, circlePaint);
        }
    }

    public void setDataPoints(float[] dataPoints) {
        this.dataPoints = dataPoints;
        invalidate(); // 请求重绘,回调onDraw方法
    }
}

该控件通过 LinearGradient 绘制水平渐变色条,并在 dataPoints 指定的位置绘制圆点,可以用于表示某一指标在区间中的程度。

相关推荐
千里马学框架4 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台4 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone4 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc4 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo4 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077004 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼5 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone5 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen5 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone5 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui