看完这篇View绘制原理,和阿里面试官扯皮就没问题了

3,009 阅读12分钟

记得看文章三部曲,点赞,评论,转发。 微信搜索【程序员小安】关注还在移动开发领域苟活的大龄程序员,移动开发“面试系列”文章将在公众号发布。

现象描述

小H最近闲来无事,准备去自己开发的商品详情页看看有没有MM图片,看得正投入时。

在这里插入图片描述
发现logcat中一直在打印log,这就有点尴尬啦。
在这里插入图片描述
小H翻开代码,找到了原因,原来是四级页单行展示Tag时,需要对展示宽度进行测量,具体实现方法是这样的:

1,	获取ViewTreeObserver对象:ViewTreeObserver vto = nameView.getViewTreeObserver();
2,	注册监听vto.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {…;
3,	Remove监听if (vto.isAlive()) { vto.removeOnPreDrawListener(this)…;

结果啪啪啪(16ms一次)的高频率打脸(log),百思不得其解。

在这里插入图片描述
看代码,add监听+ remove监听,很完美的一套传统组合拳,为什么还一直被打脸呢?
在这里插入图片描述
很明显原因是vto.isAlive()是false? 小H脱掉了外套,点了根烟,深吸一口,在烟雾的缭绕下,露出了老司机的笑容,只见他打开了debug模式 :
在这里插入图片描述
1. 初始化 final ViewTreeObserver vto = nameView.getViewTreeObserver();
在这里插入图片描述
因为此时mAttactInfo ==null , return的是mFloatingTreeObserver(临时工)。 2. 查看vto对象中mAlive=true,没有毛病。
在这里插入图片描述
回调public boolean onPreDraw()中再探,此时mAlive = false,我X,怎么是false?
在这里插入图片描述
咦,什么情况下mLive变成false的呢?false了就无法removeListener了(内部有检测机制,false的时候remove会崩溃)。导致nameView的onPreDraw()可能会继续被调用。H老师陷入了沉思。 本文主要剖析下面两个问题:onPreDraw为什么没有Remove掉?onPreDraw为什么会不停的调用。

原因分析

为什么没有remove掉?创建的时候是true,回调的时候mLive为什么变成了false? H老师带着疑问一步步探索。 首先我们发现ViewTreeObserver类中只有一个方法会改变mLive的值:

ViewTreeObserver
private void kill() {
    mAlive = false;
}

那么这个方法又是在什么情况下被调用的呢? 由于mAttactInfo ==null,那么mAttactInfo又是什么时候赋值的呢?又是什么时候把mFloatingTreeObserver开除(kill)的呢?

带着疑问,小H扶了扶眼镜,既然是ViewTreeObserver监听是在View绘制中,那么肯定与View的绘制有关系,先看看View的绘制流程, 在ViewRootImpl的构造方法中mAttachInfo = new View.AttachInfo(mWindowSession,mWindow,display,this,mHandler,this,context); 但是什么时候和ViewTreeObserver关联的呢,先不要着急,继续向下看,在performTraversals() 方法中,

private void performTraversals() {	
final View host = mView;  //DecoView
......

//重点来啦 host.dispatchAttachedToWindow(mAttachInfo,0);//赋值mAttachInfo关联

dispatchAttachedToWindow:
mAttachInfo = info;
if (mFloatingTreeObserver != null) {
    info.mTreeObserver.merge(mFloatingTreeObserver);
	}
ViewTreeObserver:
 void merge(ViewTreeObserver observer) {
if (observer.mOnPreDrawListeners != null) {
    if (mOnPreDrawListeners != null) {
        mOnPreDrawListeners.addAll(observer.mOnPreDrawListeners);
    } else {
        mOnPreDrawListeners = observer.mOnPreDrawListeners;
    }
......
performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
performLayout(lp, mWidth, mHeight);
//注意:在performDraw之前,会触发我们的回调 
boolean cancelDraw = mAttachInfo.mTreeObserver.dispatchOnPreDraw() || !isViewVisible;
//此处调用dispatchOnPreDraw(),  -> 回调listener的.onPreDraw()
performDraw();
......

在dispatchAttachedToWindow方法中把之前添加到mFloatingTreeObserver中的listerners全部merger到info.mTreeObserver中,临时工mFloatingTreeObserver退位让贤并kill自己。调用void kill(){mAlive =false} ,狡兔死,走狗烹。看到这里,小H夹烟的手颤抖了一下,想到自己从前作为CTO时呼风唤雨,如今变成了猿,惊出一身冷汗。

我们商品详情页中在onPreDraw回调中是这样写的:

final ViewTreeObserver vto = nameView.getViewTreeObserver();
public boolean onPreDraw() {
   System.out.println("showCmmdtyTag onPreDraw   w = " + width);
        if (vto.isAlive()) {
            vto.removeOnPreDrawListener(this);
        }
        showTagLayout(view, width, activeTagResultVo, showChoiceTag);
    return true;
}

总结:根据上面的分析,由于在view绑定父View之前给view添加了Listerner(创建临时ViewTreeObserver存放),绑定之后,将View中添加的listener merge到父View传递来的mAttatInfo中,并kill()原来的ViewTreeObserver(vto.isAlive()=false),导致removeOnPreDrawListener永远没有机会执行。

解决方案(mAttachInfo的传递过程)

分析完原因后,同学们都很迫切想知道解决结果,小Y先站了起来,吼道:说了半天你渴不渴啊,我要结果,结果懂吗?解决方案呢? 小H得意的笑道:猴子莫急,我这边提供了两种解决方案: 1.Listener中也使用nameView.getViewTreeObserver()获取VTO 在onPreDraw回调中,使用mAttachInfo中的ViewTreeObserver,如下:

final ViewTreeObserver vto = nameView.getViewTreeObserver();
        vto.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
            @Override
            public boolean onPreDraw() {
                    if (nameView.getViewTreeObserver().isAlive()) {
                        nameView.getViewTreeObserver().removeOnPreDrawListener(this);
                    }
                return true;

使用nameView.getViewTreeObserver()重新获取新的ViewTreeObserver对象,即mAttachInfo.mTreeObserver,它已经从临时工身上merge了所有的Listeners。

2.在mAttachInfo!=null后再addPreDrawListener 2.1调整addPreDrawListener的时机 初始化vot=nameView.getViewTreeObserver()时,如果mAttactInfo已经赋值,那就没有临时工啥事了,就可以规避这个问题了。那么我们移动初始化的位置试试呢 下面小H做了个实验,将vot =nameView.getViewTreeObserver()从onCreatView()->onViewCreated();

在这里插入图片描述
此时在回调onPreDraw()中,vto.isAlive == true,说明mAttactInfo赋值的时间在onCreatView()和onViewCreated()之间。在onViewCreated方法中mAttactInfo已经有值。
在这里插入图片描述
我们再看看Fragment的onCreatView()和onViewCreated()之间到底发生了什么?
在这里插入图片描述
addView()中:dispatchAttachedToWindow 关联了mAttachInfo。 修改后,最终打印:只执行了两次onPreDraw(),侦听被remove成功了。
在这里插入图片描述
从结果可以看出,container.addView(f.mView)之后的生命周期(onViewCreated和onActivityCreated)中注册监听都是可以规避这个问题。说明此mAttachInfo已经赋值。 下面我们看看为什么这样就ok了。

2.2 mAttachInfo传递过程 没有问题了吗?当然我们这个项目场景是没有问题的,但是我们再思考一下,非空的mAttachInfo是从哪里传过来的呢?

我们先整体看看mAttachInfo的传递场景和流程: ViewRootImpl:构造函数中new的mAttachInfo, 场景1,ViewRootImpl传递给DecoView;

performTraversals() {
host.dispatchAttachedToWindow(mAttachInfo, 0) {
	ViewGroup:遍历传给子View
	super.dispatchAttachedToWindow(info, visibility);
    for (int i = 0; i < count; i++) {
    final View child = children[i];
    child.dispatchAttachedToWindow(info, Visibility);
      }
}
}

场景2,中途addView时传递给子View

addView {
if (ai != null){
child.dispatchAttachedToWindow(mAttachInfo, (mViewFlags&VISIBILITY_MASK))
}
}

我们再详细分析一下mAttachInfo的传递,mAttachInfo是爷爷传给老子,老子传给孩子,祖宗(ViewRootImpl)的mAttachInfo 是在构造函数中new的,然后传给decorview,decorview传给孩子们(哪来的孩子?)。中途addView时会再传递给添加的view。 既然我们知道眼前的一切是从performTraversals开始的,那么我们具体看看performTraversals里面dispatchAttachedToWindow什么时候赋值的。

我们先分析下performTraversals()执行流程:

在这里插入图片描述

1.  首先,如果是第一次,则赋值mAttactInfo,将临时的Listeners  merge到mAttachInfo, kill临时工。
2.	将actions 添加到主线程的消息队列中,等待执行。
3.	执行performMeasure,performLayout。
4.	执行dispatchOnPreDraw() onPreDraw() ,执行我们的回调。
5.	performDraw()。

H老师看到mFirst很是开心,荡荡的笑了,第一次?第一次要珍惜啊,所以第一次做的事情比较特殊,比如ViewRootImpl就在第一次的时候把mAttachInfo传给下一代DecorView。那么第一次是在什么时候发生的呢,氛围很重要,有没有情调呢,小宾馆?香格里拉?

在这里插入图片描述
在这里插入图片描述
scheduleTraversals异步消息是在onResume()生命周期之后执行的,我们需要保证performTraversals > addView(fragment) > addListeners,这样mAttachInfo才能传下去,才能使用第二种方案,如何保证呢?

2.3 fragment的add时机 我们商品详情页创建CmmdtyBaseInfoFragment(fragment生命周期执行)是接口回调之后,肯定在Activity生命周期创建之后了,更准确的说应该是在第一次执行performTraversals()之后,所以没有啥问题。 但是,如果将Fragment创建放在onCreate中init呢?

在这里插入图片描述
看吧,打印又开始啪啪啪了,所以在onCreate中就不行的。因为performTraversals > addView(fragment) 无法满足。 Fragment在接口回调中add:fragment commit在activity生命周期之后,所以排到了performTraversals之后。 在activity生命周期中add:fragment生命周期与activity同步,提前执行了onCreateView。

深入一点(View测绘过程)

上面已经把无法remove preDrawListener的原因找到了,并且提供了解决方案,这个问题已经解决了,到此结束。下面所有的场景,我们就不要再想着mAttachInfo了,会乱,真的。

同学们听的都很开心,以为可以提前放学了,那真是太年轻,太天真了,难道没有其他疑问吗?幕后黑手找到了吗? 小H老师挠了挠所剩无几的头发问道:为什么第一次measure的w=0? 小Y:啊,不知道。。。 老师:如果view隐藏了,是不是还是会一直刷? 小Y:额,不知道 老师:view.post() 是什么时候执行的,能拿到view.width()吗? 小Y:呵呵。。。 老师您知道吗? 老师:一问三不知,老师啊,哈哈,我也不知道,你请坐下吧,上课不许玩手机了。

在这里插入图片描述
这里先梳理下相关测量顺序:performTraversals执行了两次,中间可能会夹杂着post的actions。
在这里插入图片描述
为什么第一次measure w=0? 先看一下布局:

<!-- 左侧图片视频部分 -->
<LinearLayout
    android:layout_width="wrap_content"
    android:layout_height="match_parent"
    android:orientation="horizontal">
    <RelativeLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content">
        <ImageView
            android:id="@+id/iv_main_image"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:src="@drawable/default_background_small" />
    </RelativeLayout>
<!-- 右侧商品基本信息 -->
    <com.test.mobile.cmmdtydetail.ui.customview.MyMyTextview
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>
</LinearLayout>

ImageView这个布局android:layout_width="match_parent",是不是恍然大悟,name的w当然是0啦。但是为什么第二次测量的时候name的宽度又有了呢,答案就在Imageview身上,有因就有果,风起云就淡。。。 看代码,为了动态设置ImageView的宽,代码是这样的:

mMainImageViewGroup = view.findViewById(R.id.rl_main_image);
final ViewTreeObserver viewTreeObserver1 = mPlayVideoBtn.getViewTreeObserver();
viewTreeObserver1.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
    @Override
    public boolean onPreDraw() {
        if (mPlayVideoBtn != null && mPlayVideoBtn.getViewTreeObserver().isAlive()) {
            mPlayVideoBtn.getViewTreeObserver().removeOnPreDrawListener(this);
        }
               ……………
        RelativeLayout.LayoutParams layoutParams = (RelativeLayout.LayoutParams) mMainImageViewGroup.getLayoutParams();
        layoutParams.width = layoutParams.height = imageAreaSize; //什么鬼,设置了宽。
        mMainImageViewGroup.setLayoutParams(layoutParams); //requestLayout 
        return true;
    }
});

在onPreDraw中限制了ImageView的宽!!不再是match_parent了,所以右边的name就有了自己的生存空间,通过setLayoutParams请求requestLayot,刚好在第二次的performTraversals中测量出来。

根据上面的测量顺序图可知,onPreDraw()发生在measure和layout之后,第一次measuer的w = 0—>然后在onPreDraw中修改ImageView的宽—>第二次measuer时,测出name的宽—>最后在name的onPreDraw获取name宽。

再深入一点(屏幕刷新机制) ----越深入越快乐

问题也解决了和原因也找到了,还稍微深入了一点点,但是小H还是不满足,他总是感觉缺少点什么,那可能大概就是G点的深度吧。小H掐灭的手中的烟,陷入了沉思,突然眼睛一亮,仿佛想到了什么。onPreDraw()虽然移除了,但是触发它的黑手我们并没有找到它。啪啪啪是那么的响快,惊醒了沉思中的小H。

小H扫了扫全班女生,果然,还是选择了班花小A来回答问题。 H老师笑着问小A:你睡觉的时候家里有蚊子,你该怎么办? 小A耸耸肩,不暇思索:钻到被窝里面就好啦。 H老师继续问道:然后呢?不热吗? 小A厌烦道:不热啊,空调16°C ,很凉爽。 H老师很无奈,但是天气热,小A的汗水已经渗透衣服慢慢向胸口袭来。H让小A先坐下。

对于小A的回答,H老师自然是不满意的,倒吸一口气,这个时候小S好像想到了什么,突然站了起来,小S是班里比较乖巧的女生,平时话不多,这时突然站了起来,大家目光都投向了她,虽然小S不是最漂亮的,但是胸却是最大的,加上平时很单纯,所以也深受H老师特殊关照。

小S羞答答的说:老师,开始您说是高频率的啪啪啪(60fps)打脸(logcat),为什么我发现在商品tab的时候是60pfs,而在其他(图文/评价/规格)tab的时候只有1fps,难道其他tab是前奏,而商品tab是高潮? 但是进来第一个展示的就是商品tab,总不能一开始就高潮吧。 小S好像显得很有经验。

H老师吐了一口老血:好,放学你留下,到我办公室来做做。。。

在这里插入图片描述
切到商品tab:60fps 啪啪啪噼噼啪啪啪啪啪噼噼啪啪…..
在这里插入图片描述
图片详情tab :1fps 啪 啪 啪 。 。 。
在这里插入图片描述
好了,如果都不追究,那到此就结束了,那就跟小A一样,钻进被窝睡觉了! 毕竟,H老师和小A不一样,小H可是顶级猿,他要找到问题的根本原因,找到那个高潮点,而不限于解决问题,问题的原因可以很简单,但是找到却不是很简单。

终于,放学后,小S怯怯的来到H老师办公室,坐到了老师的大腿上,,,呸呸呸,进入正题,为什么会这样呢?60fps和1fps?这种现象产生的原因是什么呢??有人想半夜去小A姐姐家拍蚊子吗?

其实这个涉及到底层VSync和view 的刷新机制:

1.View在绘制前先向底层注册监听下一个VSync信号,信号到来时回调给app,执行doTraversal(); 2.doTraversal() 方法会调用 performTraversals(); 3.所以啪啪啪肯定是我们在不停的注册监听VSync信息—>更新UI。

在这里插入图片描述
首先应该考虑的是商品tab是否有动画,确实有个动画,跑马灯的滚动,小H满意的笑出了声,三下五除二,跑马灯Gone!走起! 翻车,怎么还是啪啪啪。。。还停不下来了。。。

这下小H 懵逼了,小S同学眨了眨水汪汪的大眼睛,很是可爱,小H更加着急了,如果在小S同学面前开车,哦,不,翻车,那可就丢大了,小H干脆关紧了办公室门,准备一不做二不休!放大招!!只见,小H脱掉了假发,露出了发光得CPU,快速扫描代码,什么xml,java,jar,像一个个前女友在眼前飘过,突然小H眼睛停在了小S胸前,呸呸呸。。。停在了xml中一个自定义View前。

    <!--配送时间-->
<com.test.mobile.cmmdtydetail.ui.customview.LineTextView
    android:id="@+id/cmmdty_distribution"
    android:layout_width="@dimen/public_space_160px"
    android:layout_height="wrap_content"/>
public LineTextView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    init();
}
private void init() {
    this.setPadding(0, 0, 0, 0);
}
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    init();
    drawText(canvas);
}

在这里插入图片描述
无限循环了,所以商品tab 60fps 打印一次log;而其他tab之所以是1fps,是因为标题上有个1s滴答一下的闹钟。 同学们一般会去关心activity中的业务逻辑问题,却很少会关注自定义View中的小问题,毕竟轮子造好了,能转就可以啦。

小H对这块进行优化: 优化前:

在这里插入图片描述
优化后:
在这里插入图片描述
CUP 从10%降到了0% 。 屏幕的刷新三步走:CPU 计算屏幕数据、GPU 进一步处理和缓存、最后 display再将缓存中(buffer)的屏幕数据显示出来。

1, 这个循环在主线程中,为什么没有引起阻塞? 2, 其他tab的时候商品View是gone的,为什么会回调商品View的onPreDraw()? App并不是每隔16ms都刷新一次的,首先需要App向底层注册监听VSync信号,而只有当View发起刷新请求时,才会向底层注册监听VSync,App会在下一个VSync信号到达时执行doTraversal() –>performTraversals()->onPreDraw()(啪)。


微信搜索【程序员小安】“面试系列(java&andriod)”文章将在公众号同步发布。

在这里插入图片描述