自定义 View 同学们一定或多或少都接触过,毕竟设计给出的样式往往从来不是系统默认的🙂。 自定义 View 时往往遵守下面这样的套路:

  1. attrs.xml 中声明自定义属性(或使用系统预定义属性),经常用 declare-styleable 包裹起来。
  2. 继承一个 View 并写两个构造函数:constructor(context: Context), constructor(context: Context, attrs: AttributeSet?). 前者用于代码创建,后者用于 xml 创建。
  3. context.theme.obtainStyledAttributes() 来读取属性值,并在 getXXX() 函数中给出一个默认值。

一切似乎理所应当行云流水并且工作良好,但这真的合理吗?(废话 要是合理这篇文章是在自嗨嘛😅

考虑以下问题:

  • 用户(本文指使用 View 的开发者)如何统一设置默认样式?
  • 默认值硬编码在代码文件中是否耦合过重?

当然用户可以定义一个 style 每次使用都设置一下,你不觉得太麻烦了吗?

当然也可以直接写在主题的样式中,但如果有冲突属性怎么办?例如自定义 View 使用了系统定义的 android:textColor 但又不希望其他控件例如 TextView 受影响。

为了后边便于说明,我们定义个 CircleCheckBox,并定义如下属性:

<!--lib: attrs.xml-->
<resources>
    <declare-styleable name="CircleCheckBox">
        <attr name="normalColor" format="color" />
        <attr name="selectColor" format="color" />
        <attr name="outlineColor" format="color" />
        <attr name="outlineStrokeWidth" format="dimension" />
        <attr name="android:checked" />
    </declare-styleable>
</resources>

被忽略的两兄弟

除了常用的两个构造函数之外,还有一个三个参数的构造函数,从 Android 5.0 API 21 开始又多了一个四个参数的构造函数。为啥被忽略?因为这俩兄弟永远不会被系统调用,除非我们主动找上门。

三参构造统一样式

先看一下函数定义:

constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int)

defStyleAttr 指的是一个属性,它的值一般是 reference 类型,指向一个样式(style)。用户只需要在他自己的应用主题中填写这个属性,并给予适当的样式,就可以全局配置这个 View 的默认属性值了。听起来好绕口,还是来用实例解释一下。


【科普时间】

正式解释之前要插入一段科普:属性(attr) 不一定要定义在 declare-styleable 内部。回忆一下我们如何读取属性的:

context.theme.obtainStyledAttributes(attrs, R.styleable.CircleCheckBox, defStyleAttr, defStyleRes).use {
        circleChecked = it.getBoolean(R.styleable.CircleCheckBox_android_checked, circleChecked)
        normalColor = it.getColor(R.styleable.CircleCheckBox_normalColor, normalColor)
        selectColor = it.getColor(R.styleable.CircleCheckBox_selectColor, selectColor)
        outlineColor = it.getColor(R.styleable.CircleCheckBox_outlineColor, outlineColor)
        outlineWidth = it.getDimension(R.styleable.CircleCheckBox_outlineStrokeWidth, 0f)
}

首先拿到一个 TypedArray 然后调用 getXXX 来读取。这个名字很标准,一眼就能看出这玩意好像是个数组。

那如果把属性定义在 declare-styleable 之外呢?

<!--lib: attrs.xml-->
<resources>
    <attr name="normalColor" format="color" />
    <attr name="selectColor" format="color" />
    <!--其他省略-->
</resources>

我们照样可以读取:

    private val attrsId = intArrayOf(R.attr.normalColor, R.attr.selectColor)
    private val ATTR_NORMAL_COLOR = 0
    private val ATTR_SELECT_COLOR = 1

    context.theme.obtainStyledAttributes(attrs, attrsId, 0, 0, ).use {
        normalColor = it.getColor(ATTR_NORMAL_COLOR, normalColor)
        selectColor = it.getColor(ATTR_SELECT_COLOR, normalColor)
    }

应该看出来了, declare-styleable 就是系统帮助我们自动生成的数组,用来减少样板代码。说了那么多想强调的就是 attr 不依赖于 declare-styleable,后者只是为了更好地组织 attr,否则试想把不同 View 的属性定义在一起得多乱啊。

【科普结束】


首先得定义个属性,用于承载样式:

<!--lib: attrs.xml-->
<resources>
    <!--View 的属性省略-->
    <attr name="CircleCheckBoxStyle" format="reference" />
</resources>

然后把两个参数的构造函数代理给三参的,并在三参中进行属性读取:

    constructor(context: Context, attrs: AttributeSet?) 
            : this(context, attrs, R.attr.CircleCheckBoxStyle)

    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int)
            : super(context, attrs, defStyleAttr) {
        // 在这里 context.theme.obtainStyledAttributes 读取属性
    }

作为 View 开发者我们的工作就完成了。

那么用户就可以定义个专用于 CiecleCheckBox 的样式并指派给上面那个属性:

<!--app: styles.xml-->
<resources>
    <!-- Base application theme. -->
    <style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
        <item name="circleCheckBoxStyle">@style/circle_checkbox</item>
    </style>

    <style name="circle_checkbox">
        <item name="normalColor">#AA000000</item>
        <item name="selectColor">#FFFFFF</item>
        <item name="outlineColor">#FFFFFF</item>
        <item name="outlineStrokeWidth">1dp</item>
        <item name="android:checked">false</item>
    </style>
</resources>

这样就实现了允许用户进行统一的全局样式配置,并且无论是否使用了共用属性,此配置只对 CiecleCheckBox 生效。

总结一下:第三个参数就是一个属性,这个属性指明了要对这一特定的类型的 View 使用哪些样式。

四参提供默认值

假设用户没有设置 View 属性、也没有在主题中设置默认样式,那么就需要使用默认值,曾经我们直接把它写在代码中,虽然写起来方便但是维护起来就不那么舒服了,样式和其他内部变量混在一起很容易搞错。基于三参构造,四参方法给予了提供默认样式的机会。

可以形象地把三参构造方法理解成接口,它允许用户传入一个参数。而四参构造方法就是在用户没有传参的情况下指定一个默认值。

先把默认值从代码中抽离出去写到样式里:

<!--lib: styles.xml-->
<resources>
    <style name="DefaultCircleCheckBoxStyleRes">
        <item name="normalColor">#AA000000</item>
        <item name="selectColor">#FFFFFF</item>
        <item name="outlineColor">#FFFFFF</item>
        <item name="outlineStrokeWidth">@dimen/lib_circlecheckbox_default_outline_width</item>
        <item name="android:checked">false</item>
    </style>
</resources>

然后把三参函数代理给四参,并在四参构造函数中进行属性读取,最终效果如下:

    constructor(context: Context, attrs: AttributeSet?) 
            : this(context, attrs, R.attr.CircleCheckBoxStyle)

    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int)
            : this(context, attrs, defStyleAttr, R.style.DefaultCircleCheckBoxStyleRes)

    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int)
            : super(context, attrs, defStyleAttr, defStyleRes) {
        // 在这里读取属性
        context.theme.obtainStyledAttributes(attrs, R.styleable.CircleCheckBox, defStyleAttr, defStyleRes).use {
            circleChecked = it.getBoolean(R.styleable.CircleCheckBox_android_checked, circleChecked)
            // ...
        }
    }

我们传入的 R.style.DefaultCircleCheckBoxStyleRes 就是刚刚定义的默认样式。最后把四个参数一股脑传给 obtainStyledAttributes() 它会内部进行计算整合,返回给我们最终属性列表。

计算整合是有优先级的,优先级从高到低依次为:

  • 直接在 xml View 里设置的值
  • 在 xml View 里设置的 style 里的值
  • 用户利用 defAttr (就是三参里面那个东西)所指定的 style 里的值
  • defStyle(就是四参里的那个东西)所指定的 style 里的值
  • 直接在主题里定义的值

如果相同属性存在优先级高的值那么将覆盖优先级低的值。

那么 API < 21 没有四参构造函数怎么办呢?

到现在应该已经发现了,所谓四参构造函数,无非就是多了一个默认值,这个值最终会传递给 obtainStyledAttributes. 那么我们完全可以直接在三参中读取属性,并传入本来应该传给四参函数的值就行了,反而简单一些无需再多代理一步。

    constructor(context: Context, attrs: AttributeSet?) 
            : this(context, attrs, R.attr.CircleCheckBoxStyle)

    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int)
            : super(context, attrs, defStyleAttr) {
        // 在这里读取属性,注意第四个参数
        context.theme.obtainStyledAttributes(attrs, R.styleable.CircleCheckBox, defStyleAttr,  R.style.DefaultCircleCheckBoxStyleRes).use {
            circleChecked = it.getBoolean(R.styleable.CircleCheckBox_android_checked, circleChecked)
            // ...
        }
    }

但是不要偷懒哦,有四参构造还是要代理过去的,否则可能造成父类无法正确获取到默认值,从而回退到主题里的定义,最终与预期效果不同。

总结

之前看了不少网上讲4个构造函数的文章,总是似懂非懂,再加上实际使用上看不出明显差别导致依旧坚持着传统套路的坏习惯。

所以今天我们从问题出发,在寻找解决方案的过程中引入对四个构造函数的说明。希望这种思路可以帮助同学们深刻理解他们的作用,并在后续开发中可以充满信心地使用相关特性,而不是依葫芦画瓢心中发毛。

其实上面的两个小标题可以组成顺口溜:

三参构造统一样式 四参提供默认值

好吧也不是太顺,聊胜于无嘛😆

Last modification:February 16, 2020