美文网首页
自定义组合控件的一些心得

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

作者: kirito0424 | 来源:发表于2018-08-11 21:11 被阅读40次

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

    相关文章

      网友评论

          本文标题:自定义组合控件的一些心得

          本文链接:https://www.haomeiwen.com/subject/eagdbftx.html