本篇文章是讲述 iOS 无埋点数据收集 SDK 系列的第二篇。在第一篇 <iOS无埋点数据 SDK 实践之路> 中主要介绍了 SDK 整体实现思路以及基于 viewPath
与 KVC
实现 SDK 的无埋点技术。而本篇的重点是介绍一下 SDK 中的页面别名方案以及针对 React Native
页面的数据收集方案,其中在讲解 React Native
点击事件的收集时,详细的分析了 Native 端与 JS 端对点击事件的详细处理过程,相信你在看了这部分之后也会对 React Native
中的 JS 与 Native 间的通信机制有一定的了解了。
页面别名方案
为什么引入页面别名?
在 iOS 项目开发中,经常会使用同一个 ViewController
去创建与展示多个页面,其中最有代表性的就是商品详情页。对于这种情况来说,由于这些页面的类名是同一个,因此在进行数据收集时,无法对这些页面的数据进行分别统计与显示。那么为了实现对这类页面进行数据的单独统计与分析,SDK 引入了页面别名方案。
页面别名方案是什么?
页面别名方案就是指给一个页面设置另一个名字,主要用于对同类页面进行细分。对页面设置了别名之后,SDK 在数据收集时,就会使用设置的别名了,这样就能将页面的数据区分开了。
页面别名方案的实现
页面别名方案的具体实现是给 UIViewController
扩展了一个别名属性,而对属性的存取是通过 Associated Objects
(关联对象) 来实现。
在给 UIViewController
扩展别名属性时,对 Native 页面和 React Native(以下简称 RN)页面进行了分别定义。这么做的原因是 SDK 对这2种别名属性进行了不同的处理,接下来详细的介绍一下它们。
Native 页面的别名属性
/**
* 对原生页面设置别名
*/
@property (nonatomic, copy, nullable) NSString *pageAlias;
这个属性被用于对 Native 页面设置别名,比如上面提到的商品详情页,如果想查看某一个商品的详情页的数据,那就可以将 productId 设置为这个详情页的别名。
SDK 对 Native 页面的别名的处理方案如下:
- 对于 数据SDK,如果页面有别名,那么在上报的事件数据中,page 字段的值为:类名 + 别名。(page字段用于标识事件数据所归属的页面)
- 对于 圈选SDK,不论页面有无别名,在对 view 进行圈选时,page 字段的值为:类名。(page字段用于标识圈选配置所作用的页面)
为何 数据SDK 对于有别名的页面,在进行数据收集时,page字段要带上类名,而不是直接使用别名呢?又为何 圈选SDK 的圈选配置中的 page 字段不带类名?这么做的主要原因是:同一个类名的所有页面共用同一份圈选配置,避免重复的圈配。具体看下图:
后台在对这些别名页面进行统计分析时,首先通过类名获取到对应的圈选配置,然后再对每个别名页面的数据进行统计,最后将统计结果展示到对应的别名页面中。
RN 页面的别名属性
对于 RN 页面的别名属性,又针对不同的场景分别定义了别名属性。
场景1
进行 Native 与 RN 的混合开发时,封装了一个 RNViewController
来创建不同的实例去承载一个或多个 RN 页面。
表面上看,这个场景与上面的商品详情页的情况很类似,都使用同一个类名创建不同的实例来展示不同的页面,但是这 2 者却存在一个很大的不同:上面的场景创建的实例是展示相同结构的页面,只是显示的数据不同。而这个场景创建的实例是用于展示不同结构的 RN 页面。如果页面的结构都不同,就应该认为是不同的页面,因此也就不能共用同一套圈选配置了。
因此,针对这个场景单独定义了一个页面别名属性:
/**
* 用于设置 RN 页面别名(通常使用 ModuleName 作为页面别名)
*/
@property (nonatomic, copy, nullable) NSString *pageAliasInRN;
SDK 对这个别名属性的处理方案是:
- 如果设置了此别名属性,数据SDK 和 圈选SDK 中的 page 字段的值都为:别名,不再添加类名。
场景2
在 RN 混合开发项目中,使用同一个 controller 实例展示多个 RN 页面。在纯 RN 开发项目中,只有一个最外层的 controller 实例来展示所有的 RN 页面。
这个场景的主要特点在于,多个 RN 页面被放到了一个 Native 页面中展示,这样就没法直接区分里面的每一个 RN 页面,因此为了能够进一步区分不同的 RN 页面,又定义了另一个页面别名属性:
/**
* 用于设置Native页面里当前展示的 component(通常使用 componentName 作为 RN 页面别名)
*/
@property (nonatomic, copy, nullable) NSString *componentName;
如果当前的 controller 实例的这个别名属性不为空,那么数据SDK、圈选SDK中的page字段的值为:componentName。
RN 页面的数据收集
介绍完页面别名方案后,就可以讲一下 SDK 对 RN 页面的数据收集的实现方案了。对 RN 页面的数据收集主要包括3个方面:
- 页面事件(show、hide)
- 点击事件
- 滑动事件
接下来逐个介绍 SDK 的实现方案。
页面事件的收集
页面事件的收集具体又分为如下2种情况:
- 多个 RN 页面在一个 controller 实例中通过
Navigator
跳转(简称:Navigator
跳转) - 2个展示 RN 页面的 controller 实例通过原生
UINavigationController
跳转(简称:原生跳转)
上述2种情况中,由于一个 controller 实例中展示 1个或多个 RN 页面,因此不能再使用 controller 的名字来区分每个 RN 页面。在 React Native 中,一个 RN 页面可以看做是一个组件(Component
),因此这里可以使用 componentName 作为 RN 页面名。
另外,由于 component 的名字是在 RN 中定义的,SDK 是无法自动获取的,因此需要在 JS 端通过埋点的方式将相应 component 的名字传给 SDK。
Navigator 跳转的页面事件收集
这种情况是指,在一个原生的 controller 实例中展示了多个 RN 页面,而多个 RN 页面间的跳转是由 RN 中的Navigator
来管理。这种情形主要存在于纯 RN 项目中,不过在混合开发中也会存在。此情形可以用下图表示:
结合上图,在 controller 实例1中,进行了2个操作:
- 页面1通过 Navigator
push
到页面2; - 页面2通过 Navigator
pop
到页面1;
那么,SDK 对这2个操作相应的处理方案如下:
- 从 component1
push
到 component2 时,JS 端会将 component2 的名字传递给 SDK。此时,SDK 先对 component1 产生一个hide
事件,然后再对 component2 产生一个show
事件,最后将 component2 的名字设置到 controller 的别名属性componentName
上。 - 从 component2
pop
到 component1 时,JS 端会将 component1 的名字传递给 SDK。此时,SDK 先对 component2 产生一个hide
事件,然后再对 component1 产生一个show
事件,最后将 component1 的名字设置到 controller 的别名属性componentName
上。
从上面可以看出,在通过Navigator
进行 RN 页面间的跳转时,SDK 内部对每个 RN 页面都生成了相应的 show、hide 事件。除此之外,SDK 还做了另一步:将当前显示的 component 的名字设置到 controller 的别名属性 componentName
上。这一步其实是为了 RN 页面点击事件的收集做准备的,在点击事件的收集中会用到。
原生 UINavigationController 跳转的页面事件收集
这种情形是指,承载 RN 页面的原生 controller 实例通过 iOS 原生的 UINavigationController
进行跳转,这里的跳转有 3 种情况:
- 承载 RN 页面的 controller 跳转到不含 RN 页面的 controller
- 不含 RN 页面的 controller 跳转到承载 RN 页面的 controller
- 承载 RN 页面的 controller1 跳转到承载 RN 页面的 controller2
其实,对于 SDK 来说,第 3 种情况包含了前 2 种情况,因此这里主要讲解第 3 种情况时的页面事件收集方案。第 3 种情况可以表示成下图:
结合上图,SDK 的页面事件收集方案由如下 6 步组成:
- 在 controller1 的
viewWillAppear:
触发时,首先检查 controller1 的别名属性componentName
是否有值,如果有值,则对此componentName
产生一个show
事件。如果 controller 首次创建或者不含 RN 页面,此时别名属性为空。 - JS 端在 RN 组件加载时,将组件的名字传给 SDK。由于 RN 组件只会被加载 1 次,因此这步只会发生在 controller 被首次创建时。SDK 在拿到 JS 传过来的组件名时,先对它产生一个
show
事件,再将其设置到 controller1 的别名属性componentName
上。 - 在 controller1 的
viewDidDisappear:
触发时,如果 controller1 的别名属性componentName
有值,则对它产生一个hide
事件。 - 与第 1 步类似。
- 与第 2 步类似。
- 与第 3 步类似。
当从 controller1 push
到 controller2 时,按照先后顺序会执行上述的第 3、4、5 步。当从 controller2 pop
到 controller1 时,按照先后顺序会执行上述的第 6、1 步。而第 2 步则只会在 controller1 被创建时执行。
点击事件的收集
在实现对 RN 页面的点击事件的收集时,首先简单阅读了 iOS 端 RN 框架的源码,发现里面有一个类叫 RCTTouchHandler
,它继承自 UIGestureRecognizer
。RN 主要使用这个类来完成统一接收和处理用户的点击事件,它的具体实现是重写了 UIResponder
中事件分发的四个方法:touchesBegan、touchesMoved、touchesEnded、touchesCancelled,并将触摸事件封装成RCTTouchEvent
分发到 JS 端。
错误的方案(踩的一个坑)
从上面的分析看出,其实 RN 在底层也是通过UIResponder
中提供的 4 个处理触摸事件的方法来实现对用户点击事件的处理,同时发现在这 4 个方法中都调用了 _updateAndDispatchTouches:
,那么理所当然的就想到了直接让 SDK 去 hook
此方法即可拦截到各个阶段的 Touch 事件(其实这种做法是错误的,是我采坑的开始)。
有了这个思路后,很快完成了代码编写,接着就迫不及待的放到 RN 的工程里去测试,结果发现了 2 个很严重的问题:
- 在 RN 页面的任意位置的点击都会触发点击事件的处理,并执行数据收集
- 在点击一个包含多个普通子视图的视图时,由于点击到的子视图的不同,收集到的数据也是不同的。但是这几个普通子视图是不具有响应处理能力的,不应该响应触摸事件。
看来对 RN 点击事件的收集并没有之前想象的那么简单,需要认真阅读下 RN 框架的源码了。虽然是 2 个问题,但其实在查找原因时,发现这 2 个问题产生的原因是同一个。
仔细阅读源码后,发现 RCTTouchHandler
只被用到了 2 个类中:RCTRootView
、RCTModalHostView
。RCTModalHostView
应该是在做 Modal 视图时使用的;而RCTRootView
则是 RN 中最重要的一个类,功能类似于 UIView
。其实真正使用RCTTouchHandler
的类是RCTRootContentView
,它的声明与实现都在RCTRootview.m
文件中。在RCTRootContentView
的初始化方法中创建了RCTTouchHandler
的对象,并添加到了自己上。而其它的组件大多都是RCTView
,并未处理用户的触摸事件。
因此,到这里就清楚了上述问题出现的原因了:
由于
RCTRootContentView
中添加了RCTTouchHandler
对象,同时其它的RCTView
都未拦截处理触摸事件,因此在 RN 页面的任何位置被点击时,用户的Touch
事件都会交给RCTRootContentView
去响应处理,进而进入了RCTTouchHandler
的几个方法里,同时也执行了 SDK 的数据收集的代码。在 SDK 中,是通过
touch.view
获取了当前点击的view,其实也是错误的做法,因为当前点击的 view 并不一定是响应此点击事件的 view,这个真正响应点击事件的 view 其实是由 JS 端来查找到的(后面会详细分析)。
综上,上述方案是不正确的,SDK 不能通过 hook
RCTTouchHandler
类的_updateAndDispatchTouches:
方法来执行点击事件的收集,因为这个时机无法获取到真正响应与处理Touch
事件的view。
正确的方案
那么,SDK 应该在哪个时机去执行数据收集呢?这个问题涉及到了 RN 框架中的 JS 与 OC 间的通信机制以及 JS 端的触摸事件处理机制。这里不会单独介绍 RN 的通信机制是如何实现的,接下来主要以用户的点击事件如何在 Native 端传递以及如何在 JS 端处理为主线,来讲解 SDK 对 RN 页面的点击事件的收集方案。
对于用户触摸事件的处理,主要包含了 2 部分:Native 端、JS 端。下面详细分析下各自的处理流程。
Native 端触摸事件的处理过程
根据上面 错误的方案 中的分析,在用户进行点击操作时,首先是 Native 端捕获到此触摸事件,然后在主线程中对触摸事件进行一些处理, 具体的处理过程如下图:
图中涉及到 RN 框架中的几个主要的类,先简单介绍一下它们的功能:
- RCTTouchHandler:前面介绍过,它是
UIGestureRecognizer
的子类,主要用来处理用户的触摸事件,其实只是将触摸事件的信息封装成了RCTTouchEvent
对象,即 JS 端在处理触摸事件时所需要的一些信息。 - RCTEventDispatcher:将 Native 产生的 event 缓存起来,并切换至 JSThread 执行 event 的分发,其实就是调用了
-[RCTBridge enqueueJSCall:args:]
方法去主动发起 JS 的调用。 - RCTBridge:负责 JS 与 Native 间的桥接,其内部创建并持有一个
RCTBatchedBridge
对象,大部分的代码逻辑都是由这个对象来实现的。 - RCTBatchedBridge:负责实现很多核心的业务逻辑,集中在
start
方法中。简要来说,主要包含如下几点:- loadSource:从本地或网络异步获取 jsbundle
- initModules:针对每一个
RCTBridgeModule
创建对应的RCTModuleData
,其中也包括RCTJSCExecutor
并将其存储到_moduleDataByID
、_moduleDataByName
中。 - setUpExecutor:初始化 JS 代码执行器,就是创建一个
RCTJSCExecutor
对象,并将一些 block 添加到 js 的 context 中,JavaScriptCore 框架会将其转换成 JS 的 function。这一步在 JS 与 Native 的通信中是非常重要的,而且这一步是在 JSThread 上执行的。 - injectJSONConfig:获取每个模块的 conifg ,并设置到 JS 的全局变量
__fbBatchedBridgeConfig
上。 - executeSourceCode:执行 loadSource 中的 js 代码。
- RCTJSCExecutor:JS 代码的执行器,其内部使用了
JavaScriptCore
的 context 作为 JS 的执行引擎。在其初始化时,创建了一个 JSThread 来执行 JS 方法的调用。
接着来分析下上面的调用流程,从图中可以看出,Native 端对用户的点击事件的处理发生在 2 个线程上:Main-Thread、JS-Thread。调用过程中一些主要的点在图中使用 tag 标出来了,下面逐个解释一下:
tag1:将用户的 Touch 事件的信息封装成 RCTTouchEvent
对象,其中包含了 eventName、reactTag、reactTouches、changedIndexes 等信息。
tag2:将当前 event 生成一个 eventID 并放到 _events 全局字典中,并向 JS 线程提交事件处理申请。
tag3:将执行 flush event 的 block 分发到 JS 线程上去执行。如果当前不在 JS 线程,则进行切换。
接下来的操作,全部在 JSThread 上执行,JSThread 是 Native 端创建的一个专门用于执行 JS 代码的线程,所有的 JS 代码只会在这个线程上执行。
tag4:将 _events 中所有的 event 都分发给 JSBridge,并指定要调用的 JS 端的方法:RCTEventEmitter.receiveTouches
,即 RCTEventEmitter 的 receiveTouches 方法。
tag5:将上述 RCTEventEmitter.receiveTouches
通过 .
拆分开,前面的 RCTEventEmitter
是 module 名,后面的 receiveTouches
是 method。
tag6:调用 JS 执行器去执行 JS 代码。这里有一个重要的点:声明了一个回调的 block,用来处理 JS 端想调用的 Native 端的方法。这个回调会在执行完 JS 方法后执行。block 代码如下:
callback:^(id json, NSError *error) {
[weakSelf _processResponse:json error:error];
}];
tag7:这个环节才进行真正的 JS 代码调用,不过这里并没有直接调用前面 Native 所指定的 JS 方法,而是先调用了 JS 中的 1 个中转方法 callFunctionReturnFlushedQueue
,这个方法被定义在 MessageQueue.js
中。
Native 端按照上面的流程执行完对点击事件的处理后,通过 RCTBatchedBridge
主动调用 JS 方法,将点击事件交给了 JS 端处理。下面再详细分析下 JS 端的处理过程。
JS 端触摸事件的处理过程
在 JS 端对点击事件的处理过程中,包含了对 Native 方法的调用,同时 Native 端又针对 JS 发起的调用,进行 Native 方法的调用。为了使整个过程连贯起来,这里将 JS 端与 Native 端的处理放到一起分析了。整个处理过程见下图:
下面仍然针对上面所标出的点逐个讲解:
tag8:首先会进入 MessageQueue.js
的 callFunctionReturnFlushedQueue
方法,即前面所说的中转方法,这个中转方法主要做了2步:(1)调用 __callFunction
方法根据 Native 端传过来的 module、method、args 来找到 JS 端方法并触发。(2)调用 flushedQueue
将当前保存的要调用 Native 的方法的 queue 返回给 Native 端。
tag9:接着进入 ReactNativeEventEmitter.js
的 receiveTouches
方法中,即 Native 端想要调用的 JS 方法。这里有一点可能有人会问:前面在 Native 端要调用的 module 是 RCTEventEmitter
,在 JS 端怎么变成了 ReactNativeEventEmitter
,其实这是因为 JS 端在通过 BatchedBridge.registerCallableModule
注册对 Native 端暴露的 module 时,就是注册的 ReactNativeEventEmitter
,只是名字设成了 RCTEventEmitter
。看如下代码就明白了:
// RCTEventEmitter.js
const RCTEventEmitter = {
register(eventEmitter: any) {
BatchedBridge.registerCallableModule(
'RCTEventEmitter',
eventEmitter
);
}
};
// ReactNativeDefaultInjection.js
RCTEventEmitter.register(ReactNativeEventEmitter);
tag10:React.js 也提供了一种类似于 Native 端的触摸事件处理机制,用来查找能够响应触摸事件的组件,并执行事件响应。JS 端在接收到 Touch 事件后,进入触摸事件处理流程,并在查找到当前触摸事件对应的响应者时,触发 ReactNativeGlobalResponderHandler.js
的 onChange
指定的函数。
tag11:在 onChange
的函数中主动调用了 UIManager
的 setJSResponder
方法,对应到 Native 的 RCTUIManager
的 setJSResponder:blockNativeResponder:
方法。即开始了 JS -> Native 的调用过程,此时会直接进入 NativeModules.js
的 genMethod
方法来查找到对应的 moduleID、methodID、args 等信息,最终执行 MessageQueue.js
的 enqueueNativeCall
方法。
tag12:在 enqueueNativeCall
方法中,将 JS 端要调用的 Native 端方法的 moduleID、methodID、args 放入全局数组 _queue
中。然后等待 Native 端主动来取,JS 端通过 flushedQueue
方法将 _queue
传过去,接着就进入到了 tag6 中所提到的回调 block 中。这里将主要的 JS 代码也贴出来,便于理解:
// enqueueNativeCall 方法
this._queue[MODULE_IDS].push(moduleID); // MODULE_IDS = 0
this._queue[METHOD_IDS].push(methodID); // METHOD_IDS = 1
this._queue[PARAMS].push(params); // PARAMS = 2
// flushedQueue 方法
const queue = this._queue;
this._queue = [[], [], [], this._callID];
return queue[0].length ? queue : null;
tag13:除了等待 Native 端主动来取 _queue
中的值外,还有 1 种方式就是:JS 端主动发起对 Native 方法的调用,具体是调用 global.nativeFlushQueueImmediate
方法。不过这种方式有一个条件:距离 Native 上次主动获取超过 5 ms。相应的 JS 代码如下:
// enqueueNativeCall 方法
const now = new Date().getTime();
if (global.nativeFlushQueueImmediate &&
now - this._lastFlush >= MIN_TIME_BETWEEN_FLUSHES_MS) {
global.nativeFlushQueueImmediate(this._queue);
this._queue = [[], [], [], this._callID];
this._lastFlush = now;
}
tag14:通过图中的方式1 与方式2,最终转到了 Native 端,并进入 RCTBatchedBridge
的 handleBuffer:batchEnded:
方法中。接着调用 handleBuffer:
方法从 JS 的 _queue
数组中解析出 moduleID、methodID、params。
tag15:在 RN 中定义 module 时,可以为 module 指定一个 methodQueue 执行它的方法。因此,通过 dispatchBlock:queue:
方法将各个 module 的方法的调用分发到各自的 methodQueue 上。这里 RCTUIManager
的 methodQueue 是名字为 com.facebook.react.ShadowQueue 的串行队列。
tag16:在切换至指定的 methodQueue 上后,调用 callNativeModule:method:params:
方法开始 Native 方法的调用。在这个方法中,会根据 moduleID、methodID 从全局数组 _moduleDataByID
中找到 Native 端对应的 moduleData、moduleMethod。
tag17:在 invokeWithBridge:module:arguments:
方法中,将 Native 方法的调用封装成 NSInvocation
对象,并触发执行。其实就是调用在 tag11 中提到的 RCTUIManager
类的 setJSResponder:blockNativeResponder:
。这个方法的定义是:RCT_EXPORT_METHOD(setJSResponder:(nonnull NSNumber *)reactTag blockNativeResponder:(__unused BOOL)blockNativeResponder)
,它具有 2 个参数,其中第 2 个参数没有使用到,而第 1 个参数 reactTag 是非常重要的,使用它从全局字典 _viewRegistry
中能拿到对应的 view,而这个 view 就是真正响应用户点击事件的视图。
收集点击事件的正确时机
到这里,已经从 Native -> JS -> Native 完整的分析了一遍整个处理过程,可以清晰得看到 RN 页面中的用户点击事件是如何被传递与处理的。其实,对于 SDK 来说,最重要的是找到一个正确的时机去执行数据收集,通过上面的分析可以很容易的找到这个时机:在 JS 端回调 RCTUIManager
的 setJSResponder:blockNativeResponder:
时。因为在这个时机,SDK 能够拿到真正响应点击事件的 view。
最终的实现方案
那么,SDK 对 RN 的点击事件的收集方案也已经明确了,具体可以分为如下 3 步:
-
hook
RN 框架中的RCTUIManager
类的setJSResponder:blockNativeResponder:
方法。 - 根据
_viewRegistry
和reactTag
拿到真正响应点击事件的 view 对象。 - 将此点击事件的数据归属到正确的 page 中。如果
componentName
属性有值,则使用它;否则使用pageAliasInRN
属性的值。
另外,在第 2 个调用流程图中,提到了 RN 的触摸事件处理机制,并没有详细讲解,不过可以去看一下这篇文章,里面讲解的非常清楚。
滑动事件的收集
RN 中有 2 个很常用的组件:ScrollView、ListView。这 2 个组件最常用的交互动作就是滑动,因此 SDK 也需要收集它的滑动事件。由于 ListView 是基于 ScrollView 封装实现的,因此 SDK 只需要对 ScrollView 的滑动事件进行收集即可。
其实查看一下 ScrollView.js
的源码可以看出,在 iOS 平台上,ScrollView 组件其实使用的是 RN 框架中的 RCTScrollView
,相应的 JS 代码为:
else if (Platform.OS === 'ios') {
nativeOnlyProps = {
nativeOnly: {
onMomentumScrollBegin: true,
onMomentumScrollEnd : true,
onScrollBeginDrag: true,
onScrollEndDrag: true,
}
};
RCTScrollView = requireNativeComponent('RCTScrollView', ScrollView, nativeOnlyProps);
}
接着,查看一下 RN 框架中的 RCTScrollView
,发现其内部支持一个 RCTCustomScrollView
类的对象,而这个 RCTCustomScrollView
是 UIScrollView
的子类。因此,可以得出 1 个结论:RN 中的 ScrollView
组件其实对应到 iOS 中的 UIScrollView
。
那么,SDK 要收集 RN 页面中的滑动事件,就相当于收集 iOS 原生 UIScrollView
的滑动事件,因此只需要 hook
UIScrollViewDelegate
的相关方法即可。
END
全文主要讲述了无埋点 SDK 中的 RN 页面的数据收集方案,以及页面别名方案的引入。其中 RN 点击事件的收集方案占了较大篇幅,里面涉及到了 Native 与 JS 的通信过程,包括 RN 中多线程的使用。个人感觉从 RN 框架的源码中还是能发掘到不少干货的,所以建议大家有时间了可以去阅读下 RN 的源码。