事情是這樣的,因爲近期 Flutter 發佈了 1.17
的穩定版,按照「慣例」開始着手把生產項目升級到 1.12.13+hotfix.9
版本,在升級適配完成以後,一個突如其來的 Bug 讓我陷入了沉思。bash
如上圖所示,能夠看到在鍵盤 B 頁面打開後,退回上一個頁面 A 時鍵盤已經收起,可是原先鍵盤所在的區域在 A 頁面變成了空白,而 A 頁面內容也被 resize
成了鍵盤彈出後的大小。框架
針對這個問題,首先想到的 Scaffold
的 resizeToAvoidBottomInset
屬性。ide
在 Flutter 中 Scaffold
默認狀況下 resizeToAvoidBottomInset
爲 true
,當 resizeToAvoidBottomInset
爲 true
時,Scaffold
內部會將 mediaQuery.viewInsets.bottom
參與到 BoxConstraints
的大小計算,也就是鍵盤彈起時調整了內部的 bottom
位置來迎合鍵盤。測試
可是問題發送在 A 界面,這時候鍵盤已經收起,mediaQuery.viewInsets.bottom
應該更新爲 0 ,那爲什麼界面沒有產生應有的更新呢?字體
那麼猜想問題可能出如今 MediaQuery
上。ui
從源碼咱們得知 MediaQuery
是一個 InheritedWidget
,它會往下共享對應的 MediaQueryData
,在 MediaQueryData
中保存了各類設備的信息,好比 size
、devicePixelRatio
、 textScaleFactor
、 viewPadding
以及 viewInsets
等。this
那 viewInsets
是什麼的呢?官方的解釋是:spa
「能夠被系統顯示的區域,一般是和設備的鍵盤等相關,當鍵盤彈出時
viewInsets.bottom
對應的就是鍵盤的頂部。」調試
那上面的 bug 看起來可能就是 Scaffold
的 viewInsets.bottom
在鍵盤收起來時沒有正常重置。code
那這裏首先咱們要知道 MediaQuery
的 viewInsets
是怎麼被設置的?
經過分析源碼能夠知道 MediaQuery
的 MediaQueryData
來源於 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, ); } 複製代碼
如上代碼能夠看到 MediaQuery
的 MediaQueryData
是來源於 Window
,而且這裏還註冊了 WidgetsBindingObserver
的 didChangeMetrics
回調,也就是當 window
改變時,調用 setState
來更新 MediaQuery
中的 MediaQueryData
。
而在 MediaQueryData.fromWindow
中, viewInsets
是經過將 window.viewInsets
和 window.devicePixelRatio
相除後獲得的像素密度值。
viewInsets = EdgeInsets.
fromWindowPadding(window.viewInsets, window.devicePixelRatio),
複製代碼
那 Window
的值又是哪裏來的?
其實 Window
的值來源於 Flutter Engine,在鍵盤彈出時 Flutter Engine 會經過 _updateWindowMetrics
方法更新 Window
數據,並執行 window.onMetricsChanged
和 window._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
。
那按照這個狀況,不可能出現上述鍵盤致使空白區域的問題,那問題可能就是出如今 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 頁面所在的 CupertinoPageRoute
的 builder
方法,不可能在鍵盤 B 頁面打開時再次被執行纔對?
可是在通過調試後震驚的發現,程序在進入 B 頁面彈出鍵盤後,竟然會觸發了 A 頁面 CupertinoPageRoute
的 builder
方法從新執行。
可以在跨頁面觸發更新,第一個想到的就是全局的狀體管理框架,由於應用須要全局切換主題、多語言和用戶信息共享等,在應用的頂層通常會經過狀體管理框架往下共享和管理這些信息。
因爲本來項目比較複雜,因此從新作了一個簡單的測試 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'),
);
},
),
);
複製代碼
這裏不由就有疑問,爲何 MaterialApp
的更新會致使 PageRoute
從新 builder
呢?
這就涉及 Navigator
的相關邏輯,咱們經常使用的 Navigator
實際上是一個 StatefulWidget
,當 MaterialApp
被更新時,能夠看到在 NavigatorState
的 didUpdateWidget
回調中會調用 _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
清空,這樣天然下次 Route
在 build
時觸發的 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 解決思路,能夠幫助到你們在平常開發過程當中解決更多問題。