React Native 頁面瀏覽事件採集方案

語言: CN / TW / HK

一、前言

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] 使用新方案實現了 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 ( ); } ----------------------------Tab------------------------------------- const Tab = createBottomTabNavigator(); function App() { return ( ); } -----------------------------Drawer------------------------------------ const Drawer = createDrawerNavigator();

function App() { return ( ); } ```

三、具體實現

因為 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 ) { ... 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 & { state?: NavigationState | PartialState; })[]; ... }>; ```

上面我們介紹了 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 ( ); } ``` 首頁是一個 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"; 3. 找到插入位置並插入程式碼:

`` // 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 findisFirstMountRef.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 執行程式碼命令:

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]https://reactnative.dev/docs/native-modules-setup [2]https://manual.sensorsdata.cn/sa/latest/tech_sdk_client_three_react-7549534.html [3]https://www.reactnavigation.org.cn/docs/StackNavigator [4]https://www.reactnavigation.org.cn/docs/TabNavigator [5]https://www.reactnavigation.org.cn/docs/DrawerNavigator [6]https://zh-hans.reactjs.org/docs/hooks-effect.html