Flutter 最常出現的典型錯誤

Taycon_wan發表於2020-04-08

錯誤一:無法掌握的Future

典型錯誤資訊:NoSuchMethodError: The method 'markNeedsBuild' was called on null.
複製程式碼

這個錯誤常出現在非同步任務(Future)處理,比如某個頁面請求一個網路API資料,根據資料重新整理 Widget State。

非同步任務結束在頁面被pop之後,但沒有檢查State 是否還是 mounted,繼續呼叫 setState 就會出現這個錯誤。

示例程式碼

  /// 話題詳情
  void fetchDetailData({int articleId}) {
    TopicService.fetchTopicDetail(articleId: articleId).then((result) {
      if (result.code == 2000) {
        _topicDetailData = result.data;
      } else {
        showToast('獲取獲取詳情失敗: ${result.message}');
        Navigator.pop(context);
      }
    });
  }
複製程式碼

原因分析

result 的獲取為async-await非同步任務,完全有可能在_TopicDetailPageState被 dispose之後才等到返回,那時候和該State 繫結的 Element 已經不在了。故而在setState時需要容錯。

解決方法

setState之前檢查是否 mounted
複製程式碼
  /// 話題詳情
  void fetchDetailData({int articleId}) {
    TopicService.fetchTopicDetail(articleId: articleId).then((result) {
      if (result.code == 2000) {
        _topicDetailData = result.data;
      } else {
        showToast('獲取獲取詳情失敗: ${result.message}');
        Navigator.pop(context);
      }
      if (mounted) setState(() {}); // 加了這行
    });
  }
複製程式碼

這個mounted檢查很重要,其實只要涉及到非同步還有各種回撥(callback),都不要忘了檢查該值。 比如,在 FrameCallback裡執行一個動畫(AnimationController):

@override
void initState(){
  WidgetsBinding.instance.addPostFrameCallback((_) {
    if (mounted) _animationController.forward();
  });
}
複製程式碼

WidgetsBinding.instance.addPostFrameCallback 通過addPostFrameCallback可以做一些安全的操作,在有些時候是很有用的,它會在當前Frame繪製完後進行回撥,並只會回撥一次,如果要再次監聽需要再設定。 AnimationController有可能隨著 State 一起 dispose了,但是FrameCallback仍然會被執行,進而導致異常。

又比如,在動畫監聽的回撥裡搞點事:

    @override
void initState(){
  _animationController.animation.addListener(_handleAnimationTick);
}


void _handleAnimationTick() {
  if (mounted) updateWidget(...);
}
複製程式碼

同樣的在_handleAnimationTick被回撥前,State 也有可能已經被dispose了。

如果你還不理解為什麼,請仔細回味一下Event loop 還有複習一下 Dart 的執行緒模型。

錯誤二:Navigator.of(context) 是個 null

典型錯誤資訊:NoSuchMethodError: The method 'pop' was called on null.
複製程式碼

常在 showDialog 後處理 dialog 的 pop() 出現。

示例程式碼

在某個方法裡獲取網路資料,為了更好的提示使用者,會先彈一個 loading 窗,之後再根據資料執行別的操作...

// show loading dialog on request data
showDialog<void>(
  context: context,
  barrierDismissible: false,
  builder: (_) {
    return Center(
      child: CircularIndicator(),
    );
  },
);
var data = (await requestApi(...)).data;
// got it, pop dialog
Navigator.of(context).pop();
複製程式碼

原因分析:

出錯的原因在於—— Android 原生的返回鍵:雖然程式碼指定了barrierDismissible: false,使用者不可以點半透明區域關閉彈窗,但當使用者點選返回鍵時,Flutter 引擎程式碼會呼叫 NavigationChannel.popRoute(),最終這個 loading dialog 甚至包括頁面也被關掉,進而導致Navigator.of(context)返回的是null,因為該context已經被unmount,從一個已經凋零的樹葉上是找不到它的根的,於是錯誤出現。

另外,程式碼裡的Navigator.of(context) 所用的context也不是很正確,它其實是屬於showDialog呼叫者的而非 dialog 所有,理論上應該用builder裡傳過來的context,沿著錯誤的樹幹雖然也能找到根,但實際上不是那麼回事,特別是當你的APP裡有Navigator巢狀時更應該注意。

解決辦法:

首先,確保 Navigator.of(context)context 是 dialog 的context;其次,檢查 null,以應對被手動關閉的情況。

showDialog時傳入 GlobalKey,通過 GlobalKey 去獲取正確的context

GlobalKey key = GlobalKey();

showDialog<void>(
  context: context,
  barrierDismissible: false,
  builder: (_) {
    return KeyedSubtree(
      key: key,
      child: Center(
        child: CircularIndicator(),
      )
    );
  },
);
var data = (await requestApi(...)).data;


if (key.currentContext != null) {
  Navigator.of(key.currentContext)?.pop();
}
複製程式碼

key.currentContext 為null意為著該 dialog 已經被dispose,亦即已經從 WidgetTree 中unmount。

其實,類似的XXX.of(context)方法在 Flutter 程式碼裡很常見,比如 MediaQuery.of(context)Theme.of(context)DefaultTextStyle.of(context)DefaultAssetBundle.of(context)等等,都要注意傳入的context是來自正確節點的,否則會有驚喜在等你。

寫 Flutter 程式碼時,腦海裡一定要對context的樹幹脈絡有清晰的認知,如果你還不是很理解context,可以看看 《深入理解BuildContext》 - Vadaski。

錯誤三:泛型裡的 dynamic 一點也不 dynamic

典型錯誤資訊:

* type 'List<dynamic>' is not a subtype of type 'List<int>'

* type '_InternalLinkedHashMap<dynamic, dynamic>' is not a subtype of type 'Map<String, String>'
複製程式碼

常發生在給某個List、Map 變數賦值時。 這種錯誤,也較常發生在使用服務端返回的資料model時。

示例程式碼

class Model {
  final List<int> ids;
  final Map<String, String> ext;


  Model.fromJson(Map<String, dynamic> json):
    this.ids = json['ids'],
    this.ext= json['ext'];
}


var json = jsonDecode("""{"ids": [1,2,3], "ext": {"key": "value"}}""");
Model m = Model.fromJson(json);
複製程式碼

原因分析

jsonDecode() 這個方法轉換出來的map的泛型是 Map<String, dynamic>,意為 value 可能是任何型別(dynamic),當 value 是容器型別時,它其實是List<dynamic>或者Map<dynamic, dynamic>等等。

而 Dart 的型別系統中,雖然dynamic可以代表所有型別,在賦值時,如果資料型別事實上匹配(執行時型別相等)是可以被自動轉換,但泛型裡 dynamic 是不可以自動轉換的。可以認為List<dynamic>List<int>是兩種執行時型別。

解決辦法:

使用 List.from, Map.from
複製程式碼
class Model {
  final List<int> ids;
  final Map<String, String> ext;


  Model.fromJson(Map<String, dynamic> json):
    this.ids = List.from(json['ids'] ?? const []),
    this.ext= Map.from(json['ext'] ?? const {});
}
複製程式碼

相關文章