GSYFlutterBook/Flutter-keyboard-rs.md

231 lines
9.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

事情是这样的,由于近期 Flutter 发布了 `1.17` 的稳定版,按照“惯例”开始着手把生产项目升级到 `1.12.13+hotfix.9` 版本,在升级适配完成之后,一个突如其来的 Bug 让我陷入了沉思。
![](http://img.cdn.guoshuyu.cn/20200519_Flutter-keyboard-rs/image1)
如上图所示,可以看到在键盘 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`
![](http://img.cdn.guoshuyu.cn/20200519_Flutter-keyboard-rs/image2)
### 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 解决思路,可以帮助到大家在日常开发过程中解决更多问题。
![](http://img.cdn.guoshuyu.cn/20200519_Flutter-keyboard-rs/image3)