Flutter 上的一個 Bug 帶你瞭解鍵盤與路由的另類知識點

戀貓de小郭發表於2020-05-15

事情是這樣的,由於近期 Flutter 釋出了 1.17 的穩定版,按照“慣例”開始著手把生產專案升級到 1.12.13+hotfix.9 版本,在升級適配完成之後,一個突如其來的 Bug 讓我陷入了沉思。

Flutter 上的一個 Bug 帶你瞭解鍵盤與路由的另類知識點

如上圖所示,可以看到在鍵盤 B 頁面開啟後,退回上一個頁面 A 時鍵盤已經收起,但是原先鍵盤所在的區域在 A 頁面變成了空白,而 A 頁面內容也被 resize 成了鍵盤彈出後的大小。

1、Scaffold

針對這個問題,首先想到的 ScaffoldresizeToAvoidBottomInset 屬性。

在 Flutter 中 Scaffold 預設情況下 resizeToAvoidBottomInsettrue,當 resizeToAvoidBottomInsettrue 時,Scaffold 內部會將 mediaQuery.viewInsets.bottom 參與到 BoxConstraints 的大小計算,也就是鍵盤彈起時調整了內部的 bottom 位置來迎合鍵盤。

但是問題傳送在 A 介面,這時候鍵盤已經收起,mediaQuery.viewInsets.bottom 應該更新為 0 ,那為何介面沒有產生應有的更新呢?

2、MediaQuery

那麼猜測問題可能出現在 MediaQuery 上。

從原始碼我們得知 MediaQuery 是一個 InheritedWidget,它會往下共享對應的 MediaQueryData,在 MediaQueryData 中儲存了各種裝置的資訊,比如 sizedevicePixelRatiotextScaleFactorviewPadding 以及 viewInsets 等。

viewInsets 是什麼的呢?官方的解釋是:

“可以被系統顯示的區域,通常是和裝置的鍵盤等相關,當鍵盤彈出時 viewInsets.bottom 對應的就是鍵盤的頂部。”

那上面的 bug 看起來可能就是 ScaffoldviewInsets.bottom 在鍵盤收起來時沒有正常重置。

3、Window

那這裡首先我們要知道 MediaQueryviewInsets 是怎麼被設定的?

通過分析原始碼可以知道 MediaQueryMediaQueryData 來源於 WidgetsBinding.instance.window,預設是在 MaterialApp_MediaQueryFromWindow 中被設定:

  @override
  void didChangeMetrics() {
    setState(() {
      // The properties of window have changed. We use them in our build
      // function, so we need setState(), but we don't cache anything locally.
    });
  }
  
  @override
  Widget build(BuildContext context) {
    return MediaQuery(
      data: MediaQueryData.fromWindow(WidgetsBinding.instance.window),
      child: widget.child,
    );
  }
複製程式碼

如上程式碼可以看到 MediaQueryMediaQueryData 是來源於 Window,並且這裡還註冊了 WidgetsBindingObserverdidChangeMetrics 回撥,也就是當 window 改變時,呼叫 setState 來更新 MediaQuery 中的 MediaQueryData

而在 MediaQueryData.fromWindow 中, viewInsets 是通過將 window.viewInsetswindow.devicePixelRatio 相除後得到的畫素密度值。

viewInsets = EdgeInsets.
fromWindowPadding(window.viewInsets, window.devicePixelRatio),
複製程式碼

Window 的值又是哪裡來的?

其實 Window 的值來源於 Flutter Engine,在鍵盤彈出時 Flutter Engine 會通過 _updateWindowMetrics 方法更新 Window 資料,並執行 window.onMetricsChangedwindow._onMetricsChangedZone 方法。

其中 onMetricsChanged 回撥最終會觸發 handleMetricsChanged 方法,從而執行 scheduleForcedFrame() 更新介面和 observer.didChangeMetrics(); 通知 MaterialApp 中的 MediaQueryData 更新。

@pragma('vm:entry-point')
// ignore: unused_element
void _updateWindowMetrics(
  double devicePixelRatio,
  double width,
  double height,
  double depth,
  double viewPaddingTop,
  double viewPaddingRight,
  double viewPaddingBottom,
  double viewPaddingLeft,
  double viewInsetTop,
  double viewInsetRight,
  double viewInsetBottom,
  double viewInsetLeft,
  double systemGestureInsetTop,
  double systemGestureInsetRight,
  double systemGestureInsetBottom,
  double systemGestureInsetLeft,
) {
  window
    .._devicePixelRatio = devicePixelRatio
    .._physicalSize = Size(width, height)
    .._physicalDepth = depth
    .._viewPadding = WindowPadding._(
        top: viewPaddingTop,
        right: viewPaddingRight,
        bottom: viewPaddingBottom,
        left: viewPaddingLeft)
    .._viewInsets = WindowPadding._(
        top: viewInsetTop,
        right: viewInsetRight,
        bottom: viewInsetBottom,
        left: viewInsetLeft)
    .._padding = WindowPadding._(
        top: math.max(0.0, viewPaddingTop - viewInsetTop),
        right: math.max(0.0, viewPaddingRight - viewInsetRight),
        bottom: math.max(0.0, viewPaddingBottom - viewInsetBottom),
        left: math.max(0.0, viewPaddingLeft - viewInsetLeft))
    .._systemGestureInsets = WindowPadding._(
        top: math.max(0.0, systemGestureInsetTop),
        right: math.max(0.0, systemGestureInsetRight),
        bottom: math.max(0.0, systemGestureInsetBottom),
        left: math.max(0.0, systemGestureInsetLeft));
  _invoke(window.onMetricsChanged, window._onMetricsChangedZone);
}
複製程式碼

所以可以看到,當鍵盤彈出和收起時,Engine 會更新 Window 的資料,Window 觸發介面繪製更新,同時更新 MaterialApp 中的 MediaQueryData

Flutter 上的一個 Bug 帶你瞭解鍵盤與路由的另類知識點

4、Route

那按照這個情況,不可能出現上述鍵盤導致空白區域的問題,那問題可能就是出現在 Scaffold 使用的 MediaQueryData 沒有更新

這時候我突然想起,之前為了鎖定頁面的字型大小不跟隨系統縮放,我在路由層使用了 MediaQueryData.fromWindow 複製一份 MediaQuery,問題很可能出在這裡:

Navigator.of(context).push(new CupertinoPageRoute(builder: (context) {
   return MediaQuery(
      data:MediaQueryData.fromWindow(WidgetsBinding.instance.window)
                         .copyWith(textScaleFactor: 1),
                  child: Page2(), );
   }));
複製程式碼

不過這也不對,出現問題的是有鍵盤的 B 頁面返回到沒有鍵盤的 A 頁面,這時候 A 頁面已經開啟,那之前開啟 A 頁面的 WidgetsBinding.instance.window 應該是對的,而 A 頁面所在的 CupertinoPageRoutebuilder 方法,不可能在鍵盤 B 頁面開啟時再次被執行才對?

但是在經過除錯後震驚的發現,程式在進入 B 頁面彈出鍵盤後,居然會觸發了 A 頁面 CupertinoPageRoutebuilder 方法重新執行。

能夠在跨頁面觸發更新,第一個想到的就是全域性的狀體管理框架,因為應用需要全域性切換主題、多語言和使用者資訊共享等,在應用的頂層一般會通過狀體管理框架往下共享和管理這些資訊。

由於原本專案比較複雜,所以重新做了一個簡單的測試 Demo ,並且引入比較簡單的 ScopedModel 框架管理,然後在開啟有鍵盤的 B 頁面後執行延時一會執行notifyListeners();,發現果然出現了同樣的問題。

    return ScopedModel(
      model: t,
      child: ScopedModelDescendant<TestModel>(
        builder: (context, child, model) {
          return MaterialApp(
            title: 'Flutter Demo',
            theme: ThemeData(
              primarySwatch: Colors.blue,
            ),
            home: MyHomePage(title: 'Flutter Demo Home Page'),
          );
        },
      ),
    );
複製程式碼

5、Navigator

這裡不禁就有疑問,為什麼 MaterialApp 的更新會導致 PageRoute 重新 builder 呢?

這就涉及 Navigator 的相關邏輯,我們常用的 Navigator 其實是一個 StatefulWidget,當 MaterialApp 被更新時,可以看到在 NavigatorStatedidUpdateWidget 回撥中會呼叫 _history 裡所有路由的 changedExternalState() 方法。

 @override
  void didUpdateWidget(Navigator oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.observers != widget.observers) {
      for (NavigatorObserver observer in oldWidget.observers)
        observer._navigator = null;
      for (NavigatorObserver observer in widget.observers) {
        assert(observer.navigator == null);
        observer._navigator = this;
      }
    }
    for (Route<dynamic> route in _history)
      route.changedExternalState();
  }
  
複製程式碼

changedExternalState 執行後會呼叫 _forceRebuildPage 將路由裡的 _page 清空,這樣自然下次 Routebuild 時觸發的 PageRoute 重新 builder 方法。

@override
 void changedExternalState() {
   super.changedExternalState();
   if (_scopeKey.currentState != null)
     _scopeKey.currentState._forceRebuildPage();
 }
 
·····

 void _forceRebuildPage() {
   setState(() {
     _page = null;
   });
 }

複製程式碼

所以迴歸到最初的問題:這個 bug 首先是因為不規範使用了 MediaQueryData.fromWindow(WidgetsBinding.instance.window) ,之後又恰好在有鍵盤的頁面開啟後觸發了 MaterialApp 的更新,導致了 PageRoute 重新 builder, 使得沒有鍵盤的 Scaffold 使用了彈出鍵盤的 viewInsets.bottom

所以這裡只需要將 MediaQueryData.fromWindow 換成 MediaQuery.of(context) 就可以解決問題,而當在沒有 context 或者需要直接使用 MediaQueryData.fromWindow 時,那一定要搭配上 WidgetsBindingObserver.didChangeMetrics 配合更新。

    Navigator.of(context).push(new CupertinoPageRoute(builder: (context) {
      return MediaQuery(
        data:MediaQuery.of(context)
            .copyWith(textScaleFactor: 1),
        child: Page2(), );
    }));
複製程式碼

最後說一句,雖然這個 bug 並不複雜,但是恰好能帶出挺多經常忽略的知識點,所以長篇介紹這麼多,也希望這樣的 bug 解決思路,可以幫助到大家在日常開發過程中解決更多問題。

Flutter 上的一個 Bug 帶你瞭解鍵盤與路由的另類知識點

相關文章