React Native 页面浏览事件采集方案

962 阅读6分钟

一、前言

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 的安装流程:

  1. 导入必需包

在 React Native 项目中安装 React Navigation 包:

npm install @react-navigation/native

在 React Native 项目中安装依赖包:

npm install react-native-reanimated react-native-gesture-handler react-native-sc
  1. 导入可选包

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 所示:

0914 截图新1.png

图 3-1 Intro 导航组件 我们来看下 Intro 导航组件的 NavigationState 信息:

0913.4.png

可以看到 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 所示:

0914.3.png

我们来看下 Screen1 的 NavigationState 信息:

0914.5.png

从上面可以看到当前页面的 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 的信息:

0914.6.png

可以看到在 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 文件生成

  1. 创建 hook.js 文件,放到项目的根目录下,增加需要修改文件的路径:
// 系统变量
var path = require("path"),
    fs = require("fs"),
    dir = path.resolve(__dirname, "node_modules/");
var reactNavigationPath5X = dir + '/@react-navigation/core/src/BaseNaviga
  1. 需要插入的代码实现:
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";
  1. 找到插入位置并插入代码:
// 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}`
    );
  }
};
  1. 编写 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,看到已经正确触发页面浏览事件了:

0914.7.png

四、总结

总的来说,神策分析 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-…