以下是阅读《深入理解Android:卷三》第6章 深入理解控件系统时的阅读要点,按照章节做下标记,以供后续查阅。
6.2 深入理解WindowManager
WindowManager的主要作用是封装API供开发者使用,以便将控件作为一个窗口添加到系统中。
6.2.1 WindowManager的创建与体系结构
WindowManager是一个继承于ViewManager的接口(其实现为WindowManagerImpl,而WindowManagerImpl内部则通过调用WindowManagerGlobal实现相应逻辑),后者的另一个实现者是ViewGroup。因此可以将两者进行类比:设想WindowManager是一个ViewGroup,其区域为整块屏幕,而其中的各个窗口就是一个一个View。WindowManager通过WMS的帮助,将这些View按照布局参数(LayoutParams)显示到屏幕的特定位置。两者的工作核心是一样的,因此都继承自ViewManager。开发者可以通过调用Context.getSystemService(Context.WINDOW_SERVICE)来获取WindowManager的实例。其原理是ContextImpl在其静态构造函数中初始化了一系列的ServiceFetch而来响应getSystemService的调用并创建对应的服务实例。
/** Interface to let you add and remove child views to an Activity. To get an instance
* of this class, call {@link android.content.Context#getSystemService(java.lang.String) Context.getSystemService()}.
*/
public interface ViewManager
{
/**
* Assign the passed LayoutParams to the passed View and add the view to the window.
* <p>Throws {@link android.view.WindowManager.BadTokenException} for certain programming
* errors, such as adding a second view to a window without removing the first view.
* <p>Throws {@link android.view.WindowManager.InvalidDisplayException} if the window is on a
* secondary {@link Display} and the specified display can't be found
* (see {@link android.app.Presentation}).
* @param view The view to be added to this window.
* @param params The LayoutParams to assign to view.
*/
public void addView(View view, ViewGroup.LayoutParams params);
public void updateViewLayout(View view, ViewGroup.LayoutParams params);
public void removeView(View view);
}
6.2.2 通过WindowManagerGlobal添加窗口
- 每一个新窗口必须通过
LayoutParams.token向WMS初始相应的令牌。在addView()函数中通过父窗口修改这个token属性的目的是减少开发者的负担。开发者不需要关心token,只需丢一个LayoutParams给WM。父窗口修改token属性的原则是:如果新窗口的类型为子窗口(LayoutParams.FIRST_SUB_WINDOW<token<LayoutParams.LAST_SUB_WINDOW),则LayoutParams.token所持有的令牌为其父窗口的ID(也就是IWindow.asBinder()的返回值)。否则LayoutParmas.token将被修改为父窗口所属的Activity的ID(也就是APPToken),这对类型为TYPE_APPLICATION的新窗口来说非常重要。
在WMS中,这些窗口对应的WindowState所保存的mAttachedWindow就是parentWindow所对应的WindowState。 - 每个新窗口都会创建一个
ViewRootImpl。它是整个控件系统的正常运转的动力所在 - 控件系统中的窗口就是控件、布局参数、
ViewRootImpl对象的一个三元组。 - 调用
ViewRootImpl.setView()函数会将控件交给ViewRootImpl托管。
6.2.3 更新窗口布局
窗口的布局参数发生变化时,需要通知WMS进行相应的调整,该工作在WindowManagerGlobal中由updateViewLayout()函数完成。其逻辑不过是保存新的布局参数,然后调用ViewRootImpl.setLayoutParams()进行更新
6.2.4 删除窗口
ViewRootImpl的生命从setView()开始,到die()结束。WindowManagerGlobal将窗口的创建、销毁、布局更新等任务都交给ViewRootImpl完成
6.3 深入理解ViewRootImpl
-
ViewRootImpl是整棵控件树正常运作的动力所在,控件的测量、布局、绘制以及输入事件的派发处理都由ViewRootImpl触发 -
WindowManagerGlobal工作的实际实现者,负责与WMS交互通信以调整窗口的位置大小,以及对来自WMS的事件(如窗口尺寸改变等)做出相应的处理
6.3.1 ViewRootImpl的创建及其重要的成员
- 从
WindowManagerGlobal获取的IWindowSession实例,是ViewRootImpl与WMS通信的代理 -
mThread = Thread.currentThread()保存当前创建ViewRootImpl的线程作为主线程 -
mWinFrame描述了当前窗口的位置和尺寸。与WMS的WindowState.mFrame保持一致 -
mWindow = new W(this),W类型是IWindow.Stub的子类,它将在WMS中作为新窗口的ID,并接收来自WMS的回调 -
mAttachInfo存储了当前控件树所贴附的窗口的各种有用信息,并且会派发给控件树中的每一个控件 -
mChoreographer处理消息时具有VSYNC特性,因此它主要用来处理与重绘相关的操作。但是由于mChoreographer需要等待VSYNC的垂直同步事件来触发对下一条消息的处理,因此它处理消息的即时性稍逊于mHandler。 - Android的UI操作并不是线程安全的,很多操作都是建立在单线程的假设之上(如
scheduleTraversals())。采用单线程模型的目的是降低系统的复杂度,并且降低锁的开销 -
mSurface是Surface类型,在WMS通过relayoutWindow()为其分配一块Surface之前尚不能使用。
对于ViewRootImpl.setView(),它是创建窗口,建立输入事件接收机制的场所。同时,触发第一次“遍历”操作的消息已经发送给主线程,在随后的第一次“遍历”完成之后,ViewRootImpl将会完成对控件树的第一次测量、布局,并从WMS获取窗口的Surface以进行控件树的初次绘制工作:
-
WMS通过mWindow发生的回调会导致一些属性发生变化,如窗口的尺寸、Insets以及窗口的焦点等,从而有可能使得初次“遍历”的现场遭到破坏。因此需要在添加窗口之前,先发送一个“遍历”消息到主线程。也就是调用requestLayout() -
InputChannel是窗口接收来自InputDispatcher的输入事件的管道。注意,当窗口的属性inputFeatures包含INPUT_FEATURE_NO_INPUT_CHANNEL时,mInputChannel会为空,从而导致此窗口无法接收任何输入事件 -
mWindowSession.addToDisplay()会将窗口添加到WMS。完成这个操作之后,mWindow已经被添加到指定的Display中而且mInputChannel已经准备好接收事件。只是由于这个窗口没有进行过relayout(),因此它还没有有效的Surface可以进行绘制 -
mInputEventReceiver用于接收输入事件。mInputEventReceiver = newWindowInputEve太难Receiver(mInputChannel,Looper.myLooper()),第二个参数是Looper.myLooper(),因此是在主线程触发输入事件的读取以及onInputEvent() -
view.assignParent(this),导致ViewRootImpl可以从控件树中任何一个控件开始,通过回溯getParent()得到
6.3.2 控件系统的心跳:performTraversals()
View类及其子类的onMeasure()、onLayout()、onDraw()等回调都是在performTravelsals()的执行过程中直接或间接地引发。
1.performTraversals()的工作阶段
- 预测量阶段。第一个阶段。测量结果可以通过
mView.getMeasuredWidth()/Height()获得。测量的是控件树的期望的窗口尺寸。在此阶段,onMeasure()沿着控件树依次得到回调 - 布局窗口阶段。根据预测量阶段,通过
IWindowSession.relayout()方法向WMS请求调整窗口的尺寸等属性,这将引发WMS对窗口进行重新布局,并将布局结果返回给ViewRootImpl - 最终测量阶段。预测量是控件树期望的窗口尺寸,但是
WMS由于各种窗口布局因素的左右,最终不一定能满足控件树的需求。因此在此阶段,将以窗口的实际尺寸对控件进行最终测量。onMeasure()沿着控件树再次依次得到回调 - 布局控件树阶段。测量确定大小尺寸,布局确定位置。
onLayout()沿着控件树依次得到回调 - 绘制阶段。最终阶段。
onDraw()沿着控件树依次得到回调
2.预测量与测量原理
2.1 测量参数的候选
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时,会使用该mode -
MeasureSpec.AT_MOST(2): 子可以指定自己期望尺寸,但是不能超过SPEC_SIZE。当父控件LayoutParams.width/height为一确定值,或者WRAP_CONTENT时,会使用该mode
ViewRootImpl以desiredWindowWidth/Height为候选,为控件树的根mView选取其MeasureSpec:
- 第一次遍历时,使用应用可用的最大尺寸作为
SPEC_SIZE的候选 - 此窗口是一个悬浮窗,即
LayoutParams.width/height其中之一被指定为WRAP_CONTENT时,使用应用可用的最大尺寸作为SPEC_SIZE的候选 - 其他情况下,使用窗口的最新尺寸作为
SPEC_SIZE的候选
最后,通过measureHierarchy()方法进行测量。
2.2 测量协商
measureHierarchy()用户测量整个控件树。该方法有协商机制,先使用该方法所期望的尺寸(该期望值定义为一个系统资源。可以在system/framework/base/core/res/res/values/config.xml找到它的定义)限制尝试对控件树进行测量,并用测量结果检查控件树是否能够在此限制下满足其充分显示内容的要求。如果无法满足,则进行让步,放宽限制,然后再次进行测量,再做检查。倘若仍然不满足则再度进行让步。二次测量后仍然不满意的话,放弃所有限制做最终测量。最后一次将不再检查控件树是否满意,因为即使不满意也没有更多空间供其使用了。
特别地,对于非悬浮窗口(LayoutParams.width被设置为MATCH_PARENT),不存在协商过程,直接使用给定的desiredWindowWidth/Height进行测量即可。否则,measureHierarchy()可以连续进行两次让步。
2.3 测量原理
在perfomeMeasure()中体现,它也是直接调用了mView.measure()。
- 仅当给予的
MeasureSpec发生变化,或要求强制重新布局时,才会进行测量。所谓强制重新布局,是指当子控件因内容发生变化时,从子控件沿着控件树回溯到ViewrootImpl,并依次调用沿途父控件的requestLayout()方法。这个方法会在mPrivateFlags中加入标记PFLAG_FORCE_LAYOUT,从而使得这些父控件的measure()方法得以顺利执行,进而这个子控件有机会进行重新布局与测量。 -
FLAG_MEASURED_DIMENSION_SET标记用于检查控件在onMeasure()中是否通过调用setMeasuredDimenssion()存储测量结果。该方法与getMeasuredWidthAndAndState()/getMeasuredHeightAndAndState()对应,可以用来让ViewRootImpl读取测量结果并验证是否满足控件是否充分显示的需求.当满足需求时,直接将尺寸传递给setMeasuredDimenssion(),注意保证31、32位为0.否则,使用传递View.MEASURED_STATE_TOO_SMALL|measuredSize,以告知父控件对MeasureSpec进行可能的调整 -
View类的onMeasure()仅仅根据北京Drawable或style中设置的最小尺寸作为测量结果 - 在控件系统看来,一旦控件进行了测量操作,那么随后必须进行布局操作,因此在完成测量之后,将
PFLAG_LAYOUT_REQUIRED标记加入到mPrivateFlags,以便View.layout()可以顺利进行
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进行测量时必须首先考虑的。因为子控件的位置既决定了子控件的可用剩余尺寸,也决定了父控件的尺寸(父控件为WRAP_CONTENT时) - 当控件在一个方向上的空间不足以显示器内容时应该考虑利用另一个方向上的空间,不要盲目添加
MEASUREED_STATE_TOO_SMALL而导致父控件对其进行重新测量进而导致降低效率 - 当子控件的测量结果包含
MEASUREED_STATE_TOO_SMALL标记时,只要有可能,父控件就应当调整给予子控件的MeasureSpec,并进行重新测量。否则,父控件也应当将MEASURED_STATE_TOO_SMALL加入到自己的测量结果中,让它的父控件尝试进行调整 -
ViewGroup必须调用的是子控件的measure()而非onMeasure(),否则会导致子控件的PFLAG_LAYOUT_REQUIRED无法加入mPrivateFlag中,从而导致子控件无法进行布局
2.4 确定是否需要改变窗口尺寸 必要条件:
-
layoutRequested为true。即ViewRootImpl.requestLayouot()被调用过。当控件仅需要重绘时,是通过invalidate()回溯到ViewRootImpl,此时不会通过requestLayouot()触发perfomeTraversals(),而是通过scheduleTraversals()触发。此时layoutRequested为false -
windowSizeMayChange为true。这意味着,WMS单方面改变窗口尺寸而控件树的测量结果与这一尺寸有差异,或当前窗口为悬浮窗口,其控件树的测量结果将决定窗口的新尺寸。
在满足上述两个条件的情况下,以下满足其一:
- 测量结果与
ViewRootImpl中所保存的当前尺寸有差异 - 悬浮窗口的测量结果与窗口的最新尺寸有差异
3.布局窗口与最终测量
3.1 布局窗口的条件
倘若不需要进行窗口布局,则WMS不会再预测量之后修改窗口的尺寸,也就不需要进行最终测量。以下4大条件满足其一即进入布局窗口阶段:
-
mFirst, 表示第一次“遍历”,此时窗口尚未进行窗口布局,没有有效的Surface进行内容绘制。因此必须进行窗口布局 -
windowShouldResize,控件树的测量结果与窗口的当前尺寸有差异,需要通过布局窗口阶段向WMS提出修改窗口尺寸的请求 -
insetsChanged,表示WMS单方面改变了窗口的ContentInsets。这种情况一般发生在SystemUI的可见性发生了变化或输入法窗口弹出或关闭的情况下。严格说,此情况不需要重新进行窗口布局,只不过当ContentInsets发生变化时,需要执行一段渐变动画使窗口的内容过渡到新的ContentInsets下,而这段动画启动动作发生在窗口布局阶段。 - 'params != null',在进入
perfomeTraversals()方法时,params变量被设置为null。当窗口的使用者通过WindowManager.updateViewLayout()函数修改窗口的LayoutParams,或者在预测量阶段通过collectViewAttributes()函数收集到的控件属性使得LayoutParams发生变化时,params将被设置到新的LayoutParams,此时需要将该值通过窗口布局更新到WMS中使其对窗口依照新的属性进行重新布局
3.2 布局窗口前的准备工作
-
hadSurface,布局前是否有一个有效的Surface。 -
surfaceGenerationId,即Surface版本号。每当WMS为窗口重新分配Surface时都会增加版本号。当完成窗口布局后Surface版本号发生变化,在原Surface上创建的HardwareRenderer以及在其上进行的绘制都将失效,因此此变量用于决定窗口布局后是否需要将Surface重新设置到HardwareRenderer以及是否需要进行一次完整绘制
3.3 布局窗口
ViewRootImpl使用'relayoutWindow()'进行窗口布局
private int relayoutWindow(WindowManager.LayoutParams params, int viewVisibility,
boolean insetsPending) throws RemoteException {
float appScale = mAttachInfo.mApplicationScale;
········
int relayoutResult = mWindowSession.relayout(
mWindow, mSeq, params,
(int) (mView.getMeasuredWidth() * appScale + 0.5f),
(int) (mView.getMeasuredHeight() * appScale + 0.5f),
viewVisibility, insetsPending ? WindowManagerGlobal.RELAYOUT_INSETS_PENDING : 0,
mWinFrame, mPendingOverscanInsets, mPendingContentInsets, mPendingVisibleInsets,
mPendingStableInsets, mPendingOutsets, mPendingBackDropFrame,
mPendingMergedConfiguration, mSurface);
········
return relayoutResult;
}
appScale用于在兼容模式下显示一个窗口。当窗口在设备的屏幕尺寸下显示异常时,Android会尝试使用兼容尺寸显示它(例如320x480),此时测量与布局控件树都将以此兼容尺寸为准。
3.4 布局窗口后的处理——Insets
当ContentInsets发生变化时,会进行如下动作:
- 启动一个过渡动画以避免突兀的画面位移
- 保存新的
ContentInsets到mAttachInfo - 通过执行
mView.fitSystemWindows()使控件树应用新的ContentInsets。其实质是设置控件的Padding属性以便在其进行测量、布局与绘制时让出ContentInsets所指定的空间
3.5 布局窗口后的处理——Surface
处理Surface的变化是为了硬件加速而服务的。其原因在于以软件方式进行绘制时可以通过Surface.lockCanvas()函数直接获得,因此仅需在绘制前判断一下Surface.isValid()决定是否绘制即可。而在硬件加速绘制的情况下,将绘制过程委托给HardwareRenderer并且需要将其与一个有效的Surface进行绑定。因此每当Surface的状态变换都需要通知HardwareRenderer
3.6 总结
布局窗口阶段得意进行的原因是控件系统有修改窗口属性的需求。如第一次“遍历”需要确定窗口的尺寸以及一块Surface,预测量结果与窗口档期啊尺寸不一致需要进行窗口尺寸更改,mView可见性发生变化需要将窗口隐藏或显示,LayoutParams发生变化需要WMS以新的参数进行重新布局。而最终测量阶段得意进行的原因是窗口布局阶段确定的窗口尺寸与控件树的期望尺寸不一致,控件树需要对窗口尺寸进行妥协。
4.布局控件树阶段
控件的实际位置与尺寸由View的mLeft、mTop、mRight、mBottom四个成员变量存储的坐标值来表示
布局控件树阶段主要做了两件事:
- 进行控件树布局
- 设置窗口的透明区域
4.1 控件树布局
View.layout()方法主要做了三件事:
- 通过
setFrame()设置布局的4个坐标 - 调用
onLayout(),使子类得到布局变更的通知 - 通知每一个对此空间的布局变化感兴趣的监听者。可以通过调用
View.addOnLayoutChangeListener()加入对此控件的监听
对比测量和布局两个过程:
- 测量确定的是控件的尺寸,并在一定程度上确定了子控件的位置。而布局则是针对测量结果来实施,并最终确定子控件的位置
- 测量结果对布局过程没有约束力。虽说子控件在
onMeasure()中计算出自己的应有尺寸,但是由于layout()是由父控件调用,因此控件的位置尺寸的最终决定权在父控件的手中,测量结果仅仅是一个参考 - 一般来说,子控件的测量结果影响父控件的测量结果,因此测量过程是后序遍历。而父控件的布局结果影响子控件的布局结果(例如位置),所以布局过程是先序遍历
4.2 窗口透明区域
该功能主要是为了SurfaceView服务。所谓的透明区域是指Surface上的一块特定区域,在SurfaceFlinger进行混成时,Surface上的这个块区域将会被忽略,就好像在Surface上打了一个洞:
带有透明区域的窗口与其他窗口合成后的效果 当控件树中存在
SurfaceView时,它会通过调用ViewParent.requestTransparentRegion()方法启用这一机制。这个方法的调用会沿着控件树回溯到ViewRootImpl,并沿途将PFLAG_REQUEST_TRANSPARENT_REGIONS标记加入父控件的mPravateFlags字段。此标记会导致ViewRootImpl完成控件树的布局后将进行透明区域的计算与设置。透明区域的计算由
View.gatherTransparentRegion()完成。透明区域的计算采用了挖洞法,及默认整个窗口都是透明区域,在View.gatherTransparentRegion()遍历到一个控件时,如果这个控件有内容需要绘制,则将其所在的区域从当前透明区域中删除,就好似在纸上裁出一个洞一样。当遍历完成后,剩余的区域就是最终的透明区域。这个透明区域将会被设置到
WMS中,进而被WMS设置给SurfaceFlinger。SurfaceFlinger在进行Surface混合时,本窗口的透明区域部分会被忽略,从而用户能够透过这部分区域看到后面窗口(如SurfaceView的窗口)的内容。注意,
SurfaceView刚好相反,它是将自身并入到透明区。
- 绘制阶段
整个控件树的绘制阶段在performDraw()中执行,同其他阶段一样,绘制也是有可能被跳过的:
-
skipDraw: 当窗口处于动画状态时,skipDraw会被设置为true使得跳过绘制,这是出于性能的考虑,让用户能够有一个平滑的窗口动画体验 -
cancelDraw: 当mView不可见时 -
newSurface:newSurface表明窗口在本次“遍历”中获取了一块Surface(可能是由这是第一次遍历,或者mView从不可见变为可见)。在这种情况下,ViewRootImpl选择通过调用scheduleTraversals()在下次遍历中进行绘制,而不是在本次进行
-
总结
performTraversals()的5个工作阶段的工作流程
由于篇幅限制,深入了解控件树的绘制将放到下一篇继续。
performTraversals()的5个工作阶段的工作流程