一、前言 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 事件的采集功能。
使用这种方案实现有如下优点:
但是这种方案也存在如下缺点:
对 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