1. 前言

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 的方式实现 JavaScript 调用 Native 接口,神策分析的 React Native Module 使用新方案实现了 React Native 全埋点功能。

本文以 Android 项目为例,介绍了神策分析 React Native Module 是如何通过 React Navigation 来实现全埋点的页面浏览事件采集

2. 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-screens react-native-safe-area-context @react-native-community/masked-view

2. 导入可选包

React Navigation 支持三种类型的导航器,分别是 StackNavigatorTabNavigator 和 DrawerNavigator

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,如下所示:

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
-----------------------------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>
  );
}

3. 具体实现

因为 React Native 项目无法从系统层级标识页面,所以通过 React Navigation 的 RouteName 来进行页面的唯一标识。

3.1. NavigationContainer 解析

3.1.1. BaseNavigationContainer

所有的导航都包裹在 NavigationContainer 中,其中 BaseNavigationContainer 通过 React.useEffect 监听了 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 首页的代码实现:

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
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 导航组件的代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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 所示:

图 3-2 Screen1 导航组件

我们来看下 Screen1 的 NavigationState 信息:

从上面可以看到当前页面的 NavigationState 只有 BottomTabNavigator,并没有 Screen1 的 NavigationState 信息,我们再看下 NavigationState 的获取方式:

1
2
3
4
5
6
7
8
9
10
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 文件生成

1. 创建 hook.js 文件,放到项目的根目录下,增加需要修改文件的路径:

// 系统变量
var path = require("path"),
    fs = require("fs"),
    dir = path.resolve(__dirname, "node_modules/");
var reactNavigationPath5X = dir '/@react-navigation/core/src/BaseNavigationContainer.tsx';

2. 需要插入的代码实现:

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";

3. 找到插入位置并插入代码:

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
// 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}`
    );
  }
};

4. 编写 node 执行代码命令:

1
2
3
4
5
6
7
8
9
10
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 中实现的方法:

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
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,看到已经正确触发页面浏览事件了:

4. 总结

总的来说,神策分析 React Native Module 使用的方案是 Hook React Navigation 的源码,实现页面浏览事件($AppViewScreen)的采集功能。

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

  • 可以自动采集页面浏览事件;
  • 方案的实现较为简单。

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

  • 对 React Navigation 源码进行改动,一定程度上会影响项目的稳定性;
  • 可能存在的兼容性问题:目标文件的路径变更或目标代码的改动、重复会造成 hook 代码无法插入或插入位置错误。

为了实现 React Native 全埋点的页面浏览事件采集,我们调研了多种实现方案,相对而言此种方案是最优的。同时,我们也在持续优化,尽可能保证版本的兼容性和稳定性。