有货iOS数据非侵入式自动采集探索实践
随着有货APP的不断迭代开发,数据和业务部门对于客户端用户行为数据的需求越来越多;为了更好的监控APP使用的状况,客户端团队对于APP自身的运行的数据需求也愈发迫切。迫切地需要一套客户端数据采集的工具,自动、全量采集用户行为数据,满足各个部门对于数据的需求。
\\有货APP团队为此开发一套数据采集的SDK,主要的功能如下:
\\- 页面访问流。用户在使用APP期间浏览了哪些页面。\\t
- 浏览数据曝光。用户在某个页面上浏览了哪些商品。\\t
- 业务数据自动采集。用户在使用APP期间点击了哪些位置,触发了哪些操作。\\t
- 性能数据自动采集。用户使用APP期间,页面加载时长是多少,图片加载时长多少,网络请求时长多少等。\
此外,所有的数据采集要自动化,无侵入,即不需要人工埋点,集成SDK即可使用,不改动或尽量少改动原有代码。
\\基于以上需求,AOP是技术方案的最佳选择,而iOS上实现AOP则需要依靠Objective-C中runtime的黑魔法--Method Swizzle实现。漫漫的踩坑填坑的旅程由此开端,接下来我们一一品尝实现思路和方法吧。
\\页面访问流
\\用户访问页面统计需要解决的问题有两个:
\\- 统计事件切入点,即何时统计。\\t
- 统计数据字段,即统计哪些数据。\
整体流程如下图:
\\\\统计事件切入点
\\用户访问页面统计的一般思路是在View Controller生命周期方法:
\\- viewDidAppear上报页面进入事件。\\t
- viewDidDisappear上报页面退出事件,\
即可得出用户访问页面路径,两个事件时间戳之差即为用户在页面停留的时间。
\\通常我们APP中的View Controller都会继承自某个基类,我们在基类的对应方法中进行统计即可,然而对于没有从基类继承的View Controller就无能为力了。
\\借助于AOP,我们可以更优雅的完成这项工作:在UIViewController的load方法里swizzle viewDidAppear和viewDidDisappear方法,原有代码无需改动。
\\统计数据字段
\\根据数据需求,设置了如下的统计字段:
\\- PAGE_ID,当前页面的标识。\\t
- SOURCE_ID,当前页面的前一个页面的标识。\\t
- TYPE_ID,当前页面一些关键信息,如商品id,品牌id等。\\t
- TIMESTAMP,当前事件生成的时间戳。\
页面进入和退出的事件,均上报上述的数据结构。
\\其中还有几个问题是需要考虑的:
\\1.PAGE_ID和SOURCE_ID如何定义
\\因为需要统一iOS和Android的PAGE_ID,所以需要做配置下发。iOS端拿到的是一份plist的文件,文件的key的View Controller的类名的字符串表示,value则是PAGE_ID。
\\2.PAGE_ID和SOURCE_ID如何获取
\\PAGE_ID
直接根据当前View Controller
的class即可取到,SOURCE_ID稍显复杂,需要根据APP页面嵌套堆栈结构来确认具体的获取方法,通常是从UINavigationController
的导航栈中取前一个View Controller的page id即可。
至此,页面访问流统计已基本完成,根据页面进入退出的PAGE_ID
和SOURCE_ID
串出一条完整的用户浏览路径,并得出用户在每个页面的停留时间。
浏览数据曝光
\\采集到用户的浏览路径,以及在每个页面的停留时间后,在某些特定的页面,如首页、商品列表页面,我们还想知道用户在页面上滑动了几屏,看了哪些活动、商品,以便于更好的为用户推荐喜欢的商品。
\\用户看到的屏幕上的一块区域,认为是资源位,那么用户看到的内容是由一个个资源位组成。那么曝光的含义如下:
\\- 资源位从屏幕可视区域外,进入到可视区域内(任意部分可见即可),即是一次曝光。\\t
- 资源位从可视区域移出后,再次进入可视区域,算做新的一次曝光。\\t
- 页面切换和下拉刷新时,算作新的一次曝光。\
我们知道iOS中页面元素的基本组成单位是view,因此我们只需要判断view是否在可视区域,即可知悉当前view上的资源位是否需要曝光,从而做出相应的曝光操作,采集数据,上报接口等。
\\由以上的分析可知,待解决的问题主要有两个:
\\- view的可见性判断\\t
- view曝光数据采集\
view的可见性判断
\\查询UIView Class Reference可以看到setFrame:
和layoutSubivews
方法,可用于设置subview的frame。每次view fame更新均会调用此方法。因此,我们可以通过runtime swizzle此方法实现,添加一些数据采集相关的操作。
我们为UIView添加了以下属性:
\\-
yh_viewVisible:view
是否可见,默认否。可见性由否-\u0026gt;是的时候,触发一次曝光数据采集的操作。\\t -
yh_viewVisibleRect
:view可见区域,默认CGRectZero。\\t -
yh_visibleSubviews
:view所有可见的subview。\
首先明确下几个术语的定义和规则:
\\1.view的subview
可见需要同时满足的3个条件:
-
subview.hidden
为false,即view没有被隐藏。\\t -
subview.alpha
大于等于0.01,即view是可见的。\\t -
subview
的frame是否在view的可见区域内。\
反之,只要以上任何一个条件不满足,我们就认为此subview当前是不可见的。
\\2.设置view为可见
\\- 设置
yh_viewVisibleRect
为可见区域frame。\\t - 设置
yh_viewVisible
为true。\\t - 将view加入superview的
yh_visibleSubviews
数组。\
3.设置view为不可见
\\- 设置
yh_viewVisibleRect
为CGRectZero
。\\t - 设置
yh_viewVisible
为false
。\\t - 将view自
superview
的yh_visibleSubviews
数组移除。\
Swzzile setFrame:,执行以下操作:
\\\\- 若view是
UIScrollView
,则根据当前frame和contentInset计算当前yh_viewVisibleRect
。不是则将当前frame设置为yh_viewVisibleRect
。\
Swzzile layoutSubivews
,调用yh_updateVisibleSubViews
方法,其中执行以下操作:
- 判断
view.yh_viewVisible
与view自身的可见性,若view不可见,则迭代其subview的为不可见,并终止后续操作。\\t - 判断
view. yh_visibleSubviews
中view是否还是view的subview,不是则设置subview为不可见。\\t - 判断是否是
UITableViewWrapperView
,是则view的yh_viewVisibleRect
的originy取其superview的bounds的origin y。这么做是因为实践中发现UITableView
设置bounds的会使view的可见区域产生变化,需要重新设置。\\t - 遍历view的subview,若subview可见则设置其为可见,否则设置为不可见。\
经过以上的这些操作,我们就能知道某个view及其subview的是否可见。
\\view曝光数据采集
\\为了取到view对应的数据,同样为UIView添加了以下属性:
\\-
yh_exposureData
:字典类型,用来存储此view节点需要曝光的数据。\
那么还有两个问题存在:
\\- view曝光数据的粒度\\t
- view及其subview的节点的曝光数据组装时机\
view曝光数据的粒度
\\根据项目中的实践经验,一般以UITableViewCell
或者UICollectionViewCell
为最小粒度。同时,在最末节点的yh_exposureData
字典中,增加一个key:isEnd
,用来标识是否已经是最末的节点。
view及其subview的曝光数据组装时机
\\一般是在最末节点的可见性变化时,由下向上的遍历最末节点的superview,组装所有数据。
\\因此我们覆写了setYh_viewVisible:
的方法,即yh_viewVisible
的set方法。执行以下操作:
- 若当前
self.yh_viewVisible
变化为false-\u0026gt;true
,且self.yh_exposureData
包含最末节点的标记,则由下向上的遍历最末节点的superview,组装所有数据。\\t - 设置
self.yh_viewVisible
的值。\
至此,我们已经解决了view的可见性判断和曝光数据采集的问题。数据上报及策略不在赘述。
\\此方案有几个缺点
\\- 需要手动设置曝光数据。\\t
- 需要在合适时机手工调用
view.yh_viewVisible
触发数据采集,如viewdidappear等。\\t - 需要消耗一定的资源进行可视区域计算和曝光数据采集。\
还有两个问题是值得注意的:
\\-
UITableView
在setBounds:
时会对view的frame造成改变,因此需要swizzle setBounds:
方法,需要在设置bounds后,调用[self yh_updateVisibleSubViews]
;\\t -
UIScrollView在setContentInset:
时会影响view的可见区域,因此需要swizzle setContentInset:
方法,需要在设置contentInset后,调用self.yh_viewVisibleRect = UIEdgeInsetsInsetRect(self.frame, contentInset)
;\
业务数据自动采集
\\业务数据自动采集即业界流行的无埋点数据采集。
\\传统的客户端用户点击数据采集是基于手工埋点的,对哪个位置的数据感兴趣,就在这打个点,用户操作之后,随即触发数据上报。手工埋点的缺点很明显:错埋、漏埋。新版本发布后,经常有数据部门的小伙伴来反馈说,某某点位没有上报,某某点位上报错误的问题,开发的同事也苦不堪言。
\\无埋点数据采集带来了新的改变。首先基本上避免了手工埋点,个别情况需要特殊处理。其次由选择性的采集数据,变成了全量采集用户的所有点击触摸数据。
\\新的改变也会带来新的挑战,无埋点数据采集的成为现实的可能性仍然是基于Objective-C的runtime特性。实践过程中,思路上我们借鉴了iOS无埋点数据SDK的整体设计与技术实现,实现上借鉴了Sensors Analytics iOS SDK和Mixpanel iPhone。接下来,结合具体实践,介绍下我们的实现思路和遇到的一些问题。主要分以下三方面:
\\- 自动采集的点位如何确保唯一性。\\t
- 不同的点位类型,需要swizzle哪些方法。\\t
- swizzle过程中踩到的坑。\
自动采集的点位如何确保唯一性
\\自动采集脱离了手工埋点,因此也没了点位的唯一标识。那我们要怎么唯一定位到自动采集的点位呢?很容易想到的一个方案是:基于页面view的树形结构。此方案可以分解为两个问题:
\\- view唯一标识如何定义。\\t
- view唯一标识如何生成。\
view唯一标识(view path)的定义
\\我们规定,一个典型的view path如下:
\\\ViewController[0]/UIView[0]/UITableView[0]/UITableViewCell[0:2]/UIButton[0]
\\其中:
\\- 通过此标识可以在当前页面view树形结构中唯一的确定此元素。\\t
- 标识的每一项由两部分组成:一是当前元素的class的字符串表示,二是当前元素在同级元素中的序号,自0开始计算。如当前第二个UIImageView,则是UIImageView1。\\t
- 标识不同项之间以/拼接。\\t
- 标识的最顶层是当前view所在的ViewController。\\t
- 对于UITableViewCell和UICollectionViewCell及类似的自定义组件,序号部分由两部分组成:section和row,并以:拼接。\\t
- 标识的最末端是当前被点击或触摸的元素。\
view唯一标识如何生成
\\view path生成过程:由触发操作的最末端元素向上查询,一直查到ViewController为止。假设当前点击view为A_View,从当前的A_View入手遍历view树,每一级的数据存入P_Array中,过程如下:
\\\\- 如果
A_View
是UICollectionViewCell
类型,获取A_View所处UICollectionView的indexPath,P_Array push
路径信息[NSString stringWithFormat:@\"%@[%ld:%ld]\