React Native如何做線上錯誤與效能監控

xiangzhihong 發表於 2022-06-20
React

一、前言

我們每個人可能都會遇到這樣的問題:即我們的程式碼在本地測試時沒有問題,但是一上線執行,就會遇到各種奇奇怪怪的線上 Bug。由於本地測試場景並不能全面覆蓋,對於這種線上的Bug,最有效的手段就是搭建線上監控系統,然後再進行修改。所以,不管是多麼小的系統,線上錯誤與效能監控是必須具備的能力。

通常,從頭搭建和迭代一個監控系統的成本是非常高的,如果你也有線上錯誤和效能的監控需求,但是公司內部又沒有現成的監控系統,那我的建議是直接用 Sentry。Sentry譯為哨兵,是一個能夠實時監控生產環境上的監控系統,一旦線上版本發生異常回立刻會把報錯的路由路徑、錯誤所在檔案等詳細資訊通知給相關人員,然後開發人員就可以利用錯誤資訊的堆疊跟蹤快速定位到需要處理的問題。Sentry 提供了一個演示 Demo,你可以直接開啟它,體驗下它有哪些具體的功能。

image.png

而且 Sentry 的程式碼是開源的,它既支援開發者自己搭建,也支援付費直接使用。如果想自己搭建的話,Sentry 後端服務是基於 Python 和 ClickHouse 建立的,需要自己使用物理機進行搭建。不過,對於小團隊來說,直接使用付費服務即可,可以省下麻煩的維護成本。

二、基本資訊收集

首先,我們要明確一點,解決線上問題和解決本地問題的思路是不一樣的,即通過復現錯誤路徑,然後定位問題並解決問題。當然,在定位問題的過程中也可以使用除錯工具,比如 Flipper,它有打日誌、打斷點等功能。

不過,在解決線上問題時,我們並不能反覆嘗試和使用除錯工具,此時就需要類似 Sentry 這樣的線上監控工具來幫助我們排查問題。如果我們對 Sentry 線上監控 SDK 的比較瞭解的話,你會發現它主要收集了三類線上資料:

  • 裝置資訊;
  • 報錯日誌;
  • 應用效能資料。
    所以接下來,我們要先一起實現一個簡易監控 SDK,把這些資訊都收集上去,這樣你就能夠明白 Sentry 線上監控 SDK 的底層原理了。當然,以上資訊的收集必須遵守網信辦的 《網路資料安全管理條例(徵求意見稿)》,像裝置唯一標示 IMEI、使用者地理位置、運營商編號這些資訊,我們是不能收集的,如果需要收集,是需要經過使用者授權同意的。

你可能會問,不能收集裝置唯一標示 IMEI,那我們怎麼知道使用者是誰啊?替代 IMEI 方案就是 UUID。UUID 的全稱是 Universally Unique Identifier,翻譯過來就是通用唯一識別碼,它是通過一個隨機演算法生成的 128 位的標識。生成兩個重複 UUID 概率接近零,可以忽略不計,因此我們可以使用 UUID 代替與使用者裝置繫結的 IMEI 作為唯一標示符,該方法也是業內的通用方案之一。

為了收集裝置唯一UUID,我們可以使用 UUID 演算法配合 AsyncStorage 或 MMKV 生成一個使用者 ID,程式碼如下:

import uuid from 'react-native-uuid';
import { MMKV } from 'react-native-mmkv'

// 使用者唯一標示
let userId = ''

const storage = new MMKV()
const hasUserId = storage.contains('userId')

// 使用者曾經開啟過 App
if(hasUserId) {
  userId = storage.get('userId')
} else {
  // 使用者第一次開啟 App
  userId = uuid.v4(); // ⇨ '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'
    storage.set('userId', userId)
}

如上程式碼中的 react-native-uuid 是 UUID 演算法的 React Native 版本。react-native-mmkv 是持久化鍵值儲存工具,MMKV 的效能比 AsyncStorage 更好,所以我這裡就用它代替了 AsyncStorage。
生成使用者唯一標示 userId 的思路是這樣的:每次開打 App 時,先使用 storage.contains(‘userId’) 判斷一下在 MMKV 持久化鍵值儲存中心是否存在 userId。如果 userId 的鍵值對不存在,那麼該使用者是第一次開啟 App,這時使用 uuid.v4 演算法生成一個 uuid 作為使用者的唯一標示,並使用 userId 作為鍵名,呼叫 storage.get 方法將該鍵值對存在 MMKV 中。如果存在 userId 的鍵值對,那麼該使用者是就不是第一次開啟 App 了,這時直接使用 userId 這個鍵名,將第一次開啟 App 生成的使用者唯一標示,從 MMKV 中讀出來就可以了。

有了 userId 這個使用者唯一標示後,後臺分析收集上來的線上資訊時,就可以把線上報錯、效能等資訊和某個具體的使用者掛上鉤了,比如你可以通過對 userId 欄位進行去重,然後就可以確定影響的使用者。

當然,作為一個線上執行的監控系統,光有 userId、使用者畫像還是不夠清晰,你還得知道他裝置資訊,這樣使用者畫像才更立體。在 React Native 中,我們可以使用react-native-device-info 外掛來獲取裝置資訊,示例程式碼如下:

import DeviceInfo from 'react-native-device-info';

//API提供了獲取的能力,但根據 《網路資料安全管理條例(徵求意見稿)》 是不能上報的,所以推薦使用 uuid 代替。
const androidIdPromise = DeviceInfo.getAndroidId()

//將裝置資訊收集到一個 deviceInfo 物件中,統一上報。
const deviceInfo = {}
deviceInfo.systemName = DeviceInfo.getSystemName();  
deviceInfo.systemVersion = DeviceInfo.getSystemVersion();  
deviceInfo.brand = getBrand();  
deviceInfo.appName = DeviceInfo.getApplicationName(); 
deviceInfo.appVersion = DeviceInfo.getVersion(); 

三、普通資料收集

對於線上環境,應用的報錯資訊我們是直接看不到的,要通過監控 SDK 收集上來之後才能看到。那監控 SDK 如何收集這些報錯資訊呢?下面提供三種方案:

  • ErrorUtils.setGlobalHandler;
  • PromiseRejectionTracking;
  • Error Boundaries。
    我們先來看 ErrorUtils.setGlobalHandler,它是用來處理 JavaScript 的全域性異常的。如果某個 JavaScript 函式報錯,並且該報錯沒有被捕獲,該報錯就會拋到全域性中。程式碼如下:
function throwError(errorName){
    thow new Error(errorName)
}

try {
    throwError('該錯誤會被 try catch 捕獲')
} catch(){}

throwError('該錯誤沒有捕獲,會拋到全域性')

在這個示例中,第一個錯誤是被 try catch 捕獲的錯誤,由於開發者已經對錯誤進行了處理,錯誤就不會再往外拋了,本地除錯時也不會有紅屏。第二個錯誤,開發者並沒有 try catch 處理,該錯誤就會一層層往外拋,最終拋向全域性作用域。
本地除錯時,如果一個報錯拋到了全域性作用域,就會出現紅屏。本地除錯的紅屏其實是,React Native 框架在內部使用 ErrorUtils.setGlobalHandler 捕獲到全域性錯誤後,呼叫 LogBox 顯示的紅屏。紅屏報錯邏輯涉及框架原始碼的兩個檔案,分別是 setUpErrorHandling.js 和 ExceptionsManager.js,下面是呼叫 LogBox 顯示紅屏的關鍵程式碼:

ErrorUtils.setGlobalHandler( (error: Error, isFatal?: boolean) => {
  if (__DEV__) {
    const LogBox = require('../LogBox/LogBox');
    LogBox.addException({
        message: error.message,
        name: error.name,
        componentStack: error.componentStack,
        stack: error.stack,
        isFatal
    });
  }
});

從這段程式碼可以看出,沒有被 try catch 住的報錯,會觸發 setGlobalHandler 的回撥,在該回撥中會判斷,如果是 DEV 環境,那麼就用 LogBox 元件把報錯的 message、name、componentStack、stack、isFatal 等資訊展示出來,這樣一來就可以在本地報錯時,看到紅屏的報錯資訊了。
看到這兒,你可能會問:既然 React Native 框架在本地除錯時使用的是 ErrorUtils.setGlobalHandler,那麼是否可以把這段邏輯改改用於線上錯誤監控呢?

這條思路很好。沿著這條思路想下去,我們有兩個方案可以實現線上全域性錯誤資訊的上報,一種是使用 patch-package 修改 React Native 原始碼,另一種使用 ErrorUtils.setGlobalHandler 重寫回撥函式。顯然,重寫回撥函式比直接修改原始碼侵入性更小,更利於後續維護,因此我選擇了重寫回撥函式的方式,程式碼如下。

const defaultHandler =  ErrorUtils.getGlobalHandler && ErrorUtils.getGlobalHandler();

ErrorUtils.setGlobalHandler( (error: Error, isFatal?: boolean) => {
    console.log(
      `Global Error Handled: ${JSON.stringify(
          {
            isFatal,
            errorName: error.name,
            errorMessage: error.message,
            componentStack: error.componentStack,
            errorStack: error.stack,
          },
          null,
          2,
      )}`,
    );

    defaultHandler(error, isFatal);
});

在這段程式碼中,React Native 框架的程式碼會比我的程式碼先執行,所以它會先呼叫一次 ErrorUtils.setGlobalHandler 設定回撥函式,而我的程式碼會在 React Native 框架程式碼執行之後再執行,並通過 ErrorUtils.getGlobalHandler 獲取 React Native 框架設定的回撥函式 defaultHandler。接著,我再次呼叫 ErrorUtils.setGlobalHandler 重新設定回撥函式。在重置的回撥函式中,我可以先處理自己的錯誤上報邏輯,這裡用的是 console.log 代替的,然後再呼叫 React Native 框架的 defaultHandler 處理紅屏報錯。

四、Promise 報錯收集

對於普通的JavaScript錯誤,我們可以使用 try catch 捕獲,但 對於promise 錯誤, try catch 是捕獲不到的,需要用 promise.catch 來捕獲。因此,二者全域性的捕獲機制也是不一樣的。

React Native 提供了兩種 Promise 捕獲機制,一種是由新架構的 Hermes 引擎提供的捕獲機制,另一種是老架構非 Hermes 引擎提供的捕獲機制。這兩種捕獲機制,你都可以在 React Native 原始碼中找到,它涉及 polyfillPromise.js、Promise.js 、promiseRejectionTrackingOptions.js 三個檔案,下面是關鍵程式碼。

const defualtRejectionTrackingOptions = {
  allRejections: true,
  onUnhandled: (id: string, error: Error) => {},
  onHandled : (id: string) => {}
}

if (global?.HermesInternal?.hasPromise?.()) {
  if (__DEV__) {
    global.HermesInternal?.enablePromiseRejectionTracker?.(
      defualtRejectionTrackingOptions,
    );
  }
} else {
  if (__DEV__) {
    require('promise/setimmediate/rejection-tracking').enable(
      defualtRejectionTrackingOptions,
    );
  }
}

在上面這個示例中,我們先宣告瞭一個配置項 defualtRejectionTrackingOptions。這個配置項中最重要的就是 onUnhandled 回撥函式,該回撥函式是專門用來處理未被 catch 的 Promise 錯誤的。
接著,再通過 HermesInternal.hasPromise 判斷該 React Native 應用是否用的是 Hermes 引擎,如果返回 true 則為 Hermes 引擎,否則為其他引擎。如果是 Hermes 引擎,我們就使用 Hermes 引擎提供的 enablePromiseRejectionTracker 方法來捕獲未被 catch 的 Promise 錯誤,如果不是 Hermes 引擎,則使用第三方 promise 庫中 rejection-tracking 檔案暴露的 enable 方法來捕獲未被 catch 的 Promise 錯誤。

以上,就是 React Native 內部處理 Promise 的邏輯。接著還需要將未被捕獲的 Promise 錯誤進行上報。上報之前,需要呼叫上一次 Hermes 引擎提供的 enablePromiseRejectionTracker 方法,或者再呼叫一次 rejection-tracking 檔案暴露的 enable 方法,將框架的預設處理邏輯覆蓋。

const cusotomtRejectionTrackingOptions = {
  allRejections: true,
  onUnhandled: (id: string, error: Error) => {
    //上報錯誤日誌
    console.log(
      `Possible Unhandled Promise Rejection: ${JSON.stringify({
        id,
        errorMessage: error.message,
        errorStack: error.stack,
      },null,2)}`,
  },
  onHandled : (id: string) => {}
}

if (global?.HermesInternal?.hasPromise?.()) {
  if (__DEV__) {
    global.HermesInternal?.enablePromiseRejectionTracker?.(
      cusotomtRejectionTrackingOptions,
    );
  }
} else {
  if (__DEV__) {
    require('promise/setimmediate/rejection-tracking').enable(
      cusotomtRejectionTrackingOptions,
    );
  }
}

開發者自定義的未捕獲的 Promise 報錯處理邏輯就是這樣,和 React Native 框架內部的呼叫方法幾乎一樣。唯一不同的是,開發者可以在 onUnhandled 和 onHandled 回撥中自定義錯誤的上報方法。在上述程式碼中,為了方便大家的理解,我們使用 console 方式代替了錯誤上報的邏輯。

五、元件報錯收集

在 React/React Native 應用中,除了全域性 JavaScript 報錯和未捕獲的 Promise 報錯以外,還有一類報錯可以統一處理,就是 React/React Native 的 render 報錯。在類元件中,render 報錯指的是類的 render 方法執行報錯;在函式元件中,render 報錯指的就是函式本身執行報錯了。

function FunctionComponent() {
  const [renderError, setRenderError ] = useState(false)

  if(renderError) throw Error('render 報錯')

  return <View></View>
}

function ClassComponent() {
  state = {
    renderError: false
  }

     render(){
      return (
          <View>
            {this.state.renderError && <span></span>}
          </View>
      )
  }
}

可以看到,第一個 FunctionComponent 示例是,當 renderError 狀態由 false 變為 true 時,函式元件執行了到一半就會被 throw Error 報錯打斷。第二個 ClassComponent 示例是,當 this.state.renderError 狀態由 false 變為 true 時,render 方法執行時發現了一個 React Native 中不存在的元件 span,整個渲染過程被中斷。
類似這兩種元件的 render 執行報錯,在本地會拋紅屏,線上上可能就是沒有任何反應或者白屏。那如何解決整個頁面無響應或者白屏的問題呢?

React/React Native 也提供了類似 try catch 的方法,叫做 Error Boundaries。Error Boundaries 是專門用於捕獲元件 render 錯誤的。

不過,React/React Native 只提供了類元件捕獲 render 錯誤的方法,如果是函式元件,必須將其巢狀在類元件中才能捕獲其 render 錯誤。業內通常的做法是將其封裝成一個通用方法給其他元件使用,比如 Sentry 就提供了 ErrorBoundary 元件和 withErrorBoundary 方法來幫助其他類元件或函式元件捕獲 render 錯誤。比如:

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // 更新 state 使下一次渲染能夠顯示降級後的 UI
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // 你同樣可以將錯誤日誌上報給伺服器
    logErrorToMyService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // 你可以自定義降級後的 UI 並渲染
      return <View>404頁面</View>;
    }

    return this.props.children; 
  }
}

<ErrorBoundary>
    <App/>
</ErrorBoundary>

這段程式碼中的 ErrorBoundary 是用於捕獲 App 元件 render 執行報錯的元件。如果 App 元件 render 沒有報錯,那麼會走 return this.props.children 的邏輯正常渲染;如果 App 元件 render 報錯了,那麼會觸發 getDerivedStateFromError 回撥,在 getDerivedStateFromError 回撥中將控制是否有報錯的開關狀態 hasError 開啟,並重新執行 render 渲染降級後的 404 頁面,同時還會觸發 componentDidCatch 回撥。你可以在 componentDidCatch 回撥中將元件的 render 錯誤上報。
在這個示例中,我用 ErrorBoundary 包裹的是 App 元件,也就是通常意義上的根元件,只要頁面中出現任意元件的 render 錯誤,就會渲染一個“404 頁面”。實際上,你也可以使用 ErrorBoundary 包裹區域性元件,當某個區域性元件出現錯誤時,使用其他區域性元件將其替換。

六、效能資料收集

相對於錯誤收集,效能收集的優先順序會低一些,因為錯誤影響的是操作問題,是影響業務執行的,而效能影響的是體驗問題,看起來並不會多麼的迫切。早期的 Sentry 也是隻收集錯誤不收集效能的,但現在也開始重視效能收集了。Sentry 主要收集的效能包括:

  • App 啟動耗時;
  • 頁面跳轉耗時;
  • 請求耗時。
    像 App 啟動耗時、頁面跳轉耗時和請求耗時這些耗時類的統計原理,都是通過兩個時間點的間隔計算出來的,即【耗時 = 結束時間點 - 起始時間點】。可以看到,總耗時等於結束時間點減去開始時間點的差值,開始時間和結束時間點都是通過 Date.now() 獲取的當前系統時間,單位是 ms。

對於 App 啟動耗時、頁面跳轉耗時和請求耗時的時間點,我畫了一張示意圖:

![上傳中...]()

6.1 啟動耗時

App啟動的開始時間點是在 Native 元件的生命週期裡面的,一般是oncreate()方法。例如,在 Android 上就是 Fragment 所在的 Activity 啟動完成後的 onActivityCreated()方法作為開始時間點。App 啟動的結束時間點是在 React/React Native 應用的生命週期裡,也就是元件掛載完成 componentDidMount()方法作為結束的時間點。

雖然 App 只有一個,但頁面、請求有很多個。統計 App 啟動耗時可以在 Native 根元件或 React 根元件的生命週期裡面統計,只需統計一次就行。但你不可能在每個頁面的開始掛載和結束掛載的生命週期回撥裡面新增統計,也不可能在每個請求開始之前和回來之後新增統計。

6.2 頁面跳轉耗時

如何統計 App 中所有的頁面跳轉耗時呢?如果你使用的是 React Navigation,那在每次頁面跳轉之前都會觸發下達跳轉命令。在下達跳轉命令的時候會觸發 unsafe_action 事件,你可以在 unsafe_action 事件的回撥中新增頁面跳轉耗時的開始時間點。在頁面跳轉完成後,頁面的狀態會發生改變,此時會觸發 state 改變事件,此時再新增結束時間點。下面是一段示例程式碼:



function App({navigation}) {

  useEffect(()=>{
    let startTime = 0

    navigation.addListener('__unsafe_action__', (e) => {
      startTime = Date.now()
    });

    navigation.addListener('state', (e) => {
      const totalTime = Date.now() - startTime
      console.log(`totalTime:${totalTime}`)
    });
  },[])

  return <></>
}

從程式碼中可以看到,我們無須在每個元件的宣告週期裡面都新增回撥,只用在 App 根元件掛載後,直接監聽導航命令觸發的 unsafe_action 和state 事件就可以完成頁面跳轉耗時的統計。
當然上面的示例程式碼只是列舉了原理,還有些邊界情況沒有考慮到,如果你對其中細節感興趣你可以檢視一下 Sentry 的 ReactNavigation 部分的原始碼

6.3 請求耗時

請求耗時通常統計的是從請求開始,到資料返回的整個鏈路所耗費的時間。實現也很方便,即在請求的時候獲取開始時間,在響應後獲取結束時間,然後通過時間差即可得到請求耗時。示例程式碼如下:

let startTime = 0

const originalOpen = XMLHttpRequest.prototype.open

XMLHttpRequest.prototype.open(function(...args){
    startTime = Date.now()
  const xhr = this;

    const originalOnready = xhr.prototype.onreadystatechange

    xhr.prototype.onreadystatechange = function(...readyStateArgs) {
        if (xhr.readyState === 4) {
            const totalTime = Date.now() - startTime
      console.log(`totalTime:${totalTime}`)
        }
    originalOnready(...readyStateArgs)
    }

    originalOpen.apply(xhr, args)
})

可以看到,因為 React Native 中的 fetch 或 axios 請求都是基於 XMLHttpRequest 包裝的,所以要統計請求耗時,就要監聽 XMLHttpRequest 的 open 事件,以及其例項 xhr 的 onreadystatechange 事件。
即在 open 事件中,記錄請求開始的時間點,在 onreadystatechange 事件觸發時且 xhr.readyState 為成功時記錄請求的結束時間點,再做一個減法即得到請求的耗時。