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

事情是這樣的,因爲近期 Flutter 發佈了 1.17 的穩定版,按照「慣例」開始着手把生產項目升級到 1.12.13+hotfix.9 版本,在升級適配完成以後,一個突如其來的 Bug 讓我陷入了沉思。bash

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

一、Scaffold

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

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

可是問題發送在 A 界面,這時候鍵盤已經收起,mediaQuery.viewInsets.bottom 應該更新爲 0 ,那爲什麼界面沒有產生應有的更新呢?字體

二、MediaQuery

那麼猜想問題可能出如今 MediaQuery 上。ui

從源碼咱們得知 MediaQuery 是一個 InheritedWidget,它會往下共享對應的 MediaQueryData,在 MediaQueryData 中保存了各類設備的信息,好比 sizedevicePixelRatiotextScaleFactorviewPadding 以及 viewInsets 等。this

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

「能夠被系統顯示的區域,一般是和設備的鍵盤等相關,當鍵盤彈出時 viewInsets.bottom 對應的就是鍵盤的頂部。」調試

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

三、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

四、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'),
          );
        },
      ),
    );
複製代碼

五、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 解決思路,能夠幫助到你們在平常開發過程當中解決更多問題。

相關文章
相關標籤/搜索