Flutter 常見異常分析

百瓶技術發表於2022-06-20

公眾號名片
作者名片

前言

在上篇 「Sentry 在百瓶的落地實踐」中,筆者主要從方案選型 & 落地實踐兩個大的方面進行了闡述,本篇文章我們主要對 Sentry 在百瓶的落地實踐中遇到的問題進行分析。本文中主要分析的問題主要包括以下幾大類(Flutter SDK 版本為 1.22.6,Dart SDK 版本為 2.10.5):

  • NoSuchMethodError
  • Flutter 官方 bug (已經修復)
  • StateError
  • NetworkError(DNS)

NoSuchMethodError

問題一

問題描述:

在進行 List 、String 等型別資料判空處理時,直接使用 xxx.isNotEmpty,沒有進行判斷是否為 null,導致 NoSuchMethodError:The getter isNotEmpty was called null。

問題截圖:

sentry_no_such_method_error_1
sentry_no_such_method_error_2

解決方案:

// 問題程式碼
if(timeEndList.isNotEmpty){
    ...
}
// 解決方案
static bool isNotNullOrEmpty<E>(Iterable<E> iterable) => iterable != null && iterable.isNotEmpty;

if (IterableUtils.isNotNullOrEmpty(timeEndList)){
    ...
}

在進行判空處理時,需要首先判斷是否為 null,然後再使用 isNotEmpty 進行判斷,避免這種型別錯誤,考慮到我們在專案中會使用大量類似判斷,所以我們可以對同一型別的資料判斷方法進行封裝,避免每處使用都要再去寫一遍。

問題二

問題描述:

這裡是使用了 Future.wait 併發請求多個 API,並且在第二個 API 設定超時,由於第二個 API 請求超時,在後續處理響應時,沒有處理空異常判斷導致獲取不到 code。

問題截圖:

sentry_no_such_method_error_3
sentry_no_such_method_error_4

解決方案:

// 問題程式碼
if (res[1].code == HttpCode.ok) {
  ...
}

// 解決方案
if (res[1]?.code == HttpCode.ok) {
  ...
}

在使用了 Future.wait 併發請求多個 API ,如果有設定超時處理,要考慮到 API 請求超時失敗的問題儘量避免這種問題發生。

問題三

問題描述:

當我們需要獲取到 與 Widget 上下文相關聯的 RenderBox 尺寸或者位置時,發生錯誤。

問題截圖:

sentry_no_such_method_error_5
sentry_no_such_method_error_6

解決方案:

// 問題程式碼
if (IterableUtils.isNotNullOrEmpty(ctx.state.details) == true) {
  final RenderBox renderBox = ctx.state.detailsKey.currentContext.findRenderObject();
  final Offset postion = renderBox.localToGlobal(Offset.zero);
  ctx.dispatch(MallGoodsDetailActionCreator.setDetailsOffsetYAction(postion.dy));
}

// 解決方案
if (IterableUtils.isNotNullOrEmpty(ctx.state.details) == true) {
  WidgetsBinding.instance.addPostFrameCallback((_) {
    final RenderBox renderBox = ctx.state.detailsKey.currentContext.findRenderObject();
    final Offset postion = renderBox.localToGlobal(Offset.zero);
    ctx.dispatch(MallGoodsDetailActionCreator.setDetailsOffsetYAction(postion.dy));
  });
}

發生以上問題的原因是,上下文並沒有與我們的 state 進行關聯,如果要避免這種情況發生,我們可以在 Widget 渲染完畢後再進行獲取 RenderBox 尺寸或者位置。

Flutter 官方 bug (已經修復)

問題描述:

在使用 NestedScrollView 元件時,由於 position.minScrollExtent 可以為空 ,在生產環境中執行會偶現 NoSuchMethodError nested_scroll_view.dart in _NestedScrollCoordinator.hasScrolledBody NoSuchMethodError: The method '>' was called on null. Receiver: null Tried calling: >() 這個問題,目前官方已經解決並且合併到 master 分支。

問題截圖:

sentry_nested_scroll_view_error

那麼這個問題是如何發生的呢?用官方的原文來解釋就是:

  1. scheduleAttachRootWidget 將呼叫 _firstBuild 並新建一個具有空畫素的 _NestedScrollPosition;
  2. FocusManager 將安排一個微任務;
  3. 完成 firstBuild 然後重新整理 microTask,NestedScrollView 又 dirty 了;
  4. scheduleWarmUpFrame 將重建 dirty 節點並觸發異常(_NestedScrollPosition 僅在佈局後可用)。

解決方案:

// 問題程式碼
bool get hasScrolledBody {
  for (final _NestedScrollPosition position in _innerPositions) {
    assert(position.minScrollExtent != null && position.pixels != null);
    if (position.pixels > position.minScrollExtent) {
      return true;
    }
  }
  return false;
}

// 解決方案
bool get hasScrolledBody {
  for (final _NestedScrollPosition position in _innerPositions) {
    if (!position.hasContentDimensions || !position.hasPixels) {
      continue;
    } else if (position.pixels > position.minScrollExtent) {
      return true;
    }
  }
  return false;
}

StateError

問題描述:

當我們在使用 list.firstWhere 的時候,通常會引發 Bad State: No element 這類問題。

問題截圖:

sentry_state_error_1
sentry_state_error_2

解決方案:

// 問題程式碼
Map<String, String> getInitialSkuById(String skuId, List<Map<String, dynamic>> skuList) {
  final Map<String, String> selectedKeyValue = <String, String>{};
  final Map<String, dynamic> selectedSku =
      skuList.firstWhere((Map<String, dynamic> skuItem) => skuItem['id'] == skuId);

  if (selectedSku['stockNum'] > 0) {
    selectedSku.forEach((String k, dynamic v) {
      if (k.contains('keyStr')) {
        selectedKeyValue[k] = v;
      }
    });
  }

  return selectedKeyValue;
}

// 解決方案
Map<String, String> getInitialSkuById(String skuId, List<Map<String, dynamic>> skuList) {
  final Map<String, String> selectedKeyValue = <String, String>{};
  final Map<String, dynamic> selectedSku = skuList.firstWhere(
    (Map<String, dynamic> skuItem) => skuItem['id'] == skuId,
    orElse: null,
  );

  if (selectedSku != null && selectedSku['stockNum'] > 0) {
    selectedSku.forEach((String k, dynamic v) {
      if (k.contains('keyStr')) {
        selectedKeyValue[k] = v;
      }
    });
  }
  return selectedKeyValue;
}

在我們使用 list.firstWhere 的時候,通常有匹配不到條件的時候,這個時候就非常有必要使用 orElse 來進行處理這種情況。

下面的程式碼根據條件篩選為 'green' 的結果值,如果沒有的話就返回 'No matching color found',結果輸出為:No matching color found。

final List<String> list = <String>['red', 'yellow', 'pink', 'blue'];
final String item = list.firstWhere(
  (String element) => element == 'green',
  orElse: () => 'No matching color found',
);
print(item); // // No matching color found

如果沒有寫 orElse 的情況下會丟擲異常: Unhandled exception: Bad state: No element。當然如果在 Null safety 版本下,可以直接使用 firstWhereOrNull 方法來進行處理。
下面我們來對比一下 firstWhere 和 firstWhereOrNull 的原始碼:

 E firstWhere(bool test(E element), {E orElse()?}) {
  for (E element in this) {
    if (test(element)) return element;
  }
  if (orElse != null) return orElse();
  throw IterableElementError.noElement();
}

T? firstWhereOrNull(bool Function(T element) test) {
  for (var element in this) {
    if (test(element)) return element;
  }
  return null;
}

firstWhere 會首先進行匹配符合條件的結果,如果沒有匹配到,再進行處理 orElse ,如果沒有 orElse ,就會丟擲異常;firstWhereOrNull 就簡單的多了,如果沒有匹配到符合條件的值,就會直接返回 null。

NetworkError(DNS)

網路錯誤是導致網路請求失敗的錯誤條件,每個網路錯誤都有一個型別,它是一個字串,每個網路錯誤都有一個階段,它描述了錯誤發生在哪個階段:

  1. dns:DNS 解析過程中發生的錯誤;
  2. connection:安全連線建立期間發生的錯誤;
  3. application:請求和響應傳輸過程中發生的錯誤;

問題描述:

在客戶端向服務單發起網路請求時,都會經過 DNS 解析的過程,一般情況下都是基於 DNS 協議向運營商 Local DNS 發起解析請求的傳統方式,但是這種情況下可能會出現域名劫持和跨網訪問的問題,造成域名解析異常。

sentry_network_error_1

解決方案:

那麼,如果我們的 App 在發起網路請求的時候,發現 DNS 解析失敗,我們應該怎麼辦?當然我們可以接入阿里云云解析 DNS 服務或者騰訊移動解析 HTTP DNS 等服務來更加有效的保障 App、小程式正常訪問。

下面我們來一起回顧一下 DNS 相關的知識:

  • 什麼是 DNS
  • 域名分層結構
  • DNS 分層結構
  • DNS 解析過程

DNS

DNS 是域名系統 (Domain Name System) 的縮寫,是因特網的一項核心服務,它作為可以將域名和 IP 地址互相對映的一個分散式資料庫,能夠使人更方便的去訪問網際網路,而不用去記住能夠被機器讀取的 IP 數串。

域名分層結構

由於因特網的使用者數量過多,所有因特網在命名時採用的是層次樹狀結構的命名方法。
任何一個連線在因特網上的主機或路由器,都有一個唯一的層次結構(域名)。
域名可以劃分為各個子域,子域還可以繼續劃分為子域的子域,這樣就形成了頂級域名、主域名、子域名等。
  1. ".com" 是頂級域名;
  2. "baiping.com" 是主域名(也可稱託管一級域名),主要指企頁名;
  3. "example.baiping.com" 是子域名(也可稱為託管二級域名);
  4. "www.example.baiping.com" 是子域名的子域(也可稱為託管三級域名)。

sentry_network_error_2

DNS 分層結構

域名是分層結構,域名 DNS 伺服器也是對應的層級結構。有了域名結構,還需要有域名 DNS 伺服器去解析域名,且是需要由遍及全世界的域名 DNS 伺服器去解析,域名 DNS 伺服器實際上就是裝有域名系統的主機。

sentry_network_error_3

DNS 解析過程

DNS 查詢的結果通常會在本地域名伺服器中進行快取,如果本地域名伺服器中有快取的情況下,則會跳過如下 DNS 查詢步驟,很快返回解析結果。本地域名伺服器沒有快取的情況下,DNS 查詢所需的 8 個步驟:

  1. 使用者在 Web 瀏覽器中輸入 "example.com",則由本地域名伺服器開始進行遞迴查詢。
  2. 本地域名伺服器採用迭代查詢的方法,向根域名伺服器進行查詢;
  3. 根域名伺服器告訴本地域名伺服器,下一步應該查詢的頂級域名伺服器 .com TLD(頂級域名伺服器)的 IP 地址;
  4. 本地域名伺服器向頂級域名伺服器 .com TLD 進行查詢;
  5. .com TLD 伺服器告訴本地域名伺服器,下一步查詢 example.com 權威域名伺服器的 IP 地址;
  6. 本地域名伺服器向 example.com 權威域名伺服器傳送查詢;
  7. example.com 權威域名伺服器告訴本地域名伺服器所查詢的主機 IP 地址;
  8. 本地域名伺服器最後把查詢的IP地址響應給 Web 瀏覽器。一旦 DNS 查詢的 8 個步驟返回了 example.com 的 IP 地址,瀏覽器就能夠發出對網頁的請求;
  9. 瀏覽器向 IP 地址發出 HTTP 請求;
  10. 該 IP 處的 Web 伺服器返回要在瀏覽器中呈現的網頁。

名詞解釋:

  1. DNS Resolve: 指本地域名伺服器,它是 DNS 查詢中的第一站,是負責處理髮出初始請求的 DNS 伺服器。運營商 ISP 分配的 DNS、谷歌 8.8.8.8 等都屬於 DNS Resolver;
  2. Root Server:指根域名伺服器,當本地域名伺服器在本地查詢不到解析結果時,則第一步會向它進行查詢,並獲取頂級域名伺服器的 IP 地址;
  3. 遞迴查詢:是指 DNS 伺服器在收到使用者發起的請求時,必須向使用者返回一個準確的查詢結果。如果 DNS 伺服器本地沒有儲存與之對應的資訊,則該伺服器需要詢問其他伺服器,並將返回的查詢結構提交給使用者;
  4. 迭代查詢:是指 DNS 伺服器在收到使用者發起的請求時,並不直接回複查詢結果,而是告訴另一臺 DNS 伺服器的地址,使用者再向這臺 DNS 伺服器提交請求,這樣依次反覆,直到返回查詢結果。

sentry_network_error_4

總結

以上四種異常是我們在編寫程式碼初期經常遇到的問題,通過對以上四種異常的分析,我們可以得到一些經驗總結,在後續的開發中,我們可以根據這些總結,進行改進,以便更好的解決問題。

更多精彩請關注我們的公眾號「百瓶技術」,有不定期福利呦!

相關文章