写点什么

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

  • 2017-12-25
  • 本文字数:6634 字

    阅读完需:约 22 分钟

随着有货 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_IDSOURCE_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 个条件:

  • subview.hidden为 false,即 view 没有被隐藏。
  • subview.alpha大于等于 0.01,即 view 是可见的。
  • subview的 frame 是否在 view 的可见区域内。

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

2. 设置 view 为可见

  • 设置yh_viewVisibleRect为可见区域 frame。
  • 设置yh_viewVisible为 true。
  • 将 view 加入 superview 的yh_visibleSubviews数组。

3. 设置 view 为不可见

  • 设置yh_viewVisibleRectCGRectZero
  • 设置yh_viewVisiblefalse
  • 将 view 自superviewyh_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 的 origin y。这么做是因为实践中发现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. UITableViewsetBounds:时会对 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,则是 UIImageView 1
  3. 标识不同项之间以 / 拼接。
  4. 标识的最顶层是当前 view 所在的 ViewController。
  5. 对于 UITableViewCell 和 UICollectionViewCell 及类似的自定义组件,序号部分由两部分组成:section 和 row,并以: 拼接。
  6. 标识的最末端是当前被点击或触摸的元素。

view 唯一标识如何生成

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

  1. 如果A_ViewUICollectionViewCell类型,获取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 研发、管理经验。 关注前端架构、模块化 / 组件化。

感谢覃云对本文的审校。

2017-12-25 16:404793

评论

发布
暂无评论
发现更多内容

After Effects 2024 for Mac(ae视频特效制作工具) v24.0.2永久激活版

mac

windows 苹果mac After Effects 2024 AE2024 视频特效制作软件

MobPush智能推送工具,助力实现用户全生命周期管理

MobTech袤博科技

智能推送

MobPush智能推送:数智化运营释放APP用户生命周期价值

MobTech袤博科技

.NET CORE 之gRpc使用

gogo

喜讯!云起无垠获评GEEKCON 2023"前沿突破奖"

云起无垠

JAVA 调用Open AI 接口生成图片url并直接在浏览器上响应显示

风清扬

openai 图片生成 AI绘画 ChatGPT chatgpt api

我们该如何规划自己的职业生涯?

老张

职业规划 职场成长

跨平台.NET IDE集成开发 JetBrains Rider注册码激活版

mac大玩家j

代码编辑器 Mac软件

企业级API资产如何管理

RestCloud

API 资产管理 API 接口

剑指pulsar之数据写入流程

少年游侠客

消息队列 pulsar 写数据

以技术创新,让美好发生!第二届华为云杯“少年开发者”人工智能大赛总决赛成功落幕

彭飞

浙大材料学院高性能存储实践,加速 AI 新材料科研创新

焱融科技

HarmonyOS数据管理与应用数据持久化(二)

HarmonyOS开发者

软件项目验收计划书

金陵老街

Stable Diffusion 的提示词使用技巧

3D建模设计

Stable Diffusion 自动纹理

焱融全闪 | 高算力时代下的国产存储之光

焱融科技

混合云场景下基于 Fluid 的焱融高效存储方案

焱融科技

利用稳定扩散快速修复图像

3D建模设计

人工智能「 Stable Diffustion 图像修复

MobPush后台配置教程

MobTech袤博科技

智能推送

一图看懂华为云CodeArts Link六大特性,带你体验一站式跨平台数据互联

华为云PaaS服务小智

云计算 软件开发 华为云

YRCloudFile V7.0.0发布| 新增 EC 数据冗余保护功能

焱融科技

Node.js 中 HTML 解析的终极指南:探索各种方法

Liam

JavaScript node.js html 前端 Web

云原生微服务的下一站:Proxyless Service Mesh

华为云开发者联盟

微服务 云原生 华为云 华为云开发者联盟

推送没人看?MobPush助力APP运营提质增效

MobTech袤博科技

MobPush自定义智能标签,赋能精细化运营

MobTech袤博科技

智能推送

企业几种快速传输大文件的使用方法,你GET到了吗

镭速

大文件传输 传输大文件

DAPP合约代币质押算力挖矿系统开发

l8l259l3365

生信领域|焱融存储为极智基因打造高性能生物医学平台

焱融科技

Lightsail CDN 现已对 Lightsail Container Services 作为来源进行支持

亚马逊云科技 (Amazon Web Services)

CDN Amazon Lightsail Amazon CloudFront

有货iOS数据非侵入式自动采集探索实践_Android/iOS_曹镏_InfoQ精选文章