Android View绘制原理:绘制流程调度、测算等

本文主要关注View的测量、布局、绘制三个步骤,讨论这三个步骤的执行流程。本文暂不涉及View和Window之间的交互以及Window的管理。在论述完这三个步骤之后,文末以自定义TagGroup为例,讲述如何自定义ViewGroup。

目录

View 树的绘图流程

View树的绘图流程是由核心类:ViewRootImpl 来处理的,ViewRootImpl作为整个控件树的根部,它是控件树正常运作的动力所在,控件的测量、布局、绘制以及输入事件的派发处理都由ViewRootImpl触发。

核心成员变量

这里我主要讲几个Handler:

ViewRootHandler

这是ViewRootImpl调度的核心,其处理的消息事件主要有:
MSG_INVALIDATE、MSG_INVALIDATE_RECT、MSG_RESIZED、MSG_DISPATCH_INPUT_EVENT、MSG_CHECK_FOCUS、MSG_DISPATCH_DRAG_EVENT、MSG_CLOSE_SYSTEM_DIALOGS、MSG_UPDATE_CONFIGURATION等

主要有以下几类:View绘制相关、输入焦点等用户交互相关、系统通知相关。

有经验的同学肯定遇到过这样的场景:动态创建一个View之后,想要直接获取measureWidth 和 measureHeight往往取不到,这个时候我们会通过view.postDelayed()方法去获取。那么,问题来了,为什么这样就能取到呢?

答案就在ViewRootImpl中的ViewRootHandler,view.post--> attachInfo.mHandler.post --> ViewRootImpl ViewRootHandler. 这个Handler保证了当你post的runable被执行到时,view早就测量好了。

public boolean post(Runnable action) {
    final AttachInfo attachInfo = mAttachInfo;
    if (attachInfo != null) {
        return attachInfo.mHandler.post(action);
    }

    // Postpone the runnable until we know on which thread it needs to run.
    // Assume that the runnable will be successfully placed after attach.
    getRunQueue().post(action);
    return true;
}

Choreographer.FrameHandler

Choreographer这个类来控制同步处理输入(Input)、动画(Animation)、绘制(Draw)三个UI操作,这里不得不提一下Choreographer.FrameHandler目的就在于ViewRootImpl中涉及到到的View绘制流程,是通过Choreographer.FrameHandler来进行调度的。具体的调度过程如下:

1、 ViewRootImpl.scheduleTraversals

这个方法会往Choreographer注册类型为Choreographer.CALLBACK_TRAVERSAL的Callback。

// ViewRootImpl.scheduleTraversals 注册callback
mChoreographer.postCallback(
        Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);

2、 Choreographer.FrameHandler

Choreographer.FrameHandler源码如下,主要处理三个信号:
MSG_DO_FRAME:开始渲染下一帧的操作
MSG_DO_SCHEDULE_VSYNC:请求Vsync信号
MSG_DO_SCHEDULE_CALLBACK:请求执行callback

对于这三个信号,Choreographer是有一个调度过程的,最终callback的回调执行都是落实到doFrame()方法上面的。

private final class FrameHandler extends Handler {
    public FrameHandler(Looper looper) {
        super(looper);
    }

    @Override
    public void handleMessage(Message msg) {
        switch (msg.what) {
            case MSG_DO_FRAME:
                doFrame(System.nanoTime(), 0);
                break;
            case MSG_DO_SCHEDULE_VSYNC:
                doScheduleVsync();
                break;
            case MSG_DO_SCHEDULE_CALLBACK:
                doScheduleCallback(msg.arg1);
                break;
        }
    }
}

doFrame执行回调有一个顺序的,顺序依次如下:
Choreographer.CALLBACK_INPUT
Choreographer.CALLBACK_ANIMATION
Choreographer.CALLBACK_TRAVERSAL
Choreographer.CALLBACK_COMMIT

Trace.traceBegin(Trace.TRACE_TAG_VIEW, "Choreographer#doFrame"); 
mFrameInfo.markInputHandlingStart(); 
doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos); 
mFrameInfo.markAnimationsStart(); 
doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos); 
mFrameInfo.markPerformTraversalsStart(); 
doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos); 
doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos);

关于Choreographer,读者可以参考下这篇文章,讲的非常详细:Android Choreographer 源码分析

如何动态去检测APP卡顿

这里简单说一个小窍门,通过Choreographer.getInstance().postFrameCallback() 注册回调,并计算前后两帧的时间差,我们可以测算出APP的掉帧数,从而动态检测APP 卡顿。

Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {

            long lastFrameTimeNanos = 0;
            long currentFrameTimeNanos = 0;

            @Override
            public void doFrame(long frameTimeNanos) {
                if (lastFrameTimeNanos == 0) {
                    lastFrameTimeNanos = frameTimeNanos;
                }
                currentFrameTimeNanos = frameTimeNanos;
                long diffMs = TimeUnit.MILLISECONDS.convert(currentFrameTimeNanos - lastFrameTimeNanos, TimeUnit.NANOSECONDS);
                long droppedCount = 0;
                if (diffMs > 100) {
                    droppedCount = (int) (diffMs / 16.6);
                    String anrLog = collectAnrLog(applicationContext);
                    DjLog.e("Block occur, droppedCount: " + droppedCount + ", anrLog: " + anrLog);
                }
                lastFrameTimeNanos = frameTimeNanos;
                Choreographer.getInstance().postFrameCallback(this);
            }
        });

View树流程控制:performTraversals

整个 View 树的绘图流程在ViewRoot.java类的performTraversals()函数展开,该函数所做 的工作可简单概况为是否需要重新计算视图大小(measure)、是否需要重新安置视图的位置(layout)、以及是否需要重绘(draw),流程图如下:

image

更详细的图示如下:


View树绘制过程.png

performTraversals 方法非常庞大,整个源码在800行左右,看起来会让人吐血。这个方法主要的过程有四个:

预测量阶段
这是进入performTraversals()方法后的第一个阶段,它会对控件树进行第一次测量。测量结果可以通过mView. getMeasuredWidth()/Height()获得。在此阶段中将会计算出控件树为显示其内容所需的尺寸,即期望的窗口尺寸。在这个阶段中,View及其子类的onMeasure()方法将会沿着控件树依次得到回调。

布局窗口阶段
根据预测量的结果,通过IWindowSession.relayout()方法向WMS请求调整窗口的尺寸等属性,这将引发WMS对窗口进行重新布局,并将布局结果返回给ViewRootImpl。

最终测量阶段
预测量的结果是控件树所期望的窗口尺寸。然而由于在WMS中影响窗口布局的因素很多(参考第4章),WMS不一定会将窗口准确地布局为控件树所要求的尺寸,而迫于WMS作为系统服务的强势地位,控件树不得不接受WMS的布局结果。因此在这一阶段,performTraversals()将以窗口的实际尺寸对控件进行最终测量。在这个阶段中,View及其子类的onMeasure()方法将会沿着控件树依次被回调。

布局控件树阶段
完成最终测量之后便可以对控件树进行布局了。测量确定的是控件的尺寸,而布局则是确定控件的位置。在这个阶段中,View及其子类的onLayout()方法将会被回调。

绘制阶段
这是performTraversals()的最终阶段。确定了控件的位置与尺寸后,便可以对控件树进行绘制了。在这个阶段中,View及其子类的onDraw()方法将会被回调。

那问题来了,这个方法什么时候会被触发,或者说Android系统什么时候会对整个View树进行一次全量的操作呢?从源码中,我们可以看到以下几个核心的方法会触发:

  1. requestLayout: 注意在View中也有同样的一个requestLayout方法,view中的requestLayout方法调用的就是ViewRootImpl中的requestLayout,最终触发View树的绘制流程,即 measure-layout-draw;
  2. invalidate:同样的View中也有一个invalidate方法,View中该方法的调用最终调用的也是ViewRootImpl中的方法。有经验的同学肯定知道,invalidate只会触发draw,不会触发measure和 layout。具体的ViewRootImpl会通过变量mLayoutRequested控制是否要进行measure和layout,invalidate操作时这个变量为false
@Override
public void requestLayout() {
    if (!mHandlingLayoutInLayoutRequest) {
        checkThread();
        mLayoutRequested = true;
        scheduleTraversals();
    }
}

void invalidate() {

    ...

    if (!mWillDrawSoon) {
       scheduleTraversals();
    }
}

View 绘制流程函数调用链

image

有几点注意:
• invalidate/postInvalidate 只会触发 draw;
• requestLayout,会触发 measure、layout 和 draw 的过程;
• 它们都是走的 scheduleTraversals -> performTraversals,用不同的标记位来进行区分;
• resume 会触发 invalidate;
• dispatchDraw 是用来绘制 child 的,发生在自己的 onDraw 之后,child 的 draw 之前
Measure 和 Layout 的具体过程

Measure 和 Layout 的具体过程

image

关于Measure过程,不得不详细提一下MeasureSpec。MeasureSpec是一个复合整型变量(32bit),用于指导控件对自身进行测量,它有两个分量:前两位表示SPEC_MODE,后30位表示SPEC_SIZE。SPEC_MODE的取值取决于此控件的LayoutParams.width/height的设置,SPEC_SIZE则是父视图给定的指导大小。

SPEC_MODE有三种模式,具体的计算如下:

MeasureSpec.UNSPECIFIED: 表示控件在进行测量时,可以无视SPEC_SIZE的值。控件可以是它所期望的任意尺寸。

MeasureSpec.EXACTLY: 表示子控件必须为SPEC_SIZE所制定的尺寸。当控件的LayoutParams.width/height为一确定值,或者是MATCH_PARENT时,对应的MeasureSpec参数会使用这个SPEC_MODE。

MeasureSpec.AT_MOST: 表示子控件可以是它所期望的尺寸,但是不得大于SPEC_SIZE。当控件的LayoutParams.width/height为WRAP_CONTENT时,对应的MeasureSpec参数会使用这个SPEC_MODE。

自定义一个TagGroup

讲了这么多,下面我们来实操一下。

需求:自定义一个TagGroup,用来显示一系列标签元素。要求标签样式完全可以自定义,标签间距可在xml中指定,要有最多显示多少行的控制,显示不全时要展示“更多 ...”

样式协定

在attrs.xml中协定样式:

<declare-styleable name="DjTagGroup">
    <attr name="tag_horizontalSpacing" format="dimension" />
    <attr name="tag_verticalSpacing" format="dimension" />
    <attr name="max_row" format="integer"/>
</declare-styleable>

协定接口,用来提供具体的标签元素:

public interface TagViewHolder {
    View getView();
}

自定义Measure过程

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    final int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    final int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    final int heightSize = MeasureSpec.getSize(heightMeasureSpec);

    measureChildren(widthMeasureSpec, heightMeasureSpec);

    int width = 0;
    int height = 0;

    int row = 0; // The row counter.
    int rowWidth = 0; // Calc the current row width.
    int rowMaxHeight = 0; // Calc the max tag height, in current row.

    if (moreTagHolder != null) {
        moreTagMeasureWidth = moreTagHolder.getView().getMeasuredWidth();
        moreTagMeasureHeight = moreTagHolder.getView().getMeasuredHeight();
    }

    final int count = getChildCount();
    for (int i = 0; i < count; i++) {
        final View child = getChildAt(i);
        final int childWidth = child.getMeasuredWidth();
        final int childHeight = child.getMeasuredHeight();

        if (child.getVisibility() != GONE) {
            // judge the max_row
            if (row + 1 >= maxRow && rowWidth + childWidth  > widthSize) {
                break;
            }
            rowWidth += childWidth;
            if (rowWidth > widthSize) { // Next line.
                rowWidth = childWidth; // The next row width.
                height += rowMaxHeight + verticalSpacing;
                rowMaxHeight = childHeight; // The next row max height.
                row++;
            } else { // This line.
                rowMaxHeight = Math.max(rowMaxHeight, childHeight);
            }
            rowWidth += horizontalSpacing;
        }
    }

    // Account for the last row height.
    height += rowMaxHeight;

    // Account for the padding too.
    height += getPaddingTop() + getPaddingBottom();

    // If the tags grouped in one row, set the width to wrap the tags.
    if (row == 0) {
        width = rowWidth;
        width += getPaddingLeft() + getPaddingRight();
    } else {// If the tags grouped exceed one line, set the width to match the parent.
        width = widthSize;
    }

    setMeasuredDimension(widthMode == MeasureSpec.EXACTLY ? widthSize : width,
            heightMode == MeasureSpec.EXACTLY ? heightSize : height);
}

自定义layout过程

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    final int parentLeft = getPaddingLeft();
    final int parentRight = r - l - getPaddingRight();
    final int parentTop = getPaddingTop();
    final int parentBottom = b - t - getPaddingBottom();

    int childLeft = parentLeft;
    int childTop = parentTop;

    int row = 0;
    int rowMaxHeight = 0;

    boolean showMoreTag = false;

    final int count = getChildCount();
    int unTagCount = count;
    if (moreTagHolder != null) {
        unTagCount--;
    }
    for (int i = 0; i < unTagCount; i++) {
        final View child = getChildAt(i);
        final int width = child.getMeasuredWidth();
        final int height = child.getMeasuredHeight();

        if (child.getVisibility() != GONE) {
            if (row + 1 >= maxRow && childLeft + width + (horizontalSpacing + moreTagMeasureWidth)  > parentRight) {
                // 预留一个空位放置moreTag
                showMoreTag = true;
                break;
            }
            if (childLeft + width > parentRight) { // Next line
                childLeft = parentLeft;
                childTop += rowMaxHeight + verticalSpacing;
                rowMaxHeight = height;
                row++;
            } else {
                rowMaxHeight = Math.max(rowMaxHeight, height);
            }

            // this is point
            child.layout(childLeft, childTop, childLeft + width, childTop + height);

            childLeft += width + horizontalSpacing;
        }
    }

    if (showMoreTag) {
        final View child = getChildAt(count - 1);
        final int width = child.getMeasuredWidth();
        final int height = child.getMeasuredHeight();
        child.layout(childLeft, childTop, childLeft + width, childTop + height);
    }
}

使用

在xml中直接引用

<com.xud.tag.DjTagGroup
    android:id="@+id/dj_tag_group"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="16dp"
    app:tag_horizontalSpacing="8dp"
    app:tag_verticalSpacing="8dp"
    app:max_row="4"/>

定义自己的TagViewHolder

public class DjTagViewHolder implements DjTagGroup.TagViewHolder {

    public String content;

    public View rootView;

    public TextView tagView;

    public DjTagViewHolder(View itemView, String content) {
        this.rootView = itemView;
        tagView = itemView.findViewById(R.id.tag);
        tagView.setText(content);

        tagView.setOnClickListener(v -> Toast.makeText(context, "点击了:" + content, Toast.LENGTH_SHORT).show());
    }

    @Override
    public View getView() {
        return rootView;
    }
}

往DjTagGroup直接设置tags

private void initDjTags() {
    String[] tags = TagGenarator.generate(10, 6);
    List<DjTagGroup.TagViewHolder> viewHolders = new ArrayList<>();
    for (String tag: tags) {
        DjTagViewHolder viewHolder = new DjTagViewHolder(LayoutInflater.from(context).inflate(R.layout.view_tag, djTagGroup, false),
                tag);
        viewHolders.add(viewHolder);
    }
    DjTagViewHolder moreHolder = new DjTagViewHolder(LayoutInflater.from(context).inflate(R.layout.view_tag, djTagGroup, false),
            "更多 ...");
    djTagGroup.setTags(viewHolders, moreHolder);
}

实际的效果


源码地址: Github: 自定义View辑录DjCustomView

参考文章
Hencoder: 自定义View相关

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,902评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,037评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,978评论 0 332
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,867评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,763评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,104评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,565评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,236评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,379评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,313评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,363评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,034评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,637评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,719评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,952评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,371评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,948评论 2 341

推荐阅读更多精彩内容