关于 Android 布局与绘制相关逻辑的理解

前言

网上有很多关于自定义 View 的文章,什么 onMeasure() 、 layout() 之类的已经炒的不能再冷了。但是始终,有许多东西是缺失且没有被总结的。
这篇文章主要回答我对以下几个方面的疑惑:

  • ViewGroup 对于 View 到底有没有约束作用?
  • 是谁在调用绘制?
  • 为什么子 View 在 layout() 时拿到的是相对于父容器的位置,那它怎么知道该在哪里绘制?
  • 父容器的各个子 View 是在同一个画布上进行绘制吗?
  • 硬件加速在绘制中的体现?
  • ScrollView 究竟是怎么滚动起来的?
  • ScrollView 在滚动的时候为什么子 View 只需要 onDraw() 一次?那些没显示出来的部分在哪里?

文章主要阐释我自己的理解与实验结论,代码分析只占少数(这屎山代码的 case 实在是太多了,而且有很多是过时了的东西)。
文章的内容没有得到充分验证和深入分析,仅供参考,计算机图形学的大坑不想往里跳((

正文

ViewGroup 于其子 View 的关系

这其实是一个很玄妙的东西。首先,毋庸置疑的是,ViewGroup 对子 View 的 measure 是一个征求意见的过程:“诶,我有这么多空间,这样的分配条件,你怎么要?” 。在 ViewGroup 拿到各子 View 的意见后,ViewGroup 会对数据进行整理,拟定一份“终稿”,并且告诉各个子 View :“你应该把自己放在这里”。
这里就存在一个问题了,layout() 过程并不是一个命令,而仅仅只是告诉子 View ,你应该把自己放在这里,因此子 View 似乎完全可以不遵守要求,把自己随意摆放,随意绘制,那事实是这样的吗?

实验结果证明:只要劫持子 View 的 layout() 方法,子 View 确实可以随意修改自己在父 View 中的位置,但是并不能把自己绘制到父 View 之外(但也有特例)。随意修改位置意味着布局规则会被打破,并且那个乱动的家伙很有可能和别的组件发生重叠,造成不堪入目的效果。

也就是说,ViewGroup 更像是为子 View 提供了一个布局的参考,表示“如果你想要遵守我的布局规则,那么你应该把自己放在xx”,而并非强制把子 View 锁定在要求的位置。而是否遵守这种参考,则是子 View 自己的事情,当然,我觉得把自己乱放的子 View 是没人会用的。

至于为什么子 View 不能绘制在父 View 之外(以及特例),将在下面再阐述。

总结:
父 ViewGroup 对于其子 View 的限制,体现在:布局最大边界,即子 View 无法在父 ViewGroup 的外面展示内容(一般情况下)。
父 ViewGroup 无法限制子 View 在父 View 内部的位置,仅仅只是向子 View 提供“建议”供其参考。

谁在调用绘制?

之所以会好奇这个点,是因为,我已经知道调用重绘的方法是 invalidate(),而调用绘制的方法是 draw(),但是 invalidate() 方法内部并没有调用 draw(),那么 draw() 是由谁调用的?

于是我们可以在 onDraw() 的内部塞一个 Throwable 把调用栈打印出来:

    java.lang.Throwable
        at moe.xzr.test.MyView.onDraw(MyView.kt:18)
        at android.view.View.draw(View.java:22644)
        at android.view.View.updateDisplayListIfDirty(View.java:21519)
        at android.view.View.draw(View.java:22375)
        at android.view.ViewGroup.drawChild(ViewGroup.java:4528)
        at android.view.ViewGroup.dispatchDraw(ViewGroup.java:4289)
        at android.view.View.updateDisplayListIfDirty(View.java:21510)
        at android.view.View.draw(View.java:22375)
        at android.view.ViewGroup.drawChild(ViewGroup.java:4528)
        at android.view.ViewGroup.dispatchDraw(ViewGroup.java:4289)
        at android.view.View.updateDisplayListIfDirty(View.java:21510)
        at android.view.View.draw(View.java:22375)
        at android.view.ViewGroup.drawChild(ViewGroup.java:4528)
        at android.view.ViewGroup.dispatchDraw(ViewGroup.java:4289)
        at android.view.View.updateDisplayListIfDirty(View.java:21510)
        at android.view.View.draw(View.java:22375)
        at android.view.ViewGroup.drawChild(ViewGroup.java:4528)
        at android.view.ViewGroup.dispatchDraw(ViewGroup.java:4289)
        at android.view.View.updateDisplayListIfDirty(View.java:21510)
        at android.view.View.draw(View.java:22375)
        at android.view.ViewGroup.drawChild(ViewGroup.java:4528)
        at android.view.ViewGroup.dispatchDraw(ViewGroup.java:4289)
        at android.view.View.updateDisplayListIfDirty(View.java:21510)
        at android.view.View.draw(View.java:22375)
        at android.view.ViewGroup.drawChild(ViewGroup.java:4528)
        at android.view.ViewGroup.dispatchDraw(ViewGroup.java:4289)
        at android.view.View.draw(View.java:22647)
        at com.android.internal.policy.DecorView.draw(DecorView.java:820)
        at android.view.View.updateDisplayListIfDirty(View.java:21519)
        at android.view.ThreadedRenderer.updateViewTreeDisplayList(ThreadedRenderer.java:534)
        at android.view.ThreadedRenderer.updateRootDisplayList(ThreadedRenderer.java:540)
        at android.view.ThreadedRenderer.draw(ThreadedRenderer.java:616)
        at android.view.ViewRootImpl.draw(ViewRootImpl.java:4421)
        at android.view.ViewRootImpl.performDraw(ViewRootImpl.java:4149)
        at android.view.ViewRootImpl.performTraversals(ViewRootImpl.java:3309)
        at android.view.ViewRootImpl.doTraversal(ViewRootImpl.java:2126)
        at android.view.ViewRootImpl$TraversalRunnable.run(ViewRootImpl.java:8653)
        at android.view.Choreographer$CallbackRecord.run(Choreographer.java:1037)
        at android.view.Choreographer.doCallbacks(Choreographer.java:845)
        at android.view.Choreographer.doFrame(Choreographer.java:780)
        at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:1022)
        at android.os.Handler.handleCallback(Handler.java:938)
        at android.os.Handler.dispatchMessage(Handler.java:99)
        at android.os.Looper.loopOnce(Looper.java:201)
        at android.os.Looper.loop(Looper.java:288)
        at android.app.ActivityThread.main(ActivityThread.java:7839)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1003)

可以看到,draw() 的调用归根结底来源于一次 handler message ,而这一切似乎是由 Choreographer 负责的,而它主要是负责 Vsync ,说白了就是垂直同步,使渲染帧率能够配合屏幕刷新率而不会无限上升直到性能瓶颈,从而为上层应用提供一个稳定的渲染时机。

也就是说,在本质上,invalidate() 仅仅只是将 View 标记上一些 flags ,比如 PFLAG_DIRTY,在下一帧到来时,这些“脏区”会被重新绘制。

总结:invalidate()在微观上并不是实时的,它仅仅只是完成了“做标记”这一件事,而这些需要被重新渲染的区域只有在下一帧到来时,才会被刷新。

绘制的调用过程

这一部分的内容将会尝试回答问题三到五。

大概是怎么绘制的

绘制的过程说简单也简单,就是在画布上画画罢了;说复杂也可以很复杂,因为可以一直下挖到各种硬件的工作与协调。
当然我没有办法了解的很深入,按照我的理解,画画大概是这样一个过程:
(以下过程是对一个 View 来说的)

  1. 得到画布了,准备开始。
  2. 保存当前画布的属性状态 (坐标原点位置,缩放等)。
  3. 根据本组件在 layout() 过程中保存的顶点位置,将画布的坐标原点调整到被画组件“左上角”顶点的位置,并应用“缩放”等其它属性。
  4. 调用 draw() ,进行绘制。这个过程包括绘制自己和绘制子组件(如果是 ViewGroup )。
  5. 绘制完成,将之前保存的属性状态进行还原。

这个过程看着就是这么回事,但是分析起来却能发现它有一些很好的特性:

  1. 移动的是坐标原点位置,因此直接画就会画在需要的地方,而不需要进行额外的(大开销的)偏移计算。
  2. 这个过程有很好的递归特性。由子组件负责移动坐标,父组件不需要关心任何事情。每个组件所做的事情莫过于:把坐标原点从父容器的左上角移动到自己的左上角,作画,然后再把原点移回去。因此父容器的下一个子 View 就可以开启接力,继续这个过程,而不需要任何额外调整。同样的,子组件的子组件也可以重复这个过程绘制自己,而父组件也可以重复这个过程还原出爷爷组件的位置。这个过程本质上是一个深度优先搜索,一边搜索,一边绘制,一边调整画布,而在绘制完之后把画布调回原样,不留下一丝痕迹,因而下一个绘制对象可以轻松且完美的接过画笔
  3. 不再需要绝对坐标。这个过程只需要自己相对于父容器的相对坐标就可以完成,因为子组件只需要负责把坐标原点从父组件的左上角顶点移动到自己左上角顶点。而把原点移动到父组件左上角顶点是父组件自己的事情,和子组件无关。于是层层套娃后,绝对坐标便不再有存在的意义。
  4. 绘制参数具有很好的从父组件向子组件转移的特性。也就是说,改变父组件的属性,比如位置,可以导致子组件的“绝对参数”也一起变(比如在屏幕上的位置),但是呢,这个“绝对参数”在绘制过程中并不存在,绘制过程中起作用的都是相对值。因此,这就做到了父组件能影响子组件,但是这两者间却没有强耦合性,令人感到非常的舒适。

绘制的细节

好了,现在问题三已经得到了解答,想要解答问题四和五就得到代码层面稍微的看一看了。

在较新的设备上,绘制过程与上面的描述大同小异,但是为了使用高贵的硬件加速,在绘制的过程中会使用高贵的 RenderNode + DisplayList 的形式。

/* If an attached view draws to a HW canvas, it may use its RenderNode + DisplayList.
 *
 * If a view is dettached, its DisplayList shouldn't exist. If the canvas isn't
 * HW accelerated, it can't handle drawing RenderNodes.
 */
boolean drawingWithRenderNode = mAttachInfo != null
        && mAttachInfo.mHardwareAccelerated
        && hardwareAcceleratedCanvas;

这个过程我没有进行细致的分析,但是大概就是。
首先,子组件能够拿到父组件传递过来的 Canvas ,但是子组件并不会直接在这个 Canvas 上面进行绘制,而是

public RenderNode updateDisplayListIfDirty() {
    final RecordingCanvas canvas = renderNode.beginRecording(width, height);
    try {
           if (layerType == LAYER_TYPE_SOFTWARE) {
        ......
        } else {
        ......

        if ((mPrivateFlags & PFLAG_SKIP_DRAW) == PFLAG_SKIP_DRAW) {
            dispatchDraw(canvas);
            ......
        } else {
            draw(canvas);
        }
    }
    } finally {
    renderNode.endRecording();
    ......
    }
    ......
}

通过 renderNode.beginRecording 创建一个临时画布,然后把要绘制的东西绘制在这块画布上。而这块画布是属于 renderNode 的,也就是说绘制的内容最终被保存在了 renderNode 中,并向上级方法返回。

boolean draw(Canvas canvas, ViewGroup parent, long drawingTime) {

    final boolean hardwareAcceleratedCanvas = canvas.isHardwareAccelerated();
    ......
    boolean drawingWithRenderNode = mAttachInfo != null
            && mAttachInfo.mHardwareAccelerated
            && hardwareAcceleratedCanvas;

    ......

    RenderNode renderNode = null;
    ......

    if (drawingWithRenderNode) {
        ......
        renderNode = updateDisplayListIfDirty();
        ......
    }

    ......

    if (!drawingWithDrawingCache) {
        if (drawingWithRenderNode) {
            ......
            ((RecordingCanvas) canvas).drawRenderNode(renderNode);
        } else {
            ......
        }
    }
    ......
}

上级方法会使用 ((RecordingCanvas) canvas).drawRenderNode(renderNode); 来把 renderNode 的内容最终画在父组件所给的画布上,而这个方法最终会下探的 native 层中,此处不再继续分析了。

总的来说,子组件会拿到来自父组件的画布对象,但是并不会直接在上面绘制,而是创建一个 RenderNode 并把自己要绘制的内容记录在其中,再通过最终下探到 native 的方法最终将 RenderNode 中所记录的内容绘制在父组件所提供的画布上。

同样,这个过程也是递归的,也就是说子组件拿到的那个所谓“来自父组件的画布”,其实也是由父组件的 renderNode 所提供的。

ok,现在问题四和问题五也得到了解答,在带有硬件加速的新绘制方法下,子组件并不会直接和父组件绘制在同一块画布上,而 RenderNode + DisplayList 的机制,正是硬件加速的体现。

画布的大小?

这是一块承上启下的内容,将会阐释 “为什么子组件不能绘制在父组件的外面” 以及其特殊情况,也会为接下来的 ScrollView 部分进行理论上的铺垫。

首先,来回答第一个小问题,“为什么子组件不能绘制在父组件的外面”。
诶,你可能会说:“因为子组件绘制在父组件的 renderNode 提供的临时画布里,而这个临时画布在创建时是有大小限制的。不信你看,renderNode.beginRecording(width, height),这里不就有宽高吗?”

但是,这就错了,这两个参数并不是决定性的,虽然子组件的 draw() 方法中也能拿到传进来的画布的宽高,但是子组件把东西画在画布的外面并不一定会导致数据丢失,甚至在某些情况下还能把这些在画布外面的东西也显示出来,这个接下来再慢慢分析。

我们可以看看 aosp 在这个方法上方的注释:

/**
 * Starts recording a display list for the render node. All
 * operations performed on the returned canvas are recorded and
 * stored in this display list.
 *
 * {@link #endRecording()} must be called when the recording is finished in order to apply
 * the updated display list. Failing to call {@link #endRecording()} will result in an
 * {@link IllegalStateException} if {@link #beginRecording(int, int)} is called again.
 *
 * @param width  The width of the recording viewport. This will not alter the width of the
 *               RenderNode itself, that must be set with {@link #setPosition(Rect)}.
 * @param height The height of the recording viewport. This will not alter the height of the
 *               RenderNode itself, that must be set with {@link #setPosition(Rect)}.
 * @return A canvas to record drawing operations.
 * @throws IllegalStateException If a recording is already in progress. That is, the previous
 * call to {@link #beginRecording(int, int)} did not call {@link #endRecording()}.
 * @see #endRecording()
 * @see #hasDisplayList()
 */
public @NonNull RecordingCanvas beginRecording(int width, int height) {
    ......
}

嗯,This will not alter the xxx of the RenderNode itself

所以,究竟是为什么,子组件不能绘制在父组件的外面呢,又有没有什么办法能够打破这一限制呢?

接下来的东西可能有点乱,但是我会尽可能详细的去阐释这个过程。

首先,先分析一个最基本的问题:子组件能不能绘制在自己的外面?
这是什么意思?子组件不是有宽高的参数吗,而这个宽高参数不是决定了 renderNode 所提供的临时画布的大小吗,而这个大小正是 onDraw() 中得到的画布的大小。我们上面又说这个大小不是决定性的,但是我们把东西画在这个大小的外面确实又看不见,而我却说这画在外面的东西并不一定丢失?

这一系列的矛盾究竟是怎么回事?

这就不得不提到神奇的 android:clipChildren 属性了。
只要我们把这个属性加到父组件中,那么它的子组件就能够脱离自身宽高的限制,把绘制在“画布大小”之外的内容显示出来。

我们可以来简单分析一下这个过程是怎么发生的。
首先,子组件会询问父组件:“诶你的 android:clipChildren 是不是 true 啊”。
如果为真,那么子组件会为自身的 renderNode 设置 clipToBounds 属性。

void setDisplayListProperties(RenderNode renderNode) {
    if (renderNode != null) {
        ......
        renderNode.setClipToBounds(mParent instanceof ViewGroup
                && ((ViewGroup) mParent).getClipChildren());
        ......
    }
    ......
}

而正是这一操作,真正的将可见绘制区域限制在了画布大小之内。

/**
 * Set whether the Render node should clip itself to its bounds. This defaults to true,
 * and is useful to the renderer in enable quick-rejection of chunks of the tree as well as
 * better partial invalidation support. Clipping can be further restricted or controlled
 * through the combination of this property as well as {@link #setClipRect(Rect)}, which
 * allows for a different clipping rectangle to be used in addition to or instead of the
 * {@link #setPosition(int, int, int, int)} or the RenderNode.
 *
 * @param clipToBounds true if the display list should clip to its bounds, false otherwise.
 * @return True if the value changed, false if the new value was the same as the previous value.
 */
public boolean setClipToBounds(boolean clipToBounds) {
    return nSetClipToBounds(mNativeRenderNode, clipToBounds);
}

从注释可以看到,在 clipToBounds 开启的情况下,为了更好的性能,绘制在画布大小之外的东西应该是会被直接丢弃掉的,而当这个属性没有开启的时候,这些内容不仅不会被丢弃,甚至还有机会显示出来。

这个属性在 android 动画的制作中有着很大的应用,毕竟没有人希望组件移动到原先划定的区域之外就直接消失吧。
有时候,我们在打开了“显示布局边界”后,能看到某个组件在边界之外,或者周围没有边界,往往就是因为这个属性的应用,直接把组件画到不知道哪里去了。

先来整理一下这个过程,然后我们再继续讨论如何把子组件绘制到父组件之外。
(以下内容带有一定推断的成分,没有把代码看完整)
首先,子组件通过 layout() 从父组件处得到自己的位置与长宽。
然后,子组件为自己创建 renderNode ,并打开大小为自身长宽的临时画布,
然后,子组件询问父组件自己是否应该为 renderNode 设置 clipToBounds,如果是,则画到画布大小之外的东西将会被忽略。
最后,移动父组件提供的画布的坐标原点位置到组件所在位置,把 renderNode 中保存的东西画出来。

一点题外话:这个过程有一个上面提及过的特点,那就是每个子组件的绘制是由它们自己负责的,无论是位置、renderNode 生成的画布大小还是其 clipToBounds 属性。子组件完全可以篡改这些东西来不听从父组件的指挥,当然这可能需要劫持和修改一些隐藏 api ,有点麻烦且难以测试😂。

继续,在搞清楚了“子组件能不能绘制在自己的外面”之后,就可以进一步研究该如何把子组件绘制在父组件的外面了。

父组件的 renderNode 肯定也有一个 clipToBounds 属性,而提供给子组件的画布正是在这个 renderNode 中打开的画布。也就是说,子组件无法绘制在父组件外面的原因是,父组件的 renderNode 被设置了 clipToBounds = true ,导致子组件绘制在父组件提供的画布大小之外的内容被 clip 掉了。那么,该如何把父组件的 clipToBounds 设置为 false 呢?可以回顾一下父组件的 clipToBounds 属性的值是从哪里获取的,嗯,是设置爷爷组件的 android:clipChildren

也就是说,想要使子组件能够突破父组件的边界,需要的是,把爷爷组件的 android:clipChildren 属性设置为 false。

这就是我在上面所说的,把子 View 绘制在父 View 之外的 “特例” 。

ScrollView

这一块的内容将会回答最后两个问题,但是在回答这两个问题之前还要进行一点点的铺垫,那就是了解装在 ScrollView 中的组件的状态。

内部组件的大小

ScrollView 会在 onMeasure() 时向内部组件传入对应方向的 MeasureSpec.UNSPECIFIED ,意为组件在对应可滚动方向的大小全由其自己指定,要多大有多大。也就是说,子组件能够拿到一块在对应方向上“无限大”的画布,并完全的舒展自身而并不会因为大小受限而 clip 自己。

普通 View 如何滚动?

其实滚动本质上是一个很简单的东西,它仅仅只是对画布的坐标变换做了一点手脚,从而实现画布内容的平移。
滚动并不是 ScrollView 独有的特性,任何一个 View 都可以通过调用自己的 scrollTo() 或者 scrollBy() 方法来轻松的实现画布的变换,从而滚动自己的内容。

对于一个普通的 View 来说,滚动大概是这样的过程(并未详细验证):

  • 更新所保存的各方向 scroll 的值。
  • 将自身标记为脏区,等待下一次绘制调用。
  • 绘制调用时,根据上面保存的 scroll 值进行额外的画布变换(对应方向平移),然后调用 draw() 真正的把组件绘制出来。当然如果其父组件设置了 android:clipChildren ,还会对画布进行 clip ,当然此时的边界也要算上 scroll 造成的额外偏移了。

由于 onDraw() 是在 draw() 的过程中被调用的,因此直接使用 scrollTo() 滚动 View 自身时,你每滚动一次都可以看到 onDraw() 被调用了一次,这个过程也许会有不小的性能开销。

但是,对于 ScrollView 来说,其内部组件的 onDraw() 只会被调用一次,并不会在滚动时一直调用,这又是咋回事呢?

ScrollView 的滚动

接下来就来好好分析一下为什么 ScrollView 内部组件的 onDraw() 只会被调用一次。

ScrollView 的滚动也是平凡的 scrollTo(),在滚动时也会产生脏区等待下一次绘制调用,绘制调用时也会 draw()draw() 时也会绘制子组件 (dispatchDraw())。于是这晃晃悠悠的就来到了子组件的 draw() 方法,离 onDraw() 只差一点点了。既然子组件的 draw() 都会被多次调用,那 onDraw() 为何不会?

诶,这中间起到阻挡作用的就是上面已经提及到过的 renderNode 。
对于子组件来说,一开始就已经在想要多大有多大的画布上完整绘制了自己,再来调用绘制压根就没有什么可以改变的东西,因此 updateDisplayListIfDirty() 的 dirty 压根就不满足,这个方法会直接返回之前保存了绘制内容的 renderNode ,从而避免了 onDraw() 的多次调用。

那回头看看直接滚动 View 呢?为啥它就需要重新 onDraw() 呢?被直接滚动的 View 的 renderNode 是带有 clip 边界的,边界外的内容是丢失的,因此需要重新在 renderNode 中 onDraw() 才能找回那些被 clip 掉的内容。而作为 ScrollView 内部组件时 View 的 renderNode 中携带的信息是完整的,因此直接返回即可。

ScrollView 到底做了些什么?

很简单,那就是舒适自然的滚动体验,它主要完成的是把各种复杂的触摸事件转换为滚动以及滚动速度的事情。

免责声明:
1.本站所有内容由本站原创、网络转载、消息撰写、网友投稿等几部分组成。
2.本站原创文字内容若未经特别声明,则遵循协议CC3.0共享协议,转载请务必注明原文链接。
3.本站部分来源于网络转载的文章信息是出于传递更多信息之目的,不意味着赞同其观点。
4.本站所有源码与软件均为原作者提供,仅供学习和研究使用。
5.如您对本网站的相关版权有任何异议,或者认为侵犯了您的合法权益,请及时通知我们处理。
火焰兔 » 关于 Android 布局与绘制相关逻辑的理解