React Native 点击事件采集方案/数据采集

一、前言

React Native 是由 Facebook 推出的移动应用开发框架,可以用来开发 iOS、Android、Web 等跨平台应用程序,官网为:

https://facebook.github.io/react-native/。

React Native 和传统的 Hybrid 应用最大的区别就是它抛开了 WebView 控件。React Native 产出的并不是 “网页应用”、“HTML5 应用” 或者 “混合应用”,而是一个真正的移动应用,从使用感受上和用 Objective-C 或 Java 编写的应用相比几乎是没有区别的。React Native 所使用的基础 UI 组件和原生应用完全一致。我们要做的就是把这些基础组件使用 JavaScript 和 React 的方式组合起来。React Native 是一个非常优秀的跨平台框架。

React Native 可以通过自定义 Module [1] 的方式实现 JavaScript 调用 Native 接口,神策分析的 React Native Module [2]在 v2.0 版本使用新方案实现了 React Native 全埋点功能。本文主要介绍神策分析 React Native Module 是如何实现 $AppClick(全埋点的点击事件) 功能的,内容以 iOS 项目为例。

二、原理分析

2.1 触发点击

在 React Native 中没有专门的按钮组件,为了让视图能够响应用户的点击事件,我们需要借助 Touchable 系列组件来包装我们的视图。 

2.1.1 Touchable 系列组件

Touchable 系列组件中的四个组件都可以用来包装视图,从而响应用户的点击事件:

  • TouchableHighlight:在用户手指按下时背景会有变暗的效果;

  • TouchableNativeFeedback:在 Android 上可以使用 TouchableNativeFeedback,它会在用户手指按下时形成类似水波纹的视觉效果。注意,此组件只支持 Android;

  • TouchableOpacity:会在用户手指按下时降低按钮的透明度,而不会改变背景的颜色;

  • TouchableWithoutFeedback:响应用户的点击事件,如果你想在处理点击事件的同时不显示任何视觉反馈,使用它是个不错的选择。

以上组件中前三者都是在 TouchableWithoutFeedback 的基础上做了一些扩展,我们从源码中可以看出:

TouchableHighlight

1
2
3
4
5
6
7
8
9
10
11
12
type Props = $ReadOnly<{|
...TouchableWithoutFeedbackProps,
...IOSProps,
...AndroidProps,

activeOpacity?: ?number,
underlayColor?: ?ColorValue,
style?: ?ViewStyleProp,
onShowUnderlay?: ?() => void,
onHideUnderlay?: ?() => void,
testOnly_pressed?: ?boolean,
|}>;

TouchableNativeFeedback

1
2
3
4
5
propTypes: {
/* $FlowFixMe(>=0.89.0 site=react_native_android_fb) This comment
* suppresses an error found when Flow v0.89 was deployed. To see the
* error, delete this comment and run Flow. */
...TouchableWithoutFeedback.propTypes,

TouchableOpacity

1
2
3
4
5
6
type Props = $ReadOnly<{|
...TouchableWithoutFeedbackProps,
...TVProps,
activeOpacity?: ?number,
style?: ?ViewStyleProp,
|}>;

因为 TouchableWithoutFeedback 有其他组件的共同属性,所以我们只需要来了解下 TouchableWithoutFeedback 是如何实现点击功能的。

2.1.2 Touchable 功能介绍

React Native 的响应系统用起来可能比较复杂,因此官方提供了一个抽象的 Touchable 实现,用来做 “可触控” 的组件。Touchable 系列组件相关文件都在

node_modules/react-native/Libraries/Components/Touchable 文件夹中。在 Touchable 文件夹下也提供了 Touchable.js 文件,点击功能的实现都是在此文件中。

React Native 对 Touchable.js 的描述如下:  

1
2
3
4
5
6
7
8
9
10
11
* ====================== Touchable Tutorial ===============================
* The `Touchable` mixin helps you handle the "press" interaction. It analyzes
* the geometry of elements, and observes when another responder (scroll view
* etc) has stolen the touch lock. It notifies your component when it should
* give feedback to the user. (bouncing/highlighting/unhighlighting).
*
* - When a touch was activated (typically you highlight)
* - When a touch was deactivated (typically you unhighlight)
* - When a touch was "pressed" - a touch ended while still within the geometry
* of the element, and no other element (like scroller) has "stolen" touch
* lock ("responder") (Typically you bounce the element).

从描述中可以看出,Touchable 会帮助开发者处理触摸交互,当有其他响应者响应了触摸交互时,Touchable 也会及时通知控件向用户提供反馈。

2.1.3 Touchable 状态变化

React Native 控件的触摸操作是会发生变化的,为了监听控件触摸状态的变化,React Native 在 Touchable 中声明了 State 和 Signal 类型来描述用户的触摸行为。

State

1
2
3
4
5
6
7
8
9
type State =
| typeof States.NOT_RESPONDER // 非响应者
| typeof States.RESPONDER_INACTIVE_PRESS_IN // 无效的按压
| typeof States.RESPONDER_INACTIVE_PRESS_OUT // 无效的抬起
| typeof States.RESPONDER_ACTIVE_PRESS_IN // 有效的按压
| typeof States.RESPONDER_ACTIVE_PRESS_OUT // 有效的抬起
| typeof States.RESPONDER_ACTIVE_LONG_PRESS_IN // 有效的长按
| typeof States.RESPONDER_ACTIVE_LONG_PRESS_OUT // 有效的长按后抬起
| typeof States.ERROR; // 错误

Signal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* Inputs to the state machine.
*/
const Signals = keyMirror({
DELAY: null,
RESPONDER_GRANT: null,
RESPONDER_RELEASE: null,
RESPONDER_TERMINATED: null,
ENTER_PRESS_RECT: null,
LEAVE_PRESS_RECT: null,
LONG_PRESS_DETECTED: null,
});

type Signal =
| typeof Signals.DELAY // 延迟触发信号
| typeof Signals.RESPONDER_GRANT // 开始触摸
| typeof Signals.RESPONDER_RELEASE // 触摸结束
| typeof Signals.RESPONDER_TERMINATED //触摸中断
| typeof Signals.ENTER_PRESS_RECT // 进入按压范围内
| typeof Signals.LEAVE_PRESS_RECT // 离开按压范围
| typeof Signals.LONG_PRESS_DETECTED; // 检测是否为长按

交互流程如图 2-1 所示:

图 2-1 交互流程图(参考:React Native 源码 [3])

从图 2-1 中可以看出,当 State 为 RESPONDER_ACTIVE_PRESS_IN 并且 Signal 为 RESPONDER_RELEASE 时,表示用户正在点击控件。因此,我们可以在这里触发控件的点击事件采集。

_performSideEffectsForTransition 函数中已有此逻辑的判断,我们可以在这里添加打印信息来验证方案的可行性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
_performSideEffectsForTransition: function(
curState: State,
nextState: State,
signal: Signal,
e: PressEvent,
) {
// ...
const shouldInvokePress =
!IsLongPressingIn[curState] || pressIsLongButStillCallOnPress;
if (shouldInvokePress && this.touchableHandlePress) {
if (!newIsHighlight && !curIsHighlight) {
// we never highlighted because of delay, but we should highlight now
this._startHighlight(e);
this._endHighlight(e);
}
if (Platform.OS === 'android' && !this.props.touchSoundDisabled) {
this._playTouchSound();
}
console.log("这里是按钮点击");
this.touchableHandlePress(e);
}
}

this.touchableDelayTimeout && clearTimeout(this.touchableDelayTimeout);
this.touchableDelayTimeout = null;
},

在项目入口文件 App.js 中添加 Button 按钮并运行项目,点击 Button 按钮可以看到终端控制台打印内容 “这里是按钮点击”,如图 2-2 所示:

[图片上传失败…(image-3e99c3-1685460922460)]

图 2-2 控制台打印信息

至此,我们就找到了触发 $AppClick 事件的时机。

2.2 创建视图

上一节中我们已经找到了触发 $AppClick 事件的时机。但是,还存在一个问题:在 React Native 中是无法直接获取到触发点击事件对应的 View 对象。针对这一问题,我们可以通过 reactTag 来解决。

**2.2.1 reactTag **

在 React Native 项目中会给每个 View 分配一个唯一的 id(reactTag)。reactTag 是一个递增的整型数字,我们可以通过 reactTag 来找到每一个 View 对象。

RCTRootView 作为整个 React Native 项目的入口,初始化时会默认将 1 分配给 RCTRootView 作为 reactTag,即 RootTag 。

我们下面来看下 reactTag 的生成规则:

1
2
3
4
5
6
7
8
9
10
11
12
// Counter for uniquely identifying views.
// % 10 === 1 means it is a rootTag.
// % 2 === 0 means it is a Fabric tag.
var nextReactTag = 3;
function allocateTag() {
var tag = nextReactTag;
if (tag % 10 === 1) {
tag += 2;
}
nextReactTag = tag + 2;
return tag;
}

从上面的代码片段中可以看出,tag 以 +2 的方式递增,当 tag % 10 === 1 时会再做一次累加。因此,tag % 10 === 1 只会出现一次,即 RootTag。

2.2.2 创建视图

在 React Native 中所有的 View 都是通过 RCTUIManager 类来进行创建并管理的。RCTUIManager 类提供了如下方法来创建 View 对象:

1
2
3
4
RCT_EXPORT_METHOD(createView:(nonnull NSNumber *)reactTag
viewName:(NSString *)viewName
rootTag:(nonnull NSNumber *)rootTag
props:(NSDictionary *)props)

下面我们需要找到此方法是在哪里调用的,这样就可以知道在 JavaScript 端创建 View 的时机。经过在 react-native 源码中查找,定位到 /node_modules/react-native/Renderer/implementations/ReactNativeRenderer-dev.js 中有如下代码片段:

1
2
3
4
5
6
ReactNativePrivateInterface.UIManager.createView(
tag, // reactTag
viewConfig.uiViewClassName, // viewName
rootContainerInstance, // rootTag
updatePayload // props
);

可以看出,这里就是 JavaScript 端创建 View 的代码位置。我们可以在这里添加 Hook 代码将 View 的 reactTag 保存起来。

2.2.3 方案简述

根据前面两节的内容可知,我们可以在 UIManager 创建视图时将可点击视图的 reactTag 保存起来,当控件触发点击时通过对比 reactTag 判断当前点击的视图是否为可点击,并通过 reactTag 找到对应的 View 对象触发 $AppClick 点击事件。

三、准备工作

3.1 创建项目

在实现 React Native 点击事件采集方案之前,我们首先创建一个演示项目。详细的安装步骤可以参考官网 environment-setup [4]部分,现在使用下面的命令创建一个 React Native 项目。

1
2
3
react-native init AwesomeProject --version 0.61.5
cd AwesomeProject
react-native run-ios

注意:0.62.x 及以上版本针对控件点击功能源码有部分改动,我们已在神策分析 React Native Module 后续版本中进行了兼容。这里为了演示效果,我们仍以 v0.61.5 版本来进行后续功能的说明。

通过以上命令我们已经创建了一个 AwesomeProject 的 React Native 项目,并可以成功运行项目。

行项目。项目如图 3-1 所示:

图 3-1 React Native 项目截图

3.2 集成神策分析

1. 在项目目录下执行 “cd ios” 命令后再执行 “vim Podfile” 命令编辑 Podfile 文件。将” pod ‘SensorsAnalyticsSDK’ “ 添加在文件中后保存,并执行 “pod install” 命令集成神策分析 SDK。Podfile 文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
platform :ios, '9.0'
require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'

target 'AwesomeProject' do
# Pods for AwesomeProject
# ......
Pod 'SensorsAnalyticsSDK'

target 'AwesomeProjectTests' do
inherit! :search_paths
# Pods for testing
end

use_native_modules!
end

target 'AwesomeProject-tvOS' do
# Pods for AwesomeProject-tvOS

target 'AwesomeProject-tvOSTests' do
inherit! :search_paths
# Pods for testing
end

end

2. 将AwesomeProject.xcworkspace  打开(在 “ios 文件夹” 下),并在 AppDelegate 中初始化神策分析 SDK:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#import <SensorsAnalyticsSDK/SensorsAnalyticsSDK.h>

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
....

SAConfigOptions *options = [[SAConfigOptions alloc] initWithServerURL:@"" launchOptions:launchOptions];
options.autoTrackEventType = SensorsAnalyticsEventTypeAppStart | SensorsAnalyticsEventTypeAppEnd | SensorsAnalyticsEventTypeAppClick | SensorsAnalyticsEventTypeAppViewScreen;
options.enableLog = YES;
[SensorsAnalyticsSDK startWithConfigOptions:options];

return YES;
}

完成初始化 SDK 后运行项目,可以看到控制台会打印出 $AppStart 事件。

3.3 创建 Module

集成神策分析 SDK 后我们还需要创建一个 React Native Module 用来将 Native 触发 $AppClick 的接口提供给 JavaScript 端调用。

1. 打开 Xcode 并选择 File → New → Project…,输入静态库名称 SensorsAnalyticsModule。如图 3-2 所示:

图 3-2 创建 Module

2. 在静态库项目文件夹下添加 SensorsAnalyticsModule.podspec 文件,文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Pod::Spec.new do |s|
s.name = "SensorsAnalyticsModule"
s.version = "0.0.1"
s.summary = "The official React Native SDK of Sensors Analytics."
s.homepage = "http://www.sensorsdata.cn"
s.license = { :type => "Apache License, Version 2.0" }
s.author = { "Yuanyang Peng" => "pengyuanyang@sensorsdata.cn" }
s.source = { :git => "https://github.com/sensorsdata/react-native-sensors-analytics", :tag => "v#{s.version}" }
s.platform = :ios, "7.0"
s.source_files = "SensorsAnalyticsModule/*.{h,m}"
s.requires_arc = true
s.dependency "React"

end

3. 将创建的 SensorsAnalyticsModule 工程文件夹移动到演示项目根目录下,并在演示项目 “ios 文件夹” 下的 Podfile 文件中,添加 SensorsAnalyticsModule 引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
platform :ios, '9.0'
require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'

target 'AwesomeProject' do
# Pods for AwesomeProject
# ......
pod 'SensorsAnalyticsSDK'
pod 'SensorsAnalyticsModule', :path => '../SensorsAnalyticsModule/'
target 'AwesomeProjectTests' do
inherit! :search_paths
# Pods for testing
end

use_native_modules!
end

target 'AwesomeProject-tvOS' do
# Pods for AwesomeProject-tvOS

target 'AwesomeProject-tvOSTests' do
inherit! :search_paths
# Pods for testing
end

end

运行项目后可以正常工作,至此准备工作已完成。

四、代码实现

通过前面的介绍,我们已经知道了实现 $AppClick 事件功能的关键步骤,下面来详细说明下代码的实现。

4.1 Module

1. 在 SensorsAnalyticsModule.h 中添加 RCTBridgeModule 引用及实现协议内容:

1
2
3
4
5
#import <React/RCTBridgeModule.h>

@interface SensorsAnalyticsModule : NSObject <RCTBridgeModule>

@end

2. 在 SensorsAnalyticsModule.m 中新增 reactTags 集合属性来保存可点击视图的 reactTag 信息:

1
2
3
4
5
6
7
8
9
#import <SensorsAnalyticsSDK/SensorsAnalyticsSDK.h>
#import <React/RCTRootView.h>
#import <React/RCTUIManager.h>

@interface SensorsAnalyticsModule ()

@property (nonatomic, strong) NSMutableSet<NSNumber*> *reactTags;

@end

3. 在 SensorsAnalyticsModule.m 中添加 Module 声明,并添加 + sharedInstance 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@implementation SensorsAnalyticsModule

RCT_EXPORT_MODULE(SensorsAnalyticsModule)

+ (instancetype)sharedInstance {
static dispatch_once_t onceToken;
static SensorsAnalyticsModule *module;
dispatch_once(&onceToken, ^{
module = [[SensorsAnalyticsModule alloc] init];
});
return module;
}

@end

4. 新增 saveReactTag:clickable: 方法用来保存可点击视图的 reactTag,并将此方法通过 RCT_EXPORT_METHOD 提供给 JavaScript 端调用:

1
2
3
4
5
6
7
RCT_EXPORT_METHOD(saveReactTag:(NSInteger)reactTag clickable:(BOOL)clickable) {
if (!clickable) {
return;
}
SensorsAnalyticsModule *module = [SensorsAnalyticsModule sharedInstance];
[module.reactTags addObject:@(reactTag)];
}

5. 通过 reactTag 找到对应视图:

1
2
3
4
5
6
- (UIView *)viewForTag:(NSNumber *)reactTag {
UIViewController *root = [[[UIApplication sharedApplication] keyWindow] rootViewController];
RCTRootView *rootView = [root rootView];
RCTUIManager *manager = rootView.bridge.uiManager;
return [manager viewForReactTag:reactTag];
}

6. 新增 trackViewClick: 方法用来触发 $AppClick 事件。在 trackViewClick: 方法中通过 reactTag 找到对应的视图后触发 $AppClick 事件:

1
2
3
4
5
6
7
8
9
10
11
RCT_EXPORT_METHOD(trackViewClick:(NSInteger)reactTag) {
SensorsAnalyticsModule *module = [SensorsAnalyticsModule sharedInstance];
BOOL clickable = [module.reactTags containsObject:@(reactTag)];
if (!clickable) {
return;
}
dispatch_async(dispatch_get_main_queue(), ^{
UIView *view = [module viewForTag:@(reactTag)];
[[SensorsAnalyticsSDK sharedInstance] trackViewAppClick:view withProperties:nil];
});
}

4.2 手动插入代码

1.在 /node_modules/react-native/Renderer/implementations/ReactNativeRenderer-dev.js 的“ReactNativePrivateInterface.UIManager.createView” 代码前插入 Hook 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
(function(thatThis){
try{
var clickable = false;
if(props.onStartShouldSetResponder){
clickable = true;
}
var ReactNative = require('react-native');
var dataModule = ReactNative.NativeModules.SensorsAnalyticsModule;
dataModule && dataModule.saveReactTag && dataModule.saveReactTag(tag, clickable);
} catch (error) {
throw new Error('SensorsAnalyticsModule Hook Code 调用异常: ' + error);
}
})(this); /* SENSORSDATA HOOK */
ReactNativePrivateInterface.UIManager.createView(
tag, // reactTag
viewConfig.uiViewClassName, // viewName
rootContainerInstance, // rootTag
updatePayload // props
);

// 在此方法前插入代码
ReactNativePrivateInterface.UIManager.createView(
tag, // reactTag
viewConfig.uiViewClassName, // viewName
rootContainerInstance, // rootTag
updatePayload // props
);

2. 在 node_modules/react-native/Libraries/Components/Touchable/Touchable.js 的 “this.touchableHandlePress(e);” 代码前插入 Hook 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
(function(thatThis) {
try {
var ReactNative = require('react-native');
var module = ReactNative.NativeModules.SensorsAnalyticsModule;
thatThis.props.onPress && module && module.trackViewClick && module.trackViewClick(ReactNative.findNodeHandle(thatThis));
} catch (error) {
throw new Error('SensorsData RN Hook Code 调用异常: ' + error);
}
})(this); /* SENSORSDATA HOOK */

// 在此方法前插入代码
this.touchableHandlePress(e);

运行项目并点击 Button ,项目的控制台中已打印出 Button 的 $AppClick 事件信息。至此,完成了 React Native 全埋点的 $AppClick 事件采集功能。

如图 4-1 所示:

图 4-1 触发的点击事件信息

4.3 自动插入代码

在上一节中,我们是手动插入了 React Native JavaScript 端的 Hook 代码,这种方案并不利于后期代码的维护以及不同 React Native 版本的兼容。因此,在这里需要新增一个 Hook 文件用来实现源码的自动插入功能。

1. 新建 Hook.js 文件放在演示项目的根目录下,并添加系统变量和文件位置:

1
2
3
4
5
6
7
8
9
10
// 系统变量
var path = require("path"),
fs = require("fs"),
dir = path.resolve(__dirname, "node_modules/");
// RN 点击事件 Touchable.js 源码文件
// 为了兼容不同的 React Native 版本,这里可以再添加路径
var RNClickFilePath = dir + '/react-native/Libraries/Components/Touchable/Touchable.js';
var RNClickableFiles = [
dir + '/react-native/Libraries/Renderer/implementations/ReactNativeRenderer-dev.js',
dir + '/react-native/Libraries/Renderer/implementations/ReactNativeRenderer-prod.js'];

2. 添加后续需要用到的工具类方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 工具函数- add try catch
addTryCatch = function (functionBody) {
functionBody = functionBody.replace(/this/g, 'thatThis');
return "(function(thatThis){\n" +
" try{\n " + functionBody +
" \n } catch (error) { throw new Error('SensorsData RN Hook Code 调用异常: ' + error);}\n" +
"})(this); /* SENSORSDATA HOOK */";
}
// 工具函数 - 计算位置
function lastArgumentName(content, index) {
--index;
var lastComma = content.lastIndexOf(',', index);
var lastParentheses = content.lastIndexOf('(', index);
var start = Math.max(lastComma, lastParentheses);
return content.substring(start + 1, index + 1);
}

3. 添加 Hook Touchable.js 文件的代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
var sensorsdataClickHookCode =
`(function(thatThis){
try {
var ReactNative = require('react-native');
var dataModule = ReactNative.NativeModules.SensorsAnalyticsModule;
thatThis.props.onPress && dataModule && dataModule.trackViewClick && dataModule.trackViewClick(ReactNative.findNodeHandle(thatThis))
} catch (error) {
throw new Error('SensorsData RN Hook Code 调用异常: ' + error);
}})(this); /* SENSORSDATA HOOK */ `;

sensorsdataHookClickRN = function () {
// 读取文件内容
var fileContent = fs.readFileSync(RNClickFilePath, 'utf8');
// 已经 hook 过了,不需要再次 hook
if (fileContent.indexOf('SENSORSDATA HOOK') > -1) {
return;
}
// 获取 hook 的代码插入的位置
var hookIndex = fileContent.indexOf("this.touchableHandlePress(");
// 判断文件是否异常,不存在 touchableHandlePress 方法,导致无法 hook 点击事件
if (hookIndex == -1) {
throw "Can't not find touchableHandlePress function";
};
// 插入 hook 代码
var hookedContent = `${fileContent.substring(0, hookIndex)}\n${sensorsdataClickHookCode}\n${fileContent.substring(hookIndex)}`;
// 备份 Touchable.js 源文件
fs.renameSync(RNClickFilePath, `${RNClickFilePath}_sensorsdata_backup`);
// 重写 Touchable.js 文件
fs.writeFileSync(RNClickFilePath, hookedContent, 'utf8');
console.log(`found and modify Touchable.js: ${RNClickFilePath}`);
};

4. 添加 Hook 获取 reactTag 信息的代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// hook clickable
sensorsdataHookClickableRN = function (reset = false) {
RNClickableFiles.forEach(function (onefile) {
if (fs.existsSync(onefile)) {
if (reset) {
// 读取文件内容
var fileContent = fs.readFileSync(onefile, "utf8");
// 未被 hook 过代码,不需要处理
if (fileContent.indexOf('SENSORSDATA HOOK') == -1) {
return;
}
// 检查备份文件是否存在
var backFilePath = `${onefile}_sensorsdata_backup`;
if (!fs.existsSync(backFilePath)) {
throw `File: ${backFilePath} not found, Please rm -rf node_modules and npm install again`;
}
// 将备份文件重命名恢复 + 自动覆盖被 hook 过的同名文件
fs.renameSync(backFilePath, onefile);
} else {
// 读取文件内容
var content = fs.readFileSync(onefile, 'utf8');
// 已经 hook 过了,不需要再次 hook
if (content.indexOf('SENSORSDATA HOOK') > -1) {
return;
}
// 获取 hook 的代码插入的位置
var newObjRe = /ReactNativePrivateInterface\.UIManager\.createView\([\s\S]{1,60}\.uiViewClassName,[\s\S]*?\)[,;]/
var match = newObjRe.exec(content);
if (!match) {
var objRe = /UIManager\.createView\([\s\S]{1,60}\.uiViewClassName,[\s\S]*?\)[,;]/
match = objRe.exec(content);
}
if (!match)
throw "can't inject clickable js";
var lastParentheses = content.lastIndexOf(')', match.index);
var lastCommaIndex = content.lastIndexOf(',', lastParentheses);
if (lastCommaIndex == -1)
throw "can't inject clickable js,and lastCommaIndex is -1";
var nextCommaIndex = content.indexOf(',', match.index);
if (nextCommaIndex == -1)
throw "can't inject clickable js, and nextCommaIndex is -1";
var propsName = lastArgumentName(content, lastCommaIndex).trim();
var tagName = lastArgumentName(content, nextCommaIndex).trim();
var functionBody = `var clickable = false;
if(${propsName}.onStartShouldSetResponder){
clickable = true;
}
var ReactNative = require('react-native');
var dataModule = ReactNative.NativeModules.SensorsAnalyticsModule;
dataModule && dataModule.saveReactTag && dataModule.saveReactTag(${tagName}, clickable);
`;
var call = addTryCatch(functionBody);
var lastReturn = content.lastIndexOf('return', match.index);
var splitIndex = match.index;
if (lastReturn > lastParentheses) {
splitIndex = lastReturn;
}
var hookedContent = `${content.substring(0, splitIndex)}\n${call}\n${content.substring(splitIndex)}`

// 备份源文件
fs.renameSync(onefile, `${onefile}_sensorsdata_backup`);
// 重写文件
fs.writeFileSync(onefile, hookedContent, 'utf8');
console.log(`found and modify clickable.js: ${onefile}`);
}
}
});

};

5. 添加代码还原功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 恢复被 hook 过的代码
sensorsdataResetRN = function (resetFilePath) {
// 判断需要被恢复的文件是否存在
if (!fs.existsSync(resetFilePath)) {
return;
}
var fileContent = fs.readFileSync(resetFilePath, "utf8");
// 未被 hook 过代码,不需要处理
if (fileContent.indexOf('SENSORSDATA HOOK') == -1) {
return;
}
// 检查备份文件是否存在
var backFilePath = `${resetFilePath}_sensorsdata_backup`;
if (!fs.existsSync(backFilePath)) {
throw `File: ${backFilePath} not found, Please rm -rf node_modules and npm install again`;
}
// 将备份文件重命名恢复 + 自动覆盖被 hook 过的同名 Touchable.js 文件
fs.renameSync(backFilePath, resetFilePath);
};

6. 定义执行命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 全部 hook 文件恢复
resetAllSensorsdataHookRN = function () {
sensorsdataResetRN(RNClickFilePath);
sensorsdataHookClickableRN(true);
};
// 全部 hook 文件
allSensorsdataHookRN = function () {
sensorsdataHookClickRN(RNClickFilePath);
sensorsdataHookClickableRN();
};

// 命令行
switch (process.argv[2]) {
case '-run':
allSensorsdataHookRN();
break;
case '-reset':
resetAllSensorsdataHookRN();
break;
default:
console.log('can not find this options: ' + process.argv[2]);
}

7. 删除手动插入的代码片段,在演示项目的根目录执行 “node Hook.js -run”,Hook 成功后会打印出插入代码的文件路径。运行项目测试 Button 点击,可以在控制台正常打印信息。如图 4-2 所示:

图 4-2 触发的点击事件

五、总结

总的来说,神策分析 React Native Module 在 v2.0 版本使用的方案是 Hook React Native JavaScript 端的源码,实现 $AppClick 事件的采集功能。

使用这种方案实现有如下优点:

  • 点击控件采集到的信息更准确(主要是 $screen_name 的准确性,这部分内容会在后续的 React Native 页面浏览全埋点方案中重点讲解);

  • 和 Native SDK 解耦,不再需要 Native SDK 配合 React Native Module 版本更新。

但是这种方案也存在如下缺点:

  • 对 React Native JavaScript 端源码进行改动,一定程度上会造成 React Native 代码的不稳定性。

在这里我们为了保证数据的准确性仍然使用此方案,并且在 Hook 代码中做了一定的代码保护,尽最大的努力减少数据埋点带来的风险性。

参考文献:

[1]https://reactnative.dev/docs/native-modules-setup

[2]https://manual.sensorsdata.cn/sa/latest/tech_sdk_client_three_react-7549534.html

[3]https://github.com/facebook/react-native/blob/master/Libraries/Components/Touchable/Touchable.js

[4]https://reactnative.dev/docs/environment-setup