事情是這樣的,由於近期 Flutter 釋出了 1.17
的穩定版,按照“慣例”開始著手把生產專案升級到 1.12.13+hotfix.9
版本,在升級適配完成之後,一個突如其來的 Bug 讓我陷入了沉思。
如上圖所示,可以看到在鍵盤 B 頁面開啟後,退回上一個頁面 A 時鍵盤已經收起,但是原先鍵盤所在的區域在 A 頁面變成了空白,而 A 頁面內容也被 resize
成了鍵盤彈出後的大小。
1、Scaffold
針對這個問題,首先想到的 Scaffold
的 resizeToAvoidBottomInset
屬性。
在 Flutter 中 Scaffold
預設情況下 resizeToAvoidBottomInset
為 true
,當 resizeToAvoidBottomInset
為 true
時,Scaffold
內部會將 mediaQuery.viewInsets.bottom
參與到 BoxConstraints
的大小計算,也就是鍵盤彈起時調整了內部的 bottom
位置來迎合鍵盤。
但是問題傳送在 A 介面,這時候鍵盤已經收起,mediaQuery.viewInsets.bottom
應該更新為 0 ,那為何介面沒有產生應有的更新呢?
2、MediaQuery
那麼猜測問題可能出現在 MediaQuery
上。
從原始碼我們得知 MediaQuery
是一個 InheritedWidget
,它會往下共享對應的 MediaQueryData
,在 MediaQueryData
中儲存了各種裝置的資訊,比如 size
、devicePixelRatio
、 textScaleFactor
、 viewPadding
以及 viewInsets
等。
那 viewInsets
是什麼的呢?官方的解釋是:
“可以被系統顯示的區域,通常是和裝置的鍵盤等相關,當鍵盤彈出時
viewInsets.bottom
對應的就是鍵盤的頂部。”
那上面的 bug 看起來可能就是 Scaffold
的 viewInsets.bottom
在鍵盤收起來時沒有正常重置。
3、Window
那這裡首先我們要知道 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
。
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 頁面所在的 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'),
);
},
),
);
複製程式碼
5、Navigator
這裡不禁就有疑問,為什麼 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 解決思路,可以幫助到大家在日常開發過程中解決更多問題。