2020-10-22 更新

在日常使用过程中,发现在onScroll中进行上报会大大增加滑动时的性能消耗(由于在onScroll在滑动时过于频繁,而每次findFirstVisibleItemPosition都从0开始遍历一次子元素直至显示的view)。因此建议把上报的起始位置移到Adapter的onViewAttachedToWindow与onViewDetachedFromWindow方法中,此方法在item被移入和移除屏幕时会直接回调,不用再进行多余的scroll判定

    @Overridepublic void onViewAttachedToWindow(@NonNull T holder) {super.onViewAttachedToWindow(holder);final int position = holder.getLayoutPosition();if (position >= 0) {handleItemVisibleChange(position, true);}}@Overridepublic void onViewDetachedFromWindow(@NonNull T holder) {super.onViewDetachedFromWindow(holder);final int position = holder.getLayoutPosition();if (position >= 0) {handleItemVisibleChange(position, false);}}

原因

工作中一般会针对页面展示与点击事件进行数据的上报,用于给运营分析用户行为。不过这次要求的会多一些,要求统计如下内容

  1. 列表内每个商品的封面曝光次数
  2. 列表内每个商品的封面曝光总时间
  3. 列表的阅读进度

问题点

如何判断一次商品的封面曝光?

  1. 判断时机:①在RecycleView滑动时,即通过OnScrollListener判断。②在界面切换时生命周期判断
  2. 如何判断商品出现:RecycleView的LinearLayoutManager有findFirstVisibleItemPosition和findLastVisibleItemPosition方法,可以获取当前显示的item的position范围(GridLayoutManager也有该方法)

如何判断一次商品的曝光时间?

  1. 曝光起始时间:即商品的封面曝光时间,可以从上一个问题点获取
  2. 曝光结束时间:可理解为每个商品Item的消失时间,每个Item消失一定会经过从显示100%到0%的过程,我们定一个阈值如50%,在滑动过程中若从显示状态减少到50%,则判断为此商品进入消失状态。
  3. 商品出现时间 = 曝光结束时间-曝光起始时间

列表阅读进度

  1. 上报阅读进度时机:在页面退出时间上报,以Fragment为例,在Fragment的onPause回调上报即可
  2. 阅读进度=(最末一位item的位置+1)/数据总大小。最末一位item的位置可由LinearLayoutManager#findLastVisibleItemPosition获得

代码实现

如何使用

我们首先来看看最终使用该工具类的效果

//TrackListFragment.java
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {super.onViewCreated(view, savedInstanceState)//...省略RecyclerViewTrack(rv_track).startTrack(lifecycle,object : RecyclerViewTrack.ItemExposeListener {override fun onItemViewVisible(position: Int) {Log.i(TAG, "onItemViewVisible: position = $position")//在此处上报Item的曝光时间点}override fun onItemViewInvisible(position: Int, showTime: Long) {//在此处上报Item的曝光时间段Log.i(TAG, "onItemViewInvisible: position = $position,showTime = $showTime")Toast.makeText(context, "商品${position}不再显示,曝光时间为$showTime", Toast.LENGTH_SHORT).show()}})}

对外接口

下面是对外接口ItemExposeListener的方法描述

//RecyclerViewTrack.javainterface ItemExposeListener{/*** item可见回调* @param position item在列表中的位置*/fun onItemViewVisible(position: Int)/*** item消失回调* @param position item在列表中的位置* @param showTime 曝光时间*/fun onItemViewInvisible(position: Int, showTime: Long)}

实现滑动时上报

具体可以通过代码注释来看到滑动时是如何触发回调的

//RecyclerViewTrack.java/*** 开启上报* @param lifecycle 可为空,用于监听对应的生命周期* @param listener 上报监听器*/fun startTrack(lifecycle: Lifecycle?, listener: ItemExposeListener) {//...recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {super.onScrolled(recyclerView, dx, dy)checkCurrentVisibleItem()}})//...}/*** 判断Item是否展示,注意此处只支持了LinearLayoutManager和GridLayoutManager,如果是需要其它LayoutManager可以在此处修改*/private fun checkCurrentVisibleItem() {val range = IntArray(2)val manager = recyclerView.layoutManagerval orientation: Intwhen (manager) {is LinearLayoutManager -> {//获取visible的item范围range[0] = manager.findFirstVisibleItemPosition()range[1] = manager.findLastVisibleItemPosition()orientation = manager.orientation}else -> return}for (i in range[0]..range[1]) {val view = manager.findViewByPosition(i)dispatchViewVisible(view, i, orientation)}}/*** 判断View的可见性并进行分发*/private fun dispatchViewVisible(view: View?, position: Int, orientation: Int) {view?.let {val rect = Rect()//通过getGlobalVisibleRect得到在界面中展示的大小val rootVisible = view.getGlobalVisibleRect(rect)//判断若超出了一半位置则算曝光val visibleHeightEnough =orientation == OrientationHelper.VERTICAL && rect.height() >= view.measuredHeight / 2val visibleWidthEnough =orientation == OrientationHelper.HORIZONTAL && rect.width() >= view.measuredWidth / 2//可见区域超过百分之五十val visible = (visibleHeightEnough || visibleWidthEnough) && rootVisibleval lastValue = timeSparseArray[position]val curTime = System.currentTimeMillis()
//            Log.i(TAG, "checkViewVisible: position = $position, visible = $visible, lastValue = $lastValue")if (lastValue > 0) {//从显示到不显示if (!visible) {//触发Invisible分发dispatchInvisible(position, lastValue, curTime)}} else if (visible) {//从不显示到显示,触发visible分发dispatchVisible(position, curTime)}}}/*** 分发InVisible*/private fun dispatchInvisible(position: Int,lastTime: Long,curTime: Long) {Log.i(TAG, "dispatchInvisible: position = $position")if (lastTime == curTime) {return}timeSparseArray.put(position, -1)listener?.onItemViewInvisible(position, curTime - lastTime)}/*** 分发Visible*/private fun dispatchVisible(position: Int, curTime: Long) {Log.i(TAG, "dispatchVisible: position = $position")timeSparseArray.put(position, curTime)listener?.onItemViewVisible(position)}

我们会通过timeSparseArray来记录每个Item的曝光的时间点,并在item变为invisible时直接计算出这次item曝光的时间长度

实现页面切换时上报

上面的代码实现时会有一个问题,如点击Home键回到首页时,由于不会触发RecycleView的scroll事件,导致会把应用在后台的这段时间也算到曝光时间长度,所以我们需要监听界面的生命周期,并在resume和pause状态时手动触发item的对应回调

//RecyclerViewTrack.java/*** 开启上报* @param lifecycle 可为空,用于监听对应的生命周期* @param listener 上报监听器*/fun startTrack(lifecycle: Lifecycle?, listener: ItemExposeListener) {//...//通过lifecycle监听activity和fragment的生命周期lifecycle?.addObserver(LifecycleEventObserver { _, event ->if (event == Lifecycle.Event.ON_RESUME) dispatchResume()else if (event == Lifecycle.Event.ON_PAUSE) dispatchPause()})}/*** 在Fragment走到Pause时onScroll不会被触发上报,所以需要手动触发*/private fun dispatchPause() {val size = timeSparseArray.size()for (i in size - 1 downTo 0) {val key = timeSparseArray.keyAt(i)val value = timeSparseArray.valueAt(i)if (value > 0) {//是可见状态,则改为不可见状态,在resume时还需要把这些item重置为可见状态dispatchInvisible(key, value, System.currentTimeMillis())} else {//不是可见状态直接移除timeSparseArray.delete(key)}}}/*** 在Fragment在后续走到Resume时onScroll不会被触发,所以需要手动触发*/private fun dispatchResume() {val size = timeSparseArray.size()val curTime = System.currentTimeMillis()//若界面到了resume状态,则把界面退出时可见状态的item都重置成可见状态for (i in size - 1 downTo 0) {val key = timeSparseArray.keyAt(i)dispatchVisible(key, curTime)}}

Ps.若Fragment某些情形下不会触发onResume和onPause回调,建议使用ViewPager2或者手动设置fragmentTransaction.setMaxLifecycle方法来实现正确的生命周期回调

列表的阅读进度上报

实现TrackFragment统一上报

在上面曝光事件实现的基础上我们就可以实现阅读的进度上报

  1. 在onItemViewVisible回调里记录最大position
  2. 在退出界面时上传阅读进度即可
  3. 可以把此逻辑封装到抽象类中
/*** Created by wzt on 2020/9/7* 上报页面阅读进度基类*/
abstract class TrackFragment : Fragment() {//浏览数据集合的大小private var infoSize = 0//浏览的位置的最大值private var curMaxNum = 0override fun onPause() {super.onPause()//在onPause时上报postShowPercent()}/*** 设置数据总大小* @param infoSize 数据总大小*/fun setInfoSize(infoSize: Int) {this.infoSize = infoSize}/*** 设置阅读进度最大值* @param curNum 当前阅读进度*/open fun setCurNum(curNum: Int) {curMaxNum = max(curNum, curMaxNum)}/*** 得到阅读进度* @return 阅读进度,从0~1的分数*/private fun getProgressRate(): Float {return if (infoSize != 0) {curMaxNum.toFloat() / infoSize} else 0f}/*** 上报阅读进度*/private fun postShowPercent() {val progressRate = getProgressRate()if (progressRate == 0f) {return}//在此处上报界面阅读进度Toast.makeText(context, "阅读进度为$progressRate", Toast.LENGTH_SHORT).show()}
}
使用方式

只需要继承TrackFragment,并在获取到数据时调用setInfoSize,在item曝光时调用setCurNum即可在onPause中上报

class TrackListFragment : TrackFragment() {RecyclerViewTrack(rv_track).startTrack(lifecycle,object : RecyclerViewTrack.ItemExposeListener {override fun onItemViewVisible(position: Int) {Log.i(TAG, "onItemViewVisible: position = $position")//此处收到item曝光事件,调用setCurNum//由于position是从0开始计算,所以此处需要记得+1setCurNum(position+1)}})override fun onActivityCreated(savedInstanceState: Bundle?) {super.onActivityCreated(savedInstanceState)viewModel = ViewModelProvider(this).get(TrackListViewModel::class.java)viewModel.productsLiveData.observe(viewLifecycleOwner,Observer {list.clear()list.addAll(it)//此处获取到了数据,调用setInfoSizesetInfoSize(list.size)rv_track.adapter!!.notifyDataSetChanged()})}
}

总结

关键类

主要的上报工具类

package com.kyrie.proj.blog.trackimport android.graphics.Rect
import android.util.Log
import android.util.SparseLongArray
import android.view.View
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.OrientationHelper
import androidx.recyclerview.widget.RecyclerView/*** Created by wzt on 2020/9/4* RecycleView上报工具类*/
open class RecyclerViewTrack(private val recyclerView: RecyclerView) {companion object{const val TAG = "RecyclerViewTrack"}private var listener: ItemExposeListener? = nullprivate val timeSparseArray = SparseLongArray(10)/*** 开启上报* @param lifecycle 可为空,用于监听对应的生命周期* @param listener 上报监听器*/fun startTrack(lifecycle: Lifecycle?, listener: ItemExposeListener) {this.listener = listenerrecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {super.onScrolled(recyclerView, dx, dy)checkCurrentVisibleItem()}})lifecycle?.addObserver(LifecycleEventObserver { _, event ->if (event == Lifecycle.Event.ON_RESUME) dispatchResume()else if (event == Lifecycle.Event.ON_PAUSE) dispatchPause()})}/*** 判断Item是否展示*/private fun checkCurrentVisibleItem() {val range = IntArray(2)val manager = recyclerView.layoutManagerval orientation: Intwhen (manager) {is LinearLayoutManager -> {range[0] = manager.findFirstVisibleItemPosition()range[1] = manager.findLastVisibleItemPosition()orientation = manager.orientation}else -> return}for (i in range[0]..range[1]) {val view = manager.findViewByPosition(i)dispatchViewVisible(view, i, orientation)}}/*** 判断View的可见性并进行分发*/private fun dispatchViewVisible(view: View?, position: Int, orientation: Int) {view?.let {val rect = Rect()val rootVisible = view.getGlobalVisibleRect(rect)//判断若超出了一半位置则算曝光val visibleHeightEnough =orientation == OrientationHelper.VERTICAL && rect.height() >= view.measuredHeight / 2val visibleWidthEnough =orientation == OrientationHelper.HORIZONTAL && rect.width() >= view.measuredWidth / 2//可见区域超过百分之五十val visible = (visibleHeightEnough || visibleWidthEnough) && rootVisibleval lastValue = timeSparseArray[position]val curTime = System.currentTimeMillis()
//            Log.i(TAG, "checkViewVisible: position = $position, visible = $visible, lastValue = $lastValue")if (lastValue > 0) {//从显示到不显示if (!visible) {dispatchInvisible(position, lastValue, curTime)}} else if (visible) {//从不显示到显示dispatchVisible(position, curTime)}}}/*** 在Fragment走到Pause时onScroll不会被触发上报,所以需要手动触发*/private fun dispatchPause() {val size = timeSparseArray.size()for (i in size - 1 downTo 0) {val key = timeSparseArray.keyAt(i)val value = timeSparseArray.valueAt(i)if (value > 0) {//是可见状态,则改为不可见状态dispatchInvisible(key, value, System.currentTimeMillis())} else {//不是可见状态直接移除timeSparseArray.delete(key)}}}/*** 在Fragment在后续走到Resume时onScroll不会被触发,所以需要手动触发*/private fun dispatchResume() {val size = timeSparseArray.size()val curTime = System.currentTimeMillis()for (i in size - 1 downTo 0) {val key = timeSparseArray.keyAt(i)dispatchVisible(key, curTime)}}/*** 分发InVisible*/private fun dispatchInvisible(position: Int,lastTime: Long,curTime: Long) {Log.i(TAG, "dispatchInvisible: position = $position")if (lastTime == curTime) {return}timeSparseArray.put(position, -1)listener?.onItemViewInvisible(position, curTime - lastTime)}/*** 分发Visible*/private fun dispatchVisible(position: Int, curTime: Long) {Log.i(TAG, "dispatchVisible: position = $position")timeSparseArray.put(position, curTime)listener?.onItemViewVisible(position)}interface ItemExposeListener{/*** item可见回调* @param position item在列表中的位置*/fun onItemViewVisible(position: Int)/*** item消失回调* @param position item在列表中的位置* @param showTime 曝光时间*/fun onItemViewInvisible(position: Int, showTime: Long)}
}

感悟

数据上报虽然用户无法感知,但是也是很常见的app功能,更是我们了解用户怎么使用应用的手段。除了文章描述的这种侵入性较高的上报方式外,还有如AspectJ、DroidAssist等无侵入的方式,这些都是值得我以后多多研究的

引用

曝光埋点方案:recyclerView中的item曝光逻辑实现
基于此大佬的思路,解决了没有曝光时间与界面切换时间的问题

源码

https://github.com/wangzici/blog/

RecycleView的Item曝光事件、曝光时间、阅读进度上报相关推荐

  1. android 带记忆功能的播放器源码,Android实现阅读进度记忆功能

    本文实例为大家分享了android控件webview实现保存阅读进度的具体代码,供大家参考,具体内容如下 用户提了一个要求,要求保存他的阅读进度,然后在他下次阅读的时候可以继续阅读,然后动手实现了一下 ...

  2. 基于Vue的事件响应式进度条组件

    写在前面 找了很多vue进度条组件,都不包含拖拽和点击事件,input range倒是原生包含input和change事件,但是直接基于input range做进度条的话,样式部分需要做大量调整和兼容 ...

  3. 用户dsn保存位置‘_苹果iOS 13.6终于能保存文章阅读进度了 朋友都等秃了

    几天前,iOS 13.6 Beta 2和iPadOS 13.6 Beta 2发布,据外媒iPhoneHacks消息,苹果此次通过新软件更新对Apple News应用程序进行了改进,更新后的iOS 13 ...

  4. Android recycleview使用详解,recycleview实现九宫格布局即横向排列,recycleview设置item占位数量大号item或小号item

    1.添加recycleview依赖 compile('com.android.support:recyclerview-v7:25.1.1') {force = true } 2.item.xml & ...

  5. PC免费简约开源的TXT小说阅读器(提取章节、书籍分组管理、记忆阅读进度、换肤、换字体、换主题)仅支持Windows

    最近自己做了个小说阅读器,就是下面这个东西啦,目前仅支持Window系统 个人喜欢在电脑.平板上等大屏幕设备上阅读小说或电子书籍.原因其一是屏幕足够大,可以选择更舒服的字体大小:其二是觉得小屏幕看字体 ...

  6. 用户体验 | 页面阅读进度提示

    相信很多人都在项目中用过这么一个玩意 -- NProgress.js库,或者是其它类似的库,它们的作用很实用:页面加载进度提示. 顾名思义,就是在刚进入页面或刷新或请求数据时在页面顶部有一个进度条给用 ...

  7. 使用recycleview实现item多布局踩的坑

    关于使用recycleview写多种布局布局遇到的坑 最近项目中遇到多种布局嵌套使用情况,为了不多麻烦去写自定义控件监听事件的分发,便使用了recycleview.对于第一次在项目中使用这个玩意,在看 ...

  8. RecycleView的Item Animator动画

    RecyclerView能够通过mRecyclerView.setItemAnimator(ItemAnimator animator)设置添加.删除.移动.改变的动画效果. RecyclerView ...

  9. VUE“粘性”阅读进度条

    这个进度条是网上一个实例,原实例使用jQuery实现的查看,最近在用vue-cli,所以就用vue实现该组件查看. 这个进度条有有意思的地方是:用户的一系列操作都和导航息息相关.一般来说,普通的导航, ...

最新文章

  1. 激发企业大“智慧” | 深度赋能AI全场景 揭秘你不知道的移动云
  2. 节能电磁无线电导航信号放大电路 150kHz制版
  3. android指纹java_Android
  4. table表格表头不懂,内容y轴滚动
  5. js文件位置--为甚有些js必须放在尾部
  6. 利用SQL语言修正与撤销数据库
  7. linux cpu监控方案,Linux性能优化和监控系列(二)分析CPU性能
  8. 再造轮子之网易彩票-第一季(IOS 篇 by sixleaves)
  9. 中值滤波器 median filter
  10. 计算机一级考试可以带滴眼液,长期看电脑的人适宜滴眼药水缓解眼干吗?有害吗?...
  11. 选择服务器托管时应该注意什么?
  12. java getselectedrow_Java JTable.getSelectedRow方法代碼示例
  13. python获取期货数据_【python量化】期货ML策略(一)数据获取
  14. 密码分析之单表代换原理详解与算法实现
  15. 卷积神经网络的训练过程,卷积神经网络如何训练
  16. 数据库系统:第二章关系数据库
  17. ggplot2-为可视化建模2
  18. LeetCode第 844 题:比较含退格的字符串(C++)
  19. java 一元 二元 三元_一元、二元和三元关系
  20. Ubutun搭建集群遇到的一些问题

热门文章

  1. 安卓饼状图设置软件_饼图生成器app下载|饼图生成器安卓版下载_v1.1.0_9ht安卓下载...
  2. Java基于springboot+vue+elementUI股票交易模拟系统
  3. 模电三:光耦、发声器件、继电器、数码管、瞬态抑制器
  4. WSL自定义并加入usb-storage module
  5. CSS设置字体——异体和粗细
  6. 软件测试缺陷指标,软件测试质量指标算法(总结)
  7. 嗖嗖嗖Wordpress外贸企业主题制作视频教程--第三讲 WordPress网站模板构成以及目标网站的分析
  8. MySQL传智测试第四章答案_2020高校邦《MySQL数据库基础》免费答案2020知到《创业管理(浙江财经大学使用)》单元测试答案...
  9. 北京交通大学计算机学院考研,2019北京交通大学计算机考研考生科目、参考书目、招生人数...
  10. windows开启ftp服务器