Android自定义控件--属性篇

在Android开发的过程中,APP UI随着大众对于审美的变化总是在不断的演进,自定义 View 算是 Android 开发中常见的技巧之一,其实现主要包含两个部分:

  • 定义 declare-styleable 中的自定义属性,并在构造函数中获得并初始化;
  • 实现 onMeasureonLayoutonDraw 等方法。

本文我们主要记录一下关于自定义控件中的属性部分。

1、介绍

在开始介绍自定义属性之前,我们需要先搞清楚一件事情,那就是影响控件的属性都有哪些:

  1. 在布局文件中的某个View节点中,直接指定的;
  2. 在布局文件中的某个View节点中,通过style属性中设置的;
  3. 从defStyleAttr和defStyleRes中设置的;
  4. 在Theme中直接设置的属性。

接下来我们将通过一个简单的Demo来验证,这些属性是如何起作用的?首先来看一下,一般自定义控件的四个构造方法:

1
2
3
4
5
6
public class MView extends View{
public MView(Context context) ;
public MView(Context context, @Nullable AttributeSet attrs);
public MView(Context context, @Nullable AttributeSet attrs, int defStyleAttr);
public MView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) ;
}

如果你继承一些兼容库的控件比如AppCompatTextView,他是不提供四个参数的构造方法的。

通过查看View类的源码,我们能够发现前三个方法最后都会调用的最后一个四参方法上,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
this(context);

final TypedArray a = context.obtainStyledAttributes(
attrs, com.android.internal.R.styleable.View, defStyleAttr, defStyleRes);
/*此处省略部分代码*/
final int N = a.getIndexCount();
for (int i = 0; i < N; i++) {
int attr = a.getIndex(i);
switch (attr) {
case com.android.internal.R.styleable.View_background:
background = a.getDrawable(attr);
break;
case com.android.internal.R.styleable.View_padding:
padding = a.getDimensionPixelSize(attr, -1);
mUserPaddingLeftInitial = padding;
mUserPaddingRightInitial = padding;
leftPaddingDefined = true;
rightPaddingDefined = true;
break;

从上述代码中,可以看到它调用了obtainStyledAttributes方法,获取到TypedArray,再从TypedArray中获取相应的属性值,比如background等,并赋值给View的成员变量,接着我们看一下obtainStyledAttributes方法;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@NonNull
TypedArray obtainStyledAttributes(@NonNull Resources.Theme wrapper,
AttributeSet set,
@StyleableRes int[] attrs,
@AttrRes int defStyleAttr,
@StyleRes int defStyleRes) {
synchronized (mKey) {
final int len = attrs.length;
final TypedArray array = TypedArray.obtain(wrapper.getResources(), len);

// XXX note that for now we only work with compiled XML files.
// To support generic XML files we will need to manually parse
// out the attributes from the XML file (applying type information
// contained in the resources and such).
final XmlBlock.Parser parser = (XmlBlock.Parser) set;
mAssets.applyStyle(mTheme, defStyleAttr, defStyleRes, parser, attrs,
array.mDataAddress, array.mIndicesAddress);
array.mTheme = wrapper;
array.mXml = parser;
return array;
}
}

通过一系列的跳转,然后调用到android.content.res.ResourcesImpl.ThemeImpl#obtainStyledAttributes方法,在这段代码中 obtain 了我们需要的 TypedArray,根据之前说过的规则通过调用 AssetManager 的 applyStyle 方法(本地方法),确定了最后各个 attribute 的值。下面看看 android_util_AssetManager.cppandroid_content_AssetManager_applyStyle 函数的源码,里面有我们需要的 native applyStyle 方法(代码很长,只保留了注释):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static jboolean android_content_AssetManager_applyStyle(JNIEnv* env, jobject clazz, jint themeToken, jint defStyleAttr, jint defStyleRes, jint xmlParserToken, jintArray attrs, jintArray outValues, jintArray outIndices)
{
// ...
// Retrieve the style class associated with the current XML tag.
// 检索与当前 XML 标签关联的样式类
// ...
// Now lock down the resource object and start pulling stuff from it.
// 锁定资源对象并开始从其中抽取所需要的内容
// ...
// Retrieve the default style bag, if requested.
// 如有需要取出默认样式
//...
// Retrieve the XML attributes, if requested.
// 如有需要检索 XML 属性
// ...
// Now iterate through all of the attributes that the client has requested,
// filling in each with whatever data we can find.
// 遍历客户端请求的所有属性,填充每个可以找到的数据
// ...
for (// ...) {
// ...
// Try to find a value for this attribute... we prioritize values
// coming from, first XML attributes, then XML style, then default
// style, and finally the theme.
// 尝试找到这个属性的值... 优先级:
// 首先是 XML 中定义的,其次是 XML 中的 style 定义的,然后是默认样式,最后是主题
// ...
}
return JNI_TRUE;
}

从上述代码中,我们知道了一个 attribute 值的确定过程大致如下:

  1. xml 中查找,若未找到进入第 2 步;

  2. xml 中的 style 查找,若未找到进入第 3 步;

  3. 若 defStyleAttr 不为 0,由 defStyleAttr 指定的 style 中寻找,若未找到进入第 4 步;

  4. 若 defStyleAttr 为 0 或 defStyleAttr 指定的 style 中寻找失败,进入 defStyleRes 指定的 style 中寻找,若寻找失败,进入第 5 步查找;

  5. 查找在当前 Theme 中指定的属性值。

至此,关于自定义控件中属性的解析就已经结束,这儿有一个Demo来验证我们的分析:github地址

2、其他

2.1、xmlns

1
2
3
4
5
6
7
8
<LinearLayout 
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/window_background">
</LinearLayout>

xmlns是 XML 文档中的一个概念:英文叫做 XML namespace,中文翻译为 XML 命名空间。一讲到命名空间,我想很多人会联想到C++中的namespaceJava中的 packagename,而这两者的作用都是为了解决命名上的冲突(例如类名,接口名等)。类似的,XML namespace也是为了解决 XML 中元素和属性命名冲突,因为 XML 中的标签并不是预定义的,这一点与 HTML 是有区别的,HTML 中的标签是预定义的,所以我们会遇到命名冲突的问题。

XML 命名空间定义语法为xmlns:namespace-prefix="namespaceURI",一共分为三个部分:

  • xmlns:声明命名空间的保留字,其实就是XML中元素的一个属性;
  • namespace-prefix:命名空间的前缀,这个前缀与某个命名空间相关联;
  • namespaceURI:命名空间的唯一标识符,一般就是一个URI引用。

2.1.1、xmlns:android

用于 Android 系统定义的一些属性。在Android xml布局文件头部的 xmlns:android="http://schemas.android.com/apk/res/android",即Android API的Namespace。

2.1.2、xmlns:app

用于我们应用自定义的一些属性,在引用Library的第三方View时,我们需要在XML布局文件头部添加
xmlns:app="http://schemas.android.com/apk/res-auto"
或者
xmlns:app="http://schemas.android.com/apk/res/包名"

2.1.3、tools

根据官方定义,tools命名空间用于在 XML 文档记录一些,当应用打包的时候,会把这部分信息给过滤掉,不会增加应用的 size,说直白点,这些属性是为IDE提供相关信息。

2.2、TypedArray

1
2
3
4
5
6
7
8
9
10
/**
* Container for an array of values that were retrieved with
* {@link Resources.Theme#obtainStyledAttributes(AttributeSet, int[], int, int)}
* or {@link Resources#obtainAttributes}. Be
* sure to call {@link #recycle} when done with them.
*
* The indices used to retrieve values from this structure correspond to
* the positions of the attributes given to obtainStyledAttributes.
*/
public class TypedArray {}

使用 TypedArray 类可以帮助我们简化获取 attribute 值的流程。类介绍也表明了其作用,需要的是上面用 [] 括起来的一句话:用完之后必须调用 recycle 方法。对,我们通常都会这么做,但是为什么要这么做? 查看这个方法源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void recycle() {
if (mRecycled) {
throw new RuntimeException(toString() + " recycled twice!");
}

mRecycled = true;

// These may have been set by the client.
mXml = null;
mTheme = null;
mAssets = null;

mResources.mTypedArrayPool.release(this);
}

其中主要就是释放了相应的资源,注意看到 mResources.mTypedArrayPool.release(this); 这一行代码,mTypedArrayPool 是 Resource 类中的一个同步对象(存储 TypedArray 对象)池,这里使用了 Pool 来进行优化。

既然是用了 Pool,那就肯定有获取对象的方法,焦点来到 obtain 方法:

1
2
3
4
5
6
7
8
9
static TypedArray obtain(Resources res, int len) {
final TypedArray attrs = res.mTypedArrayPool.acquire();
if (attrs != null) {
// 重置从 Pool 中获取到的对象
return attrs;
}
// 如果对象池是空,返回一个新对象
return new TypedArray(res, new int[len*AssetManager.STYLE_NUM_ENTRIES], new int[1+len], len);
}

简单总结这两个方法如下:

  • recycle 方法就相当于 Pool 中的 release,用于归还对象到 Pool 中;
  • obtain 方法就相当于 Pool 中的 acquire,用于从 Pool 中请求对象。

对于 mTypedArrayPool 的大小 Android 默认是 5。对象池不能太大也不能太小,太大可能造成内存占用,太小可能造成无效对象或有无对象池无明显效果等问题。具体大小的设置,是需要根据具体的场景结合数据分析得到。

Android 应用程序就是由大量 View 构成,因此 View 成了最经常使用的对象。一个 View 创建过程中有大量的 attributes 需要设置,Android 使用了 TypedArray 来简化流程,当频繁的创建和销毁对象(对象的创建成本还比较大)时,会有一定的成本及比较差的体验(如内存抖动导致掉帧)。通过使用 Pool 来实现对 TypedArray 的缓存和复用,达到优化的目的。

3、参考

1、从 View 构造函数中被忽略的 {int defStyleAttr} 说起

2、Android中Attributes、defStyleAttr、defStyleRes关系理解与应用

3、Android xmlns

4、如何理解Android中的xmlns