作者:陈贺强
引言
❝
当一个互联网产品达到一定量级之后,为了进一步提升用户体验,大多数产品都会对展示内容的曝光次数以及时长进行统计。以此来推断用户阅读习惯以及行为路径,进行分析后对产品进行优化。
目前 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 开发工程师。