然而在这个步骤之前还有比较重要的准备步骤,onAttachWindow (View绑定窗口的绘制信息)以及onApplyWindowInsets(分发窗体的间距消费),硬件渲染的准备。当View剥离出View树进行销毁,就会调用onDetachWindow周期。
本文就围绕准备绘制的前三点进行分析。
当通过setContentView完成了View的实例化后,此时执行完了Activity的onCreate生命周期。就会走到onResume生命周期中。
AttachInfo实际上就是整个Android Framework在进行View绘制流程中绑定的一个全局的信息。它决定了整个Android整个View 树该怎么渲染。下面介绍一下核心的属性:
差不多这些就够了。能看到这些所有的信息都是View在绘制流程中需要注意的。
这里面完成的事情,除去性能跟踪的逻辑,如下三件大事情,源码分散开了,这里统筹起来:
1.根据当前从DisplayManager获取到的Display的状态,绑定到AttachInfo中。并且把ViewRootImpl的Handler注册到DisplayManager中监听回调。从Display中获取当前屏幕的兼容信息,并获取坐标转化器。如果获取到,说明此时需要屏幕中的内容需要进行缩放。最后把这些信息都存放到AttachInfo中的mApplicationScale,并在AttachInfo记录根部View(DecorView)
3.清空AccessibilityService(Android的无障碍服务)的焦点。并且校验服务是否还在链接监听中。
4.如果还没有InputChannel,则创建一个InputChannel,并且进行点击事件的监听。这个对象就是通过socket监听IMS服务发送的点击事件,最后传递到我们的Activity中。关于这个对象的设计,我们暂时不去深究,后面会有专门的IMS的源码解析专题。
我们就能看到这个过程中,就能看到为什么异步调用requestLayout方法就爆异常。在每一次调用requestLayout都会调用checkThread进行一次是否是主线程调用的。
如果正在走onLayout的方法,mHandlingLayoutInLayoutRequest的标志位为true,禁止调用requestLayout。如果调用了requestlayout之后,mLayoutRequested就会设置为true。最后调用scheduleTraversals,进行Handler下一个Looper的处理View树的遍历和绘制。
关键还是View 树的遍历绘制流程,为了可以彻底的理解这个过程,我们看看Handler的同步屏障运作原理。
实际上,每一个从Handler入队的消息,target都是指向Handler,其目的就是为了回调到Handler的回调中。而最大区别就是这里,普通的消息设置了target,而消息屏障的消息则没有设置target。
能看到接受了Vsync信号回调后的Message,就是一个Asynchronous异步消息。能够在同步屏障内执行。
其实这就是Android是如何把View的绘制消息尽可能的提高消息处理的优先级原理。其原理和flutter的绘制机制十分相似。既然知道mTraversalRunnable是如何执行的,我们看看runnable内部做了什么。
能看到,在这个过程中首先通过removeSyncBarrier移除了mTraversalBarrier这个同步屏障。这样就能继续执行Handler的接下来的消息。但是这个时候还在Handler的执行的一个方法中,所以并不会被其他消息打断View树的绘制优先级。
接下来就是整个View树的绘制核心,performTraversals。
这个方法十分长,这里我们把它分为4大部分,绘制准备,onAttachWindow绑定窗口,onApplyWindowInsets分发窗体间距,准备硬件渲染Surface,onMeasure,onLayout,onDraw。
接下来,我这边先抛开硬件渲染的流程,集中理解软件渲染的流程。
在这个过程中,我们可以把情况分为2种,第一种ViewRootImpl首次渲染,第二种ViewRootImpl非首次渲染。
首次渲染可以分为如下几个步骤:
1.如果通过shouldUseDisplaySize判断到此时是System的ui,如statusbar,音量等,就会设置desiredWindowWidth和desiredWindowHeight为屏幕的宽高。
4.调用dispatchAttachedToWindow 分发从当前的根部View(DecorView)开始,往下进行onAttachWindow的方法。
如果非首次渲染:步骤就简单很多了:
如果不可见,则清空无障碍服务的焦点。
在这个准备流程中,值得注意的点有三点:
让我们一一来解析一边。
我们可以看到实际上第一次计算窗体的区域,是根据SystemUI的标志位进行了处理:
接下来outStableInsets 稳定区域嵌入区域,内容嵌入区域。也就是说基于这些嵌入区域垫在左边,接着的位置才是真正的Stable,Content区域。
其他的逻辑暂时不管。我们可以总结了一点,mUnrestricted是最大的扫描区域。其次是restricted区域,可以包含导航栏以及状态栏,但是这两个区域可以内容重叠。接下里就是Stable区域,也就是稳定内容区域。实际上不同沉浸式模式就是改变ViewRootImpl显示区域在这三个显示区域之间切换。
由于在ViewRootImpl中第一个布局是一个DecorView,它同时是一个ViewGroup也是一个FrameLayout.能看到这个付哦凑成很简单,实际上就是遍历绑定在ViewGroup中所有子View的dispatchAttachedToWindow方法。
1.首先把ViewRootImpl的AttachInfo分发到View的mAttachInfo中,接着继续分发到View的浮层OverLayer中。这个浮层挺有用的,不占用过多层级,适合给View添加没有任何行为的图标等。
2.把View自己的mFloatingTreeObserver(如果存在)也就是ViewTreeObserver对象,合并到全局的ViewRootImpl的ViewTreeObserver监听中。
4.判断是否打开了通过setScrollContainer方法打开了PFLAG_SCROLL_CONTAINER标志位。这个标志位设定了View是否能在键盘弹出的时候进行向上移动。其效果和adjustPan类似。也可以通过xml的android:isScrollContainer设置。
5.执行View中通过post方法设置进来的Runnable方法。有一种使用方式十分常见:
这样就能保证这个Runnable对象在主线程中处理。其原理很简单:
实际上就是判断如果当前的View绑定了mAttachInfo,则把事件委托给ViewRootImpl的Handler处理。否则将会把事件预存到HandlerActionQueue中,直到真正开始渲染之前消费。
能看到只要有一个View的mKeepScreenOn为true则全局为ture。如果有一个View设置了SystemUI可见那么全局可见,其实这里是收集每一个View对SystemUI设置的标志位行为。
这里面的工作不多,主要是处理Drawable中设置了几种状态的情况,选定一种;并且打上一个标志位,告诉Accessibility无障碍服务整个View层次结构重建了;重新构建View 的ViewOutlineProvider,也就是外框(可以用于实现圆角,QMUI也是通过这种方式实现的);如果打上了foucs标志位,则告诉InputMethodManager(软键盘管理器)以此View为焦点弹出。
10.调用onVisibilityChanged方法回调。我们编写View的时候可以重写这个方法监听是否可见。
11.在上面添加了PFLAG_DRAWABLE_STATE_DIRTY标志位。此时就会执行refreshDrawableState 刷新Drawable的状态内容。
实际上这里的逻辑和上面的Window的Attach步骤几乎一致。说明只要Window从不可视到可视都会尝试的显示一次滚轮。
1.在ViewRootImpl中也有自己的RunQueue。也是通过post的方法传递进来。不过这个任务队列是专门用来处理从IMS接受到的点击事件等。
不管哪一种情况都会走到measureHierarchy方法中,重新进行通过performMeasure对View树中每一个层级中View的宽高的变化校验。关于performMeasure相关的内容,我们放到下一篇区聊聊。
4.接着处理这个标志位,windowShouldResize。windowShouldResize标志位代表着窗口是否重新计算大小。这里的判断mWindowFrame是宽高比窗口的小同时和当前的宽高不相同则为true。如果ViewRootImpl发现Activity是重新登陆或者第一次登陆也会强制设置为true。
这个过程中值得注意的是Inset的分发。
那么有三个函数值得注意:
能看到实际上这个过程就是把Window中每一种区域的Inset都设置到WindowInsets中。
实际上就把刘海屏幕的距离屏幕的间距区域给抹平了。
其实ViewGroup很简单,先处理View的dispatchApplyWindowInsets方法。接着遍历每一个子View的dispatchApplyWindowInsets方法。判断这个View是否需要消费Inset,一旦是需要消费就跳出遍历。
能看到在WindowInsets中只要有4个区域的需要消费了就是true。那么我们注重看一下View的dispatchApplyWindowInsets方法。
这里其实很简单,每一次经历这个方法先打开PFLAG3_APPLYING_INSETS标志位后关闭。如果有mOnApplyWindowInsetsListener的回调,则根据回调onApplyWindowInsets的结果返回。这里关注一下,默认情况onApplyWindowInsets方法。
注意在这个方法中,会判断PFLAG3_FITTING_SYSTEM_WINDOWS标志位是否开启。也就会走到if上方的分支。注意这里传入到fitSystemWindowsInt中进行判断的,实际上是整个WindowInsets中的mSystemWindowInsets对象,但是这里并不是操作这个对象,而是拷贝一份进行判断。因此不会影响到WindowInsets获取到的mSystemWindowInsets数值。
由于每一次经历dispatchApplyInsets都会打开PFLAG3_APPLYING_INSETS标志位,就会走到下面的分支。
在这里就会判断FITS_SYSTEM_WINDOWS这个标志位是否开启,没有开启则返回false。这个标志位是什么时候开启的呢?其实就是我们熟悉的在xml布局文件中添加的fitsSystemWindows标签:
这里面的关键的行为有2点:
注意这个方法的返回,决定了WindowInsets中的consumeSystemWindowInsets方法是否执行。
当返回后就调用isSystemWindowInsetsConsumed进行判断:
很简单,就是判断一次标志位是否打开。
到这里,似乎还是可能对WindowInsets是什么,以及计算了什么东西还有点迷惑。我们来看看WindowInsets中inset方法。
到这里我们能看到,实际上WindowInsets管理了Window中每块区域距离屏幕边缘的区域。为什么说是间距区域呢?实际上可以从addToDisplay中能看到真正的内容区域都是先加上间距区域后才是整个屏幕区域宽高。分别是
注意这里的逻辑,如果判断不需要消费对应区域的Insets间距区,就会通过inset方法WindowInsets对每一个间距进行调整,就能让App应用中的内容抹除这些距离,从而实现如沉浸式的模式,适配刘海屏。
进一步的来看这个方法,实际上是对insets的四个方向的区域都减去对应的大小。让整个Rect变得更小,但是不能低于0.
这一段代码就是整个xml布局文件中fitSystemWindows标志位的核心
这里根据消费后的insets,还剩下多少的insets还是设置整个View默认padding数值。一般来说都是设置了toppadding和bottompadding。如果遇到ScrollView这种可能可能横向滚动的View,也会根据剩下的Insets给当前的View设置padding数值。
那么通过什么判断走到这个方法进行Padding的设置呢?
一般来说,普通的ui显示SYSTEM_UI_LAYOUT_FLAGS这个标志位都是关闭的,因此会走到computeSystemWindowInsets上面的分支。
如果设置了透明状态栏,透明导航栏SYSTEM_UI_LAYOUT_FLAGS这个标志位就会打开,走computeSystemWindowInsets下面的分支。因此会返回2个完全不同的结果到上层,进行internalSetPadding设置Padding。
这样透明状态栏就能返回一个(0,0,0,0)的padding,而普通ui显示,则会返回一个(0,状态栏高度相同的间距区域,0,0)。那么是哪里进行设置的呢?
通过这种方式,让设置了透明标志位天生获取到的四个padding方向大小就是0.而没有透明(也就是沉浸式)的UI带了(0,状态栏高度相同的间距区域,0,0)四个方向的padding。
弄清楚了沉浸式和非沉浸式之间的区别设置的Padding区别后。fitWindowInsets虽然也是通过computeSystemWindowInsets获取到四个方向的padding,但是原理上是不一样的。
而DecorView中makeOptionalFitsSystemWindows就是核心。位于ViewGroup下面这个方法。
换句话说每一个DecorView的第一层级的子View都带上了OPTIONAL_FITS_SYSTEM_WINDOWS标志位,同时因为带上了这个标志位。由于判断到此时是沉浸式模式SYSTEM_UI_LAYOUT_FLAGS打开了。因此默认不给根布局加padding。
而到了自定义的Xml的根布局之后,因为打开了FITS_SYSTEM_WINDOWS标志位,则fitSystemWindowsInt会开始默认处理Insets的逻辑中。又因为没有打开OPTIONAL_FITS_SYSTEM_WINDOWS标志位,最后给当前这个自定义的布局添加了一个Padding。
结合一下上下文,如果此时打开了透明状态栏标志位
如果我们使用的主题是NoActionBar。则会有如下的表现形式:
能看到我们什么都没有做的时候,发现整个内容区域都是置顶的,和状态栏重合了。一般我们都会怎么解决呢?
一般我们都会在根布局加一个fitsSystemWindows的标志位,这样内容布局就会在透明状态栏之下。如下图:
下面是没有fitsSystemWindows标志位,透明状态栏的参数:
1.能看到所有的Insets如mContentInsets,mStableInsets,mVisibleInsets都是左右底为0,上为72.只有代表过扫描区域mOverscanInsets的是0,这些数据都是通过addToDisplay(实际上就是WMS的addWindow)获取到的。
3.而在布局文件中也没有设置padding,所以padding都为0。
4.默认情况下,所有区域的消费只有mWindowDecorInsetsConsumed,也就是Decor内容区域消费了间距。
接下来看看打开了fitsSystemWindows标志位的参数:
能看到实际上这个过程中只有打了fitSystemWindows标志位的View,自带了一个高度差和WindowInsets一致的mPaddingTop。也就符合上述的逻辑。正是因为这个PaddingTop的存在,透明的状态栏下方的颜色就是背景色。
其他的参数其实在addToDisplay中已经决定好了Insets的大小,因此会都一致。
请注意这里的标志位,只有mWindowDecorInsetsConsumed设置为true,其他都为false。由于Android 9.0这些属性和方法不少是hidden的,因此我反射的是Android 7.0的,除了没有刘海区域,几乎逻辑还是一致。
那么问题来了,为什么透明状态栏的标志位一旦打开,整个内容布局就顶上了呢?这里的就需要看下面的relayoutWindow方法了。、
在这里完成的事情有如下几件事:
2.发现如果各个区域可视的状态发生了变化则需要重新分发一次WindowInsets重新给合适的View设置padding。
到这里,View的绘制流程准备就完毕了。
在进行onMeasure之前,会执行比较重要的准备步骤,这里涉及到了整个ViewRootImpl的绘制范围。可以大致分为五个简单的步骤:
本质上,就是调用WMS的addWindow方法。这个过程会把当前的PhoneWindow的远程对象保存到WMS中进行管理。同时会把IMS服务也通过这个方法保存会App端。在这里还做了另一件重要的事情,那就是把窗体中每个不同区域距离屏幕的间距获取出来,返回给App端进行消费处理。
在这之后就会通过Choreographer监听Vsync的同步信号,开始真正的View树遍历与绘制。
dispatchAttachedToWindow本质上是View第一次绑定到整个ViewRootImpl中的View的绘制树中调用的方法。其核心实际上就是绑定同步了贯通ViewRootImpl绘制流程的参数。如WindowInests,窗体是否可见,根部布局,硬件渲染对象,屏幕状态,以及WindowSession的Binder通信者等等。有了这个贯通绘制的上下文,ViewRootImpl就能更好的管理每一个View的绘制。
在分发的过程中,也会对AutoFillManagerService进行初始化。以及对View的外框绘制对象Outline进行初始化。
这个过程实际上就是处理如fitSysytemWindows标志位的状态。本质上是Android窗体之间本身就会和屏幕有自己的间距。但是可以在这个步骤消费掉,抹除这些间距。常见的如刘海屏的适配,透明导航栏和透明状态栏的适配。
而在分发消费Insets的过程中,computeSystemWindowInsets判断了应该计算返回多少大小的Inset区域,进而给当前View四个方向的padding设置对应的数值。这才是fitSysytemWindows的核心思想。
如果设置了透明状态栏,由于判断到SYSTEM_UI_LAYOUT_FLAGS默认是打开(因为这个标志位包含了隐藏导航栏和全屏)就会返回(0,0,0,0)的四个padding数值。普通状态栏则会返回(0,状态栏高度,0,0)给Decorview这个布局。
fitSystemWindows标志位只有在沉浸式模式才有效,是因为在非沉浸式模式下,已经在DecorView的子层级中把这个Insets消费了。永远不会到达我们自定义的布局中进行padding的设置。而fitSystemWindows在沉浸式中起效,主要是因为该View没有打上OPTIONAL_FITS_SYSTEM_WINDOWS标志位,同时打了FITS_SYSTEM_WINDOWS标志位。
当我们准备好了Window的大小,以及距离绘制区域的padding数值,就开始把数据交给WMS的relayoutWindow方法,基于整个Android的系统的计算。其中状态栏,和内容区域的真正摆放位置也是在这个方法中决定的。
初始化可以分为如下3个步骤:
销毁则是
记住硬件渲染对象初始化3个步骤以及销毁。之后会有专题进行分析。
之前零零散散的知识和文章可以看到要开始串联起来了。这个沉浸式和非沉浸式的计算模式倒是有点绕。不过明白了之后,以前感觉黑箱的操作也明了了。也知道在5年前刚入行时候,感觉在低版本机子设置paddingTop从而达到沉浸式的样式有点low觉得背后有什么魔法。实际上经过文章一分析,确实也是靠着padding做事情,看来很多东西看起来复杂,实际上也不过如此。