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 解决思路,能够帮助到你们在平常开发过程当中解决更多问题。

相关文章
相关标签/搜索