在 Android 中,要实现自定义 View,可以通过继承已有的 View 来实现,并且要写对应的构造方法。

View 有四个主要的构造方法,第四个需要在 API 21 以上,一般而言我们都会实现前三个。

  • public View(Context context)
  • public View(Context context, @Nullable AttributeSet attrs)
  • public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr)
  • public View(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)

下面来看一种常见的写法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public Circle(Context context) {
this(context, null);
}

public Circle(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public Circle(Context context, AttributeSet attrs, int defStyleAttr) {
this(context, attrs, defStyleAttr, 0);
}

@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public Circle(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
init();
}

在前面几个构造方法中调用 this, 再在后面的构造方法中统一调用 super。这种写法的确很常见,在各种开源库甚至 Android 自己的一些 View 都采用了这种写法。然而如果你对此不假思索地照抄下来,还是可能会遇到坑的。

先来看下这些构造方法的用途。

  • 只有一个 Context 参数的构造方法其实主要是给我们手动创建 View(new View(context))用的。
  • 第二个构造方法用于从布局文件中创建(inflate) View,会在 inflate 过程中通过反射被调用到。
  • 后面两个构造方法并不会由系统去调用,主要是给子类 View 调用父类构造方法初始化用的。

View 的构造方法是用来初始化 View 属性(比如背景,边距,id,文字大小,文字颜色等等)的,那么这些初始值从哪里来以及如何获取就是我们首先要关心的。根据我们的经验,这些值通常是在布局文件,或者 Style 中指定。通过查看 View 的源码我们可以发现这些值的查找是通过 Context 的 public final TypedArray obtainStyledAttributes(AttributeSet set, @StyleableRes int[] attrs, @AttrRes int defStyleAttr, @StyleRes int defStyleRes) 方法完成的,这个方法中的参数对应于与 View 构造方法中的参数。

让我们来看一下这几个参数。

  • AttributeSet attrs 是我们在布局文件里面为该 View 定义的属性集,比如为一个 TextView 指定 android:textSize="16sp" android:textColor: "#666"。值得注意的是我们可以在布局文件中为 View 指定 style 属性,引用一个 Style 资源,这样如果相关属性没有在布局文件中没有直接指定的话,就会去 Style 中查找。

  • 以 def 命名的参数是指 default。int defStyleAttr 其实是一个 attr 资源,它对应于当前 Theme 中的一项属性,其值是对一个 Style 的引用。那么有了这个参数,如果要查找的属性通过 AttributeSet attrs 找不到,我们就可以去当前 Theme 中由 defStyleAttr 引用的 Style 中查找。

    举个例子。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <style name="AppTheme" parent="@style/Theme.AppCompat.Light.NoActionBar">
    <item name="colorPrimary">@color/colorPrimary</item>
    <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
    <item name="colorAccent">@color/colorAccent</item>
    <item name="buttonStyle">@style/MyButtonStyle</item>
    </style>

    <style name="MyButtonStyle" parent="Base.TextAppearance.AppCompat.Widget.Button">
    <item name="android:background">@android:color/transparent</item>
    <item name="android:textColor">@android:color/black</item>
    <item name="android:textSize">24dp</item>
    </style>

    我们可以通过在 Theme 中定义 buttonStyle 属性来更改 Button 的默认样式。这里的 buttonStyle 就是一个 attr 资源 <attr name="buttonStyle" format="reference" />,它的值就是对我们自己定义的 Style MyButtonStyle 的引用。这样,如果我们没有在布局文件里给 Button 定义 android:textSize 的话,它就会去 MyButtonStyle 中查找。

    那么如果我们没有指定 defStyleAttr 参数(传 0 进去),或者我们的 Theme 并没有定义 defStyleAttr 呢? 那就要看最后一个参数了。

  • 最后一个参数 int defStyleRes 显然是一个 Style 资源了,在前面的地方都相关属性都找不到的情况下,就可以从这个指定的 Style 中去查找相关属性。

  • 当然,还有最后一个查找位置就是我们直接在 Theme 中定义的属性了。

那么了解了这些基础之后,让我们再来回顾下上文提到的写法。

考虑这样一种情况,我们要继承的父类 View 同样实现了以上几个构造方法,而且用到了 defStyleAttrdefStyleRes 属性。

比如 Button 的构造方法是这样写的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Button(Context context) {
this(context, null);
}

public Button(Context context, AttributeSet attrs) {
this(context, attrs, com.android.internal.R.attr.buttonStyle);
}

public Button(Context context, AttributeSet attrs, int defStyleAttr) {
this(context, attrs, defStyleAttr, 0);
}

public Button(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}

它指定了会从当前 Theme 中的 buttonStyle 中去查找默认样式,而如果按照上文中的写法,我们继承了 Button 类又刚好在构造方法中去调用 this(…) 构造方法,最后才调用一下 super,那么 Button 的构造方法接收到的 defStyleAttr 也就不是 com.android.internal.R.attr.buttonStyle,而是 0,这样就导致了我们的 Button 无法从 Theme 中获取默认样式。这种情况显然不是我们所期望的。

其实后面两个构造方法要传 int defStyleAttrint defStyleRes 进去,也就意味着会改变默认属性的查找位置,所以只有我们需要覆盖父类的默认样式时才应该去调用这两个构造方法,否则我们最好调用第二个构造方法 super(context, attrs)