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

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

面試官:聽說你做過自動化埋點,那麼我們聊聊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的有效曝光時間了。

我覺得即使面試失敗的情況下,我們也還是需要在其中學習到一些東西的,畢竟機會還是給有準備的人。當然據我現在所知,應該餓了麼用的是阿里的那套控件曝光自動化埋點的方案,還是有些不同的。

面試官:老哥那麼我們繼續探討下這個問題啊。Scrollview和NestScrollView怎麼監控呢。

我:???黑人老哥又特麼來了。

分析問題

還是和上篇文章一樣,我們先看下要解決哪些問題。

  1. ScrollView NestScrollView 的滑動監控怎麼做。
  2. View有沒有像RecyclerView一樣的attach和detch方法,超過1.5s的曝光時間。
  3. View出現一半。

滑動監控

一般人肯定告訴你,這個你自定義個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滑動的時候都會觸發回調哦。而上述代碼分析,則可以說明當兩個滑動組件滑動的時候就會觸發對應的回調監聽。

View 出現一半

這個監控方法還是和上篇文章一樣,請各位大佬直接看上篇文章就好了。

1.5s的曝光時長

先回到之前的文章提到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<View>()
    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上移除。

最後

面試官:哎喲不錯喲。

 

我:謙虛有理的小菜逼。

面試官:這種方式感覺還是不夠智能,如果讓你用動態插樁呢。

我:打擾了,二營長,把我的意大利炮擡過來。

面試官:回家繼續等通知把。

粉絲技術裙:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章