android window(三)ViewRootImpl
ViewRootImpl 是一很重要的类,类似 ApplicationThread 负责跟AmS通信一样,ViewRootImpl 的一个重要职责就是跟 WmS 通信,它通静态变量 sWindowSession(IWindowSession实例)与 WmS 进行通信。
ViewRootImpl实现了ViewParent接口,作为整个控件树的根部,它是控件树正常运作的动力所在,控件的测量、布局、绘制以及输入事件的派发处理都由ViewRootImpl触发。另一方面,它是WindowManagerGlobal工作的实际实现者,因此它还需要负责与WMS交互通信以调整窗口的位置大小,以及对来自WMS的事件(如窗口尺寸改变等)作出相应的处理。
http://androidxref.com/6.0.1_r10/xref/frameworks/base/core/java/android/view/ViewRootImpl.java
ViewRootImpl的创建及其重要的成员
ViewRootImpl创建于WindowManagerGlobal的addView()方法中,而调用addView()方法的线程即是此ViewRootImpl所掌控的控件树的UI线程。ViewRootImpl的构造主要是初始化了一些重要的成员,事先对这些重要的成员有个初步的认识对随后探讨ViewRootImpl的工作原理有很大的帮助。其构造函数代码如下:
[ViewRootImpl.java-->ViewRootImpl.ViewRootImpl()] public ViewRootImpl(Context context, Displaydisplay) { /* ① 从WindowManagerGlobal中获取一个IWindowSession的实例。它是ViewRootImpl和 WMS进行通信的代理 */ mWindowSession= WindowManagerGlobal.getWindowSession(context.getMainLooper()); // **②保存参数display**,在后面setView()调用中将会把窗口添加到这个Display上 mDisplay= display; CompatibilityInfoHolder cih = display.getCompatibilityInfo(); mCompatibilityInfo = cih != null ? cih : new CompatibilityInfoHolder(); /* **③ 保存当前线程到mThread。**这个赋值操作体现了创建ViewRootImpl的线程如何成为UI主线程。 在ViewRootImpl处理来自控件树的请求时(如请求重新布局,请求重绘,改变焦点等),会检 查发起请求的thread与这个mThread是否相同。倘若不同则会拒绝这个请求并抛出一个异常*/ mThread= Thread.currentThread(); ...... /* **④ mDirty用于收集窗口中的无效区域。**所谓无效区域是指由于数据或状态发生改变时而需要进行重绘 的区域。举例说明,当应用程序修改了一个TextView的文字时,TextView会将自己的区域标记为无效 区域,并通过invalidate()方法将这块区域收集到这里的mDirty中。当下次绘制时,TextView便 可以将新的文字绘制在这块区域上 */ mDirty =new Rect(); mTempRect = new Rect(); mVisRect= new Rect(); /* **⑤ mWinFrame,描述了当前窗口的位置和尺寸。**与WMS中WindowState.mFrame保持着一致 */ mWinFrame = new Rect(); /* ⑥ 创建一个W类型的实例,W是IWindow.Stub的子类。即它将在WMS中作为新窗口的ID,以及接 收来自WMS的回调*/ mWindow= new W(this); ...... /* **⑦ 创建mAttachInfo。**mAttachInfo是控件系统中很重要的对象。它存储了此当前控件树所以贴附 的窗口的各种有用的信息,并且会派发给控件树中的每一个控件。这些控件会将这个对象保存在自己的 mAttachInfo变量中。mAttachInfo中所保存的信息有WindowSession,窗口的实例(即mWindow), ViewRootImpl实例,窗口所属的Display,窗口的Surface以及窗口在屏幕上的位置等等。所以,当 要需在一个View中查询与当前窗口相关的信息时,非常值得在mAttachInfo中搜索一下 */ mAttachInfo = new View.AttachInfo(mWindowSession, mWindow, display,this, mHandler, this); /* **⑧ 创建FallbackEventHandler。**这个类如同PhoneWindowManger一样定义在android.policy 包中,其实现为PhoneFallbackEventHandler。FallbackEventHandler是一个处理未经任何人 消费的输入事件的场所*/ mFallbackEventHandler =PolicyManager.makeNewFallbackEventHandler(context); ...... /* ⑨ 创建一个依附于当前线程,即主线程的Choreographer,用于通过VSYNC特性安排重绘行为 */ mChoreographer= Choreographer.getInstance(); ...... }
在ViewRootImpl的构造函数中初始化了一些成员变量,ViewRootImpl创建了以下几个主要对象:
(1) 通过WindowManagerGlobal.getWindowSession()得到IWindowSession的代理对象,该对象用于和WMS通信。
(2) 创建了一个W本地Binder对象mWindow,用于WMS通知应用程序进程。
static class W extends IWindow.Stub
(3) 采用单例模式创建了一个Choreographer对象,用于统一调度窗口绘图。
(4) 创建ViewRootHandler对象,用于处理当前视图消息。
(5) 构造一个AttachInfo对象;
(6) 创建Surface对象,用于绘制当前视图,当然该Surface对象的真正创建是由WMS来完成的,只不过是WMS传递给应用程序进程的。
在构造函数之外,还有另外两个重要的成员被直接初始化:
- mHandler,类型为ViewRootHandler,一个依附于创建ViewRootImpl的线程,即主线程上的,用于将某些必须主线程进行的操作安排在主线程中执行。mHandler与mChoreographer的同时存在看似有些重复,其实它们拥有明确不同的分工与意义。由于mChoreographer处理消息时具有VSYNC特性,因此它主要用于处理与重绘相关的操作。但是由于mChoreographer需要等待VSYNC的垂直同步事件来触发对下一条消息的处理,因此它处理消息的及时性稍逊于mHandler。而mHandler的作用,则是为了将发生在其他线程中的事件安排在主线程上执行。所谓发生在其他线程中的事件是指来自于WMS,由继承自IWindow.Stub的mWindow引发的回调。由于mWindow是一个Binder对象的Bn端,因此这些回调发生在Binder的线程池中。而这些回调会影响到控件系统的重新测量、布局与绘制,因此需要此Handler将回调安排到主线程中。
说明 mHandler与mThread两个成员都是为了单线程模型而存在的。Android的UI操作不是线程安全的,而且很多操作也是建立在单线程的假设之上(如scheduleTraversals())。采用单线程模型的目的是降低系统的复杂度,并且降低锁的开销。
-
mSurface,类型为Surface。采用无参构造函数创建的一个Surface实例。mSurface此时是一个没有任何内容的空壳子,在 WMS通过relayoutWindow()为其分配一块Surface之前尚不能实用。
-
mWinFrame、mPendingContentInset、mPendingVisibleInset以及mWidth,mHeight。这几个成员存储了窗口布局相关的信息。其中mWinFrame、mPendingConentInsets、mPendingVisibleInsets与窗口在WMS中的Frame、ContentInsets、VisibleInsets是保持同步的。这是因为这3个成员不仅会作为 relayoutWindow()的传出参数,而且ViewRootImpl在收到来自WMS的回调IWindow.Stub.resize()时,立即更新这3个成员的取值。因此这3个成员体现了窗口在WMS中的最新状态。与mWinFrame中的记录窗口在WMS中的尺寸不同的是,mWidth/mHeight记录了窗口在ViewRootImpl中的尺寸,二者在绝大多数情况下是相同的。当窗口在WMS中被重新布局而导致尺寸发生变化时,mWinFrame会首先被IWindow.Stub.resize()回调更新,此时mWinFrame便会与mWidth/mHeight产生差异。此时ViewRootImpl即可得知需要对控件树进行重新布局以适应新的窗口变化。在布局完成后,mWidth/mHeight会被赋值为mWinFrame中所保存的宽和高,二者重新统一。在随后分析performTraversals()方法时,读者将会看到这一处理。另外,与mWidth/mHeight类似,ViewRootImpl也保存了窗口的位置信息Left/Top以及ContentInsets/VisibleInsets供控件树查询,不过这四项信息被保存在了mAttachInfo中。
ViewRootImpl的在其构造函数中初始化了一系列的成员变量,然而其创建过程仍未完成。仅在为其指定了一个控件树进行管理,并向WMS添加了一个新的窗口之后,ViewRootImpl承上启下的角色才算完全确立下来。因此需要进一步分析ViewRootImpl.setView()方法。
[ViewRootImp.java-->ViewRootImpl.setView()] public void setView(View view,WindowManager.LayoutParams attrs, View panelParentView) { synchronized (this) { if (mView == null) { // **① mView保存了控件树的根** mView = view; ...... // ②mWindowAttributes保存了窗口所对应的LayoutParams mWindowAttributes.copyFrom(attrs); ...... /* 在添加窗口之前,先通过requestLayout()方法在主线程上安排一次“遍历”。所谓 “遍历”是指ViewRootImpl中的核心方法performTraversals()。这个方法实现了对 控件树进行测量、布局、向WMS申请修改窗口属性以及重绘的所有工作。由于此“遍历” 操作对于初次遍历做了一些特殊处理,而来自WMS通过mWindow发生的回调会导致一些属性 发生变化,如窗口的尺寸、Insets以及窗口焦点等,从而有可能使得初次“遍历”的现场遭 到破坏。因此,需要在添加窗口之前,先发送一个“遍历”消息到主线程。 在主线程中向主线程的Handler发送消息如果使用得当,可以产生很精妙的效果。例如本例 中可以实现如下的执行顺序:添加窗口->初次遍历->处理来自WMS的回调 */ requestLayout(); /***③ 初始化mInputChannel。InputChannel是窗口接受来自InputDispatcher 的输入事件的管道。 注意,仅当窗口的属性inputFeatures不含有 INPUT_FEATURE_NO_INPUT_CHANNEL时才会创建InputChannel,否则mInputChannel 为空,从而导致此窗口无法接受任何输入事件 */ if ((mWindowAttributes.inputFeatures & WindowManager.LayoutParams.INPUT_FEATURE_NO_INPUT_CHANNEL) == 0) { mInputChannel = new InputChannel(); } try { ...... /* 将窗口添加到WMS中。完成这个操作之后,mWindow已经被添加到指定的Display中去 而且mInputChannel(如果不为空)已经准备好接受事件了。只是由于这个窗口没有进行 过relayout(),因此它还没有有效的Surface可以进行绘制 */ res = mWindowSession.addToDisplay(mWindow, mSeq, mWindowAttributes, getHostVisibility(), mDisplay.getDisplayId(), mAttachInfo.mContentInsets, mInputChannel); } catch (RemoteException e) {......} finally { ...... } ...... if (res < WindowManagerGlobal.ADD_OKAY) { // 错误处理。窗口添加失败的原因通常是权限问题,重复添加,或者tokeen无效 } ...... /*④ 如果mInputChannel不为空,则创建mInputEventReceiver,用于接受输入事件。 注意第二个参数传递的是Looper.myLooper(),即mInputEventReceiver将在主线程上 触发输入事件的读取与onInputEvent()。这是应用程序可以在onTouch()等事件响应中 直接进行UI操作等根本原因。 */ if (mInputChannel != null) { ...... mInputEventReceiver = new WindowInputEventReceiver(mInputChannel, Looper.myLooper()); } /* ViewRootImpl将作为参数view的parent。所以,ViewRootImpl可以从控件树中任何一个 控件开始,通过回溯getParent()的方法得到 */ view.assignParent(this); ...... } } }
至此,ViewRootImpl所有重要的成员都已经初始化完毕,新的窗口也已经添加到WMS中。ViewRootImpl的创建过程是由构造函数和setView()方法两个环节构成的。其中构造函数主要进行成员的初始化,setView()则是创建窗口、建立输入事件接收机制的场所。同时,触发第一次“遍历”操作的消息已经发送给主线程,在随后的第一次“遍历”完成后,ViewRootImpl将会完成对控件树的第一次测量、布局,并从WMS获取窗口的Surface以进行控件树的初次绘制工作。
对ViewRootImpl中的重要成员进行了分类整理。
控件系统的心跳:performTraversals()
ViewRootImpl在其创建过程中通过requestLayout()向主线程发送了一条触发“遍历”操作的消息,“遍历”操作是指performTraversals()方法。它的性质与WMS中的performLayoutAndPlaceSurfacesLocked()类似,是一个包罗万象的方法。ViewRootImpl中接收到的各种变化,如来自WMS的窗口属性变化,来自控件树的尺寸变化、重绘请求等都引发performTraversals()的调用,并在其中完成处理。View类及其子类中的onMeasure()、onLayout()以及onDraw()等回调也都是在performTraversals()的执行过程中直接或间接地引发。也正是如此,一次次的performTraversals()调用驱动着控件树有条不紊地工作着,一旦此方法无法正常执行,整个控件树都将处于僵死状态。因此,performTraversals()函数可谓是ViewRootImpl的心跳。
由于布局的相关工作是此方法中最主要的内容,为了简化分析,并突出此方法的工作流程,本节将以布局的相关工作为主线进行探讨。待完成了这部分内容的分析之后,庞大的performTraversals()方法将不再那么难以驯服,读者便可以轻易地学习其他的工作了。
1.performTraversals()的工作阶段
performTraversals()是Android 源码中最庞大的方法之一,因此在正式探讨它的实现之前最好先将其划分为以下几个工作阶段作为指导。
-
预测量阶段。这是进入performTraversals()方法后的第一个阶段,它会对控件树进行第一次测量。测量结果可以通过mView. getMeasuredWidth()/Height()获得。在此阶段中将会计算出控件树为显示其内容所需的尺寸,即期望的窗口尺寸。在这个阶段中,View及其子类的onMeasure()方法将会沿着控件树依次得到回调。
-
布局窗口阶段。根据预测量的结果,通过IWindowSession.relayout()方法向WMS请求调整窗口的尺寸等属性,这将引发WMS对窗口进行重新布局,并将布局结果返回给ViewRootImpl。
-
最终测量阶段。预测量的结果是控件树所期望的窗口尺寸。然而由于在WMS中影响窗口布局的因素很多(参考第4章),WMS不一定会将窗口准确地布局为控件树所要求的尺寸,而迫于WMS作为系统服务的强势地位,控件树不得不接受WMS的布局结果。因此在这一阶段,performTraversals()将以窗口的实际尺寸对控件进行最终测量。在这个阶段中,View及其子类的onMeasure()方法将会沿着控件树依次被回调。
-
布局控件树阶段。完成最终测量之后便可以对控件树进行布局了。测量确定的是控件的尺寸,而布局则是确定控件的位置。在这个阶段中,View及其子类的onLayout()方法将会被回调。
-
绘制阶段。这是performTraversals()的最终阶段。确定了控件的位置与尺寸后,便可以对控件树进行绘制了。在这个阶段中,View及其子类的onDraw()方法将会被回调。
说明 很多文章都倾向于将performTraversals()的工作划分为测量、布局与绘制三个阶段。然而笔者认为如此划分隐藏了WMS在这个过程中的地位,并且没能体现出控件树对窗口尺寸的期望、WMS对窗口尺寸做最终的确定,最后以WMS给出的结果为准再次进行测量的协商过程。而这个协商过程充分体现了ViewRootImpl作为WMS与控件树的中间人的角色。
接下来将结合代码,对上述五个阶段进行深入的分析。
2.预测量与测量原理
本节将探讨performTraversals()将以何种方式对控件树进行预测量,同时,本节也会对控件的测量过程与原理进行介绍。
预测量参数的候选
预测量也是一次完整的测量过程,它与最终测量的区别仅在于参数不同而已。实际的测量工作在View或其子类的onMeasure()方法中完成,并且其测量结果需要受限于来自其父控件的指示。这个指示由onMeasure()方法的两个参数进行传达:widthSpec与heightSpec。它们是被称为MeasureSpec的复合整型变量,用于指导控件对自身进行测量。它有两个分量
MeasureSpec的结构
其1到30位给出了父控件建议尺寸。建议尺寸对测量结果的影响依不同的SPEC_MODE的不同而不同。SPEC_MODE的取值取决于此控件的LayoutParams.width/height的设置,可以是如下三种值之一。
-
MeasureSpec.UNSPECIFIED (0):表示控件在进行测量时,可以无视SPEC_SIZE的值。控件可以是它所期望的任意尺寸。
-
MeasureSpec.EXACTLY (1):表示子控件必须为SPEC_SIZE所制定的尺寸。当控件的LayoutParams.width/height为一确定值,或者是MATCH_PARENT时,对应的MeasureSpec参数会使用这个SPEC_MODE。
-
MeasureSpec.AT_MOST (2):表示子控件可以是它所期望的尺寸,但是不得大于SPEC_SIZE。当控件的LayoutParams.width/height为WRAP_CONTENT时,对应的MeasureSpec参数会使用这个SPEC_MODE。
Android提供了一个MeasureSpec类用于组合两个分量成为一个MeasureSpec,或者从MeasureSpec中分离任何一个分量。
那么ViewRootImpl会如何为控件树的根mView准备其MeasureSpec呢?
参考如下代码,注意desiredWindowWidth/Height的取值,它们将是SPEC_SIZE分量的候选。另外,这段代码分析中也解释了与测量无关,但是比较重要的代码段。
[ViewRootImpl.java-->ViewRootImpl.performTraversals()] private void performTraversals() { // 将mView保存在局部变量host中,以此提高对mView的访问效率 finalView host = mView; ...... // 声明本阶段的主角,这两个变量将是mView的SPEC_SIZE分量的候选 intdesiredWindowWidth; intdesiredWindowHeight; ....... Rectframe = mWinFrame; // 如上一节所述,mWinFrame表示了窗口的最新尺寸 if(mFirst) { /*mFirst表示了这是第一次遍历,此时窗口刚刚被添加到WMS,此时窗口尚未进行relayout,因此 mWinFrame中没有存储有效地窗口尺寸 */ if(lp.type == WindowManager.LayoutParams.TYPE_STATUS_BAR_PANEL) { ......// 为状态栏设置desiredWindowWidth/Height,其取值是屏幕尺寸 }else { //① 第一次“遍历”的测量,采用了应用可以使用的最大尺寸作为SPEC_SIZE的候选 DisplayMetrics packageMetrics = mView.getContext().getResources().getDisplayMetrics(); desiredWindowWidth = packageMetrics.widthPixels; desiredWindowHeight = packageMetrics.heightPixels; } /* 由于这是第一次进行“遍历”,控件树即将第一次被显示在窗口上,因此接下来的代码填充了 mAttachInfo中的一些字段,然后通过mView发起了dispatchAttachedToWindow()的调用 之后每一个位于控件树中的控件都会回调onAttachedToWindow() */ ...... } else { // ② 在非第一次遍历的情况下,会采用窗口的最新尺寸作为SPEC_SIZE的候选 desiredWindowWidth = frame.width(); desiredWindowHeight = frame.height(); /* 如果窗口的最新尺寸与ViewRootImpl中的现有尺寸不同,说明WMS侧单方面改变了窗口的尺寸 这将产生如下三个结果 */ if(desiredWindowWidth != mWidth || desiredWindowHeight != mHeight) { // 需要进行完整的重绘以适应新的窗口尺寸 mFullRedrawNeeded = true; // 需要对控件树进行重新布局 mLayoutRequested = true; /* 控件树有可能拒绝接受新的窗口尺寸,比如在随后的预测量中给出了不同于窗口尺寸的测量结果 产生这种情况时,就需要在窗口布局阶段尝试设置新的窗口尺寸 */ windowSizeMayChange = true; } } ...... /* 执行位于RunQueue中的回调。RunQueue是ViewRootImpl的一个静态成员,即是说它是进程唯一 的,并且可以在进程的任何位置访问RunQueue。在进行多线程任务时,开发者可以通过调用View.post() 或View.postDelayed()方法将一个Runnable对象发送到主线程执行。这两个方法的原理是将 Runnable对象发送到ViewRootImpl的mHandler去。当控件已经加入到控件树时,可以通过 AttachInfo轻易获取这个Handler。而当控件没有位于控件树中时,则没有mAttachInfo可用,此时 执行View.post()/PostDelay()方法,Runnable将会被添加到这个RunQueue队列中。 在这里,ViewRootImpl将会把RunQueue中的Runnable发送到mHandler中,进而得到执行。所以 无论控件是否显示在控件树中,View.post()/postDelay()方法都是可用的,除非当前进程中没有任何 处于活动状态的ViewRootImpl */ getRunQueue().executeActions(attachInfo.mHandler); booleanlayoutRequested = mLayoutRequested && !mStopped; /* 仅当layoutRequested为true时才进行预测量。 layoutRequested为true表示在进行“遍历”之前requestLayout()方法被调用过。 requestLayout()方法用于要求ViewRootImpl进行一次“遍历”并对控件树重新进行测量与布局 */ if(layoutRequested) { final Resources res = mView.getContext().getResources(); if(mFirst) { ......// 确定控件树是否需要进入TouchMode,本章将在6.5.1节介绍 TouchMode }else { /*检查WMS是否单方面改变了ContentInsets与VisibleInsets。注意对二者的处理的差异, ContentInsets描述了控件在布局时必须预留的空间,这样会影响控件树的布局,因此将 insetsChanged标记为true,以此作为是否进行控件布局的条件之一。而VisibleInsets则 描述了被遮挡的空间,ViewRootImpl在进行绘制时,需要调整绘制位置以保证关键控件或区域, 如正在进行输入的TextView等不被遮挡,这样VisibleInsets的变化并不会导致重新布局, 所以这里仅仅是将VisibleInsets保存到mAttachInfo中,以便绘制时使用 */ if (!mPendingContentInsets.equals(mAttachInfo.mContentInsets)) { insetsChanged = true; } if (!mPendingVisibleInsets.equals(mAttachInfo.mVisibleInsets)) { mAttachInfo.mVisibleInsets.set(mPendingVisibleInsets); } /*当窗口的width或height被指定为WRAP_CONTENT时,表示这是一个悬浮窗口。 此时会对desiredWindowWidth/Height进行调整。在前面的代码中,这两个值被设置 被设置为窗口的当前尺寸。而根据MeasureSpec的要求,测量结果不得大于SPEC_SIZE。 然而,如果这个悬浮窗口需要更大的尺寸以完整显示其内容时,例如为AlertDialog设置了 一个更长的消息内容,如此取值将导致无法得到足够大的测量结果,从而导致内容无法完整显示。 因此,对于此等类型的窗口,ViewRootImpl会调整desiredWindowWidth/Height为此应用 可以使用的最大尺寸 */ if (lp.width == ViewGroup.LayoutParams.WRAP_CONTENT || lp.height == ViewGroup.LayoutParams.WRAP_CONTENT) { // 悬浮窗口的尺寸取决于测量结果。因此有可能需要向WMS申请改变窗口的尺寸。 windowSizeMayChange = true; if (lp.type == WindowManager.LayoutParams.TYPE_STATUS_BAR_PANEL) { // } else { // ③ 设置悬浮窗口SPEC_SIZE的候选为应用可以使用的最大尺寸 DisplayMetrics packageMetrics = res.getDisplayMetrics(); desiredWindowWidth = packageMetrics.widthPixels; desiredWindowHeight = packageMetrics.heightPixels; } } } // **④ 进行预测量。**通过measureHierarchy()方法以desiredWindowWidth/Height进行测量 windowSizeMayChange |=measureHierarchy(host, lp, res, desiredWindowWidth, desiredWindowHeight); } // 其他阶段的处理 ...... }
由此可知,预测量时的SPEC_SIZE按照如下原则进行取值:
-
第一次“遍历”时,使用应用可用的最大尺寸作为SPEC_SIZE的候选。
-
此窗口是一个悬浮窗口,即LayoutParams.width/height其中之一被指定为WRAP_CONTENT时,使用应用可用的最大尺寸作为SPEC_SIZE的候选。
-
在其他情况下,使用窗口最新尺寸作为SPEC_SIZE的候选。
最后,通过measureHierarchy()方法进行测量。
测量协商
measureHierarchy()用于测量整个控件树。传入的参数desiredWindowWidth与desiredWindowHeight在前述代码中根据不同的情况作了精心的挑选。控件树本可以按照这两个参数完成测量,但是measureHierarchy()有自己的考量,即如何将窗口布局地尽可能地优雅。
这是针对将LayoutParams.width设置为了WRAP_CONTENT的悬浮窗口而言。如前文所述,在设置为WRAP_CONTENT时,指定的desiredWindowWidth是应用可用的最大宽度,如此可能会产生如图6-6左图所示的丑陋布局。这种情况较容易发生在AlertDialog中,当AlertDialog需要显示一条比较长的消息时,由于给予的宽度足够大,因此它有可能将这条消息以一行显示,并使得其窗口充满了整个屏幕宽度,在横屏模式下这种布局尤为丑陋。
倘若能够对可用宽度进行适当的限制,迫使AlertDialog将消息换行显示,则产生的布局结果将会优雅得多,如图6-6右图所示。但是,倘若不分清红皂白地对宽度进行限制,当控件树真正需要足够的横向空间时,会导致内容无法显示完全,或者无法达到最佳的显示效果。例如当一个悬浮窗口希望尽可能大地显示一张照片时就会出现这样的情况。
那么measureHierarchy()如何解决这个问呢?它采取了与控件树进行协商的办法,即先使用measureHierarchy()所期望的宽度限制尝试对控件树进行测量,然后通过测量结果来检查控件树是否能够在此限制下满足其充分显示内容的要求。倘若没能满足,则measureHierarchy()进行让步,放宽对宽度的限制,然后再次进行测量,再做检查。倘若仍不能满足则再度进行让步。
参考代码如下:
[ViewRootImpl.java-->ViewRootImpl.measureHierarchy()] private boolean measureHierarchy(final View host,final WindowManager.LayoutParams lp, final Resources res, final int desiredWindowWidth, final int desiredWindowHeight) { intchildWidthMeasureSpec; // 合成后的用于描述宽度的MeasureSpec intchildHeightMeasureSpec; // 合成后的用于描述高度的MeasureSpec booleanwindowSizeMayChange = false; // 表示测量结果是否可能导致窗口的尺寸发生变化 booleangoodMeasure = false; // goodMeasure表示了测量是否能满足控件树充分显示内容的要求 // 测量协商仅发生在LayoutParams.width被指定为WRAP_CONTENT的情况下 if(lp.width == ViewGroup.LayoutParams.WRAP_CONTENT) { /* **① 第一次协商。**measureHierarchy()使用它最期望的宽度限制进行测量。这一宽度限制定义为 一个系统资源。可以在frameworks/base/core/res/res/values/config.xml找到它的定义 */ res.getValue(com.android.internal.R.dimen.config_prefDialogWidth,mTmpValue, true); intbaseSize = 0; // 宽度限制被存放在baseSize中 if(mTmpValue.type == TypedValue.TYPE_DIMENSION) { baseSize = (int)mTmpValue.getDimension(packageMetrics); } if(baseSize != 0 && desiredWindowWidth > baseSize) { // 使用getRootMeasureSpec()函数组合SPEC_MODE与SPEC_SIZE为一个MeasureSpec childWidthMeasureSpec = getRootMeasureSpec(baseSize, lp.width); childHeightMeasureSpec = getRootMeasureSpec(desiredWindowHeight,lp.height); //**②第一次测量。**由performMeasure()方法完成 performMeasure(childWidthMeasureSpec, childHeightMeasureSpec); /* 控件树的测量结果可以通过mView的getmeasuredWidthAndState()方法获取。如果 控件树对这个测量结果不满意,则会在返回值中添加MEASURED_STATE_TOO_SMALL位 */ if ((host.getMeasuredWidthAndState()&View.MEASURED_STATE_TOO_SMALL) ==0) { goodMeasure = true; // 控件树对测量结果满意,测量完成 } else { // **③ 第二次协商。**上次测量结果表明控件树认为measureHierarchy()给予的宽度太小, 在此适当地放宽对宽度的限制,使用最大宽度与期望宽度的中间值作为宽度限制 */ baseSize = (baseSize+desiredWindowWidth)/2; childWidthMeasureSpec = getRootMeasureSpec(baseSize, lp.width); // **④ 第二次测量** performMeasure(childWidthMeasureSpec, childHeightMeasureSpec); // 再次检查控件树是否满足此次测量 if ((host.getMeasuredWidthAndState()&View.MEASURED_STATE_TOO_SMALL) == 0) { goodMeasure = true; // 控件树对测量结果满意,测量完成 } } } } if(!goodMeasure) { /* **⑤ 最终测量。**当控件树对上述两次协商的结果都不满意时,measureHierarchy()放弃所有限制 做最终测量。这一次将不再检查控件树是否满意了,因为即便其不满意,measurehierarchy()也没 有更多的空间供其使用了 */ childWidthMeasureSpec = getRootMeasureSpec(desiredWindowWidth,lp.width); childHeightMeasureSpec = getRootMeasureSpec(desiredWindowHeight,lp.height); performMeasure(childWidthMeasureSpec, childHeightMeasureSpec); /* 最后,如果测量结果与ViewRootImpl中当前的窗口尺寸不一致,则表明随后可能有必要进行窗口 尺寸的调整 */ if(mWidth != host.getMeasuredWidth() || mHeight != host.getMeasuredHeight()) { windowSizeMayChange = true; } } // 返回窗口尺寸是否可能需要发生变化 returnwindowSizeMayChange; }
显然,对于非悬浮窗口,即当LayoutParams.width被设置为MATCH_PARENT时,不存在协商过程,直接使用给定的desiredWindowWidth/Height进行测量即可。而对于悬浮窗口,measureHierarchy()可以连续进行两次让步。因而在最不利的情况下,在ViewRootImpl的一次“遍历”中,控件树需要进行三次测量,即控件树中的每一个View.onMeasure()会被连续调用三次之多,如图6-7所示。所以相对于onLayout(),onMeasure()方法的对性能的影响比较大。
协商测量的三次尝试
接下来通过performMeasure()看控件树如何进行测量。
测量原理
performMeasure()方法的实现非常简单,它直接调用mView.measure()方法,将measureHierarchy()给予的widthSpec与heightSpec交给mView。
看下View.measure()方法的实现:
[View.java-->View.measure()] public final void measure(int widthMeasureSpec,int heightMeasureSpec) { /* 仅当给予的MeasureSpec发生变化,或要求强制重新布局时,才会进行测量。 所谓强制重新布局,是指当控件树中的一个子控件的内容发生变化时,需要进行重新的测量和布局的情况 在这种情况下,这个子控件的父控件(以及其父控件的父控件)所提供的MeasureSpec必定与上次测量 时的值相同,因而导致从ViewRootImpl到这个控件的路径上的父控件的measure()方法无法得到执行 进而导致子控件无法重新测量其尺寸或布局。因此,当子控件因内容发生变化时,从子控件沿着控件树回溯 到ViewRootImpl,并依次调用沿途父控件的requestLayout()方法,在这个方法中,会在 mPrivateFlags中加入标记PFLAG_FORCE_LAYOUT,从而使得这些父控件的measure()方法得以顺利 执行,进而这个子控件有机会进行重新测量与布局。这便是强制重新布局的意义 */ if ((mPrivateFlags& PFLAG_FORCE_LAYOUT) == PFLAG_FORCE_LAYOUT || widthMeasureSpec != mOldWidthMeasureSpec || heightMeasureSpec != mOldHeightMeasureSpec) { /* **① 准备工作。**从mPrivateFlags中将PFLAG_MEASURED_DIMENSION_SET标记去除。 PFLAG_MEASURED_DIMENSION_SET标记用于检查控件在onMeasure()方法中是否通过 调用setMeasuredDimension()将测量结果存储下来 */ mPrivateFlags &= ~PFLAG_MEASURED_DIMENSION_SET; ...... /* **② 对本控件进行测量** 每个View子类都需要重载这个方法以便正确地对自身进行测量。 View类的onMeasure()方法仅仅根据背景Drawable或style中设置的最小尺寸作为 测量结果*/ onMeasure(widthMeasureSpec, heightMeasureSpec); /* ③ 检查onMeasure()的实现是否调用了setMeasuredDimension() setMeasuredDimension()会将PFLAG_MEASURED_DIMENSION_SET标记重新加入 mPrivateFlags中。之所以做这样的检查,是由于onMeasure()的实现可能由开发者完成, 而在Android看来,开发者是不可信的 */ if((mPrivateFlags & PFLAG_MEASURED_DIMENSION_SET) !=PFLAG_MEASURED_DIMENSION_SET) { throw new IllegalStateException(......); } // ④ 将PFLAG_LAYOUT_REQUIRED标记加入mPrivateFlags。这一操作会对随后的布局操作放行 mPrivateFlags |= PFLAG_LAYOUT_REQUIRED; } // 记录父控件给予的MeasureSpec,用以检查之后的测量操作是否有必要进行 mOldWidthMeasureSpec = widthMeasureSpec; mOldHeightMeasureSpec = heightMeasureSpec; }
从这段代码可以看出,View.measure()方法没有实现任何测量算法,它的作用在于引发onMeasure()的调用,并对onMeasure()行为的正确性进行检查。另外,在控件系统看来,一旦控件执行了测量操作,那么随后必须进行布局操作,因此在完成测量之后,将PFLAG_LAYOUT_REQUIRED标记加入mPrivateFlags,以便View.layout()方法可以顺利进行。
onMeasure()的结果通过setMeasuredDimension()方法尽行保存。setMeasuredDimension()方法的实现如下:
[View.java-->View.setMeasuredDimension()] protected final void setMeasuredDimension(intmeasuredWidth, int measuredHeight) { /* ① 测量结果被分别保存在成员变量mMeasuredWidth与mMeasuredHeight中 mMeasuredWidth = measuredWidth; mMeasuredHeight = measuredHeight; // ② 向mPrivateFlags中添加PFALG_MEASURED_DIMENSION_SET,以此证明onMeasure()保存了测量结果 mPrivateFlags |= PFLAG_MEASURED_DIMENSION_SET; }
其实现再简单不过。存储测量结果的两个变量可以通过getMeasuredWidthAndState()与getMeasuredHeightAndState()两个方法获得,就像ViewRootImpl.measureHierarchy()中所做的一样。此方法虽然简单,但需要注意,与MeasureSpec类似,测量结果不仅仅是一个尺寸,而是一个测量状态与尺寸的复合整、变量。其0至30位表示了测量结果的尺寸,而31、32位则表示了控件对测量结果是否满意,即父控件给予的MeasureSpec是否可以使得控件完整地显示其内容。当控件对测量结果满意时,直接将尺寸传递给setMeasuredDimension()即可,注意要保证31、32位为0。倘若对测量结果不满意,则使用View.MEASURED_STATE_TOO_SMALL | measuredSize 作为参数传递给setMeasuredDimension()以告知父控件对MeasureSpec进行可能的调整。
既然明白了onMeasure()的调用如何发起,以及它如何将测量结果告知父控件,那么onMeasure()方法应当如何实现的呢?对于非ViewGroup的控件来说其实现相对简单,只要按照MeasureSpec的原则如实计算其所需的尺寸即可。而对于ViewGroup类型的控件来说情况则复杂得多,因为它不仅拥有自身需要显示的内容(如背景),它的子控件也是其需要测量的内容。因此它不仅需要计算自身显示内容所需的尺寸,还有考虑其一系列子控件的测量结果。为此它必须为每一个子控件准备MeasureSpec,并调用每一个子控件的measure()函数。
由于各种控件所实现的效果形形色色,开发者还可以根据需求自行开发新的控件,因此onMeasure()中的测量算法也会变化万千。不从Android系统实现的角度仍能得到如下的onMeasure()算法的一些实现原则:
-
控件在进行测量时,控件需要将它的Padding尺寸计算在内,因为Padding是其尺寸的一部分。
-
ViewGroup在进行测量时,需要将子控件的Margin尺寸计算在内。因为子控件的Margin尺寸是父控件尺寸的一部分。
-
ViewGroup为子控件准备MeasureSpec时,SPEC_MODE应取决于子控件的LayoutParams.width/height的取值。取值为MATCH_PARENT或一个确定的尺寸时应为EXACTLY,WRAP_CONTENT时应为AT_MOST。至于SPEC_SIZE,应理解为ViewGroup对子控件尺寸的限制,即ViewGroup按照其实现意图所允许子控件获得的最大尺寸。并且需要扣除子控件的Margin尺寸。
-
虽然说测量的目的在于确定尺寸,与位置无关。但是子控件的位置是ViewGroup进行测量时必须要首先考虑的。因为子控件的位置即决定了子控件可用的剩余尺寸,也决定了父控件的尺寸(当父控件的LayoutParams.width/height为WRAP_CONTENT时)。
-
在测量结果中添加MEASURED_STATE_TOO_SMALL需要做到实事求是。当一个方向上的空间不足以显示其内容时应考虑利用另一个方向上的空间,例如对文字进行换行处理,因为添加这个标记有可能导致父控件对其进行重新测量从而降低效率。
-
当子控件的测量结果中包含MEASURED_STATE_TOO_SMALL标记时,只要有可能,父控件就应当调整给予子控件的MeasureSpec,并进行重新测量。倘若没有调整的余地,父控件也应当将MEASURED_STATE_TOO_SMALL加入到自己的测量结果中,让它的父控件尝试进行调整。
-
ViewGroup在测量子控件时必须调用子控件的measure()方法,而不能直接调用其onMeasure()方法。直接调用onMeasure()方法的最严重后果是子控件的PFLAG_LAYOUT_REQUIRED标识无法加入到mPrivateFlag中,从而导致子控件无法进行布局。
综上所述,测量控件树的实质是测量控件树的根控件。完成控件树的测量之后,ViewRootImpl便得知了控件树对窗口尺寸的需求。
确定是否需要改变窗口尺寸
接下来回到performTraversals()方法。在ViewRootImpl.measureHierarchy()执行完毕之后,ViewRootImpl了解了控件树所需的空间。于是便可确定是否需要改变窗口窗口尺寸以便满足控件树的空间要求。前述的代码中多处设置windowSizeMayChange变量为true。windowSizeMayChange仅表示有可能需要改变窗口尺寸。而接下来的这段代码则用来确定窗口是否需要改变尺寸。
[ViewRootImpl.java-->ViewRootImp.performTraversals()] private void performTraversals() { ......// 测量控件树的代码 /* 标记mLayoutRequested为false。因此在此之后的代码中,倘若控件树中任何一个控件执行了 requestLayout(),都会重新进行一次“遍历” */ if (layoutRequested) { mLayoutRequested = false; } // 确定窗口是否确实需要进行尺寸的改变 booleanwindowShouldResize = layoutRequested && windowSizeMayChange && ((mWidth != host.getMeasuredWidth() || mHeight !=host.getMeasuredHeight()) || (lp.width == ViewGroup.LayoutParams.WRAP_CONTENT && frame.width() < desiredWindowWidth && frame.width() !=mWidth) || (lp.height == ViewGroup.LayoutParams.WRAP_CONTENT && frame.height() < desiredWindowHeight && frame.height() !=mHeight)); }
确定窗口尺寸是否确实需要改变的条件看起来比较复杂,这里进行一下总结,先介绍必要条件:
-
layoutRequested为true,即ViewRootImpl.requestLayout()方法被调用过。View中也有requestLayout()方法。当控件内容发生变化从而需要调整其尺寸时,会调用其自身的requestLayout(),并且此方法会沿着控件树向根部回溯,最终调用到ViewRootImp.requestLayout(),从而引发一次performTraversals()调用。之所以这是一个必要条件,是因为performTraversals()还有可能因为控件需要重绘时被调用。当控件仅需要重绘而不需要重新布局时(例如背景色或前景色发生变化时),会通过invalidate()方法回溯到ViewRootImpl,此时不会通过performTraversals()触发performTraversals()调用,而是通过scheduleTraversals()进行触发。在这种情况下layoutRequested为false,即表示窗口尺寸不需发生变化。
-
windowSizeMayChange为true,如前文所讨论的,这意味着WMS单方面改变了窗口尺寸而控件树的测量结果与这一尺寸有差异,或当前窗口为悬浮窗口,其控件树的测量结果将决定窗口的新尺寸。
在满足上述两个条件的情况下,以下两个条件满足其一:
-
测量结果与ViewRootImpl中所保存的当前尺寸有差异。
-
悬浮窗口的测量结果与窗口的最新尺寸有差异。
注意ViewRootImpl对是否需要调整窗口尺寸的判断是非常小心的。调整窗口尺寸所必须调用的performLayoutAndPlaceSurfacesLocked()函数会导致WMS对系统中的所有窗口新型重新布局,而且会引发至少一个动画帧渲染,其计算开销相当之大。因此ViewRootImpl仅在必要时才会惊动WMS。
至此,预测量阶段完成了。
总结
这一阶段的工作内容是为了给后续阶段做参数的准备并且其中最重要的工作是对控件树的预测量,至此ViewRootImpl得知了控件树对窗口尺寸的要求。另外,这一阶段还准备了后续阶段所需的其他参数:
- viewVisibilityChanged。即View的可见性是否发生了变化。由于mView是窗口的内容,因此mView的可见性即是窗口的可见性。当这一属性发生变化时,需要通过通过WMS改变窗口的可见性。
LayoutParams。预测量阶段需要收集应用到LayoutParams的改动,这些改动一方面来自于WindowManager.updateViewLayout(),而另一方面则来自于控件树。以SystemUIVisibility为例,View.setSystemUIVisibility()所修改的设置需要反映到LayoutParams中,而这些设置确却保存在控件自己的成员变量里。在预测量阶段会通过ViewRootImpl.collectViewAttributes()方法遍历控件树中的所有控件以收集这些设置,然后更新LayoutParams。