NVIDIA 初创加速计划,免费加速您的创业启动 了解详情
写点什么

有货 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:404629

评论

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

市面上支持信创的堡垒机哪家好?为什么?

行云管家

网络安全 信创 数据安全 堡垒机

3步体验在DAYU200开发板上完成OpenHarmony对接华为云IoT

华为云开发者联盟

鸿蒙 物联网 华为云 华为云开发者联盟 企业号9月PK榜

CodeArts Check代码检查服务用户声音反馈集锦(3)

华为云PaaS服务小智

云计算 代码质量 华为云 代码检查

2023年8款最佳云数据库综合比较

Geek_cbbf33

软通咨询杨念农:咨询2.0是企业数字化转型的大脑

软通咨询

数字化转型 #人工智能 管理咨询 数字化转型咨询

基于异常上线场景的实时拦截与问题分发策略

百度Geek说

大数据 实时计算 企业号9月PK榜 反混淆

HarmonyOS Codelab样例—弹窗基本使用

HarmonyOS开发者

HarmonyOS

优化Java代码效率和算法设计,提升性能

互联网工科生

Java 并发编程 性能测试 数据结构和算法

数字化转型与架构-架构设计篇|什么是架构风格和架构模式?

数字随行

数字化转型

fastposter 新版本 v2.17.0 强势发布!让海报开发更简单

物有本末

图片处理 海报生成器 海报生成 海报小程序

文心一言 VS 讯飞星火 VS chatgpt (96)-- 算法导论9.3 1题

福大大架构师每日一题

福大大架构师每日一题

万能音视频转换器 Permute 3 for mac免激活中文版

mac大玩家j

Mac软件 音频格式转换器 音频转换

FIL NEW算力挖矿系统开发

l8l259l3365

详述 IntelliJ IDEA 中自动生成 serialVersionUID 的方法

南屿

IntelliJ IDEA IntelliJ IDEA 2023破解 Serializable

区块链数字货币交易所开发方案,去中化交易平台搭建

V\TG【ch3nguang】

专业开发区块链DAPP去中心化系统模式开发系统定制

V\TG【ch3nguang】

面对IT部门和业务部门跨网文件交换的不同需求,怎样才能兼顾呢?

镭速

跨网文件交换

termius使用ssh教程 【XShell的神器Termius】

南屿

SSH Termius

Markdown文本编辑器Typora Mac使用教程

南屿

Typora Markdown 编辑器

Tugraph Analytics图计算快速上手之紧密中心度算法

TuGraphAnalytics

cc 图计算 紧密中心度

OpenHarmony Meetup常州站招募令

OpenHarmony开发者

OpenHarmony

百度智能云引领建设智能云标准生态,第十二届云计算标准和应用大会成功召开

Baidu AICLOUD

智能云 大模型 AI 原生云

什么是高匿代理,与普匿和透明代理的区别是什么?它有什么作用?

巨量HTTP

代理IP http代理

一文告诉你为什么时序场景下 TDengine 数据订阅比 Kafka 好

TDengine

时序数据库 #TDengine

High-performance 4-core processor-IPQ9554-IPQ8072-more advanced Wi-Fi 6E technology

wifi6-yiyi

IPQ8072 ipq9554 4-core

数字货币swap交易所逻辑系统开发分析方案

西安链酷科技

交易所开发 DAPP智能合约交易系统开发

专业级PDF编辑和管理 Acrobat Pro DC 2023 for Mac

胖墩儿不胖y

Mac软件 pdf编辑器 编辑pdf pdf工具

行云管家支持信创吗?是真的吗?

行云管家

信创 国产化 行云管家

未来AI领域的颠覆性力量

百度开发者中心

自然语言 #人工智能 文心一言

强大但并非万能,智能客服之挑战

百度开发者中心

智能客服 #人工智能 千帆大模型平台

区块链去中化钱包开发方案,交易所钱包和元宇宙软件开发

V\TG【ch3nguang】

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