前言
这篇blog是我在阅读过csdn大牛郭霖的《带你一步步深入了解View》一系列文章后,亲身实践并做出的小结。作为有志向的前端开发工程师,怎么可以不搞懂View绘制的基本原理——简直就像做后端却对数据库一无所知一样不可原谅!
“纸上得来终觉浅,绝知此事要躬行。” 尽管自己对View的绘制仍然处于一知半解的程度,但凡事总要经过从0到1,方能从1到100。今天暂且记录下此时的理解与实践,作为千里之行中的小小一步。
综述
本篇blog先从自己平时最常用到的,在代码中引入布局文件的写法LayoutInflater.inflate讲起,探究inflate内部的机制;随后着手View绘制的三个阶段measure、layout、draw,看看一个View/ViewGroup是如何被绘制到屏幕上的;最后实现一个自定义的View。
inflate
初次接触inflate这个单词时,我在查阅词典后,知道了它的释义是“充气、膨胀”——老外定义函数名果然很恰当。想象一个没有充气的皮囊(布局文件),在充气(inflate)后变成了一件栩栩如生的女朋友立体物件(手机屏幕上的真实显示),相应地,LayoutInflater就被我翻译成了“打气筒”。布局文件我们每个人都很了解,无非是一个XXXLayout,内部再装上一些控件。那么问题来了,inflate方法内部是如何解析这一层层的View、决定它们在屏幕上显示的前后顺序、处理隐藏/显示逻辑的呢?下面是获得“打气筒inflater”的写法:
1. 通过LayoutInflater提供的静态方法,从上下文中获取
LayoutInflater inflater = LayoutInflater.from(context);
2. 通过Service获取,上面的写法最终也是通过如下方法进行调用的
LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
inflate方法的三个参数
在使用inflate方法时,我们都会注意到它需求三个参数
public View inflate (int resource, ViewGroup root, boolean attachToRoot);
另一种inflate方法不需要第三个attachToRoot参数,从源码上看,只是在上面方法的基础上进行了一次包装而已。
public View inflate(int resource, ViewGroup root) { return inflate(resource, root, root != null); }
三个参数定义如下:
- resource:布局文件id;not nullable
- root:本次生成的布局外部嵌套的父布局;nullable
- attachToRoot:是否将本次生成的布局加入到父布局
不论调用何种包装后的方法,最终使用到的inflate方法如下:
public View inflate(XmlPullParser parser, ViewGroup root, boolean attachToRoot) { synchronized (mConstructorArgs) { Trace.traceBegin(Trace.TRACE_TAG_VIEW, "inflate"); final AttributeSet attrs = Xml.asAttributeSet(parser); Context lastContext = (Context)mConstructorArgs[0]; mConstructorArgs[0] = mContext; 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 (DEBUG) { System.out.println("**************************"); System.out.println("Creating root view: " + name); System.out.println("**************************"); } 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, attrs, false, false); } else { // Temp is the root view that was found in the xml final View temp = createViewFromTag(root, name, attrs, false); 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); } } if (DEBUG) { System.out.println("-----> start inflating children"); } // Inflate all children under temp rInflate(parser, temp, attrs, true, true); if (DEBUG) { System.out.println("-----> done inflating children"); } // We are supposed to attach all the views we found (int temp) // to root. Do that now. 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; } } } catch (XmlPullParserException e) { InflateException ex = new InflateException(e.getMessage()); ex.initCause(e); throw ex; } catch (IOException e) { InflateException ex = new InflateException( parser.getPositionDescription() + ": " + e.getMessage()); ex.initCause(e); throw ex; } finally { // Don't retain static reference on context. mConstructorArgs[0] = lastContext; mConstructorArgs[1] = null; } Trace.traceEnd(Trace.TRACE_TAG_VIEW); return result; } }
方法的注释中,有几处包含重要信息重要的地方:
- 此方法依赖于编译阶段对xml文件的预处理,因此,在运行时修改XmlPullParser是行不通的
- 参数root可选,当不为null时,起到的作用是为新生成的View提供LayoutParams的限制(在下一篇blog我们会看到,View绘制过程中,最终决定其尺寸的,是“父视图的支持尺寸”与“子视图的需求尺寸”)
- 参数attachToRoot与参数root共同作用,只有 root非空&&attachToRoot==true 时,才会真正地发生attach;若 root==null && attachToRoot==true ,也不会发生attach行为
inflate方法内部
阅读上面的代码段,在inflate方法中,首先通过while循环找到开始Tag(这里使用了XmlPullParser,有兴趣的话可以深入去研究这个Xml解析接口,对于帮助理解XML文件结构很有帮助),如果发现这是一个merge节点,则调用rInflate(parser, root, attrs, false, false)。使用过merge节点的话,就会明白这是一个可以有效降低布局复杂度的技巧。这里暂时记下,后续我会专门写一篇blog研究merge(当前只需要记住一点,就是当使用merge节点时,该节点一定是根节点,且inflate的参数parent不为null,attachToRoot==true)。如果这个Tag不是merge节点,则首先根据这个Tag生成对应的View
final View temp = createViewFromTag(root, name, attrs, false);
createViewFromTag 所做的事情,主要是通过Tag的name,在当前Context的ClassLoader中加载对应的Class,拿到clazz后,通过newInstance生成目标View。可以看到不论首个Tag是否为merge,最终辗转都是走到rInflate方法中的,从方法命名开头的“r”就可以看出,这是一个递归解析xml的过程,如注释
/** Recursive method used to descend down the xml hierarchy and instantiate views, instantiate their children, and then call onFinishInflate(). */
void rInflate(XmlPullParser parser, View parent, final AttributeSet attrs, boolean finishInflate, boolean inheritContext) throws XmlPullParserException, IOException { final int depth = parser.getDepth(); int type; 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)) { parseRequestFocus(parser, parent); } 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, parent, attrs, inheritContext); } else if (TAG_MERGE.equals(name)) { throw new InflateException("<merge /> must be the root element"); } else { final View view = createViewFromTag(parent, name, attrs, inheritContext); final ViewGroup viewGroup = (ViewGroup) parent; final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs); rInflate(parser, view, attrs, true, true); viewGroup.addView(view, params); } } if (finishInflate) parent.onFinishInflate(); }
方法后半部是递归调用的过程:首先使用之前提到的createViewFromTag方法生成一层View,接着以这个View作为Parent,去继续rInflate子View们,解析到最后,调用parent.onFinishInflate(),告知上层已经解析完成。
至此为止,一个完整的inflate过程已经被解析完成了。下面我们结合具体的Demo代码,进一步巩固之前理解的知识。
Demo
很多新人在最初接触inflater时,往往困惑inflate方法后两个参数要怎么传,有时为了图方便,往往把 root = null, attachToRoot= false 一传了之。尽管大部分时间,这样处理是不会暴露出什么问题的,可是一旦习惯了这种写法,在真正出问题时就会一头雾水——“之前自己这么做明明没有问题的啊,这次怎么就不行了呢?!”。那么,我们就用下面这个demo来加深对inflate的理解。
demo的布局很简单,一个空的FrameLayout,作为Activity的背景
fake_main_activity.xml
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/fake_main_layout" android:layout_width="match_parent" android:layout_height="match_parent" > </FrameLayout>
一个TextView的布局,准备将其安插在上面的FrameLayout里面
textview_layout.xml
<?xml version="1.0" encoding="utf-8"?> <TextView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="300dp" android:layout_height="300dp" android:background="@color/blue" android:text="Hello View!" android:textColor="@color/red" android:textSize="@dimen/text_size_34"> </TextView>
在FakeMainActivity中,如下设置页面布局,并加入上面的TextView
FakeMainActivity.java
package com.leili.imhere.activity; import android.app.Activity; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import com.leili.imhere.R; /** * 写blog专用测试Activity * Created by Lei.Li on 8/23/15 2:16 PM. */ public class FakeMainActivity extends Activity { private ViewGroup fakeMainLayout; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); super.setContentView(R.layout.fake_main_activity); fakeMainLayout = (ViewGroup) super.findViewById(R.id.fake_main_layout); // 方法 1. root不为null,无需addView View textView = LayoutInflater.from(this).inflate(R.layout.textview_layout, fakeMainLayout); // 方法 2. root为null,手动addView // View textView = LayoutInflater.from(this).inflate(R.layout.textview_layout, null); // fakeMainLayout.addView(textView); } }
可以看到这里我们使用了parent != null的inflate方法,屏幕截图是这样的,其中TextView的宽高均为300dp,符合我们的预期(在TextView布局文件里声明的宽高)
如果我们注释掉方法1,使用方法2,即用 parent = null 来调用inflate,然后用ViewGroup.addView来将生成的View加入到外层FrameLayout中,会看到下面的屏幕截图
是不是很奇怪?明明声明了TextView的宽高为300dp,这里它却占满了整个屏幕!造成这个现象的原因就在于inflate时没有为TextView声明ParentView,导致其layout_width/layout_height两个参数生效。在刚接触android时,我们往往会直观地把 layout_width/layout_height 这两个参数理解为View的宽与高,以为在布局文件里声明了多少的数值,最终绘制后就会出现多少的数值。这是有失偏颇的,要知道,这两个参数之所以不简单地叫做width/height,就是因为需要处理layout的过程。简言之layout_width/layout_height这两个参数,是用来告诉ParentView,自己需求多大的尺寸的。而如果在inflate时没有指明ParentView(如我们在方法2中所做的),子View就不知道该把layout_width/layout_height传给谁,这两个参数也就自然被忽略了。
一个有趣的地方是,如果在上面的demo中,把最外层的FrameLayout改为LinearLayout,同样使用方法2(不指明ParentView),最终看到的是如下布局——TextView的宽度是match_parent,高度是wrap_content。究其原因,在于FrameLayout与LinearLayout这两种ViewGroup对于子View处理的方式不同。
需要强调的一点是,当我们在Activity.onCreate()中使用 setContentView(int resId) 来设置页面背景时,Android系统为我们在外层自动嵌套了一个宽高都是满屏的FrameLayout,所以我们使用的背景资源文件根节点所声明的layout_width/layout_height是有效的。
小结
本篇blog从源码角度解析了 LayoutInflater 如何根据布局文件生成布局的过程——通过XmlParser递归地对xml文件进行解析,并佐以demo,指出了日常开发中一个容易产生潜在错误的用法。
有了这里的基础,在下篇blog中,将迎来View绘制中最最核心的三个过程:measure、layout、draw,让我们一起拭目以待。