35、自定义属性:declare-styleable、TypedArray获取、在布局中使用自定义属性

自定义属性,说白了就是给咱们的自定义 View 开个「后门」。

你想想看,系统自带的 TextView、Button 都有 android:text、android:layout_width 这些属性。那你自己写的 View 呢?总不能每次都写死吧?

我刚开始做自定义 View 的时候,就犯过这个傻。写了个圆形头像控件,半径直接写死在代码里。结果产品经理说「这个头像大一点,那个小一点」——得,改代码重新编译。后来我才知道,原来 Android 早就给我们准备好了自定义属性的机制。

核心流程:三步走

自定义属性的使用,其实就三步:

  1. 定义属性:在 res/values/attrs.xml 里用 declare-styleable 声明
  2. 获取属性:在 View 的构造函数里用 TypedArray 读取
  3. 使用属性:在布局文件里像普通属性一样写

嗯,就这么简单。但每一步都有坑,我一个个说。

第一步:定义属性(declare-styleable)

在 res/values/ 目录下新建一个 attrs.xml 文件。注意,文件名可以随便起,但习惯上都叫 attrs.xml。

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="RoundImageView">
        <attr name="borderWidth" format="dimension" />
        <attr name="borderColor" format="color" />
        <attr name="isCircle" format="boolean" />
        <attr name="imageScaleType" format="enum">
            <enum name="center_crop" value="0" />
            <enum name="fit_center" value="1" />
            <enum name="center" value="2" />
        </attr>
    </declare-styleable>
</resources>

这里有几个要点:

  • name 要和你的自定义 View 类名一致。虽然不是强制,但这是行业惯例。我见过有人随便起名,结果自己都找不着。
  • format 决定了属性值的类型。常用的有:dimension(尺寸)、color(颜色)、boolean(布尔)、integer(整数)、float(浮点)、string(字符串)、enum(枚举)、reference(资源引用)。
  • enum 类型比较特殊,它其实是个整数,但你在布局里写的是字符串。比如 app:imageScaleType="center_crop"。
我的习惯:所有尺寸属性都用 dimension 而不是 integer。因为 dimension 支持 dp、sp、px 单位,而 integer 只能写纯数字。我曾经用 integer 写了个圆角半径,结果在不同密度屏幕上显示效果完全不一样。

第二步:获取属性(TypedArray)

这一步是核心。在自定义 View 的构造函数里,通过 TypedArray 把属性值读出来。

public class RoundImageView extends AppCompatImageView {
    private float borderWidth;
    private int borderColor;
    private boolean isCircle;
    private int imageScaleType;

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

    private void init(Context context, AttributeSet attrs) {
        // 获取 TypedArray
        TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.RoundImageView);

        // 读取属性值(第二个参数是默认值)
        borderWidth = ta.getDimension(R.styleable.RoundImageView_borderWidth, 0f);
        borderColor = ta.getColor(R.styleable.RoundImageView_borderColor, Color.TRANSPARENT);
        isCircle = ta.getBoolean(R.styleable.RoundImageView_isCircle, false);
        imageScaleType = ta.getInteger(R.styleable.RoundImageView_imageScaleType, 0);

        // 重要!一定要回收
        ta.recycle();
    }
}
我曾经踩过的坑:TypedArray 用完后必须调用 recycle()。如果不回收,会导致后续获取属性时数据错乱。我有个同事因为这个 bug 查了两天,最后发现是 TypedArray 没回收。嗯,从那以后我每次写完都检查一遍。

这里有个细节:属性名是 R.styleable.RoundImageView_borderWidth,注意是「类名_属性名」的格式。这是 Android 自动生成的,别写错了。

第三步:在布局中使用

在布局文件里使用自定义属性前,需要先声明命名空间。通常用 xmlns:app="http://schemas.android.com/apk/res-auto"。

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.example.widget.RoundImageView
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:src="@drawable/avatar"
        app:borderWidth="2dp"
        app:borderColor="#FF0000"
        app:isCircle="true"
        app:imageScaleType="center_crop" />

</LinearLayout>

注意看,系统属性用 android: 前缀,自定义属性用 app: 前缀。这个 app 就是上面声明的命名空间。

知识体系结构图

下面这张图帮你理清自定义属性的完整流程:

自定义属性完整流程 第一步:定义属性 attrs.xml declare-styleable 第二步:获取属性 View 构造函数 TypedArray 读取 第三步:使用属性 布局文件 app:属性名="值" 属性格式 • dimension:尺寸(dp/sp/px) • color:颜色(#RRGGBB) • boolean:布尔值(true/false) • enum:枚举(字符串转整数) • reference:资源引用(@drawable/xx) TypedArray 方法 • getDimension() • getColor() • getBoolean() • getInteger() • getString() 注意事项 • 必须调用 recycle() • 命名空间用 res-auto • 属性名:类名_属性名 • 默认值要合理设置 • 支持多构造函数重载

进阶技巧:属性分组与复用

有时候多个自定义 View 需要共用一些属性。比如 RoundImageView 和 RoundTextView 都需要 borderWidth 和 borderColor。这时候可以把公共属性抽出来:

<resources>
    <!-- 公共属性 -->
    <attr name="borderWidth" format="dimension" />
    <attr name="borderColor" format="color" />

    <declare-styleable name="RoundImageView">
        <attr name="borderWidth" />
        <attr name="borderColor" />
        <attr name="isCircle" format="boolean" />
    </declare-styleable>

    <declare-styleable name="RoundTextView">
        <attr name="borderWidth" />
        <attr name="borderColor" />
        <attr name="textSize" format="dimension" />
    </declare-styleable>
</resources>

注意看,borderWidth 和 borderColor 只定义了一次,然后在两个 declare-styleable 里引用。这样既避免了重复,又保持了统一。

避坑指南

我这些年用自定义属性,踩过的坑不少。挑几个典型的说说:

  • TypedArray 没回收:这个前面说过了,会导致内存泄漏和属性读取异常。我的习惯是在 finally 块里回收,确保万无一失。
  • 属性名冲突:如果你在多个 declare-styleable 里定义了同名但不同类型的属性,编译不会报错,但运行时会出问题。所以命名要规范,最好加上类名前缀。
  • 默认值不合理:比如 borderWidth 默认给 0,结果用户没设置时边框就没了。我一般给个合理的默认值,比如 1dp。
  • 构造函数重载:自定义 View 通常有多个构造函数,记得在每个构造函数里都调用 init 方法。我见过有人只在两个参数的构造函数里初始化,结果在代码里 new 的时候属性全丢了。

核心要点总结:

  • declare-styleable 的 name 要和 View 类名一致
  • TypedArray 用完必须 recycle()
  • 布局中使用 app: 命名空间
  • 属性默认值要合理,避免空指针
  • 公共属性可以复用,减少重复定义

自定义属性这个机制,说白了就是让 View 变得更灵活。你想想看,没有自定义属性,每个 View 都是「死」的。有了它,你的 View 才能像系统控件一样,在布局文件里随意配置。嗯,这就是 Android 框架设计的精妙之处。