巧用 Layout 搞定 Android 曝光统计

前端开发专家 @ 雪球

作者:陈贺强

引言

当一个互联网产品达到一定量级之后,为了进一步提升用户体验,大多数产品都会对展示内容的曝光次数以及时长进行统计。以此来推断用户阅读习惯以及行为路径,进行分析后对产品进行优化。

目前 Android 客户端的埋点采集方式可以分为三类:

代码埋点

在需要埋点的位置直接上传埋点数据。优点是准确性高,可以灵活的获取业务数据参数。缺点是代码工作量大,侵入性强,后续维护复杂等。

可视化埋点

通过可视化工具配置需要采集的数据,在后台配置埋点信息,在客户端中根据后台配置来上传需要的埋点数据。优点是埋点比较简单,客户端不依赖开发,灵活性强。缺点是前期开发成本较高,并且不能获取复杂业务参数。

无代码埋点

并不是真的不需要代码埋点,而是在客户端利用hook拦截系统的响应事件,自动采集全部事件并上报埋点数据,在后端过滤出有用的数据。优点是覆盖面全面,缺点是数据量大无用数据较多。

客户端的埋点采集事件,主要可以分为三类

  • 浏览页面事件

  • 元素点击事件

  • 内容曝光事件

浏览页面和元素点击事件相对比较简单,只需要在对应的场景下触发埋点采集事件既可。内容曝光事件则相对复杂一些,因为内容曝光的触发时机不仅依赖页面的状态,还需要判断内容的动态位置以及父类组件的生命周期等。所以内容曝光事件基本都采取手动的代码埋点。这篇文章主要也是对内容曝光事件的场景进行分析。

业务现状

图片

雪球 App 首页信息流是通过 RecyclerView 实现的,曝光统计代码如下:

 class TimelineLogger {
     companion object {
         const val STATUS_DISPLAY_DURATION = 2000 //曝光时长需要超过两秒
     }
      //记录组件开始曝光时间 key为帖子id value为时间戳
      private var startShowTime = LongSparseArray<Long>() 

      /**
       * 获取当前可见的数据集合
       */
    private fun getVisibleStatusArray():LongSparseArray<Long>{
        val visibleStatusArray = LongSparseArray<Long>() //定义可见信息集合
        val timeCurrent = System.currentTimeMillis() //当前时间戳
        //列表可见第一个数据
        var realFirstPosition = linearLayoutManager.findFirstVisibleItemPosition() 
         //列表可见最后一个数据
        val realLastPosition = linearLayoutManager.findLastVisibleItemPosition()

        for (i in realFirstPosition..realLastPosition) { //遍历可见数据
            adapter.getItem(i)?.let { status ->
                if (!startShowTime.containsKey(status.statusId)) {
                    //记录可见数据开始时间
                    startShowTime.put(status.statusId, timeCurrent)
                }
            //记录当前可见数据           
            visibleStatusArray.put(status.statusId, timeCurrent)
            }
        }  
    }

     /**
      * 触发曝光
      * immediately:是否立刻曝光
      */
      fun logStatus(immediately:Boolean){
         val visibleStatus = getVisibleStatusArray() //当前展示中的帖子
         val timeCurrent = System.currentTimeMillis() //当前时间
         for (i in 0 until startShowTime.size()) { //遍历所有帖子的开始曝光时间
            val key = startShowTime.keyAt(i)
            //immediately为false时会将所有不再展示的帖子进行曝光处理
            //immediately为true时代表页面关闭时将所有帖子进行曝光
            if (immediately == visibleStatus.containsKey(key)) {
                statusTime.get(key)?.let { timeBegin ->
                val duration = timeCurrent - timeBegin //曝光时长
                if (duration > STATUS_DISPLAY_DURATION) {
                   //触发曝光
                }                        
                startShowTime.remove(key)  //移除已经曝光的信息                                              
            }
      }
  }
    
 /**
  *  首页fragment
  */
 class HomeFragment:Fragment{
    /**
     * 初始化recyclerView
     */
    fun initRecyclerView(){
        recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
                super.onScrollStateChanged(recyclerView, newState)
            }

            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
                //滚动时触发曝光统计逻辑,数据第一次设置时也会触发onScrolled方法
                timelineLogger.logStatus(false) 
            }
        })
    }
  
  /**
  * 当前页面展示
  */
   override fun onPageSelected() {
       timelineLogger.logStatus(false)  //展示页面触发曝光统计逻辑
   }
  
  /**
  * 当前页面不再显示
  */
   override fun onPageUnselected() {
       timelineLogger.logStatus(true)  //离开页面触发曝光统计逻辑
   }
  
}

复制代码

以上是 Android 客户端中非常常见的曝光统计代码,逻辑如下:

  • 当组件 (RecyclerView) 可见后将当前展示的信息保存到数据集合

  • 随着组件的滑动,将移动到屏幕之外的信息进行曝光处理,将移动到屏幕之内的信息保存到数据集合

  • 当页面状态变为不可见时将数据集合中的所有信息进行曝光处理

图片

图片

这样写法有两个的弊端

  • 复用性差:

    每个组件都有自己的独特生命周期回调方法,不同的组件之间代码几乎不可能复用。比如上图中 Feed 流一般使用 RecyclerView 组件,而 Banner 位使用 ViewPager 组件。曝光的触发依赖于上层容器组件,不受自身控制。触发代码如下:

/**
* ViewPager监听
*/
val pageChangeListener = object : ViewPager.OnPageChangeListener {
   override fun onPageScrollStateChanged(newState: Int) { }

   override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {}

   override fun onPageSelected(index: Int) {

   /** 曝光逻辑*/

  }
 
/**
* RecyclerView监听
*/
private val onScrollListener = object : RecyclerView.OnScrollListener() {
     override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
         super.onScrollStateChanged(recyclerView, newState)
    }

     override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
         super.onScrolled(recyclerView, dx, dy)

     /** 曝光逻辑*/
    }         
} 


复制代码
  • 逻辑复杂:

    组件的曝光条件依赖于上级容器的生命周期,例如在 Activity 中需要监控 onresume 和 onPuase 的切换,当 onPause 方法调用时,就要终止组件的曝光时间。如果嵌套了 Fragment,则还需要监控 fragment 生命周期。在Activity(Fragment)+ViewPager+Fragment的组合中,Fragment 的生命周期并不能准确的定义当前 fragment 是否可见,增加了判断组件可见的复杂性。

    雪球首页是 Activity+TabHost(Fragment)+ViewPager+Fragment 的组合,所以在最里层的 Fragment 中判断当前是否可见需要结合外层TabHost 被选中+自身 Fragment 被选中+onResume(onPause) 来判断。而前两个条件并不是 Android 原生组件自带的生命周期,需要业务逻辑判断

    图片

    组件展示依赖于自身以及上级组件的生命周期

解决思路

组件的曝光代码难以复用,主要原因是组件的曝光依赖于父容器的生命周期,而父容器的生命周期又会被他的父容器生命周期所影响。这样业务展示形式越复杂,组件的曝光逻辑也就越复杂。所以解决问题的关键就是组件的曝光逻辑收敛到自身。

图片

收敛到自身的话,必然就要依赖到自身的生命周期函数,Android 中UI组件都是最终继承自 View ,所以从通用角度来考虑,就要依赖于View的生命周期回调。下面是用到的几个生命周期方法

图片

1、onViewAttachToWindow() 和onViewDetachedFromWindow()

这两个方法是成对出现的,代表当前 view 附加到了 window 上和从 window 上分离。需要注意的是 onViewAttachToWindow() 并不代表组件真的出现到了屏幕上,因为此时可能在屏幕之外。

onViewAttachToWindow() 可以作为曝光的必要不充分条件,就是 onViewAttachToWindow() 调用不代表曝光,但是曝光一定需要提前调用 onViewAttachToWindow() 。

onViewDetachedFormWindow()  可以作为曝光结束的充分不必要条件,就是 onViewDetachedFormWindow() 肯定会导致曝光结束,但是曝光结束的时候不一定需要调用 onViewDetachedFormWindow() ,例如 View.setVisible(View.GONE)

2.onWidowsFocusChanged(hasWindowFocus: Boolean)

窗口状态发生变化时会调用,但是在一些组件中,组件展示出来时并没有回调此方法。例如 RecyclerView ViewHolder 中的自定义 View进入屏幕时没有调用此方法,但是在页面关闭时会调用。查看源码在 ViewGroup 中 dispatchWindowFocusChanged 调用子 View  的 onWidowsFocusChanged 方法,然而在 RecyclerView 中并没有相关代码。通过测试 在 Android 页面中生命周期方法之外调用 addView 方法添加的 View 也不会调用 onWindowFocusChanged 方法,说明此方法只能在父容器触发时调用,如果父容器已经调用之后再去动态添加的View是不会调用此方法的

另外 View.setVisibility() 时也不会调用

3.onVisibilityAggregated(isVisible:Boolean)

主要用来处理 View.setVisibility() 导致的View可见性改变。根据 View 中的注释 该方法会在 View 自身,父类视图或者附属的 window 可见性改变时调用,可以用来作为判断View是否可见的一个标准。但是实际情况是 onVisibilityAggregated() 方法调用时也不一定可以证明可见性,例如在屏幕以外的区域。测试发现,ViewPager嵌套Fragment中也会出现左右滑动时不被调用的问题。参考网上文章Android各版本差异 按 Home 键,应用退到后台,Android 7,View会调用 onVisibilityAggregated 回调,Android 6,View 不走 onVisibilityAggregated 回调。

根据以上描述 View 自身的生命周期方法很难判断是否曝光,主要问题在于不能根据某一个方法证明View的曝光 ,其中 onWindowFocusChanged 和 onVisibilityAggregated 更是是否会被调用都不能被保证!

所以这时候还需要其他的方法

4.ViewTreeObserver.OnPreDrawListener.onPreDraw()

onPreDraw 方法官方描述是即将绘制视图树时执行的回调函数。也就是视图发生改变时就会调用。这样我们就能在组件移动的时候判断其当前位置

5.getLocalVisibleRect(Rect r)

View 中的一个获取当前可视面积的方法,当调用时从参数  r 中会获得当前曝光在屏幕上的面积。用在onPreDraw方法中实时获取组件的可见区域。但是当 View 为 InVisible 和Gone状态时依然返回可见

6.isShown()

判断当前 View 以及父 View 是否是 Visible

至此,通过以上六个方法的配合使用,就可以精准的判断出组件的可见性了。

1.当 onViewAttachToWindow() 调用时可以确认View组件添加到了 window 中,但是是否可见无法判断

2.通过 onPreDraw 方法, getLocalVisibleRect 和 isShown() 方法可以精准获得在屏幕中的曝光位置,这样可以保证视图确实出现在了屏幕的可见区域内

3.当跳转新页面,或者按Home键离开的时候 onWindowFocusChanged 和 onVisibilityAggregated 会被调用

4.当 View 的 Visibility 改变时( View 或者父 View 调用 View.setVisibility()) onVisibilityAggregated 会被调用

5.当页面关闭 finish 的时候肯定会调用 onViewDetachedFormWindow

3.代码实现

/**
 * 定义一个Layout作为曝光组件的父布局
 */
 class ExposureLayout : FrameLayout {
   /**
    * 定义曝光处理类
    */
    private val mExposureHandler by lazy {
       ExposureHandler(this)
    }

   constructor(context: Context) : super(context)
   constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
   constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)


   /**
    * 添加到视图
    */
   override fun onAttachedToWindow() {
       super.onAttachedToWindow()
       mExposureHandler.onAttachedToWindow()
  }
   /**
    * 从视图中移除
    */
   override fun onDetachedFromWindow() {
       super.onDetachedFromWindow()
       mExposureHandler.onDetachedFromWindow()
  }

   /**
    * 视图焦点改变
    */
   override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
       super.onWindowFocusChanged(hasWindowFocus)
       mExposureHandler.onWindowFocusChanged(hasWindowFocus)
  }

   /**
    * 视图可见性
    */
   override fun onVisibilityAggregated(isVisible: Boolean) {
       super.onVisibilityAggregated(isVisible)
       mExposureHandler.onVisibilityAggregated(isVisible)

  }

   /**
    * 曝光回调
    */
   fun setExposureCallback(callback: IExposureCallback) {
       mExposureHandler.setExposureCallback( callback)
  }

   /**
    * 设置曝光条件 曝光区域大小,例如展示超过50%才算曝光
    */
   fun setShowRatio(ratio: Float) {
       mExposureHandler.setShowArea(ratio)
  }

  /**
   * 设置曝光最小时间,例如必须曝光超过两秒才算曝光
   */
   fun setTimeLimit(timeLimit:Int){
       mExposureHandler.setTimeLimit(timeLimit)
    }

}

复制代码

以上代码定义了一个自定义 Layout,用来作为曝光 View 的父组件。声明了一个 mExposureHandler 作为曝光辅助类,具体的曝光逻辑在 ExposureHandler 中处理。

下面是 ExposureHandler 实现代码

class ExposureHandler(private val view: View) : ViewTreeObserver.OnPreDrawListener {

   private var mAttachedToWindow = false //添加到视图中的状态
   private var mHasWindowFocus = true // 视图获取到焦点的状态 ,默认为true,避免某些场景不被调用
   private var mVisibilityAggregated = true //可见性的状态 ,默认为true,避免某些场景不被调用
   private var mExposure = false //当前是否处于曝光状态
   private var mExposureCallback: IExposureCallback? = null //曝光回调
   private var mStartExposureTime: Long = 0L //开始曝光时间戳
   private var mShowRatio: Float = 0f //曝光条件超过多大面积 0~1f
   private var mTimeLimit: Int = 0 //曝光条件超过多久才算曝光,例如2秒(2000)
   private val mRect = Rect() //实时曝光面积


   /**
    * 添加到视图时添加OnPreDrawListener
    */
   fun onAttachedToWindow() {
       mAttachedToWindow = true
       view.viewTreeObserver.addOnPreDrawListener(this)

  }

   /**
    * 从视图中移除时去掉OnPreDrawListener
    * 尝试取消曝光
    */
   fun onDetachedFromWindow() {
       mAttachedToWindow = false
       view.viewTreeObserver.removeOnPreDrawListener(this)
       tryStopExposure()

  }

   /**
    * 视图焦点改变
    * 尝试取消曝光
    */
   fun onWindowFocusChanged(hasWindowFocus: Boolean) {
       mHasWindowFocus = hasWindowFocus
       tryStopExposure()

  }

   /**
    * 可见性改变
    * 尝试取消曝光
    */
   fun onVisibilityAggregated(isVisible: Boolean) {
       mVisibilityAggregated = isVisible
       tryStopExposure()

  }

   /**
    * 视图预绘制
    * 当曝光面积达到条件是尝试曝光
    * 当视图面积不满足条件时尝试取消曝光
    */
   override fun onPreDraw(): Boolean {
       val visible = view.getLocalVisibleRect(mRect)&&view.isShown //获取曝光面积 和View的Visible
        if (!visible) {
            tryStopExposure()//不达到曝光条件时尝试取消曝光
            return true
        }
        if (mShowRatio > 0) {//存在曝光面积限制条件时
            if (kotlin.math.abs(mRect.bottom - mRect.top) > view.height * mShowRatio
               && kotlin.math.abs(mRect.right - mRect.left) > view.width * mShowRatio
            ) {
               tryExposure() //达到曝光条件时尝试曝光
            } else {
               tryStopExposure()//不达到曝光条件时尝试取消曝光
            }
        } else {
            tryExposure() ////达到曝光条件时尝试曝光
        }
        return true
    }

   /**
    * 曝光回调
    */
   fun setExposureCallback(callback: IExposureCallback) {
       mExposureCallback = callback
  }

   /**
    * 设置曝光面积条件
    */
   fun setShowRatio(area: Float) {
       mShowRatio = area
  }

   /**
    * 设置曝光时间限制条件
    */
   fun setTimeLimit(index: Int) {
       this.mTimeLimit = index
  }

   /**
    * 尝试曝光
    */
   private fun tryExposure() {
       if (mAttachedToWindow && mHasWindowFocus && mVisibilityAggregated && !mExposure) {
           mExposure = true //曝光中
           mStartExposureTime = System.currentTimeMillis() //曝光开始时间
           if (mTimeLimit==0){
            mExposureCallback?.show() //回调开始曝光
          }


      }
  }

   /**
    * 尝试取消曝光
    */
   private fun tryStopExposure() {
       if ((!mAttachedToWindow || !mHasWindowFocus || !mVisibilityAggregated) && mExposure) {
           mExposure = false //重置曝光状态
             if(mTimeLimit >0 && System.currentTimeMillis() - mStartExposureTime >               mTimeLimit){
               //满足时长限制曝光
               mExposureCallback?.show()
            }

      }
  }


}

复制代码

以上曝光逻辑为

1.判断 mAttachedToWindow mHasWindowFocus mVisibilityAggregated 和 getLocalVisibleRect 四个条件全都满足时到达了生命周期方法的曝光条件

2.在 1 的基础上判断曝光面积限制( mShowRatio ) 是否满足来决定是否曝光或取消曝光

3.在 2 的基础上判断曝光时长限制( mTimeLimit )是否满足来决定是否曝光

最后是曝光回调

interface IExposureCallback {
   fun show() //曝光
}

复制代码

基于以上代码首页的信息流统计可以调整为:

class TimeLineAdapter : RecyclerView.Adapter<TimeLineViewHolder>() {
      ....
  
       override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
           holder.exposureLayout.run {
               setShowRatio(0.5f) //需要暴露大于50%才能曝光
               setTimeLimit(2000) //需要显示时长超过两秒才能曝光
               setExposureCallback(object : IExposureCallback {
                   override fun show() {
                       //曝光
               }
           })
       }
     
  }


  class TimeLineViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
      //在布局文件中使用ExposureLayout作为父布局
       val exposureLayout: ExposureLayout = itemView.findViewById<ExposureLayout>(R.id.layout_exposure)
  }

复制代码

4.总结

以上曝光逻辑代码,可以基本满足当前业务的曝光需求。通过很少的代码量,解决了之前巨量并且复杂的曝光逻辑判断。大大的提高了代码效率。相比之前的代码,新的统计代码优点如下:

  • 通用性强,曝光逻辑实现基于 View 的生命周期,对于 Android 各种原生组件以及第三方开源组件都适用,避免了重复开发代码。

  • 代码侵入性小,和业务代码相分离,提高了代码的整洁度

  • 后续维护工作简单,业务变动时可以继续使用,几乎不用做调整

俗话说磨刀不误砍柴工,程序员工作中不能光沿着前人的轨迹低头耕耘,还要不定时的进行学习思考,找寻更优的解决方案,这样才能在工作中不断进步,为以后的人生注入力量

还有一件事

雪球业务正在突飞猛进的发展,工程师团队期待牛人的加入。如果你对「做中国人首选的在线财富管理平台」感兴趣,希望你能一起来添砖加瓦,点击「阅读原文」查看热招职位,就等你了。

热招岗位:大前端架构师、Android/iOS/FE 技术专家、推荐算法工程师、Java 开发工程师。

文章分类
前端
文章标签