事情是這樣的纱昧,由于近期 Flutter 發(fā)布了 1.17
的穩(wěn)定版刨啸,按照“慣例”開始著手把生產(chǎn)項(xiàng)目升級(jí)到 1.12.13+hotfix.9
版本,在升級(jí)適配完成之后识脆,一個(gè)突如其來的 Bug 讓我陷入了沉思设联。
如上圖所示善已,可以看到在鍵盤 B 頁面打開后,退回上一個(gè)頁面 A 時(shí)鍵盤已經(jīng)收起离例,但是原先鍵盤所在的區(qū)域在 A 頁面變成了空白换团,而 A 頁面內(nèi)容也被 resize
成了鍵盤彈出后的大小。
1宫蛆、Scaffold
針對(duì)這個(gè)問題艘包,首先想到的 Scaffold
的 resizeToAvoidBottomInset
屬性。
在 Flutter 中 Scaffold
默認(rèn)情況下 resizeToAvoidBottomInset
為 true
耀盗,當(dāng) resizeToAvoidBottomInset
為 true
時(shí)想虎,Scaffold
內(nèi)部會(huì)將 mediaQuery.viewInsets.bottom
參與到 BoxConstraints
的大小計(jì)算,也就是鍵盤彈起時(shí)調(diào)整了內(nèi)部的 bottom
位置來迎合鍵盤叛拷。
但是問題發(fā)送在 A 界面舌厨,這時(shí)候鍵盤已經(jīng)收起,mediaQuery.viewInsets.bottom
應(yīng)該更新為 0 忿薇,那為何界面沒有產(chǎn)生應(yīng)有的更新呢邓线?
2、MediaQuery
那么猜測(cè)問題可能出現(xiàn)在 MediaQuery
上煌恢。
從源碼我們得知 MediaQuery
是一個(gè) InheritedWidget
,它會(huì)往下共享對(duì)應(yīng)的 MediaQueryData
震庭,在 MediaQueryData
中保存了各種設(shè)備的信息瑰抵,比如 size
、devicePixelRatio
器联、 textScaleFactor
二汛、 viewPadding
以及 viewInsets
等。
那 viewInsets
是什么的呢拨拓?官方的解釋是:
“可以被系統(tǒng)顯示的區(qū)域肴颊,通常是和設(shè)備的鍵盤等相關(guān),當(dāng)鍵盤彈出時(shí)
viewInsets.bottom
對(duì)應(yīng)的就是鍵盤的頂部渣磷⌒鲎牛”
那上面的 bug 看起來可能就是 Scaffold
的 viewInsets.bottom
在鍵盤收起來時(shí)沒有正常重置。
3醋界、Window
那這里首先我們要知道 MediaQuery
的 viewInsets
是怎么被設(shè)置的竟宋?
通過分析源碼可以知道 MediaQuery
的 MediaQueryData
來源于 WidgetsBinding.instance.window
,默認(rèn)是在 MaterialApp
的 _MediaQueryFromWindow
中被設(shè)置:
@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
形纺,并且這里還注冊(cè)了 WidgetsBindingObserver
的 didChangeMetrics
回調(diào)丘侠,也就是當(dāng) window
改變時(shí),調(diào)用 setState
來更新 MediaQuery
中的 MediaQueryData
逐样。
而在 MediaQueryData.fromWindow
中蜗字, viewInsets
是通過將 window.viewInsets
和 window.devicePixelRatio
相除后得到的像素密度值打肝。
viewInsets = EdgeInsets.
fromWindowPadding(window.viewInsets, window.devicePixelRatio),
那 Window
的值又是哪里來的?
其實(shí) Window
的值來源于 Flutter Engine挪捕,在鍵盤彈出時(shí) Flutter Engine 會(huì)通過 _updateWindowMetrics
方法更新 Window
數(shù)據(jù)粗梭,并執(zhí)行 window.onMetricsChanged
和 window._onMetricsChangedZone
方法。
其中 onMetricsChanged
回調(diào)最終會(huì)觸發(fā) handleMetricsChanged
方法担神,從而執(zhí)行 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);
}
所以可以看到,當(dāng)鍵盤彈出和收起時(shí)妄讯,Engine
會(huì)更新 Window
的數(shù)據(jù)孩锡,Window
觸發(fā)界面繪制更新,同時(shí)更新 MaterialApp
中的 MediaQueryData
亥贸。
4躬窜、Route
那按照這個(gè)情況,不可能出現(xiàn)上述鍵盤導(dǎo)致空白區(qū)域的問題炕置,那問題可能就是出現(xiàn)在 Scaffold
使用的 MediaQueryData
沒有更新荣挨。
這時(shí)候我突然想起,之前為了鎖定頁面的字體大小不跟隨系統(tǒng)縮放朴摊,我在路由層使用了 MediaQueryData.fromWindow
復(fù)制一份 MediaQuery
默垄,問題很可能出在這里:
Navigator.of(context).push(new CupertinoPageRoute(builder: (context) {
return MediaQuery(
data:MediaQueryData.fromWindow(WidgetsBinding.instance.window)
.copyWith(textScaleFactor: 1),
child: Page2(), );
}));
不過這也不對(duì),出現(xiàn)問題的是有鍵盤的 B 頁面返回到?jīng)]有鍵盤的 A 頁面甚纲,這時(shí)候 A 頁面已經(jīng)打開口锭,那之前打開 A 頁面的 WidgetsBinding.instance.window
應(yīng)該是對(duì)的,而 A 頁面所在的 CupertinoPageRoute
的 builder
方法介杆,不可能在鍵盤 B 頁面打開時(shí)再次被執(zhí)行才對(duì)鹃操?
但是在經(jīng)過調(diào)試后震驚的發(fā)現(xiàn),程序在進(jìn)入 B 頁面彈出鍵盤后春哨,居然會(huì)觸發(fā)了 A 頁面 CupertinoPageRoute
的 builder
方法重新執(zhí)行荆隘。
能夠在跨頁面觸發(fā)更新,第一個(gè)想到的就是全局的狀體管理框架赴背,因?yàn)閼?yīng)用需要全局切換主題椰拒、多語言和用戶信息共享等,在應(yīng)用的頂層一般會(huì)通過狀體管理框架往下共享和管理這些信息癞尚。
由于原本項(xiàng)目比較復(fù)雜耸三,所以重新做了一個(gè)簡(jiǎn)單的測(cè)試 Demo ,并且引入比較簡(jiǎn)單的 ScopedModel
框架管理浇揩,然后在打開有鍵盤的 B 頁面后執(zhí)行延時(shí)一會(huì)執(zhí)行notifyListeners();
仪壮,發(fā)現(xiàn)果然出現(xiàn)了同樣的問題。
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
的更新會(huì)導(dǎo)致 PageRoute
重新 builder
呢爽彤?
這就涉及 Navigator
的相關(guān)邏輯,我們常用的 Navigator
其實(shí)是一個(gè) StatefulWidget
缚陷,當(dāng) MaterialApp
被更新時(shí)适篙,可以看到在 NavigatorState
的 didUpdateWidget
回調(diào)中會(huì)調(diào)用 _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
執(zhí)行后會(huì)調(diào)用 _forceRebuildPage
將路由里的 _page
清空箫爷,這樣自然下次 Route
在 build
時(shí)觸發(fā)的 PageRoute
重新 builder
方法嚷节。
@override
void changedExternalState() {
super.changedExternalState();
if (_scopeKey.currentState != null)
_scopeKey.currentState._forceRebuildPage();
}
·····
void _forceRebuildPage() {
setState(() {
_page = null;
});
}
所以回歸到最初的問題:這個(gè) bug 首先是因?yàn)椴灰?guī)范使用了 MediaQueryData.fromWindow(WidgetsBinding.instance.window)
,之后又恰好在有鍵盤的頁面打開后觸發(fā)了 MaterialApp
的更新虎锚,導(dǎo)致了 PageRoute
重新 builder
硫痰, 使得沒有鍵盤的 Scaffold
使用了彈出鍵盤的 viewInsets.bottom
。
所以這里只需要將 MediaQueryData.fromWindow
換成 MediaQuery.of(context)
就可以解決問題窜护,而當(dāng)在沒有 context
或者需要直接使用 MediaQueryData.fromWindow
時(shí)效斑,那一定要搭配上 WidgetsBindingObserver.didChangeMetrics
配合更新。
Navigator.of(context).push(new CupertinoPageRoute(builder: (context) {
return MediaQuery(
data:MediaQuery.of(context)
.copyWith(textScaleFactor: 1),
child: Page2(), );
}));
最后說一句柱徙,雖然這個(gè) bug 并不復(fù)雜缓屠,但是恰好能帶出挺多經(jīng)常忽略的知識(shí)點(diǎn),所以長(zhǎng)篇介紹這么多护侮,也希望這樣的 bug 解決思路敌完,可以幫助到大家在日常開發(fā)過程中解決更多問題。