Flutter開發中的一些Tips(二)

唯鹿發表於2019-07-11

接著上篇 Flutter開發中的一些Tips,今天再分享一些我遇到的問題,這篇較上一篇,細節方面更多,希望“引以為戒”,畢竟細節決定成敗。本篇的所有例子,都在我開源的flutter_deer中。希望Star、Fork支援,有問題可以Issue。附上鍊接:github.com/simplezhli/…

在這裡插入圖片描述

1. setState() called after dispose()

這個是我偶然在控制檯發現的,完整的錯誤資訊如下:

Unhandled Exception: setState() called after dispose(): _AboutState#9c33a(lifecycle state: defunct, not mounted)

當然flutter在錯誤資訊之後還有給出問題原因及解決方法:

This error happens if you call setState() on a State object for a widget that no longer appears in the widget tree (e.g., whose parent widget no longer includes the widget in its build). This error can occur when code calls setState() from a timer or an animation callback. The preferred solution is to cancel the timer or stop listening to the animation in the dispose() callback. Another solution is to check the "mounted" property of this object before calling setState() to ensure the object is still in the tree. This error might indicate a memory leak if setState() is being called because another object is retaining a reference to this State object after it has been removed from the tree. To avoid memory leaks, consider breaking the reference to this object during dispose().

大致的意思是,widget已經在dispose方法時銷燬了,但在這之後卻呼叫了setState方法,那麼會發生此錯誤。比如定時器或動畫回撥呼叫setState(),但此時頁面已關閉時,就會發生此錯誤。這個錯誤一般並不會程式崩潰,只是會造成記憶體的洩露。

那麼解決的辦法分為兩部分:

  1. 及時停止或者銷燬監聽,例如一個定時器:
  Timer _countdownTimer;

  @override
  void dispose() {
    _countdownTimer?.cancel();
    _countdownTimer = null;
    super.dispose();
  }
複製程式碼
  1. 為了保險我們還要在呼叫setState()前判斷當前頁面是否存在:
  _countdownTimer = Timer.periodic(Duration(seconds: 2), (timer) {
    if (mounted){
      setState(() {
        
      });
    }
  });    
複製程式碼

我們可以看看 mounted在原始碼中是什麼

  BuildContext get context => _element;
  StatefulElement _element;

  /// Whether this [State] object is currently in a tree.
  ///
  /// After creating a [State] object and before calling [initState], the
  /// framework "mounts" the [State] object by associating it with a
  /// [BuildContext]. The [State] object remains mounted until the framework
  /// calls [dispose], after which time the framework will never ask the [State]
  /// object to [build] again.
  ///
  /// It is an error to call [setState] unless [mounted] is true.
  bool get mounted => _element != null;

複製程式碼

BuildContextElement的抽象類,你可以認為mounted 就是 context 是否存在。那麼同樣在回撥中用到 context時,也需要判斷一下mounted。比如我們要彈出一個 Dialog 時,或者在請求介面成功時退出當前頁面。BuildContext的概念是比較重要,需要掌握它,錯誤使用一般雖不會崩潰,但是會使得程式碼無效。

本問題詳細的程式碼見:點選檢視

2.監聽Dialog的關閉

問題描述:我在每次的介面請求前都會彈出一個Dialog 做loading提示,當介面請求成功或者失敗時關閉它。可是如果在請求中,我們點選了返回鍵人為的關閉了它,那麼當真正請求成功或者失敗關閉它時,由於我們呼叫了Navigator.pop(context) 導致我們錯誤的關閉了當前頁面。

那麼解決問題的突破口就是知道何時Dialog的關閉,那麼就可以使用 WillPopScope 攔截到返回鍵的輸入,同時記錄到Dialog的關閉。


  bool _isShowDialog = false;

  void closeDialog() {
    if (mounted && _isShowDialog){
      _isShowDialog = false;
      Navigator.pop(context);
    }
  }
  
  void showDialog() {
    /// 避免重複彈出
    if (mounted && !_isShowDialog){
      _isShowDialog = true;
      showDialog(
        context: context,
        barrierDismissible: false,
        builder:(_) {
          return WillPopScope(
            onWillPop: () async {
              // 攔截到返回鍵,證明dialog被手動關閉
              _isShowDialog = false;
              return Future.value(true);
            },
            child: ProgressDialog(hintText: "正在載入..."),
          );
        }
      );
    }
  }
複製程式碼

本問題詳細的程式碼見:點選檢視

3.addPostFrameCallback

addPostFrameCallback回撥方法在Widget渲染完成時觸發,所以一般我們在獲取頁面中的Widget大小、位置時使用到。

前面第二點我有說到我會在介面請求前彈出loading。如果我將請求方法放在了initState方法中,異常如下:

inheritFromWidgetOfExactType(_InheritedTheme) or inheritFromElement() was called before initState() completed. When an inherited widget changes, for example if the value of Theme.of() changes, its dependent widgets are rebuilt. If the dependent widget's reference to the inherited widget is in a constructor or an initState() method, then the rebuilt dependent widget will not reflect the changes in the inherited widget. Typically references to inherited widgets should occur in widget build() methods. Alternatively, initialization based on inherited widgets can be placed in the didChangeDependencies method, which is called after initState and whenever the dependencies change thereafter.

原因:彈出一個DIalog的showDialog方法會呼叫Theme.of(context, shadowThemeOnly: true),而這個方法會通過inheritFromWidgetOfExactType來跨元件獲取Theme物件。

在這裡插入圖片描述

inheritFromWidgetOfExactType方法呼叫inheritFromElement

在這裡插入圖片描述

但是在_StateLifecyclecreateddefunct 時是無法跨元件拿到資料的,也就是initState()時和dispose()後。所以錯誤資訊提示我們在 didChangeDependencies 呼叫。

然而放在didChangeDependencies後,新的異常:

setState() or markNeedsBuild() called during build. This Overlay widget cannot be marked as needing to build because the framework is already in the process of building widgets. A widget can be marked as needing to be built during the build phase only if one of its ancestors is currently building. This exception is allowed because the framework builds parent widgets before children, which means a dirty descendant will always be built. Otherwise, the framework might not visit this widget during this build phase.

提示我們必須在頁面build時,才可以去建立這個新的元件(這裡就是Dialog)。

所以解決方法就是使用addPostFrameCallback回撥方法,等待頁面build完成後在請求資料:

  @override
  void initState() {
    WidgetsBinding.instance.addPostFrameCallback((_){
      /// 介面請求
    });
  }
 
複製程式碼

導致這類問題的場景很多,但是大體解決思路就是上述的辦法。

本問題詳細的程式碼見:點選檢視

4.刪除emoji

不多嗶嗶,直接看圖:

Flutter開發中的一些Tips(二)

簡單說就是刪除一個emoji表情,一般需要點選刪除兩次。碰到個別的emoji,需要刪除11次!!其實這問題,也別吐槽Flutter,基本emoji在各個平臺上都或多或少有點問題。

原因就是:

在這裡插入圖片描述
這個問題我發現在Flutter 的1.5.4+hotfix.2版本,解決方法可以參考:github.com/flutter/eng… 雖然只適用於長度為2位的emoji。

幸運的是在最新的穩定版1.7.8+hotfix.3中修復了這個問題。不幸的是我發現了其他的問題,比如在我小米MIX 2s上刪除文字時,有時會程式崩潰,其他一些機型正常。異常如下圖:

Flutter開發中的一些Tips(二)

我也在Flutter上發現了同樣的問題Issue,具體情況可以關注這個Issue :github.com/flutter/flu… ,據Flutter團隊的人員的回覆,這個問題修復後不太可能進入1.7的穩定版本。。

在這裡插入圖片描述

所以建議大家謹慎升級,尤其是用於生產環境。那麼這個問題暫時只能擱置下來了,等待更穩定的版本。。。

5.鍵盤

1.是否彈起

MediaQuery.of(context).viewInsets.bottom > 0
複製程式碼

viewInsets.bottom就是鍵盤的頂部距離底部的高度,也就是彈起的鍵盤高度。如果你想實時過去鍵盤的彈出狀態,配合使用didChangeMetrics。完整如下:

import 'package:flutter/material.dart';

typedef KeyboardShowCallback = void Function(bool isKeyboardShowing);

class KeyboardDetector extends StatefulWidget {

  KeyboardShowCallback keyboardShowCallback;

  Widget content;

  KeyboardDetector({this.keyboardShowCallback, @required this.content});

  @override
  _KeyboardDetectorState createState() => _KeyboardDetectorState();
}

class _KeyboardDetectorState extends State<KeyboardDetector>
    with WidgetsBindingObserver {
  @override
  void initState() {
    WidgetsBinding.instance.addObserver(this);
    super.initState();
  }

  @override
  void didChangeMetrics() {
    super.didChangeMetrics();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      print(MediaQuery.of(context).viewInsets.bottom);
      setState(() {
        widget.keyboardShowCallback
            ?.call(MediaQuery.of(context).viewInsets.bottom > 0);
      });
    });
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return widget.content;
  }
}

程式碼來自專案GSYFlutterDemo:https://github.com/CarGuo/GSYFlutterDemo
複製程式碼

2.彈出鍵盤

if (MediaQuery.of(context).viewInsets.bottom == 0){
  final focusScope = FocusScope.of(context);
  focusScope.requestFocus(FocusNode());
  Future.delayed(Duration.zero, () => focusScope.requestFocus(_focusNode));
}
複製程式碼

其中_focusNode是對應的TextFieldfocusNode屬性。

3.關閉鍵盤

FocusScope.of(context).requestFocus(FocusNode());
複製程式碼

這裡提一下關閉,一般來說即使鍵盤彈出,點選返回頁面關閉,鍵盤就會自動收起。但是順序是:

頁面關閉 --> 鍵盤關閉

這樣會導致鍵盤短暫的出現在你的上一頁面,也就會出現短暫的部件溢位(關於溢位可見上篇)。

所以這時你就需要在頁面關閉前手動呼叫關閉鍵盤的程式碼了。按道理是要放到deactivate或者dispose中處理的,可誰讓context已經為null了,所以,老辦法,攔截返回鍵:

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
      onWillPop: () async {
        // 攔截返回鍵
        FocusScope.of(context).requestFocus(FocusNode());
        return Future.value(true);
      },
      child: Container()
    );
  }   
複製程式碼

本問題詳細的程式碼見:點選檢視

6.Android 9.0適配

話說現在新建的Flutter專案,Android的 targetSdkVersion 預設都是28。所以不可避免的就是Android 9.0的適配甚至6,7,8的適配,那我碰到的一個問題是接入的高德2D地圖在9.0的機子上顯示不出來。

問題的主要原因是Android 9.0 要求預設使用加密連線,簡單地說就是不允許使用http請求,要求使用https。高德的2D地圖sdk懷疑是使用了http請求,所以會載入不出。

解決方法兩個:

  1. targetSdkVersion 改為28以下(長遠看來不推薦)

  2. android -> app - > src -> main -> res 目錄下新建xml,新增network_security_config.xml檔案:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <base-config cleartextTrafficPermitted="true" />
</network-security-config>
複製程式碼

AndroidManifest.xml中的application新增:

android:networkSecurityConfig="@xml/network_security_config"
複製程式碼

這個問題只是Android適配中的一小部分,相應的iOS中也有適配問題。比如常用的許可權適配等。

不得不說做Flutter的開發需要對原生開發有一定了解。尤其是之前在寫Flutter的地圖外掛時感受深刻,那麼我本來就是做Android開發的,所以Android端的部分很快就完成了。iOS部分就很吃力,首先OC的語法就不會,其次說實話寫完了心裡也沒底,還是需要向iOS的同事請教確保一下。所以跨平臺方案的出現並不會對原生開發造成衝擊,反而是對原生開發提出了更高的要求。

本問題詳細的程式碼見:點選檢視

7.其他

  1. Flutter開發中的json解析確實很麻煩,當然有許多的外掛來解決我們的問題。我個人推薦使用FlutterJsonBeanFactory。關於它的一系列使用可以參看:www.jianshu.com/nb/33360539

  2. UI層面的功能最好還是使用Flutter來解決。比如Toast功能,很多人都會選擇fluttertoast這個外掛,而我推薦oktoast這類使用Flutter的解決方案 。因為fluttertoast是呼叫了Android原生的Toast,首先在各個系統上的樣式就不統一,同時部分系統機型上受限通知許可權,會導致Toast無法彈出。

篇幅有限,那麼先分享以上幾條Tips,如果本篇對你有所幫助,可以點贊支援!其實收藏起來不是以後遇到問題時查詢更方便嗎?。

最後再次奉上Github地址:github.com/simplezhli/…

相關文章