去年面試餓了麼的時候吧,被問到了個技術問題。
面試官:聽說你做過自動化埋點,那麼我們聊聊view的曝光監控吧。
我:之前我是把我們廣告的曝光監控放在廣告的模型層,然後在bindview的時候做一次曝光的,然後內部做了一次曝光防抖動,避免多次曝光。
面試官:你這樣就意味着快速滑動的情況下也會計算一次曝光了,如果我需要的是一個停留超過1.5s同時出現超過view的一半作爲有效曝光呢。
我:
來個背景音樂吧。
面試官:回去等通知吧。
閉關一年後
要解決問題,先歸納下都有那些問題.
- 控件在頻幕上出現的時間超過1.5s
- 有效區域出現超過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的時候被觸發,核心源代碼在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方法。
解決問題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怎麼監控呢。
我:???黑人老哥又特麼來了。
分析問題
還是和上篇文章一樣,我們先看下要解決哪些問題。
- ScrollView NestScrollView 的滑動監控怎麼做。
- View有沒有像RecyclerView一樣的attach和detch方法,超過1.5s的曝光時間。
- 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上移除。
最後
面試官:哎喲不錯喲。
我:謙虛有理的小菜逼。
面試官:這種方式感覺還是不夠智能,如果讓你用動態插樁呢。
我:打擾了,二營長,把我的意大利炮擡過來。
面試官:回家繼續等通知把。
粉絲技術裙: