View的有效曝光监控(上)|RecyclerView 篇

7,635 阅读5分钟

各位大佬,能不能随便给我的项目或者之前的文章点个star,苦兮兮。github.com/ 掘金文章

去年面试饿了么的时候吧,被问到了个技术问题。

面试官:听说你做过自动化埋点,那么我们聊聊view的曝光监控吧。

我:之前我是把我们广告的曝光监控放在广告的模型层,然后在bindview的时候做一次曝光的,然后内部做了一次曝光防抖动,避免多次曝光。

面试官:你这样就意味着快速滑动的情况下也会计算一次曝光了,如果我需要的是一个停留超过1.5s同时出现超过view的一半作为有效曝光呢。

我:

来个背景音乐吧。

面试官:回去等通知吧。

闭关一年后

要解决问题,先归纳下都有那些问题.

  1. 控件在频幕上出现的时间超过1.5s
  2. 有效区域出现超过1半

监听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的时候被触发,核心源代码在ViewRootimphost.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方法。

解决问题1.5s的问题

从上面的代码分析完之后,我们可以在onAttachedToWindow的方法尾部打上第一个曝光开始的节点,在onDetachedFromWindow的方法下面埋下曝光结束的方法,计算他们的差值,如果当值大于1.5s之后,则调用接口。

View有效区域出现超过1半

这个吧,说起来有点丢脸,我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的有效曝光时间了。

我觉得即使面试失败的情况下,我们也还是需要在其中学习到一些东西的,毕竟机会还是给有准备的人。当然据我现在所知,应该饿了么用的是阿里的那套控件曝光自动化埋点的方案,还是有些不同的。