去年面试饿了么的时候吧,被问到了个技术问题。
面试官:听说你做过自动化埋点,那么我们聊聊view的曝光监控吧。
我:之前我是把我们广告的曝光监控放在广告的模型层,然后在bindview的时候做一次曝光的,然后内部做了一次曝光防抖动,避免多次曝光。
面试官:你这样就意味着快速滑动的情况下也会计算一次曝光了,如果我需要的是一个停留超过1.5s同时出现超过view的一半作为有效曝光呢。
我:
来个背景音乐吧。
面试官:回去等通知吧。
要解决问题,先归纳下都有那些问题.
先解决RecyclerView的1.5s这个问题,大家第一个想到的可能都是addOnScrollListener,然后通过layoutmanager计算可见区域,之后计算两次滑动之后的差异区间。但是不好意思,在下不可能这么简单的被你们猜透。
override fun onAttachedToWindow() {
super.onAttachedToWindow()
exposeChecker.updateStartTime()
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
onExpose()
exposeChecker.updateStartTime()
}
我看到这两个方法在RecyclerView内部会在View移动出可视区域的时候被触发。但是为什么呢???带着问题分析源代码。
如果各位关心过view的绘制流程,那么应该都知道这两个方法。这两个方法会在页面绑定到window的时候被触发,核心源代码在ViewRootimp的 host.dispatchVisibilityAggregated(viewVisibility == View.VISIBLE);
被触发之后,host就是我们的Activity的DecorView。
mChildHelper = new ChildHelper(new ChildHelper.Callback(){
@Override
public void addView(View child, int index) {
if (VERBOSE_TRACING) {
TraceCompat.beginSection("RV addView");
}
RecyclerView.this.addView(child, index);
if (VERBOSE_TRACING) {
TraceCompat.endSection();
}
dispatchChildAttached(child);
}
@Override
public void attachViewToParent(View child, int index,
ViewGroup.LayoutParams layoutParams) {
final ViewHolder vh = getChildViewHolderInt(child);
if (vh != null) {
if (!vh.isTmpDetached() && !vh.shouldIgnore()) {
throw new IllegalArgumentException("Called attach on a child which is not"
+ " detached: " + vh + exceptionLabel());
}
if (DEBUG) {
Log.d(TAG, "reAttach " + vh);
}
vh.clearTmpDetachFlag();
}
RecyclerView.this.attachViewToParent(child, index, layoutParams);
}
}
ChildHelper是RecyclerView内部负责专门管理所有子View的一个帮助类。其中通过暴露了接口回调的方式让它和RecyclerView可以绑定到一起。其中我们可以看到当child的add,attach都会触发attachViewToParent,重头戏自然在这个地方,而这个核心源在ViewGroup内了,我们继续看。
protected void removeDetachedView(View child, boolean animate) {
if (mTransition != null) {
mTransition.removeChild(this, child);
}
if (child == mFocused) {
child.clearFocus();
}
if (child == mDefaultFocus) {
clearDefaultFocus(child);
}
if (child == mFocusedInCluster) {
clearFocusedInCluster(child);
}
child.clearAccessibilityFocus();
cancelTouchTarget(child);
cancelHoverTarget(child);
if ((animate && child.getAnimation() != null) ||
(mTransitioningViews != null && mTransitioningViews.contains(child))) {
addDisappearingView(child);
} else if (child.mAttachInfo != null) {
child.dispatchDetachedFromWindow();
}
if (child.hasTransientState()) {
childHasTransientStateChanged(child, false);
}
dispatchViewRemoved(child);
}
protected void attachViewToParent(View child, int index, LayoutParams params) {
child.mLayoutParams = params;
if (index < 0) {
index = mChildrenCount;
}
addInArray(child, index);
child.mParent = this;
child.mPrivateFlags = (child.mPrivateFlags & ~PFLAG_DIRTY_MASK
& ~PFLAG_DRAWING_CACHE_VALID)
| PFLAG_DRAWN | PFLAG_INVALIDATED;
this.mPrivateFlags |= PFLAG_INVALIDATED;
if (child.hasFocus()) {
requestChildFocus(child, child.findFocus());
}
dispatchVisibilityAggregated(isAttachedToWindow() && getWindowVisibility() == VISIBLE
&& isShown());
notifySubtreeAccessibilityStateChangedIfNeeded();
}
@Override
boolean dispatchVisibilityAggregated(boolean isVisible) {
isVisible = super.dispatchVisibilityAggregated(isVisible);
final int count = mChildrenCount;
final View[] children = mChildren;
for (int i = 0; i < count; i++) {
// Only dispatch to visible children. Not visible children and their subtrees already
// know that they aren't visible and that's not going to change as a result of
// whatever triggered this dispatch.
if (children[i].getVisibility() == VISIBLE) {
children[i].dispatchVisibilityAggregated(isVisible);
}
}
return isVisible;
}
其中dispatchVisibilityAggregated就是我们最前面说的ViewRoot所触发的ViewGroup内的方法,会逐层向下view分发View的attach方法。那么也就是当RecyclerView的子控件被添加到RecyclerView上时,就会触发子view的attachToWindow方法。
剩下来的就是View的detch方法是在哪里被触发的呢,这个就是要看recyclerview的另外一个方法了,就是tryGetViewHolderForPositionByDeadline了。
@Nullable
ViewHolder tryGetViewHolderForPositionByDeadline(int position,
boolean dryRun, long deadlineNs) {
if (position < 0 || position >= mState.getItemCount()) {
throw new IndexOutOfBoundsException("Invalid item position " + position
+ "(" + position + "). Item count:" + mState.getItemCount()
+ exceptionLabel());
}
boolean fromScrapOrHiddenOrCache = false;
ViewHolder holder = null;
// 0) If there is a changed scrap, try to find from there
if (mState.isPreLayout()) {
holder = getChangedScrapViewForPosition(position);
fromScrapOrHiddenOrCache = holder != null;
}
// 1) Find by position from scrap/hidden list/cache
if (holder == null) {
holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
if (holder != null) {
if (!validateViewHolderForOffsetPosition(holder)) {
// recycle holder (and unscrap if relevant) since it can't be used
if (!dryRun) {
// we would like to recycle this but need to make sure it is not used by
// animation logic etc.
holder.addFlags(ViewHolder.FLAG_INVALID);
if (holder.isScrap()) {
removeDetachedView(holder.itemView, false);
holder.unScrap();
} else if (holder.wasReturnedFromScrap()) {
holder.clearReturnedFromScrapFlag();
}
recycleViewHolderInternal(holder);
}
holder = null;
} else {
fromScrapOrHiddenOrCache = true;
}
}
}
........
return holder;
}
当ViewHolder要被回收的时候就会触发RecyclerView的tryGetViewHolderForPositionByDeadline这个方法,然后我们可以观察到当holder.isScrap()的时候会removeDetachedView(holder.itemView, false);而这个正好触发了子项的viewDetch方法。
从上面的代码分析完之后,我们可以在onAttachedToWindow的方法尾部打上第一个曝光开始的节点,在onDetachedFromWindow的方法下面埋下曝光结束的方法,计算他们的差值,如果当值大于1.5s之后,则调用接口。
这个吧,说起来有点丢脸,我google查出来的,其中核心在于 view.getLocalVisibleRect,这个方法会返回当前的view是否出现在window上了。
fun View.isCover(): Boolean {
var view = this
val currentViewRect = Rect()
val partVisible: Boolean = view.getLocalVisibleRect(currentViewRect)
val totalHeightVisible =
currentViewRect.bottom - currentViewRect.top >= view.measuredHeight
val totalWidthVisible =
currentViewRect.right - currentViewRect.left >= view.measuredWidth
val totalViewVisible = partVisible && totalHeightVisible && totalWidthVisible
if (!totalViewVisible)
return true
while (view.parent is ViewGroup) {
val currentParent = view.parent as ViewGroup
if (currentParent.visibility != View.VISIBLE) //if the parent of view is not visible,return true
return true
val start = view.indexOfViewInParent(currentParent)
for (i in start + 1 until currentParent.childCount) {
val viewRect = Rect()
view.getGlobalVisibleRect(viewRect)
val otherView = currentParent.getChildAt(i)
val otherViewRect = Rect()
otherView.getGlobalVisibleRect(otherViewRect)
if (Rect.intersects(viewRect, otherViewRect)) {
//if view intersects its older brother(covered),return true
return true
}
}
view = currentParent
}
return false
}
fun View.indexOfViewInParent(parent: ViewGroup): Int {
var index = 0
while (index < parent.childCount) {
if (parent.getChildAt(index) === this) break
index++
}
return index
}
凡事还是不能忽略到页面切换,当页面切换的时候,我们需要重新计算页面的曝光,你说对不对,最简单的方式是什么呢。
不知道各位有没有关心过viewTree里面的onWindowFocusChanged这个方法,其实当页面切换的情况下,就会触发这个方法。
核心原理其实也是ViewRootImp的handleWindowFocusChanged这个方法会向下分发是否脱离window的方法,然后当接受到IWindow.Stub接受到了WMS的信号之后,则会给ViewRootImp发送一个message,然后从ViewRootImp开始向下分发view变化的生命周期。
override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
super.onWindowFocusChanged(hasWindowFocus)
if (hasWindowFocus) {
exposeChecker.updateStartTime()
} else {
onExpose()
}
}
总结性结论咯,也就是我们只要在ViewHolder的控件最外面包裹一个我们自定义的Layout,然后通过接口回调的方式,我们就能监控到view的有效曝光时间了。
我觉得即使面试失败的情况下,我们也还是需要在其中学习到一些东西的,毕竟机会还是给有准备的人。当然据我现在所知,应该饿了么用的是阿里的那套控件曝光自动化埋点的方案,还是有些不同的。
面试官:老哥那么我们继续探讨下这个问题啊。Scrollview和NestScrollView怎么监控呢。
我:???黑人老哥又特么来了。
还是和上篇文章一样,我们先看下要解决哪些问题。
一般人肯定告诉你,这个你自定义个scrollview,然后在onScrollChanged实现个滑动监听的回调什么的。不好意思,我偏不,带你看看另外一个神奇的方法。
先给大家介绍下ViewTreeObserver里面所包含的一些接口。
内部类接口 | 备注 |
---|---|
ViewTreeObserver.OnPreDrawListener | 当视图树将要被绘制时,会调用的接口 |
ViewTreeObserver.OnGlobalLayoutListener | 当视图树的布局发生改变或者View在视图树的可见状态发生改变时会调用的接口 |
ViewTreeObserver.OnGlobalFocusChangeListener | 当一个视图树的焦点状态改变时,会调用的接口 |
ViewTreeObserver.OnScrollChangedListener | 当视图树的一些组件发生滚动时会调用的接口 |
ViewTreeObserver.OnTouchModeChangeListener | 当视图树的触摸模式发生改变时,会调用的接口格 |
各位老哥有没有发现一些奇怪的东西混在里面,哈哈哈。
理论上来说,所有视图状态之类的都是和ViewRootImp相关的。特别是ViewTreeObserver相关的,所以我们的源码分析也是从ViewRootImp开始的。
class ViewRootImp {
// 根视图绘制
private boolean draw(boolean fullRedrawNeeded) {
Surface surface = mSurface;
if (!surface.isValid()) {
return false;
}
if (DEBUG_FPS) {
trackFPS();
}
if (!sFirstDrawComplete) {
synchronized (sFirstDrawHandlers) {
sFirstDrawComplete = true;
final int count = sFirstDrawHandlers.size();
for (int i = 0; i< count; i++) {
mHandler.post(sFirstDrawHandlers.get(i));
}
}
}
scrollToRectOrFocus(null, false);
if (mAttachInfo.mViewScrollChanged) {
mAttachInfo.mViewScrollChanged = false;
// 调用viewtree的滑动监听
mAttachInfo.mTreeObserver.dispatchOnScrollChanged();
}
.....
return useAsyncReport;
}
}
上面的代码可以看出,当mAttachInfo.mViewScrollChanged这个状态位被设置成true的情况下,就会通知viewTree调用滑动监听了。 那么我们的切入点就很简单了,什么时候谁把这个值设置成ture了,是不是就会触发滑动监听了呢。
class View {
final static class AttachInfo {
/**
* Set to true if a view has been scrolled.
*/
@UnsupportedAppUsage
boolean mViewScrollChanged;
}
/**
* This is called in response to an internal scroll in this view (i.e., the
* view scrolled its own contents). This is typically as a result of
* {@link #scrollBy(int, int)} or {@link #scrollTo(int, int)} having been
* called.
*
* @param l Current horizontal scroll origin.
* @param t Current vertical scroll origin.
* @param oldl Previous horizontal scroll origin.
* @param oldt Previous vertical scroll origin.
*/
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
notifySubtreeAccessibilityStateChangedIfNeeded();
if (AccessibilityManager.getInstance(mContext).isEnabled()) {
postSendViewScrolledAccessibilityEventCallback(l - oldl, t - oldt);
}
mBackgroundSizeChanged = true;
mDefaultFocusHighlightSizeChanged = true;
if (mForegroundInfo != null) {
mForegroundInfo.mBoundsChanged = true;
}
final AttachInfo ai = mAttachInfo;
if (ai != null) {
ai.mViewScrollChanged = true;
}
if (mListenerInfo != null && mListenerInfo.mOnScrollChangeListener != null) {
mListenerInfo.mOnScrollChangeListener.onScrollChange(this, l, t, oldl, oldt);
}
}
}
View.AttachInfo是View的内部类,其注释已经描述了,当view滑动的时候把这个值设置成true。onScrollChanged也是View的protected的方法,而当ScrollView和NestScrollView的滑动状态被改变的时候就会调用这个方法,而这个方法内则就会把状态设置成true。
经过在下的测试吧,OnScrollChangedListener在ScrollView和NestScrollView滑动的时候都会触发回调哦。而上述代码分析,则可以说明当两个滑动组件滑动的时候就会触发对应的回调监听。
这个监控方法还是和上篇文章一样,请各位大佬直接看上篇文章就好了。
先回到之前的文章提到onAttachedToWindow
onDetachedFromWindow
的两个方法,这两个可以用吗?答案肯定是不行的。那么我们应该怎么办呢??
没有枪没有炮,还是自己造吧。
interface ExposeViewAdapter {
fun setExposeListener(listener: (Float) -> Unit)
fun setExposeListener(listener: OnExposeListener)
fun onVisibleChange(isCover: Boolean)
}
首先我们可以先提供一个适配器,提供onVisibleChange
这个方法来代替onAttachedToWindow
onDetachedFromWindow
。
class ExposeScrollChangeListener(scrollView: ViewGroup) :
ViewTreeObserver.OnScrollChangedListener, ViewTreeObserver.OnGlobalLayoutListener {
private val rootView: ViewGroup? = scrollView.getChildAt(0) as ViewGroup?
private val views = hashSetOf()
private var lastChildCount = 0
init {
}
override fun onScrollChanged() {
views.forEach {
val exposeView = it as ExposeViewAdapter
exposeView.onVisibleChange(it.visibleRect())
}
}
private fun checkViewSize() {
rootView?.apply {
lastChildCount = childCount
getChildExpose(rootView)
}
}
private fun getChildExpose(view: View?) {
view?.let {
if (it is ExposeViewAdapter) {
views.add(it)
}
if (view is ViewGroup) {
//遍历ViewGroup,是子view加1,是ViewGroup递归调用
for (i in 0 until view.childCount) {
val child = view.getChildAt(i)
if (child is ExposeViewAdapter) {
views.add(child)
}
if (child is ViewGroup) {
getChildExpose(child)
}
}
}
}
}
override fun onGlobalLayout() {
val timeUsage = System.currentTimeMillis()
checkViewSize()
Log.i("expose", "timeCoast:${System.currentTimeMillis() - timeUsage}")
}
}
首先我们需要监控onGlobalLayout这个方法,在这个方法触发的情况下,去扫描当前的ViewTree,去获取实现了ExposeViewAdapter的所有的View。当滑动监听触发的时候调用之前的view是否被遮挡的方法来判断当前的view是不是在视图上出现了,然后调用onVisibleChange
来通知视图是否已经从window上移除。
面试官:哎哟不错哟。
我:谦虚有理的小菜逼。
面试官:这种方式感觉还是不够智能,如果让你用动态插桩呢。
我:打扰了,二营长,把我的意大利炮抬过来。
面试官:回家继续等通知把。
粉丝技术裙: