记得看文章三部曲,点赞,评论,转发。
微信搜索【程序员小安】关注还在移动开发领域苟活的大龄程序员,移动开发“面试系列”文章将在公众号发布。
该篇文章由韦云亮同学提供
小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永远没有机会执行。
分析完原因后,同学们都很迫切想知道解决结果,小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。
上面已经把无法remove preDrawListener的原因找到了,并且提供了解决方案,这个问题已经解决了,到此结束。下面所有的场景,我们就不要再想着mAttachInfo了,会乱,真的。
同学们听的都很开心,以为可以提前放学了,那真是太年轻,太天真了,难道没有其他疑问吗?幕后黑手找到了吗?
小H老师挠了挠所剩无几的头发问道:为什么第一次measure的w=0?
小Y:啊,不知道。。。
老师:如果view隐藏了,是不是还是会一直刷?
小Y:额,不知道
老师:view.post() 是什么时候执行的,能拿到view.width()吗?
小Y:呵呵。。。 老师您知道吗?
老师:一问三不知,老师啊,哈哈,我也不知道,你请坐下吧,上课不许玩手机了。
这里先梳理下相关测量顺序:performTraversals执行了两次,中间可能会夹杂着post的actions。
为什么第一次measure w=0?
先看一下布局:
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前。
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()(啪)。