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

去年面試餓了麼的時候吧,被問到了個技術問題。java

面試官:據說你作過自動化埋點,那麼咱們聊聊view的曝光監控吧。面試

我:以前我是把咱們廣告的曝光監控放在廣告的模型層,而後在bindview的時候作一次曝光的,而後內部作了一次曝光防抖動,避免屢次曝光。app

面試官:你這樣就意味着快速滑動的狀況下也會計算一次曝光了,若是我須要的是一個停留超過1.5s同時出現超過view的一半做爲有效曝光呢。ide

我:源碼分析

來個背景音樂吧。學習

面試官:回去等通知吧。ui

閉關一年後

要解決問題,先概括下都有那些問題.this

  1. 控件在頻幕上出現的時間超過1.5s
  2. 有效區域出現超過1半

監聽View的移入和移出事件

先解決RecyclerView的1.5s這個問題,你們第一個想到的可能都是addOnScrollListener,而後經過layoutmanager計算可見區域,以後計算兩次滑動以後的差別區間。可是很差意思,在下不可能這麼簡單的被大家猜透。google

override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        exposeChecker.updateStartTime()
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        onExpose()
        exposeChecker.updateStartTime()
    }
複製代碼

我在一篇技術博客傳送門,我看到這兩個方法在RecyclerView內部會在View移動出可視區域的時候被觸發。可是爲何呢???帶着問題分析源代碼。spa

源碼分析

若是各位關心過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的有效曝光時間了。

我以爲即便面試失敗的狀況下,咱們也仍是須要在其中學習到一些東西的,畢竟機會仍是給有準備的人。固然據我如今所知,應該餓了麼用的是阿里的那套控件曝光自動化埋點的方案,仍是有些不一樣的。

相關文章
相關標籤/搜索