有货iOS数据非侵入式自动采集探索实践

2017 年 12 月 13 日 移动开发前线 曹镏
作者|曹镏
编辑|覃云
随着有货 APP 的不断迭代开发,数据和业务部门对于客户端用户行为数据的需求越来越多;为了更好的监控 APP 使用的状况,客户端团队对于 APP 自身的运行的数据需求也愈发迫切。迫切地需要一套客户端数据采集的工具,自动、全量采集用户行为数据,满足各个部门对于数据的需求。

有货 APP 团队为此开发一套数据采集的 SDK,主要的功能如下:

  1. 页面访问流。用户在使用 APP 期间浏览了哪些页面。

  2. 浏览数据曝光。用户在某个页面上浏览了哪些商品。

  3. 业务数据自动采集。用户在使用 APP 期间点击了哪些位置,触发了哪些操作。

  4. 性能数据自动采集。用户使用 APP 期间,页面加载时长是多少,图片加载时长多少,网络请求时长多少等。

此外,所有的数据采集要自动化,无侵入,即不需要人工埋点,集成 SDK 即可使用,不改动或尽量少改动原有代码。

基于以上需求,AOP 是技术方案的最佳选择,而 iOS 上实现 AOP 则需要依靠 Objective-C 中 runtime 的黑魔法 --Method Swizzle 实现。漫漫的踩坑填坑的旅程由此开端,接下来我们一一品尝实现思路和方法吧。

页面访问流

用户访问页面统计需要解决的问题有两个:

  1. 统计事件切入点,即何时统计。

  2. 统计数据字段,即统计哪些数据。

整体流程如下图:


统计事件切入点

用户访问页面统计的一般思路是在 View Controller 生命周期方法:

  • viewDidAppear 上报页面进入事件。

  • viewDidDisappear 上报页面退出事件,

即可得出用户访问页面路径,两个事件时间戳之差即为用户在页面停留的时间。

通常我们 APP 中的 View Controller 都会继承自某个基类,我们在基类的对应方法中进行统计即可,然而对于没有从基类继承的 View Controller 就无能为力了。

借助于 AOP,我们可以更优雅的完成这项工作:在 UIViewController 的 load 方法里 swizzle viewDidAppear 和 viewDidDisappear 方法,原有代码无需改动。

统计数据字段

根据数据需求,设置了如下的统计字段:

  • PAGE_ID,当前页面的标识。

  • SOURCE_ID,当前页面的前一个页面的标识。

  • TYPE_ID, 当前页面一些关键信息,如商品 id,品牌 id 等。

  • 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 串出一条完整的用户浏览路径,并得出用户在每个页面的停留时间。

浏览数据曝光

采集到用户的浏览路径,以及在每个页面的停留时间后,在某些特定的页面,如首页、商品列表页面,我们还想知道用户在页面上滑动了几屏,看了哪些活动、商品,以便于更好的为用户推荐喜欢的商品。

用户看到的屏幕上的一块区域,认为是资源位,那么用户看到的内容是由一个个资源位组成。那么曝光的含义如下:

  • 资源位从屏幕可视区域外,进入到可视区域内(任意部分可见即可),即是一次曝光。

  • 资源位从可视区域移出后,再次进入可视区域,算做新的一次曝光。

  • 页面切换和下拉刷新时,算作新的一次曝光。

我们知道 iOS 中页面元素的基本组成单位是 view,因此我们只需要判断 view 是否在可视区域,即可知悉当前 view 上的资源位是否需要曝光,从而做出相应的曝光操作,采集数据,上报接口等。

由以上的分析可知,待解决的问题主要有两个:

  1. view 的可见性判断

  2. view 曝光数据采集

  view 的可见性判断

查询 UIView Class Reference 可以看到 setFrame: 和 layoutSubivews 方法,可用于设置 subview 的 frame。每次 view fame 更新均会调用此方法。因此,我们可以通过 runtime swizzle 此方法实现,添加一些数据采集相关的操作。

我们为 UIView 添加了以下属性:

  • yh_viewVisible:view 是否可见,默认否。可见性由否 ->是的时候,触发一次曝光数据采集的操作。

  • yh_viewVisibleRect:view 可见区域,默认 CGRectZero。

  • yh_visibleSubviews:view 所有可见的 subview。

首先明确下几个术语的定义和规则:

  1. view 的 subview 可见需要同时满足的 3 个条件:

  2. subview.hidden 为 false,即 view 没有被隐藏。

  3. subview.alpha 大于等于 0.01,即 view 是可见的。

  4. subview 的 frame 是否在 view 的可见区域内。

反之,只要以上任何一个条件不满足,我们就认为此 subview 当前是不可见的。

  1. 设置 view 为可见

  2. 设置 yh_viewVisibleRect 为可见区域 frame。

  3. 设置 yh_viewVisible 为 true。

  4. 将 view 加入 superview 的 yh_visibleSubviews 数组。

  5. 设置 view 为不可见

  6. 设置 yh_viewVisibleRect 为 CGRectZero。

  7. 设置 yh_viewVisible 为 false。

  8. 将 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 的为不可见,并终止后续操作。

  • 判断 view. yh_visibleSubviews 中 view 是否还是 view 的 subview,不是则设置 subview 为不可见。

  • 判断是否是 UITableViewWrapperView,是则 view 的 yh_viewVisibleRect 的 originy 取其 superview 的 bounds 的 originy。这么做是因为实践中发现 UITableView 设置 bounds 的会使 view 的可见区域产生变化,需要重新设置。

  • 遍历 view 的 subview,若 subview 可见则设置其为可见,否则设置为不可见。经过以上的这些操作,我们就能知道某个 view 及其 subview 的是否可见。

  view 曝光数据采集

为了取到 view 对应的数据,同样为 UIView 添加了以下属性:

yh_exposureData:字典类型,用来存储此 view 节点需要曝光的数据。

那么还有两个问题存在:

  1. view 曝光数据的粒度

  2. view 及其 subview 的节点的曝光数据组装时机

  view 曝光数据的粒度

根据项目中的实践经验,一般以 UITableViewCell 或者 UICollectionViewCell 为最小粒度。同时,在最末节点的 yh_exposureData 字典中,增加一个 key:isEnd,用来标识是否已经是最末的节点。

  view 及其 subview 的曝光数据组装时机

一般是在最末节点的可见性变化时,由下向上的遍历最末节点的 superview,组装所有数据。

因此我们覆写了 setYh_viewVisible: 的方法,即 yh_viewVisible 的 set 方法。执行以下操作:

  • 若当前 self.yh_viewVisible 变化为 false->true,且 self.yh_exposureData 包含最末节点的标记,则由下向上的遍历最末节点的 superview,组装所有数据。

  • 设置 self.yh_viewVisible 的值。

至此,我们已经解决了 view 的可见性判断和曝光数据采集的问题。数据上报及策略不在赘述。

此方案有几个缺点

  1. 需要手动设置曝光数据。

  2. 需要在合适时机手工调用 view.yh_viewVisible 触发数据采集,如 viewdidappear 等。

  3. 需要消耗一定的资源进行可视区域计算和曝光数据采集。

还有两个问题是值得注意的:

  1. UITableView 在 setBounds: 时会对 view 的 frame 造成改变,因此需要 swizzle setBounds: 方法,需要在设置 bounds 后,调用 [self yh_updateVisibleSubViews];

  2. UIScrollView 在 setContentInset: 时会影响 view 的可见区域,因此需要 swizzle setContentInset: 方法,需要在设置 contentInset 后,调用 self.yh_viewVisibleRect = UIEdgeInsetsInsetRect(self.frame, contentInset)

业务数据自动采集

业务数据自动采集即业界流行的无埋点数据采集。传统的客户端用户点击数据采集是基于手工埋点的,对哪个位置的数据感兴趣,就在这打个点,用户操作之后,随即触发数据上报。手工埋点的缺点很明显:错埋、漏埋。新版本发布后,经常有数据部门的小伙伴来反馈说,某某点位没有上报,某某点位上报错误的问题,开发的同事也苦不堪言。

无埋点数据采集带来了新的改变。首先基本上避免了手工埋点,个别情况需要特殊处理。其次由选择性的采集数据,变成了全量采集用户的所有点击触摸数据。

新的改变也会带来新的挑战,无埋点数据采集的成为现实的可能性仍然是基于 Objective-C 的 runtime 特性。实践过程中,思路上我们借鉴了 iOS 无埋点数据 SDK 的整体设计与技术实现,实现上借鉴了 Sensors Analytics iOS SDK 和 Mixpanel iPhone。接下来,结合具体实践,介绍下我们的实现思路和遇到的一些问题。主要分以下三方面:

  1. 自动采集的点位如何确保唯一性。

  2. 不同的点位类型,需要 swizzle 哪些方法。

  3. swizzle 过程中踩到的坑。

自动采集的点位如何确保唯一性

自动采集脱离了手工埋点,因此也没了点位的唯一标识。那我们要怎么唯一定位到自动采集的点位呢?很容易想到的一个方案是:基于页面 view 的树形结构。此方案可以分解为两个问题:

  1. view 唯一标识如何定义。

  2. view 唯一标识如何生成。

  view 唯一标识(view path)的定义

我们规定,一个典型的 view path 如下:

ViewController[0]/UIView[0]/UITableView[0]/UITableViewCell[0:2]/UIButton[0]

其中:

  1. 通过此标识可以在当前页面 view 树形结构中唯一的确定此元素。

  2. 标识的每一项由两部分组成:一是当前元素的 class 的字符串表示,二是当前元素在同级元素中的序号,自 0 开始计算。如当前第二个 UIImageView,则是 UIImageView1。

  3. 标识不同项之间以 / 拼接。

  4. 标识的最顶层是当前 view 所在的 ViewController。

  5. 对于 UITableViewCell 和 UICollectionViewCell 及类似的自定义组件,序号部分由两部分组成:section 和 row,并以: 拼接。

  6. 标识的最末端是当前被点击或触摸的元素。

  view 唯一标识如何生成

view path 生成过程:由触发操作的最末端元素向上查询,一直查到 ViewController 为止。假设当前点击 view 为 A_View, 从当前的 A_View 入手遍历 view 树,每一级的数据存入 P_Array 中,过程如下:

  1. 如果 A_View 是 UICollectionViewCell 类型,获取 A_View 所处 UICollectionView 的 indexPath,P_Array push 路径信息 [NSString stringWithFormat:@"%@[%ld:%ld]",[NSString stringWithFormat:@"%@",NSStringFromClass([A_View class])],(long)indexPath.section, (long)indexPath.row];

  2. 如果 A_View 是 UITableViewCell 类型,获取 A_View 所处 UITableView 的 indexPath,P_Array push 路径信息 [NSString stringWithFormat:@"%@[%ld:%ld]",[NSString stringWithFormat:@"%@",NSStringFromClass([A_View class])],(long)indexPath.section, (long)indexPath.row];

  3. 遍历 A_View.superview 的所有 subviews,获取 A_View 处于同层级, 并且同类型 ([A_View class]) 中第几个 (index),P_Array push 路径信息 [NSString stringWithFormat:@"%@[%d]",NSStringFromClass([A_View class]),index];

  4. 获取 A_View 所处的控制器 A_VC,如果 A_View 是 A_VC.view, 则遍历结束。如果 A_View 不等同于 A_VC.view,A_View = A_View.superview,重复步骤 1-4 直到 A_View 等同于 A_VC.view。

  5. 遍历 P_Array 拼接 A_View 的完整路径。

  各种类型的点位需要 swizzle 的方法

我们把 APP 中用户的操作分成四类:

  1. UICollectionView 和 UITableView 的 cell 点击事件。

  2. UIControl(UISwitch,UIStepper,UISegmentedControl,UINavigationButton,UISlider,UIButton)类控件的点击事件。

  3. UIImageView 和 UILabel 上的 UITapGestureRecognizer 触摸事件。

  4. UITabBar、UIAlertView、UIActionSheet 等的点击事件。

这四类操作,需要 swizzle 的方法如下表所示:

其中 UICollectionView,UITableView,UITabBar,UIAlertView,UIActionSheet 实现方式类似,都是在 load 方法里 swizzle setDelegate 方法,在 setDelegate 后进行代理回调方法的 swizzle 操作,在回调方法中,先去执行原有的逻辑,再去获取对应的 viewPath。

UIControl 类的组件回调 target 的时候都会通过 UIApplication 的 sendAction:to:from:forEvent: 调用,因此我们选择 swizzle 此方法。实践中,先去获取对应的 view path,再去执行原有逻辑。原因是考虑到如果先执行原有逻辑,页面可能发生变化,获取到的 View Controller 会出错。

UITapGestureRecognizer 的事件则只处理应用在 UIImageView、UILabel 上的。swizzle addGestureRecognizer: 方法,先去执行原有逻辑,然后再给 view 加上一个自定义的回调方法,这样,当手势触发时,自定义的回调也会被调用,我们在这时获取 view path。

  swizzle 过程中踩到的坑

在测试过程中发现滑动商品列表偶现页面卡死现象,堆栈中的 log 显示是 UICollectionView 的 yh_didSelectRowAtIndexPath: 自定义的函数被循环调用。初步判断,应该是 swizzle collectionView:didSelectItemAtIndexPath: 出了问题。进一步查看代码,此处的 swizzle 逻辑是:先判断当前子类有没有具体的 IMP,如果有就直接交换原 IMP 和我们自定义函数的 IMP,如果子类是继承的,没有具体的函数实现,则先通过 class_getMethodImplementation 函数读取到在父类中的 IMP,添加到子类上,再与自定义函数交换。

这样就会有一个问题:如果上述父类在其他地方,已经被执行过 swizzle 的逻辑,也就是说父类中的 IMP 已经与我们自定义的函数交换过,那么此处子类中读取到的 IMP 就已经是我们自定义函数的实现,所以后续再执行的交换就都是自定义函数 IMP 的交换,这样就导致了循环调用

因此,我们重新调整了此处 swizzle 的逻辑:首先判断子类能否响应自定义的函数,如果能则不处理;如果不能说明子类及其父类没有被 swizzle 过,则执行以下逻辑:按照从子类到父类的顺序,判断是否实现了 collectionView:didSelectItemAtIndexPath:,如果有实现则执行原生函数 IMP 与自定义函数 IMP 的交换,swizzle 结束。这样就避免了重复 swizzle 的情况。

性能数据自动采集

为了不断的优化客户端的用户体验,需要采集客户端发生的一些操作的性能数据。现阶段我们关心的数据类型有以下几类:

1. UIViewController 加载耗时。

2. UIWebView 加载耗时。

3. 图片加载耗时。

4. HTTP 请求耗时。

5. 点击操作响应耗时。

想要无侵入的统计上述数据,还是要借助 runtime 的魔力。为了获取上述数据,需要 swizzle 的方法如下:

相应说明如下:

UIViewController 加载时间统计,只需要在 viewDidLoad 里记录开始时间,在 viewDidAppear 里记录结束时间,即可得出加载耗时。

UIWebView 加载时间统计,统计口径稍有不同。我们关注的是 UIViewController 打开到用户看到 UIWebView 内容这之间的耗时。因此我们需要在 viewDidLoad 里记录开始时间,在 webViewDidFinishLoad 里记录结束时间,即可得出加载耗时。

图片加载时间统计,图片加载我们用的是 SDWebImage,因此只需对 SDWebImageManager swizzle downloadImageWithURL:options:progress:completed: 方法即可。

HTTP 请求加载时间统计,需要同时处理 NSURLConnection 和 NSURLSession 的情况。对于 NSURLConnection,除了上表列出来的方法,还需要自定义一个 delegate 接管所有的 delegate 相关操作,并转发到用户定义的 delegate 上。还要注意重写 forwardingTargetForSelector: 方法,增加以下操作:我们自定义的 delegate 里没有实现的代理方法,先判断是用户自定义的 delegate 是否实现了,如果是,则转发过去。

对于 NSURLSession,请求开始时间要在 resume 方法里统计,此外 swizzle resume 方法时,需要采用一些特别是技巧,不然会 swizzle 不到,这点请自行 google。

swizzle dataTaskWithRequest:completionHandler: downloadTaskWithRequest:completionHandler: 时,注意点是先判断 completionHandler 是否为 nil,如果是直接调用原有逻辑即可。不然会导致 completionHandler 为 nil 的请求超时。

点击操作的响应耗时,我们只关注 UIButton,只需 swizzle sendAction:to:forEvent:,获取此方法原有逻辑的执行时间即可。

至此,有货 iOS APP 数据采集的思路和做法已经大略介绍结束。实践过程中,会遇到更多的困难和挑战。runtime 像是一把锋利的菜刀,大厨拿在手里能做出美味佳肴,学徒却会不小心切到手,伤到自己,甚至别人。然而发现问题,解决问题这才是乐趣所在,踩坑挖坑填坑之路永不会停止,盼与诸君砥砺前行。

  作者简介

曹镏,有货技术部前端总监, 有超过 5 年的 APP 研发、管理经验。 关注前端架构、模块化 / 组件化。

  移动开发前线

「移动开发前线」是 InfoQ 旗下关注移动开发技术的垂直社群。投稿请发邮件到 editors@cn.infoq.com,注明“移动开发前线投稿”。

  活动推荐

InfoQ 中国团队为大家梳理了目前机器学习领域的最新动态,并邀请到了来自 Amazon、BAT、360、小米、京东、饿了么等一线 AI 技术专家前来分享他们的机器学习落地实践经验,希望大家能从中获得所需方法论和应用经验,早日年薪百万!目前大会 8 折倒计时 4 天,欢迎点击“阅读原文”了解详情!购票咨询:18510377288(同微信)

登录查看更多
0

相关内容

【2020新书】实战R语言4,323页pdf
专知会员服务
100+阅读 · 2020年7月1日
【2020新书】使用高级C# 提升你的编程技能,412页pdf
专知会员服务
57+阅读 · 2020年6月26日
干净的数据:数据清洗入门与实践,204页pdf
专知会员服务
161+阅读 · 2020年5月14日
【实用书】Python爬虫Web抓取数据,第二版,306页pdf
专知会员服务
117+阅读 · 2020年5月10日
专知会员服务
123+阅读 · 2020年3月26日
【干货】大数据入门指南:Hadoop、Hive、Spark、 Storm等
专知会员服务
95+阅读 · 2019年12月4日
【数据中台】什么是数据中台?
产业智能官
17+阅读 · 2019年7月30日
渗透某德棋牌游戏
黑白之道
12+阅读 · 2019年5月17日
硬核实践经验 - 企鹅辅导 RN 迁移及优化总结
IMWeb前端社区
5+阅读 · 2019年5月6日
使用 C# 和 Blazor 进行全栈开发
DotNet
6+阅读 · 2019年4月15日
比Selenium快100倍的方法爬东方财富网财务报表
程序人生
8+阅读 · 2018年10月31日
Python 爬虫实践:《战狼2》豆瓣影评分析
数据库开发
5+阅读 · 2018年3月19日
一篇文章读懂阿里企业级数据库最佳实践
阿里巴巴数据库技术
5+阅读 · 2017年12月20日
情感分析:数据采集与词向量构造方法
北京思腾合力科技有限公司
29+阅读 · 2017年12月20日
朴素贝叶斯和贝叶斯网络算法及其R语言实现
R语言中文社区
10+阅读 · 2017年10月2日
基于LDA的主题模型实践(一)
机器学习深度学习实战原创交流
20+阅读 · 2015年9月9日
Meta-Learning with Implicit Gradients
Arxiv
13+阅读 · 2019年9月10日
AutoML: A Survey of the State-of-the-Art
Arxiv
69+阅读 · 2019年8月14日
Arxiv
3+阅读 · 2019年3月1日
Arxiv
7+阅读 · 2018年12月26日
Arxiv
5+阅读 · 2018年5月1日
VIP会员
相关资讯
【数据中台】什么是数据中台?
产业智能官
17+阅读 · 2019年7月30日
渗透某德棋牌游戏
黑白之道
12+阅读 · 2019年5月17日
硬核实践经验 - 企鹅辅导 RN 迁移及优化总结
IMWeb前端社区
5+阅读 · 2019年5月6日
使用 C# 和 Blazor 进行全栈开发
DotNet
6+阅读 · 2019年4月15日
比Selenium快100倍的方法爬东方财富网财务报表
程序人生
8+阅读 · 2018年10月31日
Python 爬虫实践:《战狼2》豆瓣影评分析
数据库开发
5+阅读 · 2018年3月19日
一篇文章读懂阿里企业级数据库最佳实践
阿里巴巴数据库技术
5+阅读 · 2017年12月20日
情感分析:数据采集与词向量构造方法
北京思腾合力科技有限公司
29+阅读 · 2017年12月20日
朴素贝叶斯和贝叶斯网络算法及其R语言实现
R语言中文社区
10+阅读 · 2017年10月2日
基于LDA的主题模型实践(一)
机器学习深度学习实战原创交流
20+阅读 · 2015年9月9日
Top
微信扫码咨询专知VIP会员