React Native 頁面瀏覽事件採集方案
一、前言
React Native 是由 Facebook 推出的移動應用開發框架,可以用來開發 iOS、Android、Web 等跨平臺應用程式,官網為:http://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 的安裝流程:
- 匯入必需包
在 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 (
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
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 http://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
上面我們介紹了 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 (
圖 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";
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 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 執行程式碼命令:
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]http://reactnative.dev/docs/native-modules-setup [2]http://manual.sensorsdata.cn/sa/latest/tech_sdk_client_three_react-7549534.html [3]http://www.reactnavigation.org.cn/docs/StackNavigator [4]http://www.reactnavigation.org.cn/docs/TabNavigator [5]http://www.reactnavigation.org.cn/docs/DrawerNavigator [6]http://zh-hans.reactjs.org/docs/hooks-effect.html