自定义组合控件的一些心得

2018-08-11  本文已影响40人  kirito0424

日常开发中,我们常常碰到需要复用一组控件的时候,比如常见的标题栏,一些列表的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来看一下:


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中的样子

控件的xml对应的preview

显然,merge不可能像正常的ViewGroup一样支持那么多属性,所以这里我们是无法正常预览出效果的。

那么在引用FollowLayout控件的xml里看到的是什么样子呢?

引用FollowLayout控件处的preview

谢天谢地,我们发现这里显示的是没有问题的,感谢google爸爸。

这也就告诉我们,基本所有的自定义控件都可以这么优化。但是唯一的弊端就是预览控件的效果不方便。这里我也建议大家在使用merge优化自定义控件时,先用正常的ViewGroup作为根布局,等到确认无误之后再换成merge。

同时如果阅读别人写的自定义控件的xml代码时,也可以把merge手动改成对应的java类的父类所对应的Viewgroup,来查看显示效果。比如阅读上面的代码时,把merge改成ConstraintLayout就能看到正确的效果了。

所以除了merge, 还有没有什么办法呢,接着往下看。

3. onFinishInflate方式

3.1 代码实例

这个方式我还没有在别的博客里看到过,可能是我孤陋寡闻了哈。

我们工程里有些自定义的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 中所说的弊端,见图


Layout Inspector

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布局。

上一篇下一篇

猜你喜欢

热点阅读