一、前言
React Native 是由 Facebook 推出的移动应用开发框架,可以用来开发 iOS、Android、Web 等跨平台应用程序,官网为:facebook.github.io/react-nativ…
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] 使用新方案实现了 React Native 全埋点功能。
本文以 Android 项目为例,介绍了神策分析 React Native Module 是如何通过 React Navigation 来实现全埋点的页面浏览事件采集。
二、React Navigation
2.1简介
React Navigation 的诞生源于 React Native 社区对基于 JavaScript 的导航组件可扩展和易用性的需求。
React Navigation 是 Facebook,Expo 和 React 社区的开发者们合作的结果:它取代并改进了 React Native 生态系统中的多个导航库,包括 Ex-Navigation、React Native 的 Navigator 和 NavigationExperimental 组件。
2.2 安装
下面以 npm 方式为例介绍下 React Navigation 的安装流程:
- 导入必需包
在 React Native 项目中安装 React Navigation 包:
npm install @react-navigation/native
在 React Native 项目中安装依赖包:
npm install react-native-reanimated react-native-gesture-handler react-native-sc
- 导入可选包
React Navigation 支持三种类型的导航器,分别是 StackNavigator[3]、TabNavigator[4] 和 DrawerNavigator[5]。
StackNavigator
一次只渲染一个页面,并提供页面之间跳转的方法。当打开一个新的页面时,它被放置在堆栈的顶部。
引入方式如下:
npm install @react-navigation/stack
TabNavigator
渲染一个选项卡,让用户可以在几个页面之间切换。
引入方式:
npm install @react-navigation/bottom-tabs
DrawerNavigator
提供一个从屏幕左侧滑入的抽屉。
引入方式:
npm install @react-navigation/drawer
2.3 使用方式
通过 NavigationContainer 包裹需要使用的导航器 Stack.Navigator、Tab.Navigator、Drawer.Navigator,如下所示:
-----------------------------Stack------------------------------------
const Stack = createStackNavigator();
function App() {
return (
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Store" component={StoreScreen} />
</Stack.Navigator>
</NavigationContainer>
);
}
----------------------------Tab-------------------------------------
const Tab = createBottomTabNavigator();
function App() {
return (
<NavigationContainer>
<Tab.Navigator>
<Tab.Screen name="Home" component={HomeScreen} />
<Tab.Screen name="Store" component={StoreScreen} />
</Tab.Navigator>
</NavigationContainer>
);
}
-----------------------------Drawer------------------------------------
const Drawer = createDrawerNavigator();
function App() {
return (
<NavigationContainer>
<Drawer.Navigator>
<Drawer.Screen name="Home" component={HomeScreen} />
<Drawer.Screen name="Store" component={StoreScreen} />
</Drawer.Navigator>
</NavigationContainer>
);
}
三、具体实现
因为 React Native 项目无法从系统层级标识页面,所以通过 React Navigation 的 RouteName 来进行页面的唯一标识。
3.1 NavigationContainer 解析
3.1.1. BaseNavigationContainer
所有的导航都包裹在 NavigationContainer 中,其中 BaseNavigationContainer 通过 React.useEffect[6] 监听了 state:
BaseNavigationContainer
const BaseNavigationContainer = React.forwardRef(
function BaseNavigationContainer(
{
initialState,
onStateChange,
independent,
children,
}: NavigationContainerProps,
ref?: React.Ref<NavigationContainerRef>
) {
...
React.useEffect(() => {
if (process.env.NODE_ENV !== 'production') {
if (
state !== undefined &&
!isSerializable(state) &&
!hasWarnedForSerialization
) {
hasWarnedForSerialization = true;
console.warn(
"Non-serializable values were found in the navigation state, which can break usage such as persisting and restoring state. This might happen if you passed non-serializable values such as function, class instances etc. in params. If you need to use components with callbacks in your options, you can use 'navigation.setOptions' instead. See https://reactnavigation.org/docs/troubleshooting#i-get-the-warning-non-serializable-values-were-found-in-the-navigation-state for more details."
);
}
}
emitter.emit({ type: 'state', data: { state } });
if (!isFirstMountRef.current && onStateChangeRef.current) {
onStateChangeRef.current(getRootState());
}
isFirstMountRef.current = false;
}, [getRootState, emitter, state]);
return (
<ScheduleUpdateContext.Provider value={scheduleContext}>
<NavigationBuilderContext.Provider value={builderContext}>
<NavigationStateContext.Provider value={context}>
<EnsureSingleNavigator>{children}</EnsureSingleNavigator>
</NavigationStateContext.Provider>
</NavigationBuilderContext.Provider>
</ScheduleUpdateContext.Provider>
);
}
export default BaseNavigationContainer;
3.1.2. state
state 是一个 NavigationState 对象,一个 NavigationState 对象中保存了已渲染的路由树。而当任何一个页面重新渲染时,都会变更 NavigationState 中的信息,此时就会回调到 BaseNavigationContainer 中:
NavigationState
export type NavigationState = Readonly<{
...
/**
* Index of the currently focused route.
*/
index: number;
/**
* List of rendered routes.
*/
routes: (Route<string> & {
state?: NavigationState | PartialState<NavigationState>;
})[];
...
}>;
上面我们介绍了 React Navigation 的相关信息,下面我们通过一个 Demo 来看下是如何实现 React Native 全埋点的页面浏览事件采集。
3.2 获取 RouteName
我们先来看下 Demo 首页的代码实现:
import BottomTabNavigator from './BottomTabNavigator';
import DrawerNavigator from './DrawerNavigator';
import Intro from '../screen/Intro';
import MaterialBottomTabNavigator from './MaterialBottomTabNavigator';
import MaterialTopTabNavigator from './MaterialTopTabNavigator';
const Stack = createNativeStackNavigator();
function RootNavigator(): React.ReactElement {
const { theme } = useThemeContext();
return (
<NavigationContainer>
<Stack.Navigator
screenOptions={{
headerStyle: {
backgroundColor: theme.background,
},
headerTitleStyle: { color: theme.fontColor },
headerTintColor: theme.tintColor,
}}
>
<Stack.Screen name="Intro" component={Intro} />
<Stack.Screen name="StackNavigator" component={StackNavigator} />
<Stack.Screen name="DrawerNavigator" component={DrawerNavigator} />
<Stack.Screen
name="BottomTabNavigator"
component={BottomTabNavigator}
/>
<Stack.Screen
name="MaterialTopTabNavigator"
component={MaterialTopTabNavigator}
/>
<Stack.Screen
name="MaterialBottomTabNavigator"
component={MaterialBottomTabNavigator}
/>
</Stack.Navigator>
</NavigationContainer>
);
}
首页是一个 StackNavigator,默认展示 Intro 这个导航组件,如图 3-1 所示:
图 3-1 Intro 导航组件 我们来看下 Intro 导航组件的 NavigationState 信息:
可以看到 routes(路由树)中有个 name 为 Intro 的 route,通过这个 name 就可以拿到当前展示路由组件的 RouteName。但是,如果是 Tab 或者 Drawer 这种嵌套类型的导航组件呢?
现在我们来看下 TabNavigator 导航组件的代码实现:
function BottomTabNavigator(): ReactElement {
return (
<Tab.Navigator
screenOptions={{
tabBarIcon: ({ focused }): React.ReactElement => TabBarIcon(focused),
}}
>
<Tab.Screen
name="Screen1"
component={Screen1}
options={{
tabBarLabel: 'Screen1',
tabBarIcon: ({ focused }): React.ReactElement => TabBarIcon(focused),
}}
/>
<Tab.Screen name="Screen2" component={Screen2} />
<Tab.Screen name="Screen3" component={Screen3} />
<Tab.Screen name="Screen4" component={Screen4} />
</Tab.Navigator>
);
}
接着跳转到该组件,可以看到 Screen1 这个组件,如图 3-2 所示:
我们来看下 Screen1 的 NavigationState 信息:
从上面可以看到当前页面的 NavigationState 只有 BottomTabNavigator,并没有 Screen1 的 NavigationState 信息,我们再看下 NavigationState 的获取方式:
const getCurrentRoute = React.useCallback(() => {
let state = getRootState();
if (state === undefined) {
return undefined;
}
while (state.routes[state.index].state !== undefined) {
state = state.routes[state.index].state as NavigationState;
}
return state.routes[state.index];
}, [getRootState]);
可以看到是其实是通过 RootState 获取,我们再来看下 RootState 的信息:
可以看到在 RootState 中不但有 BottomTabNavigator 的 NavigationState 也有子导航组件 Screen1、Screen2 等 NavigationState 信息,这样我们就可以根据 index 获取当前组件的 RouteName,而 Drawer 的 NavigationState 其实和 Tab 的类似,这里不再赘述。
至此,我们已经可以获取到 Stack、Tab 和 Drawer 类型的 RouteName 了。
3.3全埋点的页面浏览事件
神策 React Native Module 中提供了原生与 JavaScript 交互的 Module,其中有一个 trackViewScreen 方法:
/**
* 导出 trackViewScreen 方法给 RN 使用.
* <p>
* 此方法用于 RN 中切换页面的时候调用,用于记录 $AppViewScreen 事件.
*
* @param url 页面的 url 记录到 $url 字段中.
* @param properties 页面的属性.
* <p>
* 注:为保证记录到的 $AppViewScreen 事件和 Auto Track 采集的一致,
* 需要传入 $title(页面的标题) 、$screen_name (页面的名称,即 包名.类名)字段.
* <p>
* RN 中使用示例:
* <Button
* title="Button"
* onPress={()=>
* RNSensorsAnalyticsModule.trackViewScreen(url, {"$title":"RN主页","$screen_name":"cn.sensorsdata.demo.RNHome"})}>
* </Button>
*/
@ReactMethod
public void trackViewScreen(String url, ReadableMap properties) {
try {
RNAgent.trackViewScreen(url, RNUtils.convertToJSONObject(properties), false);
} catch (Exception e) {
e.printStackTrace();
Log.e(LOGTAG, e.toString() + "");
}
}
那我们是否可以在页面跳转时自动调用 trackViewScreen 方法,将获取到的 RouteName 作为页面标识呢?答案是肯定的。这里通过 node 命令执行 JavaScript 方法,将获取 RouteName 和调用 trackViewScreen 方法的代码插入到 BaseNavigationContanier 中,下面我们来看下如何实现。
3.3.1. hook 文件生成
- 创建 hook.js 文件,放到项目的根目录下,增加需要修改文件的路径:
// 系统变量
var path = require("path"),
fs = require("fs"),
dir = path.resolve(__dirname, "node_modules/");
var reactNavigationPath5X = dir + '/@react-navigation/core/src/BaseNaviga
- 需要插入的代码实现:
var sensorsdataNavigation5ImportHookCode ="import ReactNative from 'react-native';\n";
var sensorsdataNavigation5HookCode = "function getParams(state:any):any{\n"
+" if(!state){\n"
+" return null;\n"
+" }\n"
+" var route = state.routes[state.index];\n"
+" var params = route.params;\n"
+" if(route.state){\n"
+" var p = getParams(route.state);\n"
+" if(p){\n"
+" params = p;\n"
+" }\n"
+" }\n"
+" return params;\n"
+"}\n"
+"function trackViewScreen(state: any): void {\n"
+" if (!state) {\n"
+" return;\n"
+" }\n"
+" var route = state.routes[state.index];\n"
+" if (route.name === 'Root') {\n"
+" trackViewScreen(route.state);\n"
+" return;\n"
+" }\n"
+" var screenName = getCurrentRoute()?.name;\n"
+" var params = getParams(state);\n"
+" if (params) {\n"
+" if (!params.sensorsdataurl) {\n"
+" params.sensorsdataurl = screenName;\n"
+" }\n"
+" } else {\n"
+" params = {\n"
+" sensorsdataurl: screenName,\n"
+" };\n"
+" }\n"
+" var dataModule = ReactNative?.NativeModules?.RNSensorsDataModule;\n"
+" dataModule?.trackViewScreen && dataModule.trackViewScreen(params);\n"
+"}\n"
+"trackViewScreen(getRootState());\n"
+"/* SENSORSDATA HOOK */\n";
- 找到插入位置并插入代码:
// hook navigation 5.x
sensorsdataHookNavigation5 = function () {
if (fs.existsSync(reactNavigationPath5X)) {
// 读取文件内容
var fileContent = fs.readFileSync(reactNavigationPath5X, 'utf8');
// 已经 hook 过了,不需要再次 hook
if (fileContent.indexOf('SENSORSDATA HOOK') > -1) {
return;
}
// 获取 hook 的代码插入的位置
var scriptStr = 'isFirstMountRef.current = false;';
var hookIndex = fileContent.lastIndexOf(scriptStr);
// 判断文件是否异常,不存在代码,导致无法 hook 点击事件
if (hookIndex == -1) {
throw "navigation Can't not find `isFirstMountRef.current = false;` code";
}
// 插入 hook 代码
var hookedContent = `${fileContent.substring(
0,
hookIndex
)}\n${sensorsdataNavigation5HookCode}\n${fileContent.substring(hookIndex)}`;
// BaseNavigationContainer.tsx
fs.renameSync(
reactNavigationPath5X,
`${reactNavigationPath5X}_sensorsdata_backup`
);
hookedContent = sensorsdataNavigation5ImportHookCode+hookedContent;
// BaseNavigationContainer.tsx
fs.writeFileSync(reactNavigationPath5X, hookedContent, 'utf8');
console.log(
`found and modify BaseNavigationContainer.tsx: ${reactNavigationPath5X}`
);
}
};
- 编写 node 执行代码命令:
switch (process.argv[2]) {
case '-run':
sensorsdataHookNavigation5();
break;
case '-reset':
sensorsdataResetRN(reactNavigationPath5X);
break;
default:
console.log('can not find this options: ' + process.argv[2]);
}
这样,代码插入的 JavaScript 文件就完成了。
3.3.2. 代码插入
进行代码插入只需要在控制台执行 node 命令:
node hook.js -run
3.4 结果验证
再次打开 BaseNavigationContainer.tsx,可以看到在 “isFirstMountRef.current = false;” 这行代码前插入了我们在 hook.js 中实现的方法:
function getParams(state:any):any{
if(!state){
return null;
}
var route = state.routes[state.index];
var params = route.params;
if(route.state){
var p = getParams(route.state);
if(p){
params = p;
}
}
return params;
}
function trackViewScreen(state: any): void {
if (!state) {
return;
}
var route = state.routes[state.index];
if (route.name === 'Root') {
trackViewScreen(route.state);
return;
}
var screenName = getCurrentRoute()?.name;
var params = getParams(state);
if (params) {
if (!params.sensorsdataurl) {
params.sensorsdataurl = screenName;
}
} else {
params = {
sensorsdataurl: screenName,
};
}
var dataModule = ReactNative?.NativeModules?.RNSensorsDataModule;
dataModule?.trackViewScreen && dataModule.trackViewScreen(params);
}
console.log(getRootState());
trackViewScreen(getRootState());
/* SENSORSDATA HOOK */
isFirstMountRef.current = false;
再次运行 demo,看到已经正确触发页面浏览事件了:
四、总结
总的来说,神策分析 React Native Module 使用的方案是 Hook React Navigation 的源码,实现页面浏览事件($AppViewScreen)的采集功能。
使用这种方案实现具有如下优点:
可以自动采集页面浏览事件;
方案的实现较为简单。
但是这种方案也存在如下缺点:
对 React Navigation 源码进行改动,一定程度上会影响项目的稳定性;
可能存在的兼容性问题:目标文件的路径变更或目标代码的改动、重复会造成 hook 代码无法插入或插入位置错误。
为了实现 React Native 全埋点的页面浏览事件采集,我们调研了多种实现方案,相对而言此种方案是最优的。同时,我们也在持续优化,尽可能保证版本的兼容性和稳定性。
参考文献: [1]reactnative.dev/docs/native… [2]manual.sensorsdata.cn/sa/latest/t… [3]www.reactnavigation.org.cn/docs/StackN… [4]www.reactnavigation.org.cn/docs/TabNav… [5]www.reactnavigation.org.cn/docs/Drawer… [6]zh-hans.reactjs.org/docs/hooks-…