日常开发中,我们常常碰到需要复用一组控件的时候,比如常见的标题栏,一些列表的itemview等。这种由一系列普通View组合起来复用的形式,一般叫做组合控件。
组合控件相比于自定义控件,即不依靠原生的控件,完全自己设计的新控件,要简单、易上手的多,我们在实际开发中碰到的几率也大的多。我在自己参与的某主流APP的开发过程中,也常常碰到需要使用组合控件的情况。中间有一些学习和思考,在这里记录一下,同时也是跟大家一起分享。
下面来看例子,假如因为业务需要,我们创建了下面这个XML文件(R.layout.follow_layout):
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout 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="wrap_content"
android:background="@color/white">
<TextView
android:id="@+id/textView3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/add_follow_hint"
android:textColor="@color/base_txt_gray1"
android:textSize="14sp"
app:layout_constraintBottom_toTopOf="@+id/textView4"
app:layout_constraintStart_toStartOf="@+id/textView4"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/textView4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:text="及时接受对方最新动态"
android:textColor="#aaaaaa"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView3" />
<Button
android:id="@+id/chat_header_foolow_layout_btn"
android:layout_width="60dp"
android:layout_height="30dp"
android:layout_marginBottom="10dp"
android:layout_marginEnd="15dp"
android:layout_marginTop="10dp"
android:background="@drawable/follow_btn_player_selector"
android:drawablePadding="2dp"
android:text="@string/follow"
android:textColor="#FFFFFFFF"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</android.support.constraint.ConstraintLayout>
我们在需要复用的时候有下面三种方式,我们一一道来。
1. 不太灵活的方式
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!" />
<include layout="@layout/follow_layout"/>
</LinearLayout>
效果如下
这是我们复用的第一种方式。也是郭琳大神在《第一行代码》里的“引入布局”一节里提到的方式。为什么说不太灵活呢,是因为我们完全无法给自定义View添加自定义属性。
2. 最常见的方式
《Android进阶之光》一书中,刘望舒大神在“自定义组合控件”一节里提到了类似下面的这种方式。
注意,XML文件内容不变,跟上面提到的一致,但是额外创建了一个新的控件类,FollowLayout.java :
public class FollowLayout extends ConstraintLayout {
private Button mFollowBtn;
public FollowLayout(Context context) {
super(context);
initView(context);
}
public FollowLayout(Context context, AttributeSet attrs) {
super(context, attrs);
initView(context);
}
public FollowLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initView(context);
}
private void initView(Context context) {
/**
* XML文件就是上面提到的那个
*/
LayoutInflater.from(context).inflate(R.layout.follow_layout, this);
mFollowBtn = findViewById(R.id.chat_header_foolow_layout_btn);
mFollowBtn.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
/**
* 点击事件
*/
}
});
}
}
写了这样一个控件类的好处,除了可以统一的设置很多东西之外,引用控件的时候也可以这么修改:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!" />
<!--<include layout="@layout/follow_layout"/>-->
<com.example.xuqi.customlayoutdemo.FollowLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
这样的好处是灵活性更好,而且我们可以自定义控件属性,关于自定义空间属性的方法大家可以自行查询,这里不展开来说了。
2.1 相关原理
这个写法,是大家在开发中使用最多的方式。需要解释的就是代码
LayoutInflater.from(context).inflate(R.layout.follow_layout, this);
这句代码将follow_layout.xml文件与FollowLayout.java文件关联在一起。我们看inflate方法的源码,会发现它实际会调用
inflate(parser, root, root != null)
即下面的代码:
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
synchronized (mConstructorArgs) {
// 省略
View result = root;
try {
// Look for the root node.
int type;
while ((type = parser.next()) != XmlPullParser.START_TAG &&
type != XmlPullParser.END_DOCUMENT) {
// Empty
}
if (type != XmlPullParser.START_TAG) {
throw new InflateException(parser.getPositionDescription()
+ ": No start tag found!");
}
final String name = parser.getName();
// 省略
if (TAG_MERGE.equals(name)) {
if (root == null || !attachToRoot) {
throw new InflateException("<merge /> can be used only with a valid "
+ "ViewGroup root and attachToRoot=true");
}
rInflate(parser, root, inflaterContext, attrs, false);
} else {
// Temp is the root view that was found in the xml
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
ViewGroup.LayoutParams params = null;
if (root != null) {
if (DEBUG) {
System.out.println("Creating params from root: " +
root);
}
// Create layout params that match root, if supplied
params = root.generateLayoutParams(attrs);
if (!attachToRoot) {
// Set the layout params for temp if we are not
// attaching. (If we are, we use addView, below)
temp.setLayoutParams(params);
}
}
// 省略
// Inflate all children under temp against its context.
rInflateChildren(parser, temp, attrs, true);
// 省略
// We are supposed to attach all the views we found (int temp)
// to root. Do that now.
/**
* 关注这里就好
* attachToRoot 为 true
* 会将inflate的xml里的内容addView到传入的this里
*/
if (root != null && attachToRoot) {
root.addView(temp, params);
}
// Decide whether to return the root that was passed in or the
// top view found in xml.
if (root == null || !attachToRoot) {
result = temp;
}
}
}
// 省略
return result;
}
}
上面的注释里讲的很清楚,就是说inflate传入的layout文件里的内容,最终会被addView到FollowLayout这个自定义View里。我们用Layout Inspector来看一下:
这张图引出了下面的话题。
2.2 弊端
其实上面这个方式就是大家使用的最多的方式了,而且很好用。唯一的一个缺点就是像上面Layout Inspector里看到的那样,FollowLayout里面包裹了一个ConstraintLayout。ConstraintLayout就是R.layout.follow_layout的root节点。
而这个ConstraintLayout明显是多余的,我们的FollowLayout本身就是继承FollowLayout的,没必要里面再额外裹一层。想要去掉这一层,我们可以使用<merge>。
2.3 使用<merge>
来看控件的xml代码
<?xml version="1.0" encoding="utf-8"?>
<merge 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="wrap_content"
android:background="@color/white">
<TextView
android:id="@+id/textView3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/add_follow_hint"
android:textColor="@color/base_txt_gray1"
android:textSize="14sp"
app:layout_constraintBottom_toTopOf="@+id/textView4"
app:layout_constraintStart_toStartOf="@+id/textView4"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/textView4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:text="及时接受对方最新动态"
android:textColor="#aaaaaa"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView3" />
<Button
android:id="@+id/chat_header_foolow_layout_btn"
android:layout_width="60dp"
android:layout_height="30dp"
android:layout_marginBottom="10dp"
android:layout_marginEnd="15dp"
android:layout_marginTop="10dp"
android:background="@drawable/follow_btn_player_selector"
android:drawablePadding="2dp"
android:text="@string/follow"
android:textColor="#FFFFFFFF"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</merge>
除了最外层从ConstraintLayout改为merge之外没有别的变化,FollowLayout.java 不需要变化,别的xml中引用这个控件的代码也不用变。
我们接着看preview中的样子
显然,merge不可能像正常的ViewGroup一样支持那么多属性,所以这里我们是无法正常预览出效果的。之前我以为使用merge就只能忍受这种恶心的效果了。。最近我才知道,还有一个属性tools:parentTag="android.support.constraint.ConstraintLayout"
,这个加上之后,就能预览你想要的效果了。。简直完美~~
那么在引用FollowLayout控件的xml里看到的是什么样子呢?
这也就告诉我们,基本所有的自定义控件都可以这么优化。这应该是最好的一种方式了。
2.4 merge的一些注意事项
- 必须指定父ViewGroup,attachToRoot 必须为true
- merge标签中,不需要设置属性,因为设置了不起作用,会被系统忽略掉
具体的原因,我们可以继续看2.1中的 inflate方法,中间有这么一段
if (TAG_MERGE.equals(name)) {
// 这里说明了第一点
if (root == null || !attachToRoot) {
throw new InflateException("<merge /> can be used only with a valid "+ "ViewGroup root and attachToRoot=true");
}
rInflate(parser, root, inflaterContext, attrs, false);
}
满足root == null || !attachToRoot之后,我们继续看rInflate方法
void rInflate(XmlPullParser parser, View parent, Context context,
AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException {
final int depth = parser.getDepth();
int type;
boolean pendingRequestFocus = false;
while (((type = parser.next()) != XmlPullParser.END_TAG ||
parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) {
if (type != XmlPullParser.START_TAG) {
continue;
}
final String name = parser.getName();
if (TAG_REQUEST_FOCUS.equals(name)) {
pendingRequestFocus = true;
consumeChildElements(parser);
} else if (TAG_TAG.equals(name)) {
parseViewTag(parser, parent, attrs);
} else if (TAG_INCLUDE.equals(name)) {
if (parser.getDepth() == 0) {
throw new InflateException("<include /> cannot be the root element");
}
parseInclude(parser, context, parent, attrs);
} else if (TAG_MERGE.equals(name)) {
throw new InflateException("<merge /> must be the root element");
} else {
// 注意看这里,要通过name和xml的attrs创建对应的View
final View view = createViewFromTag(parent, name, context, attrs);
final ViewGroup viewGroup = (ViewGroup) parent;
// 获取控件对应的LayoutParams
final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
// 如果该控件还包裹了子控件,会递归这个流程
rInflateChildren(parser, view, attrs, true);
// 将最终的View添加到parent里
viewGroup.addView(view, params);
}
}
if (pendingRequestFocus) {
parent.restoreDefaultFocus();
}
if (finishInflate) {
parent.onFinishInflate();
}
}
在上面的整个过程中,merge的xml参数都始终没有使用过。所以merge设置参数也会被系统忽略掉,最后起作用的是parent里的xml参数。
所以除了merge, 还有没有什么办法呢,接着往下看。
3. onFinishInflate方式
3.1 代码实例
这个方式我还没有在别的博客里看到过,可能是我孤陋寡闻了哈。但是我个人觉得,没有上面
merge
那种方式使用的方便,我是不太推荐。
我们工程里有些自定义的View,没有像上面那样,在constructor里使用initView来初始化各种子View。而是在onFinishInflate里初始化。上面的FollowLayout可以用下面的方式写:
public class FollowLayout extends ConstraintLayout {
private Button mFollowBtn;
public FollowLayout(Context context) {
super(context);
}
public FollowLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
public FollowLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
mFollowBtn = findViewById(R.id.chat_header_foolow_layout_btn);
mFollowBtn.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
/**
* 点击事件
*/
}
});
}
同时,chat_header_foolow_layout_btn.xml文件也修改了root节点,从ConstraintLayout改成了FollowLayout
<?xml version="1.0" encoding="utf-8"?>
<com.example.xuqi.customlayoutdemo.FollowLayout 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="wrap_content"
android:background="@color/white">
<TextView
android:id="@+id/textView3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/add_follow_hint"
android:textColor="@color/base_txt_gray1"
android:textSize="14sp"
app:layout_constraintBottom_toTopOf="@+id/textView4"
app:layout_constraintStart_toStartOf="@+id/textView4"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/textView4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="15dp"
android:text="及时接受对方最新动态"
android:textColor="#aaaaaa"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView3" />
<Button
android:id="@+id/chat_header_foolow_layout_btn"
android:layout_width="60dp"
android:layout_height="30dp"
android:layout_marginBottom="10dp"
android:layout_marginEnd="15dp"
android:layout_marginTop="10dp"
android:background="@drawable/follow_btn_player_selector"
android:drawablePadding="2dp"
android:text="@string/follow"
android:textColor="#FFFFFFFF"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</com.example.xuqi.customlayoutdemo.FollowLayout>
引用这个控件的时候无法像2.中的那样使用,需要用1.中的方式,include上面的chat_header_foolow_layout_btn.xml文件。
3.2 onFinishInflate()
可能很多人是第一次关注onFinishInflate方法。网上查了一下,大部分人都是草草的说了一下----当View中所有的子控件均被映射成xml后触发。这里我们详细解释一下。
onFinishInflate()方法是在LayoutInflater里的rInflate方法里调用的,按照如下的顺序
graph TB
A[LayoutInflater.from.inflate]-->B[inflate -> parser, root, attachToRoot]
B-->|填充子View|C[rInflateChildren -> finishInflate = true]
C-->D[rInflate]
D-->|调用|E[parent.onFinishInflate]
下面按顺序给出源码:
3.2.1 inflate的源码
其实上面给过了,为了大家看的方便,再放一份吧~~~
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
synchronized (mConstructorArgs) {
// 省略
View result = root;
try {
// Look for the root node.
int type;
while ((type = parser.next()) != XmlPullParser.START_TAG &&
type != XmlPullParser.END_DOCUMENT) {
// Empty
}
if (type != XmlPullParser.START_TAG) {
throw new InflateException(parser.getPositionDescription()
+ ": No start tag found!");
}
final String name = parser.getName();
// 省略
if (TAG_MERGE.equals(name)) {
if (root == null || !attachToRoot) {
throw new InflateException("<merge /> can be used only with a valid "
+ "ViewGroup root and attachToRoot=true");
}
/**
* 注意这里与下面调用rInflateChildren相比
* 最后一个参数传的是false
* 会导致rInflate方法的最后不调用onFinishInflate方法
*/
rInflate(parser, root, inflaterContext, attrs, false);
} else {
// Temp is the root view that was found in the xml
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
ViewGroup.LayoutParams params = null;
if (root != null) {
if (DEBUG) {
System.out.println("Creating params from root: " +
root);
}
// Create layout params that match root, if supplied
params = root.generateLayoutParams(attrs);
if (!attachToRoot) {
// Set the layout params for temp if we are not
// attaching. (If we are, we use addView, below)
temp.setLayoutParams(params);
}
}
// 省略
// 调用rInflateChildren来填充子View
rInflateChildren(parser, temp, attrs, true);
// 省略
if (root != null && attachToRoot) {
root.addView(temp, params);
}
// Decide whether to return the root that was passed in or the
// top view found in xml.
if (root == null || !attachToRoot) {
result = temp;
}
}
}
// 省略
return result;
}
}
3.2.2 rInflateChildren的源码
/**
* Recursive method used to inflate internal (non-root) children. This
* method calls through to {@link #rInflate} using the parent context as
* the inflation context.
* <strong>Note:</strong> Default visibility so the BridgeInflater can
* call it.
*/
final void rInflateChildren(XmlPullParser parser, View parent, AttributeSet attrs,
boolean finishInflate) throws XmlPullParserException, IOException {
rInflate(parser, parent, parent.getContext(), attrs, finishInflate);
}
3.2.3 rInflate的源码
/**
* 该方法是一个递归方法
* 用于遍历xml层次结构并实例化View和他们的子View
* 然后调用最外层的View的onFinishInflate
* <strong>Note:</strong> Default visibility so the BridgeInflater can
* override it.
*/
void rInflate(XmlPullParser parser, View parent, Context context,
AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException {
final int depth = parser.getDepth();
int type;
boolean pendingRequestFocus = false;
while (((type = parser.next()) != XmlPullParser.END_TAG ||
parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) {
if (type != XmlPullParser.START_TAG) {
continue;
}
final String name = parser.getName();
if (TAG_REQUEST_FOCUS.equals(name)) {
pendingRequestFocus = true;
consumeChildElements(parser);
} else if (TAG_TAG.equals(name)) {
parseViewTag(parser, parent, attrs);
} else if (TAG_INCLUDE.equals(name)) {
if (parser.getDepth() == 0) {
throw new InflateException("<include /> cannot be the root element");
}
parseInclude(parser, context, parent, attrs);
} else if (TAG_MERGE.equals(name)) {
throw new InflateException("<merge /> must be the root element");
} else {
final View view = createViewFromTag(parent, name, context, attrs);
final ViewGroup viewGroup = (ViewGroup) parent;
final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
// 1. 注意这个方法,会再次调用rInflate方法,且finishInflate为true
rInflateChildren(parser, view, attrs, true);
viewGroup.addView(view, params);
}
}
// 省略。。。
// 2. 注意这里 调用了parent的onFinishInflate()方法
// finishInflate决定是否调用onFinishInflate
if (finishInflate) {
parent.onFinishInflate();
}
}
源码开头的注释中说的递归,说白了就是inflate方法中调用rInflateChildren, rInflateChildren里面调rInflate,rInflate中的 1. 处调用 rInflateChildren。
3.2.4 解释
结合上面的源码我们不难看出,只有inflate方法中 type == XmlPullParser.START_TAG 时,才会调用 rInflateChildren(parser, temp, attrs, true)。
只有在finishInflate == true,rInflate方法中才能调用parent.onFinishInflate()。
START_TAG一般都是像下面这种
<android.support.constraint.ConstraintLayout 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="wrap_content">
</android.support.constraint.ConstraintLayout>
因此当FollowLayout在XML中引用时,必须像上面那样去写,才能回调到我们重写的onFinishInflate方法。
而 2.中的写法,是无法触发onFinishInflate回调的,而且即使触发了。。
ChatHeadFollowLayout里面没有写子View,初始化的还是个null。。
<com.changba.message.view.ChatHeadFollowLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
3.2.5 优点
解决了2.2 中所说的弊端,见图
FollowLayout直接包裹了内容,少了ConstraintLayout。不要小看少了这一层,积少成多带来的优势是很大的。
但是相比于2.的方式,还是牺牲了一部分的灵活性,具体用哪种方式就仁者见仁,智者见智了。
3.3 总结
总结一下就是,自定义的View, 比如CustomView,如果在XML里面是以
<CustomView>
</CustomView>
形式调用的,系统就会调用他的onFinishInflate。因为他是START_TAG。
如果以
<CustomView/>
形式就不会,因为不满足START_TAG。
这也是为什么定义XML文件时,以CustonView为root tag来定义里面的子View内容,并在CustomView类的onFinishInflate方法里实例化子View。
这个方式下,我们没办法在别的XML文件里以<CustonView/>的形式使用。因为拿不到内容。如果用<CustomView> </CustomView>,里面填上内容,那就可以了。所以onFinishInflate方式自定义的View,我们往往在XML使用他的时候,选择include整个CustomView的xml布局。